hilbertplot 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ """hilbertplot -- the complete set of 40 two-dimensional Hilbert curves, and data plots on them.
2
+
3
+ ``hilbertplot`` implements every two-dimensional Hilbert-type space-filling curve proved to
4
+ exist by Estevez-Rams *et al.*, "Hilbert curves in two dimensions", *Rev. Cub. Fis.*
5
+ **34**, 9 (2017): twelve homogeneous (six proper, six improper) and twenty-eight
6
+ inhomogeneous -- forty in all -- and uses them to lay long 1-D data vectors onto 2-D
7
+ images (Estevez-Rams *et al.*, *Comput. Phys. Commun.* **197** (2015) 118).
8
+
9
+ Curves
10
+ ------
11
+ >>> import hilbertplot
12
+ >>> hilbertplot.curve("Moore").show(6) # doctest: +SKIP
13
+ >>> hilbertplot.curve(0).points(3).shape
14
+ (64, 2)
15
+ >>> print(hilbertplot.proper()) # doctest: +SKIP
16
+ # name kind closed
17
+ ...
18
+
19
+ Data plots
20
+ ----------
21
+ ``plot_data`` / ``hilbert_plot`` return a ``HilbertPlot`` that holds the data and draws
22
+ nothing until you call ``.show`` / ``.save`` -- where you pick the colormap (a name or a
23
+ list of colours to interpolate), the granularity, and how to fit a length that is not a
24
+ power of four:
25
+
26
+ >>> plot = hilbertplot.hilbert_plot(0, range(1024)) # curve 0, the Hilbert curve
27
+ >>> plot.show(["red", "green", "tab:blue"]) # doctest: +SKIP
28
+ >>> plot.show("viridis", granularity=4) # doctest: +SKIP
29
+
30
+ Round trip
31
+ ----------
32
+ ``curve.unroll(image)`` reads a ``2**k`` by ``2**k`` array back into a 1-D vector in
33
+ curve order -- the inverse of the data mapping.
34
+
35
+ The public API is exactly the names in ``hilbertplot.__all__``; everything else is internal.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from ._core.curve import (
41
+ by_kernels,
42
+ catalog,
43
+ closed,
44
+ curve,
45
+ generalizing,
46
+ homogeneous,
47
+ improper,
48
+ inhomogeneous,
49
+ proper,
50
+ )
51
+ from ._viz.hilbert_plot import hilbert_plot
52
+ from ._viz.render import gallery
53
+
54
+ __version__ = "0.0.0"
55
+
56
+ __all__ = [
57
+ "__version__",
58
+ "curve",
59
+ "catalog",
60
+ "homogeneous",
61
+ "inhomogeneous",
62
+ "proper",
63
+ "improper",
64
+ "closed",
65
+ "generalizing",
66
+ "by_kernels",
67
+ "hilbert_plot",
68
+ "gallery",
69
+ ]
@@ -0,0 +1 @@
1
+ """Internal subpackage (not part of the public API)."""
@@ -0,0 +1,114 @@
1
+ """Arbitrary-size (non-power-of-two) square generation for the curves that support it.
2
+
3
+ Only eight of the forty curves can be laid on an arbitrary ``n x n`` square as a single
4
+ connected space-filling path (the rest are provably obstructed -- see
5
+ ``research/quasisquares/``). This module implements the approximately-even-partition
6
+ (AEP) construction of Wu & Chang, generalized to those eight curves via the catalog
7
+ quadrant rules and the full dihedral group ``D4``, with a per-curve *swap rule* that
8
+ keeps the path connected at odd sizes.
9
+
10
+ Public surface is the :meth:`hilbertplot.Curve.grid` method and the :func:`hilbertplot.generalizing`
11
+ group; this module is the engine behind them. ``grid(2**k)`` reproduces the standard
12
+ ``points(k)`` curve exactly for all eight, so the two are interchangeable.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+
19
+ from .catalog import SPECS
20
+ from .symmetry import MORPHISMS
21
+
22
+ #: The eight curves that tile arbitrary squares (proved in research/quasisquares/).
23
+ FEASIBLE: frozenset[int] = frozenset({0, 3, 4, 5, 24, 29, 30, 31})
24
+
25
+ #: The size-independent swap rule for each feasible curve: (width-split, height-split),
26
+ #: each in {"first" = force low half even, "second" = high half even, "none"}.
27
+ _SWAP: dict[int, tuple[str, str]] = {
28
+ 0: ("first", "first"), 3: ("first", "second"), 4: ("second", "first"),
29
+ 5: ("first", "second"), 24: ("first", "second"), 29: ("second", "first"),
30
+ 30: ("second", "first"), 31: ("first", "second"),
31
+ }
32
+
33
+ #: For each infeasible curve, a small square size at which it provably fails (for messages).
34
+ WITNESS: dict[int, int] = {
35
+ 1: 7, 2: 7, 6: 3, 7: 9, 8: 3, 9: 5, 10: 5, 11: 5, 12: 3, 13: 5, 14: 3, 15: 5,
36
+ 16: 7, 17: 5, 18: 13, 19: 7, 20: 7, 21: 13, 22: 7, 23: 9, 25: 13, 26: 7, 27: 7,
37
+ 28: 13, 32: 5, 33: 5, 34: 5, 35: 5, 36: 5, 37: 3, 38: 7, 39: 14,
38
+ }
39
+
40
+ # D4 matrices (act on column (x, y): new = M @ (x, y)) and catalog rules.
41
+ _MAT = {code: (tuple(s.matrix[0]), tuple(s.matrix[1])) for code, s in MORPHISMS.items()}
42
+ _IDENT = _MAT["e"]
43
+ _RULES = {s.index: [(q.base, _MAT[q.morphism], q.reverse) for q in s.quadrants] for s in SPECS}
44
+
45
+
46
+ def _matmul(A, B):
47
+ return ((A[0][0]*B[0][0]+A[0][1]*B[1][0], A[0][0]*B[0][1]+A[0][1]*B[1][1]),
48
+ (A[1][0]*B[0][0]+A[1][1]*B[1][0], A[1][0]*B[0][1]+A[1][1]*B[1][1]))
49
+
50
+
51
+ # Canonical base-tile visit orders (Wc, Hc) -> cells; the 2x2 is orientation A's U.
52
+ _BASE = {(1, 1): ((0, 0),), (2, 1): ((0, 0), (1, 0)), (1, 2): ((0, 0), (0, 1)),
53
+ (2, 2): ((0, 0), (0, 1), (1, 1), (1, 0))}
54
+ _ORDER = (0, 1, 2, 3)
55
+ _ORDER_REV = (3, 2, 1, 0)
56
+
57
+
58
+ def _fill(index, x0, y0, w, h, M, rev, out):
59
+ """Append the (x, y) cells of curve `index` on region (x0,y0,w,h), orientation `M`,
60
+ to the list `out` (reversed if `rev`). Hot path -- all affine maths is inlined."""
61
+ (a, b), (c, d) = M
62
+ Wc, Hc = (h, w) if a == 0 else (w, h)
63
+ # translate so the canonical box maps into the region's own frame (D4 matrices only)
64
+ ox = x0 - ((a * (Wc - 1) if a < 0 else 0) + (b * (Hc - 1) if b < 0 else 0))
65
+ oy = y0 - ((c * (Wc - 1) if c < 0 else 0) + (d * (Hc - 1) if d < 0 else 0))
66
+ if w <= 2 and h <= 2:
67
+ cells = _BASE[(Wc, Hc)]
68
+ mapped = [(ox + a * cx + b * cy, oy + c * cx + d * cy) for (cx, cy) in cells]
69
+ out.extend(reversed(mapped) if rev else mapped)
70
+ return
71
+ sw, sh = _SWAP.get(index, ("first", "first"))
72
+ Wc1 = Wc // 2; Wc2 = Wc - Wc1
73
+ if sw == "first" and Wc1 & 1: Wc1, Wc2 = Wc2, Wc1
74
+ elif sw == "second" and Wc2 & 1: Wc1, Wc2 = Wc2, Wc1
75
+ Hc1 = Hc // 2; Hc2 = Hc - Hc1
76
+ if sh == "first" and Hc1 & 1: Hc1, Hc2 = Hc2, Hc1
77
+ elif sh == "second" and Hc2 & 1: Hc1, Hc2 = Hc2, Hc1
78
+ boxes = ((0, 0, Wc1, Hc1), (0, Hc1, Wc1, Hc2), (Wc1, Hc1, Wc2, Hc2), (Wc1, 0, Wc2, Hc1))
79
+ rules = _RULES[index]
80
+ for i in (_ORDER_REV if rev else _ORDER):
81
+ base_i, morph_i, revi = rules[i]
82
+ cx0, cy0, cw, ch = boxes[i]
83
+ x1 = cx0 + cw - 1; y1 = cy0 + ch - 1
84
+ # absolute bounding box of the four transformed sub-box corners
85
+ u0 = ox + a * cx0 + b * cy0; u1 = ox + a * x1 + b * cy0
86
+ u2 = ox + a * cx0 + b * y1; u3 = ox + a * x1 + b * y1
87
+ v0 = oy + c * cx0 + d * cy0; v1 = oy + c * x1 + d * cy0
88
+ v2 = oy + c * cx0 + d * y1; v3 = oy + c * x1 + d * y1
89
+ axmin = u0 if u0 < u1 else u1
90
+ if u2 < axmin: axmin = u2
91
+ if u3 < axmin: axmin = u3
92
+ axmax = u0 if u0 > u1 else u1
93
+ if u2 > axmax: axmax = u2
94
+ if u3 > axmax: axmax = u3
95
+ aymin = v0 if v0 < v1 else v1
96
+ if v2 < aymin: aymin = v2
97
+ if v3 < aymin: aymin = v3
98
+ aymax = v0 if v0 > v1 else v1
99
+ if v2 > aymax: aymax = v2
100
+ if v3 > aymax: aymax = v3
101
+ _fill(base_i, axmin, aymin, axmax - axmin + 1, aymax - aymin + 1,
102
+ _matmul(M, morph_i), revi ^ rev, out)
103
+
104
+
105
+ def square_points(index: int, n: int) -> np.ndarray:
106
+ """Return the ``(n*n, 2)`` cell path of feasible curve ``index`` on an ``n x n`` grid.
107
+
108
+ The caller is responsible for checking ``index in FEASIBLE`` and ``n >= 1``.
109
+ """
110
+ if n == 1:
111
+ return np.zeros((1, 2), dtype=np.int64)
112
+ out: list = []
113
+ _fill(index, 0, 0, n, n, _IDENT, False, out)
114
+ return np.array(out, dtype=np.int64)
@@ -0,0 +1,451 @@
1
+ """Declarative specification of the forty two-dimensional Hilbert curves.
2
+
3
+ This module is pure data plus the small dataclasses that give it structure. It is
4
+ the single source of truth for the whole library and maps one-to-one onto the tag
5
+ systems of Estevez-Rams *et al.*, "Hilbert curves in two dimensions",
6
+ *Rev. Cub. Fis.* **34**, 9 (2017).
7
+
8
+ The construction
9
+ ----------------
10
+ Every order ``k`` curve is assembled from four sub-curves of order ``k - 1`` -- one
11
+ per quadrant -- joined by the fixed connectors ``u``, ``r``, ``d``::
12
+
13
+ word_k(nu) = Q0 . 'u' . Q1 . 'r' . Q2 . 'd' . Q3
14
+
15
+ The quadrants are numbered as in Fig. 1 of the paper, clockwise from the lower
16
+ left: ``0`` = bottom-left, ``1`` = top-left, ``2`` = top-right, ``3`` =
17
+ bottom-right. The connectors therefore trace ``0 -(up)-> 1 -(right)-> 2 -(down)->
18
+ 3``.
19
+
20
+ Each quadrant ``Qi`` is obtained from a *kernel* curve ``base`` (also of order
21
+ ``k - 1``) by applying a morphism and, optionally, a reversion:
22
+
23
+ Qi = [reversion?] ( morphism ( word_{k-1}(base) ) )
24
+
25
+ The classification the paper draws -- and that the user asked us to reflect -- falls
26
+ straight out of the kernels:
27
+
28
+ * **homogeneous** curves use a single kernel type in all four quadrants
29
+ (Definition III.1: "built from one and only one Hilbert curve of order k-1");
30
+ * **inhomogeneous** curves mix at least two kernel types (Definition VI.1).
31
+
32
+ Homogeneous curves split further into *proper* (kernel = the Hilbert curve ``0H``)
33
+ and *improper* (kernel = the Liu-4 curve ``5H``).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import enum
39
+ from collections.abc import Mapping
40
+ from dataclasses import dataclass, field, replace
41
+
42
+ __all__ = [
43
+ "Family",
44
+ "QuadrantRule",
45
+ "CurveSpec",
46
+ "SPECS",
47
+ "spec_by_index",
48
+ ]
49
+
50
+
51
+ class Family(enum.Enum):
52
+ """The three families the forty curves fall into."""
53
+
54
+ HOMOGENEOUS_PROPER = "homogeneous-proper"
55
+ HOMOGENEOUS_IMPROPER = "homogeneous-improper"
56
+ INHOMOGENEOUS = "inhomogeneous"
57
+
58
+ @property
59
+ def is_homogeneous(self) -> bool:
60
+ return self is not Family.INHOMOGENEOUS
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class QuadrantRule:
65
+ """How one quadrant of an order ``k`` curve is built from an order ``k-1`` curve.
66
+
67
+ Parameters
68
+ ----------
69
+ base:
70
+ Index of the *kernel* curve supplying this quadrant's sub-curve. Only the
71
+ homogeneous building blocks ``{0, 1, 3, 4, 5}`` (and never ``2``) ever
72
+ appear here, exactly as proved in the paper.
73
+ morphism:
74
+ Single-letter morphism code (see :data:`hilbertplot._core.symmetry.MORPHISMS`).
75
+ reverse:
76
+ Whether the reversion :math:`\\bar p` is applied after the morphism.
77
+ """
78
+
79
+ base: int
80
+ morphism: str
81
+ reverse: bool = False
82
+
83
+ def __str__(self) -> str:
84
+ inner = f"{self.base}H"
85
+ if self.morphism != "e":
86
+ inner = f"d_{self.morphism}({inner})"
87
+ return f"rev({inner})" if self.reverse else inner
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class CurveSpec:
92
+ """Immutable description of one of the forty curves.
93
+
94
+ Attributes
95
+ ----------
96
+ index:
97
+ The curve number ``nu`` in ``0..39`` (the paper's :math:`{}_\\nu H`).
98
+ name:
99
+ Human-readable name (``"Hilbert"``, ``"Moore"``, ``"Liu1"``, ..., ``"I1"``,
100
+ ``"IH12"`` ...).
101
+ family:
102
+ Which :class:`Family` the curve belongs to.
103
+ quadrants:
104
+ The four :class:`QuadrantRule` s, in quadrant order ``0, 1, 2, 3``.
105
+ combination:
106
+ Short label of the kernel mix (e.g. ``"0H"``, ``"5H"``, ``"3H+5H"``), matching
107
+ the paper's grouping of the inhomogeneous curves.
108
+ closed:
109
+ ``True`` if the curve's entry and exit lie in adjacent sub-squares so that the
110
+ infinite curve closes into a loop (Moore, Liu1, I1, I4; Table 3 of the paper).
111
+ """
112
+
113
+ index: int
114
+ name: str
115
+ family: Family
116
+ quadrants: tuple[QuadrantRule, QuadrantRule, QuadrantRule, QuadrantRule]
117
+ combination: str
118
+ closed: bool = False
119
+ #: Optional free-form geometric notes lifted from Table 3 of the paper.
120
+ notes: str = field(default="", compare=False)
121
+
122
+ @property
123
+ def kernels(self) -> frozenset[int]:
124
+ """The set of distinct kernel curve indices used across the four quadrants."""
125
+ return frozenset(q.base for q in self.quadrants)
126
+
127
+ @property
128
+ def is_homogeneous(self) -> bool:
129
+ return self.family.is_homogeneous
130
+
131
+ def rule_str(self) -> str:
132
+ """Reconstruct the paper's tag-system line for this curve."""
133
+ q0, q1, q2, q3 = self.quadrants
134
+ return f"{self.index}h(k) = {q0} u {q1} r {q2} d {q3}"
135
+
136
+
137
+ def _q(base: int, morphism: str = "e", reverse: bool = False) -> QuadrantRule:
138
+ return QuadrantRule(base, morphism, reverse)
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # The forty curves.
143
+ #
144
+ # Transcribed directly from the tag systems in sections V and VII of the paper.
145
+ # Homogeneous proper curves are built over the Hilbert kernel 0H; improper curves
146
+ # over the Liu-4 kernel 5H; inhomogeneous curves mix kernels. A bar over a term in
147
+ # the paper (reversion) is recorded as ``reverse=True``.
148
+ # ---------------------------------------------------------------------------
149
+
150
+ _PROPER: tuple[CurveSpec, ...] = (
151
+ CurveSpec(
152
+ 0,
153
+ "Hilbert",
154
+ Family.HOMOGENEOUS_PROPER,
155
+ (_q(0, "o"), _q(0), _q(0), _q(0, "a")),
156
+ "0H",
157
+ notes="corner-corner; the original Hilbert curve",
158
+ ),
159
+ CurveSpec(
160
+ 1,
161
+ "Moore",
162
+ Family.HOMOGENEOUS_PROPER,
163
+ (_q(0, "g"), _q(0, "g"), _q(0, "x"), _q(0, "x")),
164
+ "0H",
165
+ closed=True,
166
+ notes="edge-edge shared; Moore's closed curve",
167
+ ),
168
+ CurveSpec(
169
+ 2,
170
+ "Liu1",
171
+ Family.HOMOGENEOUS_PROPER,
172
+ (_q(0, "f"), _q(0), _q(0), _q(0, "f")),
173
+ "0H",
174
+ closed=True,
175
+ notes="interior-interior; cannot serve as a building block",
176
+ ),
177
+ CurveSpec(
178
+ 3,
179
+ "Liu2",
180
+ Family.HOMOGENEOUS_PROPER,
181
+ (_q(0, "m"), _q(0, "g"), _q(0, "x"), _q(0, "m")),
182
+ "0H",
183
+ notes="edge-edge opposed",
184
+ ),
185
+ CurveSpec(
186
+ 4,
187
+ "Liu3",
188
+ Family.HOMOGENEOUS_PROPER,
189
+ (_q(0, "o"), _q(0), _q(0), _q(0, "f")),
190
+ "0H",
191
+ notes="corner-interior",
192
+ ),
193
+ CurveSpec(
194
+ 5,
195
+ "Liu4",
196
+ Family.HOMOGENEOUS_PROPER,
197
+ (_q(0, "m"), _q(0, "g"), _q(0, "x"), _q(0, "x")),
198
+ "0H",
199
+ notes="edge-edge adjacent; kernel of every improper curve",
200
+ ),
201
+ )
202
+
203
+ _IMPROPER: tuple[CurveSpec, ...] = (
204
+ CurveSpec(
205
+ 6,
206
+ "I1",
207
+ Family.HOMOGENEOUS_IMPROPER,
208
+ (_q(5, "f"), _q(5, "y", True), _q(5), _q(5, "m", True)),
209
+ "5H",
210
+ closed=True,
211
+ notes="interior-interior",
212
+ ),
213
+ CurveSpec(
214
+ 7,
215
+ "I2",
216
+ Family.HOMOGENEOUS_IMPROPER,
217
+ (_q(5, "f"), _q(5, "y", True), _q(5), _q(5, "a")),
218
+ "5H",
219
+ notes="interior-edge",
220
+ ),
221
+ CurveSpec(
222
+ 8,
223
+ "I3",
224
+ Family.HOMOGENEOUS_IMPROPER,
225
+ (_q(5, "x", True), _q(5, "y", True), _q(5), _q(5, "a")),
226
+ "5H",
227
+ notes="edge-edge opposed",
228
+ ),
229
+ CurveSpec(
230
+ 9,
231
+ "I4",
232
+ Family.HOMOGENEOUS_IMPROPER,
233
+ (_q(5, "a", True), _q(5, "g"), _q(5, "o", True), _q(5, "x")),
234
+ "5H",
235
+ closed=True,
236
+ notes="interior-interior",
237
+ ),
238
+ CurveSpec(
239
+ 10,
240
+ "I5",
241
+ Family.HOMOGENEOUS_IMPROPER,
242
+ (_q(5, "m"), _q(5, "g"), _q(5, "o", True), _q(5, "f", True)),
243
+ "5H",
244
+ notes="edge-edge opposed",
245
+ ),
246
+ CurveSpec(
247
+ 11,
248
+ "I6",
249
+ Family.HOMOGENEOUS_IMPROPER,
250
+ (_q(5, "m"), _q(5, "g"), _q(5, "o", True), _q(5, "x")),
251
+ "5H",
252
+ notes="edge-interior",
253
+ ),
254
+ )
255
+
256
+ # Inhomogeneous curves 12-39. Grouped exactly as in the paper (Figures 6-9).
257
+ _INHOMOGENEOUS: tuple[CurveSpec, ...] = (
258
+ # 3H + 5H (Figure 6): two 3H + two 5H, then one 3H + three 5H.
259
+ CurveSpec(
260
+ 12, "IH12", Family.INHOMOGENEOUS, (_q(3, "o"), _q(5, "y", True), _q(5), _q(3, "a")), "3H+5H"
261
+ ),
262
+ CurveSpec(
263
+ 13,
264
+ "IH13",
265
+ Family.INHOMOGENEOUS,
266
+ (_q(3, "g"), _q(5, "g"), _q(5, "o", True), _q(3, "x")),
267
+ "3H+5H",
268
+ ),
269
+ CurveSpec(
270
+ 14, "IH14", Family.INHOMOGENEOUS, (_q(3, "o"), _q(5, "y", True), _q(5), _q(5, "a")), "3H+5H"
271
+ ),
272
+ CurveSpec(
273
+ 15,
274
+ "IH15",
275
+ Family.INHOMOGENEOUS,
276
+ (_q(3, "g"), _q(5, "g"), _q(5, "o", True), _q(5, "f", True)),
277
+ "3H+5H",
278
+ ),
279
+ CurveSpec(
280
+ 16,
281
+ "IH16",
282
+ Family.INHOMOGENEOUS,
283
+ (_q(3, "o"), _q(5, "y", True), _q(5), _q(5, "m", True)),
284
+ "3H+5H",
285
+ ),
286
+ CurveSpec(
287
+ 17,
288
+ "IH17",
289
+ Family.INHOMOGENEOUS,
290
+ (_q(3, "g"), _q(5, "g"), _q(5, "o", True), _q(5, "x")),
291
+ "3H+5H",
292
+ ),
293
+ # 0H + 4H (Figure 7): fourteen curves.
294
+ CurveSpec(
295
+ 18, "IH18", Family.INHOMOGENEOUS, (_q(4, "x", True), _q(0), _q(0), _q(4, "a")), "0H+4H"
296
+ ),
297
+ CurveSpec(
298
+ 19, "IH19", Family.INHOMOGENEOUS, (_q(4, "m", True), _q(0), _q(0), _q(4, "f")), "0H+4H"
299
+ ),
300
+ CurveSpec(
301
+ 20, "IH20", Family.INHOMOGENEOUS, (_q(4, "x", True), _q(0), _q(0), _q(4, "f")), "0H+4H"
302
+ ),
303
+ CurveSpec(
304
+ 21,
305
+ "IH21",
306
+ Family.INHOMOGENEOUS,
307
+ (_q(4, "f", True), _q(0, "g"), _q(0, "x"), _q(4, "m")),
308
+ "0H+4H",
309
+ ),
310
+ CurveSpec(
311
+ 22,
312
+ "IH22",
313
+ Family.INHOMOGENEOUS,
314
+ (_q(4, "a", True), _q(0, "g"), _q(0, "x"), _q(4, "x")),
315
+ "0H+4H",
316
+ ),
317
+ CurveSpec(
318
+ 23,
319
+ "IH23",
320
+ Family.INHOMOGENEOUS,
321
+ (_q(4, "a", True), _q(0, "g"), _q(0, "x"), _q(4, "m")),
322
+ "0H+4H",
323
+ ),
324
+ CurveSpec(
325
+ 24, "IH24", Family.INHOMOGENEOUS, (_q(0, "m"), _q(0, "g"), _q(0, "x"), _q(4, "x")), "0H+4H"
326
+ ),
327
+ CurveSpec(
328
+ 25, "IH25", Family.INHOMOGENEOUS, (_q(0, "g"), _q(0, "g"), _q(0, "x"), _q(4, "m")), "0H+4H"
329
+ ),
330
+ CurveSpec(
331
+ 26, "IH26", Family.INHOMOGENEOUS, (_q(0, "g"), _q(0, "g"), _q(0, "x"), _q(4, "x")), "0H+4H"
332
+ ),
333
+ CurveSpec(27, "IH27", Family.INHOMOGENEOUS, (_q(0, "f"), _q(0), _q(0), _q(4, "f")), "0H+4H"),
334
+ CurveSpec(28, "IH28", Family.INHOMOGENEOUS, (_q(0, "f"), _q(0), _q(0), _q(4, "a")), "0H+4H"),
335
+ CurveSpec(29, "IH29", Family.INHOMOGENEOUS, (_q(0, "o"), _q(0), _q(0), _q(4, "f")), "0H+4H"),
336
+ CurveSpec(30, "IH30", Family.INHOMOGENEOUS, (_q(0, "o"), _q(0), _q(0), _q(4, "a")), "0H+4H"),
337
+ CurveSpec(
338
+ 31, "IH31", Family.INHOMOGENEOUS, (_q(0, "m"), _q(0, "g"), _q(0, "x"), _q(4, "m")), "0H+4H"
339
+ ),
340
+ # 1H + 5H (Figure 8): two 1H + two 5H, then one 1H + three 5H.
341
+ CurveSpec(
342
+ 32, "IH32", Family.INHOMOGENEOUS, (_q(1, "f"), _q(5, "y", True), _q(5), _q(1, "f")), "1H+5H"
343
+ ),
344
+ CurveSpec(
345
+ 33,
346
+ "IH33",
347
+ Family.INHOMOGENEOUS,
348
+ (_q(1, "m"), _q(5, "g"), _q(5, "o", True), _q(1, "m")),
349
+ "1H+5H",
350
+ ),
351
+ CurveSpec(
352
+ 34, "IH34", Family.INHOMOGENEOUS, (_q(5, "f"), _q(5, "y", True), _q(5), _q(1, "f")), "1H+5H"
353
+ ),
354
+ CurveSpec(
355
+ 35,
356
+ "IH35",
357
+ Family.INHOMOGENEOUS,
358
+ (_q(5, "a", True), _q(5, "g"), _q(5, "o", True), _q(1, "m")),
359
+ "1H+5H",
360
+ ),
361
+ CurveSpec(
362
+ 36,
363
+ "IH36",
364
+ Family.INHOMOGENEOUS,
365
+ (_q(5, "x", True), _q(5, "y", True), _q(5), _q(1, "f")),
366
+ "1H+5H",
367
+ ),
368
+ CurveSpec(
369
+ 37,
370
+ "IH37",
371
+ Family.INHOMOGENEOUS,
372
+ (_q(5, "m"), _q(5, "g"), _q(5, "o", True), _q(1, "m")),
373
+ "1H+5H",
374
+ ),
375
+ # 1H + 3H + 5H (Figure 9): two curves.
376
+ CurveSpec(
377
+ 38,
378
+ "IH38",
379
+ Family.INHOMOGENEOUS,
380
+ (_q(3, "o"), _q(5, "y", True), _q(5), _q(1, "f")),
381
+ "1H+3H+5H",
382
+ ),
383
+ CurveSpec(
384
+ 39,
385
+ "IH39",
386
+ Family.INHOMOGENEOUS,
387
+ (_q(3, "g"), _q(5, "g"), _q(5, "o", True), _q(1, "m")),
388
+ "1H+3H+5H",
389
+ ),
390
+ )
391
+
392
+ # Friendly, human-readable names. Proper curves keep their established names
393
+ # (Hilbert, Moore, Liu1-4); improper curves become Improper1-6; and each
394
+ # inhomogeneous curve is named by the curves it mixes plus an index within the
395
+ # group, e.g. "Liu2+Liu4 #1" -- so the name states the recipe.
396
+ _PROPER_NAME = {0: "Hilbert", 1: "Moore", 2: "Liu1", 3: "Liu2", 4: "Liu3", 5: "Liu4"}
397
+
398
+
399
+ def _with_friendly_names(specs: tuple[CurveSpec, ...]) -> tuple[CurveSpec, ...]:
400
+ counters: dict[str, int] = {}
401
+ out = []
402
+ for s in specs:
403
+ if s.family is Family.HOMOGENEOUS_IMPROPER:
404
+ s = replace(s, name=f"Improper{s.index - 5}")
405
+ elif s.family is Family.INHOMOGENEOUS:
406
+ recipe = "+".join(_PROPER_NAME[k] for k in sorted(s.kernels))
407
+ counters[recipe] = counters.get(recipe, 0) + 1
408
+ s = replace(s, name=f"{recipe} #{counters[recipe]}")
409
+ out.append(s)
410
+ return tuple(out)
411
+
412
+
413
+ #: All forty specifications, ordered by index.
414
+ SPECS: tuple[CurveSpec, ...] = _with_friendly_names(_PROPER + _IMPROPER + _INHOMOGENEOUS)
415
+
416
+ _BY_INDEX: Mapping[int, CurveSpec] = {s.index: s for s in SPECS}
417
+
418
+
419
+ def spec_by_index(index: int) -> CurveSpec:
420
+ """Return the :class:`CurveSpec` for curve number ``index`` (0..39)."""
421
+ try:
422
+ return _BY_INDEX[index]
423
+ except KeyError:
424
+ raise KeyError(
425
+ f"curve index {index!r} out of range; expected an integer in 0..39"
426
+ ) from None
427
+
428
+
429
+ # --- internal consistency checks (cheap, run once at import) ----------------
430
+ # These guard against typos in the table above: the declared family must agree
431
+ # with what the kernels imply, and only valid building blocks may appear.
432
+ def _self_check() -> None:
433
+ assert len(SPECS) == 40, f"expected 40 curves, found {len(SPECS)}"
434
+ assert [s.index for s in SPECS] == list(range(40)), "curve indices must be 0..39"
435
+ valid_kernels = {0, 1, 3, 4, 5} # never 2 (Liu1 cannot connect quadrants)
436
+ for s in SPECS:
437
+ assert len(s.quadrants) == 4, f"curve {s.index} needs four quadrants"
438
+ assert s.kernels <= valid_kernels, (
439
+ f"curve {s.index} uses invalid kernel(s) {s.kernels - valid_kernels}"
440
+ )
441
+ implied_homogeneous = len(s.kernels) == 1
442
+ assert implied_homogeneous == s.is_homogeneous, (
443
+ f"curve {s.index}: family {s.family} disagrees with kernels {set(s.kernels)}"
444
+ )
445
+ if s.family is Family.HOMOGENEOUS_PROPER:
446
+ assert s.kernels == {0}, f"proper curve {s.index} must use kernel 0H"
447
+ elif s.family is Family.HOMOGENEOUS_IMPROPER:
448
+ assert s.kernels == {5}, f"improper curve {s.index} must use kernel 5H"
449
+
450
+
451
+ _self_check()