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