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,604 @@
1
+ """The public :class:`Curve` object, the :class:`CurveList` container, and lookups.
2
+
3
+ Everything a user touches starts here. A :class:`Curve` is a light, hashable handle
4
+ onto one of the forty curves; :func:`curve` fetches one and the group functions
5
+ (:func:`catalog`, :func:`homogeneous`, ...) return a :class:`CurveList` that prints as a
6
+ tidy table.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ from .catalog import SPECS, CurveSpec, Family, spec_by_index
14
+ from .generation import curve_word
15
+
16
+ __all__ = [
17
+ "Curve",
18
+ "CurveList",
19
+ "curve",
20
+ "catalog",
21
+ "homogeneous",
22
+ "inhomogeneous",
23
+ "proper",
24
+ "improper",
25
+ "closed",
26
+ "generalizing",
27
+ "by_kernels",
28
+ ]
29
+
30
+ _KIND = {
31
+ Family.HOMOGENEOUS_PROPER: "proper",
32
+ Family.HOMOGENEOUS_IMPROPER: "improper",
33
+ Family.INHOMOGENEOUS: "inhomogeneous",
34
+ }
35
+
36
+
37
+ class Curve:
38
+ """One of the forty two-dimensional Hilbert curves.
39
+
40
+ A :class:`Curve` carries no per-order state; fetch one with :func:`curve` and then
41
+ ask it for a word, points, a plot, or a data mapping at whatever order you need.
42
+
43
+ Parameters
44
+ ----------
45
+ spec : CurveSpec
46
+ The immutable specification backing this curve. You normally never build a
47
+ :class:`Curve` yourself -- use :func:`curve`.
48
+
49
+ Attributes
50
+ ----------
51
+ index : int
52
+ The curve number, ``0``..``39``.
53
+ name : str
54
+ Human-readable name, e.g. ``"Hilbert"``, ``"Moore"``, ``"Improper1"`` or
55
+ ``"Liu2+Liu4 #1"``.
56
+ kind : str
57
+ ``"proper"``, ``"improper"`` or ``"inhomogeneous"``.
58
+
59
+ Examples
60
+ --------
61
+ >>> import hilbertplot
62
+ >>> c = hilbertplot.curve("Moore")
63
+ >>> c.name
64
+ 'Moore'
65
+ >>> c.word(2)
66
+ 'lurulurrrdldrdl'
67
+ >>> c.points(3).shape
68
+ (64, 2)
69
+ """
70
+
71
+ __slots__ = ("_spec",)
72
+
73
+ def __init__(self, spec: CurveSpec) -> None:
74
+ self._spec = spec
75
+
76
+ # -- identity ------------------------------------------------------------
77
+ @property
78
+ def index(self) -> int:
79
+ """int: The curve number in ``0``..``39``."""
80
+ return self._spec.index
81
+
82
+ @property
83
+ def name(self) -> str:
84
+ """str: Human-readable name (``"Hilbert"``, ``"Moore"``, ``"Liu2+Liu4 #1"``, ...)."""
85
+ return self._spec.name
86
+
87
+ @property
88
+ def symbol(self) -> str:
89
+ """str: The paper's ``{}_nu H`` symbol as ``"<index>H"`` (e.g. ``"0H"``)."""
90
+ return f"{self._spec.index}H"
91
+
92
+ @property
93
+ def kind(self) -> str:
94
+ """str: One of ``"proper"``, ``"improper"`` or ``"inhomogeneous"``."""
95
+ return _KIND[self._spec.family]
96
+
97
+ @property
98
+ def is_homogeneous(self) -> bool:
99
+ """bool: ``True`` for the twelve homogeneous curves (a single kernel type)."""
100
+ return self._spec.is_homogeneous
101
+
102
+ @property
103
+ def is_closed(self) -> bool:
104
+ """bool: ``True`` if the curve closes into a loop (Moore, Liu1, Improper1, Improper4)."""
105
+ return self._spec.closed
106
+
107
+ @property
108
+ def generalizes(self) -> bool:
109
+ """bool: ``True`` if the curve tiles an arbitrary ``n x n`` square (see :meth:`grid`).
110
+
111
+ Only eight of the forty curves do (proved in ``research/quasisquares/``); the rest
112
+ exist only on ``2**k`` grids. Use :func:`hilbertplot.generalizing` for the whole group.
113
+ """
114
+ from .arbitrary import FEASIBLE
115
+
116
+ return self._spec.index in FEASIBLE
117
+
118
+ @property
119
+ def kernels(self) -> frozenset[int]:
120
+ """frozenset[int]: Indices of the building-block curves used in the four quadrants."""
121
+ return self._spec.kernels
122
+
123
+ @property
124
+ def combination(self) -> str:
125
+ """str: Kernel mix in paper symbols, e.g. ``"0H"``, ``"5H"``, ``"3H+5H"``."""
126
+ return self._spec.combination
127
+
128
+ @property
129
+ def rule(self) -> str:
130
+ """str: The construction rule in the paper's tag-system notation."""
131
+ return self._spec.rule_str()
132
+
133
+ # -- construction --------------------------------------------------------
134
+ def word(self, order: int) -> str:
135
+ """Return the curve word at ``order``.
136
+
137
+ Parameters
138
+ ----------
139
+ order : int
140
+ Recursion depth ``>= 0``.
141
+
142
+ Returns
143
+ -------
144
+ str
145
+ A string of ``4**order - 1`` letters over ``{'u', 'd', 'l', 'r'}``
146
+ (up/down/left/right unit strokes).
147
+
148
+ Examples
149
+ --------
150
+ >>> import hilbertplot
151
+ >>> hilbertplot.curve(0).word(2)
152
+ 'ruluurdrurddldr'
153
+ """
154
+ return curve_word(self._spec.index, order)
155
+
156
+ def points(self, order: int) -> np.ndarray:
157
+ """Return the ordered lattice points the curve visits at ``order``.
158
+
159
+ Parameters
160
+ ----------
161
+ order : int
162
+ Recursion depth ``>= 0``.
163
+
164
+ Returns
165
+ -------
166
+ numpy.ndarray
167
+ An ``(4**order, 2)`` integer array of ``(x, y)`` cells in visit order,
168
+ shifted so the minimum corner is ``(0, 0)`` (spanning ``0``..``2**order - 1``
169
+ on each axis).
170
+
171
+ Examples
172
+ --------
173
+ >>> import hilbertplot
174
+ >>> hilbertplot.curve(0).points(1).tolist()
175
+ [[0, 0], [0, 1], [1, 1], [1, 0]]
176
+ """
177
+ from .generation import curve_codes
178
+ from .geometry import codes_to_points
179
+
180
+ return codes_to_points(curve_codes(self._spec.index, order))
181
+
182
+ def grid(self, n: int) -> np.ndarray:
183
+ """Return the curve on an arbitrary ``n x n`` square: ``(n*n, 2)`` cells, in order.
184
+
185
+ Unlike :meth:`points` (which only reaches ``2**k`` grids), ``grid`` fills any
186
+ square side ``n`` using the approximately-even-partition construction. It agrees
187
+ with :meth:`points` exactly at ``n = 2**k``.
188
+
189
+ Parameters
190
+ ----------
191
+ n : int
192
+ Grid side, any integer ``>= 1``.
193
+
194
+ Returns
195
+ -------
196
+ numpy.ndarray
197
+ An ``(n*n, 2)`` integer array of ``(x, y)`` cells in visit order.
198
+
199
+ Raises
200
+ ------
201
+ ValueError
202
+ If this curve does not generalize to arbitrary squares
203
+ (:attr:`generalizes` is ``False``), or ``n < 1``.
204
+
205
+ Examples
206
+ --------
207
+ >>> import hilbertplot
208
+ >>> hilbertplot.curve(0).grid(100).shape
209
+ (10000, 2)
210
+ """
211
+ from .arbitrary import FEASIBLE, WITNESS, square_points
212
+
213
+ idx = self._spec.index
214
+ if idx not in FEASIBLE:
215
+ raise ValueError(
216
+ f"{self.name} does not tile arbitrary squares (it fails at "
217
+ f"n={WITNESS.get(idx, '?')}); use points(order) for a 2**order grid, "
218
+ f"or pick a curve from hilbertplot.generalizing()."
219
+ )
220
+ if n < 1:
221
+ raise ValueError(f"n must be >= 1, got {n}")
222
+ return square_points(idx, n)
223
+
224
+ def unroll(self, array) -> np.ndarray:
225
+ """Read a 2-D array along this curve's path, returning a 1-D vector.
226
+
227
+ The inverse of laying data onto the plane (:func:`hilbertplot.hilbert_plot`): it walks
228
+ the curve over a square array and reads the values in visit order, so neighbouring
229
+ entries of the result are neighbours along the curve.
230
+
231
+ The array side may be **any** integer when the curve :attr:`generalizes`
232
+ (arbitrary squares via :meth:`grid`); otherwise it must be a power of two. At
233
+ ``2**k`` the two paths coincide, so a round trip with :func:`hilbertplot.hilbert_plot`
234
+ is exact either way.
235
+
236
+ Parameters
237
+ ----------
238
+ array : array_like
239
+ A square 2-D array indexed ``array[y, x]`` (row ``y``, column ``x``).
240
+
241
+ Returns
242
+ -------
243
+ numpy.ndarray
244
+ A 1-D array of length ``side * side`` in curve order.
245
+
246
+ Raises
247
+ ------
248
+ ValueError
249
+ If ``array`` is not square, or its side is not a power of two and this curve
250
+ does not generalize to arbitrary squares.
251
+
252
+ Examples
253
+ --------
254
+ >>> import numpy as np, hilbertplot
255
+ >>> data = np.arange(100) # not a power of four
256
+ >>> img = hilbertplot.hilbert_plot(0, data).image() # 10x10 (Hilbert generalizes)
257
+ >>> np.array_equal(hilbertplot.curve(0).unroll(img), data) # ...and read it back
258
+ True
259
+ """
260
+ from .._data.mapping import gather
261
+
262
+ array = np.asarray(array)
263
+ if array.ndim != 2 or array.shape[0] != array.shape[1]:
264
+ raise ValueError(f"expected a square 2-D array, got shape {array.shape!r}")
265
+ side = array.shape[0]
266
+ if side < 1:
267
+ raise ValueError("array must have side >= 1")
268
+ order = side.bit_length() - 1
269
+ if (1 << order) == side:
270
+ points = self.points(order) # power of two: fast path
271
+ elif self.generalizes:
272
+ points = self.grid(side) # arbitrary square
273
+ else:
274
+ raise ValueError(
275
+ f"array side {side} is not a power of two, and {self.name} does not "
276
+ f"generalize to arbitrary squares (see hilbertplot.generalizing())"
277
+ )
278
+ return gather(points, array)
279
+
280
+ def difference_map(self, order: int) -> np.ndarray:
281
+ """Return the curve's *difference map* at ``order`` (a locality-loss field).
282
+
283
+ At each cell the value is the mean of ``|label - neighbour_label|`` over its
284
+ up-to-8 nearest neighbours, where ``label`` is the step at which the curve
285
+ reaches a cell. Large values mark where cells that are neighbours on the plane
286
+ are far apart along the curve -- i.e. where the curve breaks locality (there is
287
+ always such a barrier between quadrants 1 and 4). Depends only on the curve, not
288
+ on any data.
289
+
290
+ Parameters
291
+ ----------
292
+ order : int
293
+ Recursion depth; the map is ``2**order`` by ``2**order``.
294
+
295
+ Returns
296
+ -------
297
+ numpy.ndarray
298
+ An ``(2**order, 2**order)`` float array, indexed ``[y, x]``.
299
+
300
+ See Also
301
+ --------
302
+ HilbertPlot.show : overlay the strong-locality barrier on a data plot with
303
+ ``difference=True``.
304
+
305
+ Examples
306
+ --------
307
+ >>> import hilbertplot
308
+ >>> hilbertplot.curve(0).difference_map(3).shape
309
+ (8, 8)
310
+ """
311
+ from .._data.mapping import difference_map
312
+
313
+ return difference_map(self.points(order), 2**order)
314
+
315
+ # -- data plotting -------------------------------------------------------
316
+ def plot_data(self, data) -> HilbertPlot: # noqa: F821 (forward ref)
317
+ """Bind a 1-D ``data`` vector to this curve, returning a :class:`HilbertPlot`.
318
+
319
+ Nothing is drawn. The returned object renders on demand -- choose the colormap,
320
+ granularity and power-of-four fit when you call its ``show``/``save`` methods.
321
+
322
+ Parameters
323
+ ----------
324
+ data : array_like
325
+ The 1-D sequence to visualise.
326
+
327
+ Returns
328
+ -------
329
+ HilbertPlot
330
+
331
+ Examples
332
+ --------
333
+ >>> import hilbertplot
334
+ >>> plot = hilbertplot.curve(0).plot_data(range(1024))
335
+ >>> plot.show(["red", "green", "tab:blue"]) # doctest: +SKIP
336
+ """
337
+ from .._viz.hilbert_plot import HilbertPlot
338
+
339
+ return HilbertPlot(self, data)
340
+
341
+ # -- curve plotting ------------------------------------------------------
342
+ def plot(self, order: int, ax=None, **style):
343
+ """Draw the curve at ``order`` and return the matplotlib ``Axes``.
344
+
345
+ Parameters
346
+ ----------
347
+ order : int
348
+ Recursion depth to render.
349
+ ax : matplotlib.axes.Axes, optional
350
+ Axes to draw on; a new figure is created when ``None``.
351
+ **style
352
+ Forwarded to the renderer (``mode``, ``color``, ``cmap``, ``linewidth``, ...).
353
+
354
+ Returns
355
+ -------
356
+ matplotlib.axes.Axes
357
+ """
358
+ from .._viz.render import draw
359
+
360
+ return draw(self, order, ax=ax, **style)
361
+
362
+ def show(self, order: int, **style) -> None:
363
+ """Draw the curve at ``order`` and open a window.
364
+
365
+ Parameters
366
+ ----------
367
+ order : int
368
+ Recursion depth to render.
369
+ **style
370
+ Forwarded to :meth:`plot`.
371
+ """
372
+ from .._viz.render import draw, pyplot
373
+
374
+ draw(self, order, ax=None, **style)
375
+ pyplot().show()
376
+
377
+ def save(self, order: int, path: str, *, dpi: int = 140, **style) -> str:
378
+ """Draw the curve at ``order``, save it to ``path`` and return ``path``.
379
+
380
+ Parameters
381
+ ----------
382
+ order : int
383
+ Recursion depth to render.
384
+ path : str
385
+ Output file path (format inferred from the extension).
386
+ dpi : int, default 140
387
+ Output resolution.
388
+ **style
389
+ Forwarded to :meth:`plot`.
390
+
391
+ Returns
392
+ -------
393
+ str
394
+ ``path``.
395
+ """
396
+ from .._viz.render import draw, pyplot
397
+
398
+ ax = draw(self, order, ax=None, **style)
399
+ ax.figure.savefig(path, dpi=dpi, bbox_inches="tight")
400
+ pyplot().close(ax.figure)
401
+ return path
402
+
403
+ # -- dunders -------------------------------------------------------------
404
+ def __eq__(self, other: object) -> bool:
405
+ return isinstance(other, Curve) and other._spec.index == self._spec.index
406
+
407
+ def __hash__(self) -> int:
408
+ return hash(self._spec.index)
409
+
410
+ def __repr__(self) -> str:
411
+ return self._spec.name
412
+
413
+ __str__ = __repr__
414
+
415
+
416
+ class CurveList(list):
417
+ """A ``list`` of :class:`Curve` objects that prints as a readable table.
418
+
419
+ Behaves exactly like a list (indexing, iteration, ``len``); only its text
420
+ representation is prettier.
421
+
422
+ Examples
423
+ --------
424
+ >>> import hilbertplot
425
+ >>> print(hilbertplot.proper()) # doctest: +NORMALIZE_WHITESPACE
426
+ # name kind closed
427
+ - ------- ------ ------
428
+ 0 Hilbert proper
429
+ 1 Moore proper yes
430
+ 2 Liu1 proper yes
431
+ 3 Liu2 proper
432
+ 4 Liu3 proper
433
+ 5 Liu4 proper
434
+ """
435
+
436
+ def __repr__(self) -> str:
437
+ if not self:
438
+ return "CurveList([])"
439
+ header = ("#", "name", "kind", "closed")
440
+ rows = [header]
441
+ rows += [(str(c.index), c.name, c.kind, "yes" if c.is_closed else "") for c in self]
442
+ widths = [max(len(row[i]) for row in rows) for i in range(len(header))]
443
+ out = []
444
+ for i, row in enumerate(rows):
445
+ out.append(" ".join(cell.ljust(widths[j]) for j, cell in enumerate(row)).rstrip())
446
+ if i == 0:
447
+ out.append(" ".join("-" * w for w in widths))
448
+ return "\n".join(out)
449
+
450
+ __str__ = __repr__
451
+
452
+
453
+ # The forty curves, built once.
454
+ _CATALOG: tuple[Curve, ...] = tuple(Curve(spec) for spec in SPECS)
455
+
456
+
457
+ def _norm(name: str) -> str:
458
+ """Normalise a name/symbol for tolerant lookup (drop case, spaces and ``#``)."""
459
+ return name.lower().replace(" ", "").replace("#", "")
460
+
461
+
462
+ # Look up by friendly name ("Liu2+Liu4 #1") or paper symbol ("12H"), both tolerant.
463
+ _BY_NAME: dict[str, Curve] = {}
464
+ for _c in _CATALOG:
465
+ _BY_NAME[_norm(_c.name)] = _c
466
+ _BY_NAME[_norm(_c.symbol)] = _c
467
+
468
+
469
+ def curve(which: int | str) -> Curve:
470
+ """Fetch a curve by index, friendly name, or paper symbol.
471
+
472
+ Parameters
473
+ ----------
474
+ which : int or str
475
+ A curve index (``0``..``39``), a friendly name (``"Moore"``,
476
+ ``"Liu2+Liu4 #1"``), or a paper symbol (``"12H"``). Name/symbol lookup is
477
+ case-insensitive and ignores spaces and ``#``, so ``"Liu2+Liu4 #1"``,
478
+ ``"liu2+liu4 1"`` and ``"12H"`` all resolve to curve 12.
479
+
480
+ Returns
481
+ -------
482
+ Curve
483
+
484
+ Raises
485
+ ------
486
+ KeyError
487
+ If no curve matches ``which``.
488
+
489
+ Examples
490
+ --------
491
+ >>> import hilbertplot
492
+ >>> hilbertplot.curve(0).name
493
+ 'Hilbert'
494
+ >>> hilbertplot.curve("moore").index
495
+ 1
496
+ """
497
+ if isinstance(which, str):
498
+ try:
499
+ return _BY_NAME[_norm(which)]
500
+ except KeyError:
501
+ raise KeyError(f"no curve named {which!r}") from None
502
+ return _CATALOG[spec_by_index(which).index]
503
+
504
+
505
+ def catalog() -> CurveList:
506
+ """Return all forty curves, ordered by index.
507
+
508
+ Returns
509
+ -------
510
+ CurveList
511
+
512
+ Examples
513
+ --------
514
+ >>> import hilbertplot
515
+ >>> len(hilbertplot.catalog())
516
+ 40
517
+ """
518
+ return CurveList(_CATALOG)
519
+
520
+
521
+ def homogeneous() -> CurveList:
522
+ """Return the twelve homogeneous curves (a single kernel type): indices 0-11.
523
+
524
+ Returns
525
+ -------
526
+ CurveList
527
+ """
528
+ return CurveList(c for c in _CATALOG if c.is_homogeneous)
529
+
530
+
531
+ def inhomogeneous() -> CurveList:
532
+ """Return the twenty-eight inhomogeneous curves (mixed kernels): indices 12-39.
533
+
534
+ Returns
535
+ -------
536
+ CurveList
537
+ """
538
+ return CurveList(c for c in _CATALOG if not c.is_homogeneous)
539
+
540
+
541
+ def proper() -> CurveList:
542
+ """Return the six proper curves (kernel Hilbert): Hilbert, Moore, Liu1-4.
543
+
544
+ Returns
545
+ -------
546
+ CurveList
547
+ """
548
+ return CurveList(c for c in _CATALOG if c.kind == "proper")
549
+
550
+
551
+ def improper() -> CurveList:
552
+ """Return the six improper curves (kernel Liu4): Improper1-6.
553
+
554
+ Returns
555
+ -------
556
+ CurveList
557
+ """
558
+ return CurveList(c for c in _CATALOG if c.kind == "improper")
559
+
560
+
561
+ def closed() -> CurveList:
562
+ """Return the curves that close into a loop: Moore, Liu1, Improper1, Improper4.
563
+
564
+ Returns
565
+ -------
566
+ CurveList
567
+ """
568
+ return CurveList(c for c in _CATALOG if c.is_closed)
569
+
570
+
571
+ def generalizing() -> CurveList:
572
+ """Return the curves that tile an arbitrary ``n x n`` square (see :meth:`Curve.grid`).
573
+
574
+ Exactly eight of the forty: Hilbert, Liu2, Liu3, Liu4, and the four composites
575
+ Hilbert+Liu3 #7/#12/#13/#14. The other thirty-two exist only on ``2**k`` grids
576
+ (proved in ``research/quasisquares/``).
577
+
578
+ Returns
579
+ -------
580
+ CurveList
581
+ """
582
+ return CurveList(c for c in _CATALOG if c.generalizes)
583
+
584
+
585
+ def by_kernels(*indices: int) -> CurveList:
586
+ """Return curves whose kernel set is exactly ``indices``.
587
+
588
+ Parameters
589
+ ----------
590
+ *indices : int
591
+ Kernel curve indices, e.g. ``by_kernels(3, 5)`` for the six Liu2+Liu4 curves.
592
+
593
+ Returns
594
+ -------
595
+ CurveList
596
+
597
+ Examples
598
+ --------
599
+ >>> import hilbertplot
600
+ >>> [c.index for c in hilbertplot.by_kernels(3, 5)]
601
+ [12, 13, 14, 15, 16, 17]
602
+ """
603
+ target = frozenset(indices)
604
+ return CurveList(c for c in _CATALOG if c.kernels == target)
@@ -0,0 +1,102 @@
1
+ """Generation of curves from the tag-system specifications.
2
+
3
+ A curve of order ``k`` is a sequence of ``4**k - 1`` unit strokes over the alphabet
4
+ ``{u, d, l, r}``. Internally each stroke is a small integer *code* (``u=0, d=1,
5
+ l=2, r=3``) so the whole construction runs on ``numpy`` arrays -- morphisms become a
6
+ 4-entry lookup, reversion an array reversal, and quadrant assembly a
7
+ ``concatenate``. There is no Python-level loop over strokes, so an order-10 curve
8
+ (over a million strokes) is built in a few tens of milliseconds.
9
+
10
+ The recursion is the paper's rule
11
+
12
+ codes_k(nu) = Q0 . [u] . Q1 . [r] . Q2 . [d] . Q3
13
+
14
+ where each quadrant recurses into its kernel curve at order ``k - 1`` and then has a
15
+ morphism and (optionally) a reversion applied. Results are memoised on
16
+ ``(index, order)`` so the shared Hilbert kernel is built once.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from functools import cache
22
+
23
+ import numpy as np
24
+
25
+ from .catalog import spec_by_index
26
+ from .symmetry import DIRECTIONS, MORPHISMS, ROT180
27
+
28
+ __all__ = ["curve_codes", "curve_word", "word_length", "clear_cache"]
29
+
30
+ # Stroke codes follow the canonical direction order: u=0, d=1, l=2, r=3.
31
+ _CODE = {letter: i for i, letter in enumerate(DIRECTIONS)}
32
+ _U, _D, _L, _R = _CODE["u"], _CODE["d"], _CODE["l"], _CODE["r"]
33
+
34
+ # Bytes translation for turning a code array back into a u/d/l/r string (C speed).
35
+ _CODE_TO_LETTER = bytes.maketrans(bytes(range(len(DIRECTIONS))), "".join(DIRECTIONS).encode())
36
+
37
+
38
+ def _code_table(sym) -> np.ndarray:
39
+ """A 4-entry lookup ``new_code = table[old_code]`` for a symmetry/morphism."""
40
+ return np.array([_CODE[sym.apply_letter(letter)] for letter in DIRECTIONS], dtype=np.uint8)
41
+
42
+
43
+ _MORPH_TABLES = {code: _code_table(sym) for code, sym in MORPHISMS.items()}
44
+ _REVERSION_TABLE = _code_table(ROT180) # reversion negates each stroke: u<->d, l<->r
45
+
46
+ # Connector strokes between quadrants 0->1 (up), 1->2 (right), 2->3 (down).
47
+ _CONNECTORS = (
48
+ np.array([_U], dtype=np.uint8),
49
+ np.array([_R], dtype=np.uint8),
50
+ np.array([_D], dtype=np.uint8),
51
+ )
52
+ _EMPTY = np.empty(0, dtype=np.uint8)
53
+
54
+
55
+ def word_length(order: int) -> int:
56
+ """Number of strokes in an order ``order`` curve: ``4**order - 1``."""
57
+ if order < 0:
58
+ raise ValueError(f"order must be non-negative, got {order}")
59
+ return 4**order - 1
60
+
61
+
62
+ @cache
63
+ def _codes(index: int, order: int) -> np.ndarray:
64
+ if order == 0:
65
+ return _EMPTY
66
+ spec = spec_by_index(index)
67
+ q = []
68
+ for rule in spec.quadrants:
69
+ sub = _codes(rule.base, order - 1)
70
+ if rule.morphism != "e":
71
+ sub = _MORPH_TABLES[rule.morphism][sub]
72
+ if rule.reverse:
73
+ sub = _REVERSION_TABLE[sub[::-1]]
74
+ q.append(sub)
75
+ out = np.concatenate((q[0], _CONNECTORS[0], q[1], _CONNECTORS[1], q[2], _CONNECTORS[2], q[3]))
76
+ out.flags.writeable = False # cached; never mutate in place
77
+ return out
78
+
79
+
80
+ def curve_codes(index: int, order: int) -> np.ndarray:
81
+ """Return the order ``order`` stroke codes for curve ``index`` as a ``uint8`` array.
82
+
83
+ The array is read-only (it is shared from the memo cache); copy it if you need to
84
+ mutate it.
85
+ """
86
+ if order < 0:
87
+ raise ValueError(f"order must be non-negative, got {order}")
88
+ return _codes(index, order)
89
+
90
+
91
+ def curve_word(index: int, order: int) -> str:
92
+ """Return the order ``order`` word for curve ``index`` (0..39) as a ``u/d/l/r`` string.
93
+
94
+ >>> curve_word(0, 2) # the classic Hilbert curve
95
+ 'ruluurdrurddldr'
96
+ """
97
+ return curve_codes(index, order).tobytes().translate(_CODE_TO_LETTER).decode("ascii")
98
+
99
+
100
+ def clear_cache() -> None:
101
+ """Drop all memoised curves (frees memory after generating high-order curves)."""
102
+ _codes.cache_clear()