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.
- hilbertplot/__init__.py +69 -0
- hilbertplot/_core/__init__.py +1 -0
- hilbertplot/_core/arbitrary.py +114 -0
- hilbertplot/_core/catalog.py +451 -0
- hilbertplot/_core/curve.py +604 -0
- hilbertplot/_core/generation.py +102 -0
- hilbertplot/_core/geometry.py +142 -0
- hilbertplot/_core/symmetry.py +186 -0
- hilbertplot/_data/__init__.py +1 -0
- hilbertplot/_data/mapping.py +255 -0
- hilbertplot/_viz/__init__.py +1 -0
- hilbertplot/_viz/hilbert_plot.py +413 -0
- hilbertplot/_viz/render.py +193 -0
- hilbertplot/py.typed +0 -0
- hilbertplot-0.0.0.dist-info/METADATA +285 -0
- hilbertplot-0.0.0.dist-info/RECORD +18 -0
- hilbertplot-0.0.0.dist-info/WHEEL +4 -0
- hilbertplot-0.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|