import math
import sympy as sp
import numpy as np
__all__ = [
'MasterSet',
'MASTER_SETS',
]
_ALPHA = {chr(65 + i): sp.Symbol(chr(65 + i)) for i in range(26)}
[docs]
class MasterSet:
"""
A geometric layout template for Combination Product Sets.
Defines node positions and edges that determine the spatial
arrangement and relational structure of CPS nodes. Named
constructors (``tetrad``, ``asterisk``, etc.) provide common
geometric templates in 2-D, 3-D, or N-D.
Parameters
----------
positions : dict
Mapping of single-letter node labels to coordinate tuples.
edges : list of tuple
Pairs of node labels defining the edge connections.
name : str or None, optional
Human-readable name for this layout.
factors : tuple of int or None, optional
If provided, assigns integer factors to node labels in
sorted order.
"""
[docs]
def __init__(self, positions, edges, name=None, factors=None):
max_dim = max(len(v) for v in positions.values()) if positions else 2
max_dim = max(max_dim, 2)
self._positions = {}
for k, v in positions.items():
padded = tuple(float(c) for c in v) + (0.0,) * (max_dim - len(v))
self._positions[k] = padded
self._edge_pairs = list(edges)
self._name = name
self._n_factors = len(positions)
self._factors = tuple(sorted(factors)) if factors else None
self._aliases = sorted(positions.keys())
self._factor_to_alias = None
self._alias_to_factor = None
if self._factors is not None:
if len(self._factors) != self._n_factors:
raise ValueError(
f"Expected {self._n_factors} factors, got {len(self._factors)}")
self._factor_to_alias = {f: a for f, a in zip(self._factors, self._aliases)}
self._alias_to_factor = {a: f for f, a in self._factor_to_alias.items()}
self._relationship_dict = self._build_relationship_dict()
def _build_relationship_dict(self):
rd = {}
for fr, to in self._edge_pairs:
p1 = self._positions[fr]
p2 = self._positions[to]
disp = tuple(p2[i] - p1[i] for i in range(len(p1)))
dx = disp[0] if len(disp) > 0 else 0.0
dy = disp[1] if len(disp) > 1 else 0.0
dz = disp[2] if len(disp) > 2 else 0.0
angle = math.atan2(dy, dx)
horiz = math.sqrt(dx * dx + dy * dy)
dist = math.sqrt(sum(d * d for d in disp))
elev = math.atan2(dz, horiz) if dist > 1e-12 else 0.0
sym_fwd = _ALPHA[fr] / _ALPHA[to]
sym_rev = _ALPHA[to] / _ALPHA[fr]
rd[sym_fwd] = {'angle': angle, 'distance': dist, 'elevation': elev,
'displacement': disp}
rd[sym_rev] = {'angle': angle + math.pi, 'distance': dist, 'elevation': -elev,
'displacement': tuple(-d for d in disp)}
return rd
@property
def dimensionality(self):
"""int : The effective spatial dimensionality (2 or more)."""
if not self._positions:
return 2
dim = len(next(iter(self._positions.values())))
for d in range(dim, 2, -1):
if any(abs(p[d - 1]) > 1e-12 for p in self._positions.values()):
return d
return 2
@property
def relationship_dict(self):
"""dict : Symbolic ratio keys mapping to angle, distance, and displacement data."""
return self._relationship_dict
@property
def positions(self):
"""dict : Mapping of node labels to coordinate tuples."""
return dict(self._positions)
@property
def n_factors(self):
"""int : The number of factor nodes in this layout."""
return self._n_factors
@property
def name(self):
"""str or None : The layout template name."""
return self._name
@property
def edges(self):
"""list of tuple : Edge pairs as ``(from_label, to_label)``."""
return list(self._edge_pairs)
@property
def factors(self):
"""tuple of int or None : The integer factors assigned to node labels."""
return self._factors
@property
def factor_to_alias(self):
"""dict or None : Mapping from integer factors to single-letter aliases."""
return dict(self._factor_to_alias) if self._factor_to_alias else None
@property
def alias_to_factor(self):
"""dict or None : Mapping from single-letter aliases to integer factors."""
return dict(self._alias_to_factor) if self._alias_to_factor else None
@property
def aliases(self):
"""list of str : Sorted single-letter node aliases."""
return list(self._aliases)
@property
def ratios(self):
"""tuple of Fraction or None : Equave-reduced ratios of the assigned factors."""
if self._factors is None:
return None
from klotho.tonos.utils.interval_normalization import equave_reduce
return tuple(sorted(equave_reduce(f) for f in self._factors))
[docs]
def node_data(self):
"""
Return a dict of per-node data including alias, factor, and ratio.
Returns
-------
dict
Keyed by alias letter, values are dicts with ``'alias'``,
``'factor'``, and ``'ratio'`` entries.
"""
if self._factors is None:
return {a: {'alias': a} for a in self._aliases}
from klotho.tonos.utils.interval_normalization import equave_reduce
result = {}
for f, a in self._factor_to_alias.items():
result[a] = {
'alias': a,
'factor': f,
'ratio': equave_reduce(f),
}
return result
[docs]
def with_factors(self, factors):
"""
Return a new MasterSet with specific factors assigned.
Parameters
----------
factors : tuple of int
Integer factors to assign to the node positions.
Returns
-------
MasterSet
"""
return MasterSet(self._positions, self._edge_pairs,
name=self._name, factors=factors)
def __repr__(self):
return f"MasterSet('{self._name}', {self._n_factors} nodes, {len(self._edge_pairs)} edges)"
# ------------------------------------------------------------------
# Named constructors
# ------------------------------------------------------------------
[docs]
@classmethod
def tetrad(cls):
positions = {
'A': (-1.5, -math.sqrt(3) / 2),
'B': (0.0, math.sqrt(3)),
'C': (1.5, -math.sqrt(3) / 2),
'D': (0.0, 0.0),
}
edges = [
('D', 'A'), ('D', 'B'), ('D', 'C'),
('A', 'B'), ('B', 'C'), ('C', 'A'),
]
return cls(positions, edges, name='tetrad')
[docs]
@classmethod
def asterisk(cls):
r = 3.0
angles = {
'B': math.pi * 3 / 2,
'C': math.pi * 11 / 10,
'D': math.pi * 7 / 10,
'E': math.pi * 3 / 10,
'F': math.pi * 19 / 10,
}
positions = {'A': (0.0, 0.0)}
for label, ang in angles.items():
positions[label] = (r * math.cos(ang), r * math.sin(ang))
edges = [('A', lbl) for lbl in angles]
return cls(positions, edges, name='asterisk')
[docs]
@classmethod
def centered_pentagon(cls):
r = 3.0
gen_angles = {
('B', 'F'): math.pi * 6 / 5,
('B', 'C'): math.pi * 9 / 5,
('F', 'E'): math.pi * 8 / 5,
('C', 'D'): math.pi * 7 / 5,
}
positions = {'B': (0.0, 0.0)}
positions['F'] = (r * math.cos(gen_angles[('B', 'F')]),
r * math.sin(gen_angles[('B', 'F')]))
positions['C'] = (r * math.cos(gen_angles[('B', 'C')]),
r * math.sin(gen_angles[('B', 'C')]))
positions['E'] = (positions['F'][0] + r * math.cos(gen_angles[('F', 'E')]),
positions['F'][1] + r * math.sin(gen_angles[('F', 'E')]))
positions['D'] = (positions['C'][0] + r * math.cos(gen_angles[('C', 'D')]),
positions['C'][1] + r * math.sin(gen_angles[('C', 'D')]))
cx = np.mean([v[0] for v in positions.values()])
cy = np.mean([v[1] for v in positions.values()])
positions = {k: (v[0] - cx, v[1] - cy) for k, v in positions.items()}
positions['A'] = (0.0, 0.0)
edges = [('B', 'F'), ('B', 'C'), ('F', 'E'), ('C', 'D'), ('E', 'D')]
return cls(positions, edges, name='centered_pentagon')
[docs]
@classmethod
def connected_centered_pentagon(cls):
r = 3.0
gen_angles = {
('B', 'F'): math.pi * 6 / 5,
('B', 'C'): math.pi * 9 / 5,
('F', 'E'): math.pi * 8 / 5,
('C', 'D'): math.pi * 7 / 5,
}
positions = {'B': (0.0, 0.0)}
positions['F'] = (r * math.cos(gen_angles[('B', 'F')]),
r * math.sin(gen_angles[('B', 'F')]))
positions['C'] = (r * math.cos(gen_angles[('B', 'C')]),
r * math.sin(gen_angles[('B', 'C')]))
positions['E'] = (positions['F'][0] + r * math.cos(gen_angles[('F', 'E')]),
positions['F'][1] + r * math.sin(gen_angles[('F', 'E')]))
positions['D'] = (positions['C'][0] + r * math.cos(gen_angles[('C', 'D')]),
positions['C'][1] + r * math.sin(gen_angles[('C', 'D')]))
cx = np.mean([v[0] for v in positions.values()])
cy = np.mean([v[1] for v in positions.values()])
positions = {k: (v[0] - cx, v[1] - cy) for k, v in positions.items()}
positions['A'] = (0.0, 0.0)
outer = ['B', 'C', 'D', 'E', 'F']
edges = [('B', 'F'), ('B', 'C'), ('F', 'E'), ('C', 'D'), ('E', 'D')]
edges += [('A', lbl) for lbl in outer]
return cls(positions, edges, name='connected_centered_pentagon')
[docs]
@classmethod
def hexagon(cls):
r = 3.0
int_angles = [90, 150, 90, 150, 90, 150]
order = ['C', 'F', 'A', 'E', 'B', 'D']
heading_deg = 315
pts = [(0.0, 0.0)]
heading_rad = math.radians(heading_deg)
for i in range(5):
x = pts[-1][0] + r * math.cos(heading_rad)
y = pts[-1][1] + r * math.sin(heading_rad)
pts.append((x, y))
ext = 180 - int_angles[(i + 1) % 6]
heading_deg -= ext
heading_rad = math.radians(heading_deg)
cx = np.mean([p[0] for p in pts])
cy = np.mean([p[1] for p in pts])
pts = [(p[0] - cx, p[1] - cy) for p in pts]
positions = {order[i]: pts[i] for i in range(6)}
edges = [
('C', 'F'), ('F', 'A'), ('A', 'E'),
('E', 'B'), ('B', 'D'), ('D', 'C'),
]
return cls(positions, edges, name='hexagon')
[docs]
@classmethod
def irregular_hexagon(cls):
r = 3.0
target_short = 2.333
int_angles = [90, 150, 90, 150, 90, 150]
order = ['C', 'F', 'A', 'E', 'B', 'D']
heading_deg = 315
pts = [(0.0, 0.0)]
heading_rad = math.radians(heading_deg)
for i in range(5):
x = pts[-1][0] + r * math.cos(heading_rad)
y = pts[-1][1] + r * math.sin(heading_rad)
pts.append((x, y))
ext = 180 - int_angles[(i + 1) % 6]
heading_deg -= ext
heading_rad = math.radians(heading_deg)
positions = {order[i]: pts[i] for i in range(6)}
FA_x = positions['A'][0] - positions['F'][0]
FA_y = positions['A'][1] - positions['F'][1]
d = -FA_y - math.sqrt(target_short ** 2 - FA_x ** 2)
top_nodes = {'C', 'F', 'D'}
for label in top_nodes:
x, y = positions[label]
positions[label] = (x, y - d)
cx = np.mean([p[0] for p in positions.values()])
cy = np.mean([p[1] for p in positions.values()])
positions = {k: (v[0] - cx, v[1] - cy) for k, v in positions.items()}
edges = [
('C', 'F'), ('F', 'A'), ('A', 'E'),
('E', 'B'), ('B', 'D'), ('D', 'C'),
]
return cls(positions, edges, name='irregular_hexagon')
[docs]
@classmethod
def ogdoad(cls):
r = 3.0
outer = {
'B': math.pi * 1 / 2,
'C': math.pi * 3 / 14,
'D': math.pi * 27 / 14,
'E': math.pi * 23 / 14,
'F': math.pi * 19 / 14,
'G': math.pi * 15 / 14,
'H': math.pi * 11 / 14,
}
positions = {'A': (0.0, 0.0)}
for label, ang in outer.items():
positions[label] = (r * math.cos(ang), r * math.sin(ang))
edges = [('A', lbl) for lbl in outer]
return cls(positions, edges, name='ogdoad')
# ------------------------------------------------------------------
# Additional 2D Named constructors
# ------------------------------------------------------------------
[docs]
@classmethod
def heptagon(cls):
r = 3.0
angles = [2 * math.pi * i / 7 + math.pi / 2 for i in range(7)]
labels = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
positions = {labels[i]: (r * math.cos(a), r * math.sin(a)) for i, a in enumerate(angles)}
edges = [(labels[i], labels[(i + 1) % 7]) for i in range(7)]
return cls(positions, edges, name='heptagon')
[docs]
@classmethod
def kite(cls):
r = 3.0
positions = {
'A': ( 0, r * 0.85),
'B': ( r * 0.6, 0),
'C': ( 0, -r * 0.5),
'D': (-r * 0.6, 0),
}
edges = [('A', 'B'), ('B', 'C'), ('C', 'D'), ('D', 'A'), ('A', 'C')]
return cls(positions, edges, name='kite')
[docs]
@classmethod
def arrow(cls):
r = 3.0
positions = {
'A': ( 0, r),
'B': ( r * 0.8, 0),
'C': ( r * 0.3, r * 0.3),
'D': (-r * 0.3, r * 0.3),
'E': (-r * 0.8, 0),
}
edges = [('A', 'B'), ('B', 'C'), ('C', 'D'), ('D', 'E'), ('E', 'A')]
return cls(positions, edges, name='arrow')
[docs]
@classmethod
def k23_bipartite(cls):
r = 3.0
positions = {
'A': (-r * 0.5, r * 0.6),
'B': ( r * 0.5, r * 0.6),
'C': (-r, -r * 0.5),
'D': ( 0, -r * 0.7),
'E': ( r, -r * 0.5),
}
edges = [('A', 'C'), ('A', 'D'), ('A', 'E'),
('B', 'C'), ('B', 'D'), ('B', 'E')]
return cls(positions, edges, name='k23_bipartite')
[docs]
@classmethod
def wheel5(cls):
r = 3.0
angles = [2 * math.pi * i / 5 + math.pi / 2 for i in range(5)]
outer = ['B', 'C', 'D', 'E', 'F']
positions = {'A': (0.0, 0.0)}
for i, label in enumerate(outer):
positions[label] = (r * math.cos(angles[i]), r * math.sin(angles[i]))
edges = [(outer[i], outer[(i + 1) % 5]) for i in range(5)]
edges += [('A', lbl) for lbl in outer]
return cls(positions, edges, name='wheel5')
[docs]
@classmethod
def bowtie(cls):
r = 3.0
positions = {
'A': ( 0, 0),
'B': ( r * 1.15, r * 0.7),
'C': ( r * 1.15, -r * 0.7),
'D': (-r * 0.85, r * 0.5),
'E': (-r * 0.85, -r * 0.5),
}
edges = [('A', 'B'), ('A', 'C'), ('B', 'C'),
('A', 'D'), ('A', 'E'), ('D', 'E')]
return cls(positions, edges, name='bowtie')
[docs]
@classmethod
def house(cls):
sq = 2.0
positions = {
'A': ( 0, sq * 1.2),
'B': (-sq * 0.8, 0),
'C': ( sq * 0.8, 0),
'D': ( sq * 1.1, -2 * sq),
'E': (-sq * 1.1, -2 * sq),
}
edges = [('A', 'B'), ('A', 'C'), ('B', 'C'),
('C', 'D'), ('D', 'E'), ('E', 'B')]
return cls(positions, edges, name='house')
[docs]
@classmethod
def wheel4(cls):
sq = 2.0
positions = {
'A': ( 0, 0.15),
'B': ( 0, sq * 1.3),
'C': (-sq * 0.9, 0),
'D': ( 0, -sq * 0.8),
'E': ( sq * 0.9, 0),
}
edges = [('B', 'C'), ('C', 'D'), ('D', 'E'), ('E', 'B'),
('A', 'B'), ('A', 'C'), ('A', 'D'), ('A', 'E')]
return cls(positions, edges, name='wheel4')
[docs]
@classmethod
def h_shape(cls):
r = 3.0
positions = {
'A': (-r, r * 0.9),
'B': (-r, -r * 0.5),
'C': ( 0, r * 0.35),
'D': ( 0, -r * 0.45),
'E': ( r, r * 0.55),
'F': ( r, -r * 0.65),
}
edges = [('A', 'B'), ('A', 'C'), ('B', 'D'),
('C', 'D'), ('C', 'E'), ('D', 'F'), ('E', 'F')]
return cls(positions, edges, name='h_shape')
[docs]
@classmethod
def nested_triangles(cls):
r_out = 3.0
r_in = 1.0
sy = 0.1
ang_out = [2 * math.pi * i / 3 + math.pi / 2 for i in range(3)]
ang_in = [2 * math.pi * i / 3 + math.pi / 2 + math.pi / 3 for i in range(3)]
positions = {}
for i, a in enumerate(ang_out):
positions[chr(65 + i)] = (r_out * math.cos(a), r_out * math.sin(a))
for i, a in enumerate(ang_in):
positions[chr(68 + i)] = (r_in * math.cos(a), r_in * math.sin(a) + sy)
edges = [
('A', 'B'), ('B', 'C'), ('C', 'A'),
('D', 'E'), ('E', 'F'), ('F', 'D'),
('A', 'D'), ('A', 'F'), ('B', 'D'), ('B', 'E'), ('C', 'E'), ('C', 'F'),
]
return cls(positions, edges, name='nested_triangles')
# ------------------------------------------------------------------
# 3D Named constructors
# ------------------------------------------------------------------
[docs]
@classmethod
def tetrad_3d(cls):
base = cls.tetrad()
apex_height = math.sqrt(6)
positions = {
'A': base._positions['A'][:2] + (0.0,),
'B': base._positions['B'][:2] + (0.0,),
'C': base._positions['C'][:2] + (0.0,),
'D': (0.0, 0.0, apex_height),
}
edges = [
('D', 'A'), ('D', 'B'), ('D', 'C'),
('A', 'B'), ('B', 'C'), ('C', 'A'),
]
return cls(positions, edges, name='tetrad_3d')
[docs]
@classmethod
def octahedron(cls):
s = 3.0
d = 0.5
positions = {
'A': ( s + d, 0, 0), 'B': (-s, 0, 0),
'C': ( 0, s + d, 0), 'D': ( 0, -s, 0),
'E': ( 0, 0, s + d), 'F': ( 0, 0, -s),
}
edges = [
('A', 'C'), ('A', 'D'), ('A', 'E'), ('A', 'F'),
('B', 'C'), ('B', 'D'), ('B', 'E'), ('B', 'F'),
('C', 'E'), ('C', 'F'), ('D', 'E'), ('D', 'F'),
]
return cls(positions, edges, name='octahedron')
[docs]
@classmethod
def trigonal_bipyramid(cls):
r = 3.0
h = r * math.sqrt(2.0 / 3.0)
s32 = r * math.sqrt(3) / 2
positions = {
'A': ( r, 0, 0),
'B': (-r / 2, s32, 0),
'C': (-r / 2, -s32, 0),
'D': ( 0, 0, h),
'E': ( 0, 0, -h),
}
edges = [
('A', 'B'), ('A', 'C'), ('B', 'C'),
('A', 'D'), ('B', 'D'), ('C', 'D'),
('A', 'E'), ('B', 'E'), ('C', 'E'),
]
return cls(positions, edges, name='trigonal_bipyramid')
[docs]
@classmethod
def triangular_prism(cls):
r = 3.0
h = 3.0
t = 2/3
s32 = r * math.sqrt(3) / 2
positions = {
'A': ( r, 0, 0),
'B': (-r / 2, s32, 0),
'C': (-r / 2, -s32, 0),
'D': ( r * t, 0, h),
'E': (-r / 2 * t, s32 * t, h),
'F': (-r / 2 * t, -s32 * t, h),
}
edges = [
('A', 'B'), ('B', 'C'), ('C', 'A'),
('D', 'E'), ('E', 'F'), ('F', 'D'),
('A', 'D'), ('B', 'E'), ('C', 'F'),
]
return cls(positions, edges, name='triangular_prism')
[docs]
@classmethod
def pentagonal_bipyramid(cls):
r = 3.0
h = r * math.sqrt(2.0 / 3.0)
angles = [2 * math.pi * i / 5 for i in range(5)]
positions = {
'A': (r * math.cos(angles[0]), r * math.sin(angles[0]), 0),
'B': (r * math.cos(angles[1]), r * math.sin(angles[1]), 0),
'C': (r * math.cos(angles[2]), r * math.sin(angles[2]), 0),
'D': (r * math.cos(angles[3]), r * math.sin(angles[3]), 0),
'E': (r * math.cos(angles[4]), r * math.sin(angles[4]), 0),
'F': (0, 0, h),
'G': (0, 0, -h),
}
edges = [
('A', 'B'), ('B', 'C'), ('C', 'D'), ('D', 'E'), ('E', 'A'),
('A', 'F'), ('B', 'F'), ('C', 'F'), ('D', 'F'), ('E', 'F'),
('A', 'G'), ('B', 'G'), ('C', 'G'), ('D', 'G'), ('E', 'G'),
]
return cls(positions, edges, name='pentagonal_bipyramid')
[docs]
@classmethod
def kite_pyramid(cls):
r = 3.0
positions = {
'A': ( 0, r * 0.85, 0),
'B': ( r * 0.7, 0, 0),
'C': ( 0, -r * 0.5, 0),
'D': (-r * 0.7, 0, 0),
'E': ( 0, 0, r),
}
edges = [
('A', 'B'), ('B', 'C'), ('C', 'D'), ('D', 'A'),
('A', 'E'), ('B', 'E'), ('C', 'E'), ('D', 'E'),
]
return cls(positions, edges, name='kite_pyramid')
# ------------------------------------------------------------------
# N-D Named constructors
# ------------------------------------------------------------------
[docs]
@classmethod
def asterisk_nd(cls):
r = 3.0
outer_labels = ['B', 'C', 'D', 'E', 'F']
n_dims = len(outer_labels)
positions = {'A': tuple(0.0 for _ in range(n_dims))}
for i, label in enumerate(outer_labels):
pos = [0.0] * n_dims
pos[i] = r
positions[label] = tuple(pos)
edges = [('A', lbl) for lbl in outer_labels]
return cls(positions, edges, name='asterisk_nd')
[docs]
@classmethod
def ogdoad_nd(cls):
r = 3.0
outer_labels = ['B', 'C', 'D', 'E', 'F', 'G', 'H']
n_dims = len(outer_labels)
positions = {'A': tuple(0.0 for _ in range(n_dims))}
for i, label in enumerate(outer_labels):
pos = [0.0] * n_dims
pos[i] = r
positions[label] = tuple(pos)
edges = [('A', lbl) for lbl in outer_labels]
return cls(positions, edges, name='ogdoad_nd')
[docs]
@classmethod
def twisted_asterisk_s2(cls):
"""
Center + 5 spokes along a deterministic Fibonacci spiral on S^2.
Native 3-D, 6 nodes. Fits any 6-factor nkany preset
(``Pentadekany``, ``Eikosany``). Spokes are at unit directions
determined only by the node index, so the geometry is entirely
number-agnostic.
Returns
-------
MasterSet
"""
scale = 3.0
outer_labels = ['B', 'C', 'D', 'E', 'F']
n = len(outer_labels)
phi = (1 + math.sqrt(5)) / 2
ga = 2 * math.pi * (1 - 1 / phi)
positions = {'A': (0.0, 0.0, 0.0)}
for i, label in enumerate(outer_labels):
z = 1 - (2 * i + 1) / n
r_xy = math.sqrt(max(0.0, 1 - z * z))
theta = i * ga
positions[label] = (scale * r_xy * math.cos(theta),
scale * r_xy * math.sin(theta),
scale * z)
edges = [('A', lbl) for lbl in outer_labels]
return cls(positions, edges, name='twisted_asterisk_s2')
[docs]
@classmethod
def antiprism_3(cls):
"""
Trigonal antiprism: two triangles rotated 60 degrees along z.
Native 3-D, 6 nodes, *no center hub*. Top triangle is slightly
smaller than the bottom to break translational symmetry that
would otherwise trigger subset-sum collisions. Fits any
6-factor nkany preset.
Returns
-------
MasterSet
"""
radius = 3.0
separation = 2.0
top_scale = 0.9
bottom = ['A', 'B', 'C']
top = ['D', 'E', 'F']
positions = {}
for i, lab in enumerate(bottom):
theta = 2 * math.pi * i / 3
positions[lab] = (radius * math.cos(theta),
radius * math.sin(theta),
-separation / 2)
for i, lab in enumerate(top):
theta = 2 * math.pi * i / 3 + math.pi / 3
positions[lab] = (radius * top_scale * math.cos(theta),
radius * top_scale * math.sin(theta),
separation / 2)
edges = [
(bottom[0], bottom[1]), (bottom[1], bottom[2]), (bottom[2], bottom[0]),
(top[0], top[1]), (top[1], top[2]), (top[2], top[0]),
(bottom[0], top[0]), (bottom[0], top[2]),
(bottom[1], top[0]), (bottom[1], top[1]),
(bottom[2], top[1]), (bottom[2], top[2]),
]
return cls(positions, edges, name='antiprism_3')
[docs]
@classmethod
def halton_asterisk(cls, n=6, ambient_d=5):
"""
Center + (n-1) unit spokes on S^(ambient_d - 1) via Halton inverse-CDF.
A generic-position N-D generalization of Klotho's ``asterisk``
family. Default ``(n=6, ambient_d=5)`` matches the node count
and ambient dimension of ``asterisk_nd`` while replacing its
orthogonal-axes geometry with deterministically quasi-random
unit spokes.
Parameters
----------
n : int, optional
Number of master-set nodes (1 center + n-1 spokes). Default 6.
ambient_d : int, optional
Ambient dimension. Default 5.
Returns
-------
MasterSet
"""
from scipy.stats import norm
scale = 3.0
bases = (2, 3, 5, 7, 11, 13, 17, 19, 23)
if ambient_d > len(bases):
raise ValueError(
f'halton_asterisk supports ambient_d up to {len(bases)}')
if n < 2:
raise ValueError('halton_asterisk needs at least 2 nodes')
outer = n - 1
labels = [chr(65 + i) for i in range(n)]
positions = {labels[0]: tuple([0.0] * ambient_d)}
def _halton(index, base):
f, out = 1.0, 0.0
while index > 0:
f /= base
out += f * (index % base)
index //= base
return out
for i in range(outer):
gaussian = np.array([norm.ppf(_halton(i + 1, bases[k]))
for k in range(ambient_d)])
v = gaussian / np.linalg.norm(gaussian)
positions[labels[i + 1]] = tuple(float(x) for x in v * scale)
edges = [(labels[0], lab) for lab in labels[1:]]
return cls(positions, edges, name='halton_asterisk')
[docs]
@classmethod
def wheel_nd(cls, n=6, ambient_d=5):
"""
Hub + ring of (n-1) outer nodes, with per-label Halton offsets
in every dim >= 2.
The outer ring lives in dims 0/1 as a regular polygon; each
ring node additionally receives a unique, deterministic offset
in every higher dim, which makes the master set genuinely N-D
while preserving hub-and-ring topology. Default parameters
(n=6, ambient_d=5) match ``asterisk_nd``'s factor count and
ambient dim.
Parameters
----------
n : int, optional
Number of master-set nodes (1 hub + n-1 ring nodes). Default 6.
ambient_d : int, optional
Ambient dimension. Default 5. Must be >= 2.
Returns
-------
MasterSet
"""
if ambient_d < 2:
raise ValueError('wheel_nd needs ambient_d >= 2')
if n < 3:
raise ValueError('wheel_nd needs at least 3 nodes')
ring_radius = 3.0
offset_scale = 1.5
bases = (2, 3, 5, 7, 11, 13, 17, 19, 23)
if ambient_d - 2 > len(bases):
raise ValueError(
f'wheel_nd supports ambient_d up to {len(bases) + 2}')
outer = n - 1
labels = [chr(65 + i) for i in range(n)]
def _halton(index, base):
f, out = 1.0, 0.0
while index > 0:
f /= base
out += f * (index % base)
index //= base
return out
positions = {labels[0]: tuple([0.0] * ambient_d)}
for i in range(outer):
theta = 2 * math.pi * i / outer
coords = [ring_radius * math.cos(theta),
ring_radius * math.sin(theta)]
for k in range(2, ambient_d):
coords.append(offset_scale * (_halton(i + 1, bases[k - 2]) - 0.5))
positions[labels[i + 1]] = tuple(coords)
outer_labels = labels[1:]
edges = [(labels[0], lab) for lab in outer_labels]
edges += [(outer_labels[j], outer_labels[(j + 1) % outer])
for j in range(outer)]
return cls(positions, edges, name='wheel_nd')
MASTER_SETS = {
'tetrad': MasterSet.tetrad,
'asterisk': MasterSet.asterisk,
'centered_pentagon': MasterSet.centered_pentagon,
'connected_centered_pentagon': MasterSet.connected_centered_pentagon,
'hexagon': MasterSet.hexagon,
'irregular_hexagon': MasterSet.irregular_hexagon,
'ogdoad': MasterSet.ogdoad,
'heptagon': MasterSet.heptagon,
'kite': MasterSet.kite,
'arrow': MasterSet.arrow,
'k23_bipartite': MasterSet.k23_bipartite,
'wheel5': MasterSet.wheel5,
'bowtie': MasterSet.bowtie,
'house': MasterSet.house,
'wheel4': MasterSet.wheel4,
'h_shape': MasterSet.h_shape,
'nested_triangles': MasterSet.nested_triangles,
'tetrad_3d': MasterSet.tetrad_3d,
'octahedron': MasterSet.octahedron,
'trigonal_bipyramid': MasterSet.trigonal_bipyramid,
'triangular_prism': MasterSet.triangular_prism,
'pentagonal_bipyramid': MasterSet.pentagonal_bipyramid,
'kite_pyramid': MasterSet.kite_pyramid,
'asterisk_nd': MasterSet.asterisk_nd,
'ogdoad_nd': MasterSet.ogdoad_nd,
'twisted_asterisk_s2': MasterSet.twisted_asterisk_s2,
'antiprism_3': MasterSet.antiprism_3,
'halton_asterisk': MasterSet.halton_asterisk,
'wheel_nd': MasterSet.wheel_nd,
}