From 118b1a2477f34f59fa99528733febe360716f0af Mon Sep 17 00:00:00 2001 From: queue-miscreant Date: Sun, 23 Mar 2025 15:22:54 -0500 Subject: [PATCH] refactor into package, refactor pentagons.1 --- posts/pentagons/1/goldberg/__init__.py | 0 posts/pentagons/1/goldberg/common.py | 89 ++++++ posts/pentagons/1/goldberg/dodecahedral.py | 29 ++ posts/pentagons/1/goldberg/operators.py | 251 +++++++++++++++++ .../triangles.py} | 0 posts/pentagons/1/index.qmd | 253 +++++++----------- posts/pentagons/2/goldberg_triangles.py | 1 - 7 files changed, 471 insertions(+), 152 deletions(-) create mode 100644 posts/pentagons/1/goldberg/__init__.py create mode 100644 posts/pentagons/1/goldberg/common.py create mode 100644 posts/pentagons/1/goldberg/dodecahedral.py create mode 100644 posts/pentagons/1/goldberg/operators.py rename posts/pentagons/1/{goldberg_triangles.py => goldberg/triangles.py} (100%) delete mode 120000 posts/pentagons/2/goldberg_triangles.py diff --git a/posts/pentagons/1/goldberg/__init__.py b/posts/pentagons/1/goldberg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/posts/pentagons/1/goldberg/common.py b/posts/pentagons/1/goldberg/common.py new file mode 100644 index 0000000..00fff63 --- /dev/null +++ b/posts/pentagons/1/goldberg/common.py @@ -0,0 +1,89 @@ +from ast import TypeAlias +import itertools +from dataclasses import dataclass, field +from typing import Iterable, Literal +import re + +import sympy + +from goldberg.operators import GoldbergOperator + +t_param = sympy.symbols("T") + +GoldbergClass = Literal["I", "II", "III"] + +def group_dict(xs: Iterable, key=None): + """ + Group the items of `xs` into a dict using `sorted` and `itertools.groupby`. + `key` is passed to both functions. + """ + return {i: list(j) for i, j in itertools.groupby(sorted(xs, key=key), key=key)} + + +@dataclass +class PolyData: + hexagon_count: int + vertex_count: int + edge_count: int + + @classmethod + def from_hexagons(cls, hexagon_count: int): + return cls( + hexagon_count=hexagon_count, + vertex_count=2 * hexagon_count + 20, + edge_count=3 * hexagon_count + 30, + ) + + +@dataclass +class GoldbergData: + parameter: tuple[int, int] + conway: str + + parameter_link: str | None = None + conway_recipe: str | None = None + + extra: list = field(default_factory=list) + + +def link_polyhedronisme(text: str, recipe: str) -> str: + return f"[{text}](https://levskaya.github.io/polyhedronisme/?recipe={recipe}){{target=\"_blank\"}}" + + +@dataclass +class GCOperator: + name: GoldbergOperator + matrix: sympy.Matrix + parameter: int | sympy.Symbol + + +def parameter_to_class(parameter: tuple[int, int]) -> GoldbergClass: + if min(*parameter) == 0: + return "I" + elif parameter[0] == parameter[1]: + return "II" + return "III" + + +def display_conway(conway_notation: str, seed: str | None = None): + # If a seed is given, split at the equals and apply to both sides + if seed is not None: + conway_notation = " = ".join( + operation + "D" + for operation in conway_notation.split(" = ") + ) + + # Latexify and subscript the numbers in u, t, and k + return f"${re.sub('([utk])(\\d+)', '\\1_\\2', conway_notation)}$" + + +def remove_repeats(xs: list[str]) -> list[str]: + """Mutate a list of strings. Repeated entries are replaced with the empty string.""" + prev = None + for i, x in enumerate(xs): + if x == prev: + xs[i] = "" + else: + prev = x + + return xs diff --git a/posts/pentagons/1/goldberg/dodecahedral.py b/posts/pentagons/1/goldberg/dodecahedral.py new file mode 100644 index 0000000..7405858 --- /dev/null +++ b/posts/pentagons/1/goldberg/dodecahedral.py @@ -0,0 +1,29 @@ +from goldberg.operators import goldberg_parameters_to_operations, verify_recipes + +dodecahedral_goldberg_recipes: dict[tuple[int, int], str] = { + # Class I + (1, 0): "D", + (2, 0): "K300cD", + (3, 0): "tkD", + (4, 0): "du4I", + (5, 0): "du5I", + # Class II + (1, 1): "tI", + (2, 2): "K300tdcD", + (3, 3): "tktI", + # Class III + (2, 1): "K300wD", + (3, 1): "*", + (3, 2): "*", + (4, 1): "K300tdwD", +} + +# Assert all the above recipes are correct +verify_recipes( + { + param: goldberg_parameters_to_operations[param] + "D" + for param in dodecahedral_recipes.keys() + }, + dodecahedral_recipes, + "Dodecahedral recipe does not match!", +) diff --git a/posts/pentagons/1/goldberg/operators.py b/posts/pentagons/1/goldberg/operators.py new file mode 100644 index 0000000..52490a3 --- /dev/null +++ b/posts/pentagons/1/goldberg/operators.py @@ -0,0 +1,251 @@ +from dataclasses import dataclass +from functools import reduce +import itertools +from operator import matmul, mul +from pprint import pformat +import re +from typing import Iterator, Literal, TypeAlias + +import sympy + +t_param = sympy.symbols("T") + + +@dataclass +class Polyhedron: + vertex_count: int + edge_count: int + face_count: int + + def as_matrix(self) -> sympy.Matrix: + return sympy.Matrix([[self.vertex_count, self.edge_count, self.face_count]]).T + + +def loeschian_norm(a: int, b: int) -> int: + """Euclidean norm with respect to the sixth root of unity""" + return a * a + a * b + b * b + + +def loeschian_numbers() -> Iterator[int]: + """ + Numbers of the form a^2 + ab + b^2. + No guarantees are made that this produces a sorted sequence without repeats. + See https://oeis.org/A003136 + """ + for a in itertools.count(1): + for b in range(a + 1): + yield loeschian_norm(a, b) + + +GoldbergOperator: TypeAlias = Literal["c", "dk", "tk", "w", "g_T"] + +goldberg_parameters_to_operations = { + # Class I + (1, 0): "", + (2, 0): "dud = c", + (3, 0): "du3d = tk", + (4, 0): "du4d = cc", + (5, 0): "du5d", + (6, 0): "duu3d = ctk", + # Class II + (1, 1): "dk", + (2, 2): "dkdud = dkc", + (3, 3): "dkdu3d = dktk", + (4, 4): "dkduud = dkcc", + # Class III + (2, 1): "w", + (3, 1): "*", # loeschian(3, 1) = 13, which is prime + (3, 2): "*", # loeschian(3, 2) = 19, which is prime + (4, 1): "wdk", + (4, 2): "wc", +} + +# Reverse-lookup for the above table +goldberg_operators_to_parameters = { + operation: parameter + for parameter in goldberg_parameters_to_operations + for operation in goldberg_parameters_to_operations[parameter].split(" = ") + if operation != "*" +} + +# Duals of operators appearing in the above table +_goldberg_operator_duals = { + "k": "t", + "c": "u2", + "tk": "u3", +} +# Commutating pairs of operators +_goldberg_operator_commutations = { + ("dk", "c"), + ("dk", "w"), +} +# Multiply adjacent subdivisions +_gather_subdivisions = lambda formula: re.sub( + "(u\\d*){2,}", + lambda match: "u" + + str(reduce(mul, [int(i) for i in match.group(0).split("u") if i])), + formula, +) + + +def generalize_recipe(recipe: str | list[str], depth: int = 3) -> set[str]: + """ + Rewrite a recipe by dualizing, commutating, and gathering operators together + """ + # get rid of all of the regularization operators and double-dualizations + if isinstance(recipe, str): + recipe = recipe.split(" = ") + + ret = set( + [ + re.sub( + "u(\\D)", + "u2\\1", + re.sub("([KC]\\d*)", "", rec), + ).replace("dd", "") + for rec in recipe + ] + ) + prev = ret.copy() + for _ in range(depth): + new_recipes: set[str] = set( + [ + dualize_polyhedron + for op, dual in _goldberg_operator_duals.items() + for left_comm, right_comm in _goldberg_operator_commutations + for recipe in prev + for dualize_operator in [ + recipe.replace(op, f"d{dual}d").replace("dd", ""), + recipe.replace(dual, f"d{op}d").replace("dd", ""), + ] + for commute_operators in set( + [ + dualize_operator, + dualize_operator.replace( + left_comm + right_comm, right_comm + left_comm + ), + ] + ) + for subdivide_synonyms in set( + [ + commute_operators, + _gather_subdivisions(commute_operators), + ] + ) + # only implementing tetrahedron, dodecahedron, and icosahedron + for dualize_polyhedron in set( + [ + subdivide_synonyms, + subdivide_synonyms.replace("dT", "T"), + subdivide_synonyms.replace("dI", "D"), + subdivide_synonyms.replace("dD", "I"), + ] + ) + ] + ) + + ret.update(new_recipes) + prev = new_recipes + + return ret + + +def verify_recipes( + left: dict[tuple[int, int], str], right: dict[tuple[int, int], str], message: str +) -> None: + """Ensure all entries in `left` also exist in `right` and agree on at least one generalizationed recipe""" + for i, left_recipe in left.items(): + assert i in right, f"Could not find tuple {i} on the RHS" + if left_recipe.find("*") == 0 or right[i].find("*") == 0: + continue + + left_recipes = generalize_recipe(left_recipe) + right_recipes = generalize_recipe(right[i]) + assert ( + left_recipes & right_recipes + ), f"{message}\nLeft recipes: {pformat(left_recipes)}, Right recipes: {pformat(right_recipes)}" + + +def from_same_eigenspace( + uneigen: sympy.Matrix, values: list[int | sympy.Symbol] +) -> sympy.Matrix: + """Diagonalize `uneigen`, then apply its eigenvectors to `values` as a diagonal matrix.""" + eigenvectors = uneigen.diagonalize()[0] + return eigenvectors @ sympy.diag(*values) @ eigenvectors.inv() + + +# Matrices applied over column vectors of the form [v, e, f] +goldberg_operators: dict[GoldbergOperator, sympy.Matrix] = { + "dk": sympy.Matrix( + [ + [0, 2, 0], + [0, 3, 0], + [1, 0, 1], + ] + ), + "c": sympy.Matrix( + [ + [1, 2, 0], + [0, 4, 0], + [0, 1, 1], + ] + ), + "w": sympy.Matrix( + [ + [1, 4, 0], + [0, 7, 0], + [0, 2, 1], + ] + ), +} +goldberg_operators["tk"] = goldberg_operators["dk"] @ goldberg_operators["dk"] +goldberg_operators["g_T"] = from_same_eigenspace(goldberg_operators["dk"], [t_param % 3, 1, t_param]) + + +def apply_goldberg_operator( + operator: GoldbergOperator | tuple[int, int] | str, + poly: Polyhedron, +) -> Polyhedron: + """Apply a (list of) operator(s) to a polyhedron. Operator can also be given as a parameter.""" + # Naive combinatorial method + if isinstance(operator, tuple): + return Polyhedron( + *(goldberg_operators["g_T"] @ poly.as_matrix()).subs(t_param, loeschian_norm(*operator)) + ) + + # Try lookup parameter + if (parameter := goldberg_operators_to_parameters.get(operator)) is not None: + return Polyhedron( + *(goldberg_operators["g_T"] @ poly.as_matrix()).subs(t_param, loeschian_norm(*parameter)) + ) + elif (matrix := goldberg_operators.get(operator)) is not None: # pyright: ignore[reportArgumentType] + return Polyhedron( + *(matrix @ poly.as_matrix()) + ) + + # Partition the recipe into operators for which we have the matrix + partitioned = [] + for recipe in generalize_recipe(operator): + partitioned.clear() + bad_recipe = False + while recipe != "": + for op in goldberg_operators.keys(): + if recipe.find(op) == 0: + partitioned.append(op) + recipe = recipe[len(op):] + break + else: + bad_recipe = True + partitioned.clear() + break + if not bad_recipe: + break + + if partitioned: + # Technically you could just pointwise multiply the eigenvalues + matrix = reduce(matmul, (goldberg_operators[i] for i in partitioned)) + return Polyhedron( + *(matrix @ poly.as_matrix()) + ) + + raise ValueError("Could not partition operators!") diff --git a/posts/pentagons/1/goldberg_triangles.py b/posts/pentagons/1/goldberg/triangles.py similarity index 100% rename from posts/pentagons/1/goldberg_triangles.py rename to posts/pentagons/1/goldberg/triangles.py diff --git a/posts/pentagons/1/index.qmd b/posts/pentagons/1/index.qmd index 7834ee6..107543c 100644 --- a/posts/pentagons/1/index.qmd +++ b/posts/pentagons/1/index.qmd @@ -12,22 +12,6 @@ categories: - geometry - combinatorics - symmetry -# Get rid of the figure label -crossref: - fig-title: "" - fig-labels: " " - tbl-title: "" - tbl-labels: " " - title-delim: "" - custom: - - key: fig - kind: float - reference-prefix: Figure - space-before-numbering: false - - key: tbl - kind: float - reference-prefix: Table - space-before-numbering: false --- ```{python} -#| echo: false -from dataclasses import dataclass +from dataclasses import dataclass, asdict -from matplotlib import pyplot as plt from IPython.display import Markdown +from matplotlib import pyplot as plt +import sympy from tabulate import tabulate -import goldberg_triangles - -@dataclass -class PolyData: - hexagon_count: int - vertex_count: int - edge_count: int - - @classmethod - def from_hexagons(cls, hexagon_count: int): - return cls( - hexagon_count=hexagon_count, - vertex_count=2*hexagon_count + 20, - edge_count=3*hexagon_count + 30, - ) - - -@dataclass -class GoldbergData: - parameter: tuple[int, int] - conway: str - - parameter_link: str | None = None - conway_recipe: str | None = None - - -def polyhedronisme(recipe: str) -> str: - return f"https://levskaya.github.io/polyhedronisme/?recipe={recipe}" +import goldberg.triangles as goldberg_triangles +from goldberg.common import link_polyhedronisme, parameter_to_class, display_conway, remove_repeats +from goldberg.operators import ( + Polyhedron, + goldberg_parameters_to_operations, + apply_goldberg_operator, +) +from goldberg.dodecahedral import dodecahedral_goldberg_recipes ``` Recently I've been trying my hand at a little 3D geometry. @@ -249,7 +213,6 @@ As mentioned above, each sector must transform like a triangle under rotation. It therefore makes sense to work on a triangle grid and identify hexagons on it. ```{python} -#| echo: false #| layout-ncol: 2 #| fig-cap: #| - "Triangular grid before..." @@ -306,7 +269,6 @@ Then, we turn to face an adjacent edge, then walk across *b* edges onto another ::: { #fig-goldberg-path } ```{python} -#| echo: false #| layout-ncol: 2 goldberg_triangles.draw_sector((4, 2), path=True) @@ -331,7 +293,6 @@ Geometrically, there are three classes. These are points of the form $(a, 0)$ (or symmetrically, $(0, a)$). ```{python} -#| echo: false #| fig-cap: | #| Areas corresponding to tuple (4, 0). #| This is the only class where we only need to consider pairs. @@ -349,7 +310,6 @@ Coincidentally, $\|(a, 0)\| = a^2$. These are points of the form $(a, a)$. ```{python} -#| echo: false #| fig-cap: | #| Areas corresponding to tuple (4, 4). @@ -370,7 +330,6 @@ It should go without saying that this is the most complicated case. ::: { #fig-chiral-triangle } ```{python} -#| echo: false #| layout-ncol: 3 goldberg_triangles.draw_sector((4, 1), fill_all=True) @@ -562,113 +521,92 @@ Though this list can be found I'll duplicate the first few entries here. ```{python} -#| echo: false #| label: tbl-goldberg -#| tbl-cap: "\\\\* higher-order whirl needed" -#| tbl-colwidths: [20, 25, 10, 10, 10, 25] +#| tbl-cap: "\\\\* higher-order whirl needed
$T = a^2 + ab + b^2$" +#| tbl-colwidths: [20, 15, 20, 10, 10, 25] #| classes: plain -goldberg_classes = { - "I": [ - GoldbergData( - parameter=(1, 0), - parameter_link="https://en.wikipedia.org/wiki/Regular_dodecahedron", - conway="D", - conway_recipe="D", - ), - GoldbergData( - parameter=(2, 0), - parameter_link="https://en.wikipedia.org/wiki/Chamfered_dodecahedron", - conway="cD", - conway_recipe="K300cD", - ), - GoldbergData( - parameter=(3, 0), - conway="tkD = du3I", - conway_recipe="tkD", - ), - GoldbergData( - parameter=(4, 0), - conway="ccD = du4I", - conway_recipe="du4I", - ), - GoldbergData( - parameter=(5, 0), - conway="du5I", - conway_recipe="du5I", - ), - ], - "II": [ - GoldbergData( - parameter=(1, 1), - parameter_link="https://en.wikipedia.org/wiki/Truncated_icosahedron", - conway="tI", - conway_recipe="tI", - ), - GoldbergData( - parameter=(2, 2), - parameter_link="https://en.wikipedia.org/wiki/Truncated_icosahedron", - conway="tdcD", - conway_recipe="K300tdcD", - ), - GoldbergData( - parameter=(3, 3), - conway="tktI", - conway_recipe="tktI", - ), - ], - "III": [ - GoldbergData( - parameter=(2, 1), - conway="wD", - conway_recipe="K300wD", - ), - GoldbergData( - parameter=(3, 1), - conway="*", - ), - GoldbergData( - parameter=(3, 2), - conway="*", - ), - GoldbergData( - parameter=(4, 1), - conway="dkwD", - conway_recipe="K300tdwD", - ), - ], +@dataclass +class DodecahedralGoldbergSolution: + klass: str + parameter: str + hexagon_count: str + edge_count: str + vertex_count: str + conway: str + +special_names: dict[tuple[int, int], str] = { + # Class I + (1, 0): "https://en.wikipedia.org/wiki/Regular_dodecahedron", + (2, 0): "https://en.wikipedia.org/wiki/Chamfered_dodecahedron", + # Class II + (1, 1): "https://en.wikipedia.org/wiki/Truncated_icosahedron", } +rows: list[DodecahedralGoldbergSolution] = [ + # General solution + DodecahedralGoldbergSolution( + klass="", + parameter="(a, b)", + hexagon_count=f"${sympy.latex(polyhedron.face_count - 12)}$", + edge_count=f"${sympy.latex(polyhedron.edge_count)}$", + vertex_count=f"${sympy.latex(polyhedron.vertex_count)}$", + conway="", + ) + for polyhedron in ( + apply_goldberg_operator( + "g_T", + Polyhedron(face_count=12, edge_count=30, vertex_count=20), + ), + ) +] + sorted( + [ + # Particular solutions + DodecahedralGoldbergSolution( + klass=parameter_to_class(parameter), + parameter=f"[{parameter}]({special_name})" if special_name is not None else str(parameter), + hexagon_count=str(polyhedron.face_count - 12), + edge_count=str(polyhedron.edge_count), + vertex_count=str(polyhedron.vertex_count), + conway=( + "*" + if recipe == "*" + else link_polyhedronisme( + display_conway(goldberg_parameters_to_operations[parameter], "D"), + recipe, + ) + ), + ) + for parameter, recipe in dodecahedral_goldberg_recipes.items() + for polyhedron in ( + apply_goldberg_operator( + parameter, + Polyhedron(face_count=12, edge_count=30, vertex_count=20), + ), + ) + for special_name in (special_names.get(parameter),) + ], + key = lambda x: x.klass +) -def poly_from_dodecahedral_goldberg_parameter(parameter: tuple[int, int]): - a, b = parameter - hexagon_count = 10*(a*a + a*b + b*b - 1) - return PolyData.from_hexagons(hexagon_count) - - -def goldberg_table_row(data: GoldbergData) -> list[str]: - poly_data = poly_from_dodecahedral_goldberg_parameter(data.parameter) - return [ - f"[{data.parameter}]({data.parameter_link})" - if data.parameter_link is not None - else str(data.parameter), - str(poly_data.hexagon_count), - str(poly_data.vertex_count), - str(poly_data.edge_count), - f"[{data.conway}]({polyhedronisme(data.conway_recipe)})" - if data.conway_recipe is not None - else str(data.conway) - ] - +headers = DodecahedralGoldbergSolution( + klass="Class", + parameter="Parameter", + hexagon_count="$F_6$", + edge_count="E", + vertex_count="V", + conway="Conway", +) Markdown(tabulate( - [ - ([class_ if i == 0 else ""]) + goldberg_table_row(item) - for class_, items in goldberg_classes.items() - for i, item in enumerate(items) - ], - headers=["Class", "Parameter", "$F_6$", "V", "E", "Conway"], - numalign="left", + { + field: [name] + (remove_repeats if field == "klass" else lambda x: x)( + [getattr(item, field) for item in rows] + ) + for field, name in asdict(headers).items() + }, + headers="firstrow", + numalign="left", )) ``` @@ -678,6 +616,19 @@ Similarly to how the entry (1, 0) is special because the dodecahedron is a Plato (1, 1) is special in that it is an [*Archimedean solid*](https://en.wikipedia.org/wiki/Archimedean_solid). This means that every vertex has the same configuration: 2 hexagons and 1 pentagon ($V = V^1$). +The Class I and Class II solutions are fairly generalizable. +Class I solutions can be achieved dualizing subdivisions. +Class II solutions can be generated from Class I solutions by applying an additional *dk*. + +The Class III solutions are not. +The whirl operator is fairly special, corresponding directly to the (2, 1) case. +The norm of this parameter is 7, which is prime. +Similarly, the entries with missing recipes, (3, 1) and (3, 2), have norms of 13 and 19, + both of which are prime. +Consequently, they do not have operators associated to them. +On the other hand, (4, 1) has a norm of 21, which can be factored into 3 and 7 -- + these correspond to the operators *dk* and *w*, respectively. + Closing ------- diff --git a/posts/pentagons/2/goldberg_triangles.py b/posts/pentagons/2/goldberg_triangles.py deleted file mode 120000 index 84f40de..0000000 --- a/posts/pentagons/2/goldberg_triangles.py +++ /dev/null @@ -1 +0,0 @@ -../1/goldberg_triangles.py \ No newline at end of file