refactor into package, refactor pentagons.1

This commit is contained in:
queue-miscreant 2025-03-23 15:22:54 -05:00
parent e6188cc36a
commit 118b1a2477
7 changed files with 471 additions and 152 deletions

View File

View File

@ -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

View File

@ -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!",
)

View File

@ -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!")

View File

@ -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
---
<style>
@ -38,41 +22,21 @@ crossref:
</style>
```{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 <br> $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
-------

View File

@ -1 +0,0 @@
../1/goldberg_triangles.py