diff --git a/USAGE.md b/USAGE.md index 423dc7f..ffc6a12 100644 --- a/USAGE.md +++ b/USAGE.md @@ -54,19 +54,28 @@ decimal — use `MortonIndexArray.decimal_repr()` for the readable form. ## Resolution Orders -Morton encoding supports tessellation orders from 0 to 29. The `res2display()` function shows orders 0-19 for reference: +Morton encoding supports tessellation orders from 0 to 29. `res2display()` returns +the resolution ladder as records — one `ResolutionLevel(order, value, unit, km)` +per order, with `value`/`unit` the display pair and `km` the unrounded resolution: ```python from mortie import res2display -# View available resolutions -res2display() +levels = res2display() -# Output: -# 6514.02758 km at tessellation order 0 -# 3257.013790 km at tessellation order 1 -# ... -# 0.00006361 km at tessellation order 18 +levels[0] +# ResolutionLevel(order=0, value=6519.623, unit='km', km=6519.623461602107) + +# Format them however you like: +for lvl in res2display(max_order=2): + print(f"{lvl.value} {lvl.unit} at tessellation order {lvl.order}") +# 6519.623 km at tessellation order 0 +# 3259.812 km at tessellation order 1 +# 1629.906 km at tessellation order 2 + +# The unit ladder switches to m below 1 km and cm below 1 m: +levels[18] +# ResolutionLevel(order=18, value=24.87, unit='m', km=0.024870389791878156) ``` Example with different orders: @@ -202,18 +211,22 @@ inverse of `mort2norm`). **Returns:** - Packed morton word(s) as int64 -### `clip2order(clip_order, midx=None, print_factor=False)` +### `clip2order(clip_order, midx)` Coarsen packed morton words to a lower resolution (kernel coarsen). **Parameters:** - `clip_order` (int): Target resolution order - `midx` (array): Packed morton words to coarsen -- `print_factor` (bool): If True, return the level count dropped from order 18 - (`18 - clip_order`) instead of coarsening **Returns:** -- Coarsened morton words or the level count +- Coarsened morton words, one per input word + +> The `print_factor` flag was removed for the 1.x freeze. It returned +> `18 - clip_order`, a level count anchored to the retired decimal encoding's +> order-18 ceiling, so it went negative for the order-19..29 words this package +> now encodes. The levels a word actually drops is `order - clip_order` against +> its own decoded order, available from `orders_of()`. ### `order2res(order)` @@ -225,9 +238,15 @@ Calculate approximate resolution in km for a given order. **Returns:** - Resolution in kilometers (float) -### `res2display()` +### `res2display(max_order=29)` -Print resolution table for all tessellation orders (0-19). +Return the resolution ladder for tessellation orders `0..max_order` as a list of +`ResolutionLevel(order, value, unit, km)` named tuples. `value`/`unit` are the +display pair (km, m or cm, rounded to three decimals within the bracket); `km` is +the unrounded resolution for arithmetic. + +**Returns:** +- `list[ResolutionLevel]` ### `split_children(morton_array, max_depth=4)` diff --git a/examples/ExampleUsage.ipynb b/examples/ExampleUsage.ipynb index 98d2417..0bf3664 100644 --- a/examples/ExampleUsage.ipynb +++ b/examples/ExampleUsage.ipynb @@ -509,7 +509,7 @@ "order = 9\n", "uniq = mt.geo2uniq(b4.Lat.values, b4.Lon.values, order)\n", "parents = mt.unique2parent(uniq)\n", - "normed = mt.heal_norm(parents,order, uniq)\n", + "normed = uniq - parents * 4**order # heal_norm was removed (issue #68)\n", "\n", "mortons6 = mt.norm2mort(normed.ravel(), parents.ravel(), order)\n", "print(len(np.unique(mortons6).ravel()))\n", diff --git a/mortie/__init__.py b/mortie/__init__.py index ff68d6c..b612360 100644 --- a/mortie/__init__.py +++ b/mortie/__init__.py @@ -50,7 +50,6 @@ generate_morton_children, geo2mort, geo2uniq, - heal_norm, # Inverse functions infer_order_from_morton, is_point, @@ -86,7 +85,6 @@ 'order2res', 'res2display', 'unique2parent', - 'heal_norm', 'norm2mort', 'geo2uniq', 'clip2order', diff --git a/mortie/prefix_trie.py b/mortie/prefix_trie.py index e0a1468..cc3ebb8 100644 --- a/mortie/prefix_trie.py +++ b/mortie/prefix_trie.py @@ -44,6 +44,17 @@ class MortonChild: a characteristic prefix string. Children are created lazily when the column under the mask diverges. + Obtain nodes from :func:`split_children` / :func:`split_children_geo` + (or the ``morton_polygon*`` helpers); **do not construct them + directly.** The read surface below is the frozen 1.x contract: + ``characteristic``, ``len``, ``children``, ``nchildren``, + :attr:`mantissa_array` and :attr:`cell_area`. The constructor is + internal and its signature is *not* part of that contract -- it takes + the trie's internal representation (a shared character array plus row + masks), which is free to change. The production path does not use it + at all: ``split_children`` builds nodes in Rust and rebuilds them via + ``__new__``, bypassing ``__init__`` entirely. + Parameters ---------- char_array : ndarray of shape (N, L), dtype='U1' @@ -229,7 +240,7 @@ def split_children(morton_array, max_depth=4): Parameters ---------- morton_array : array-like of int - Morton indices (signed integers). + Morton indices (packed ``uint64`` words; base cells 7-11 set bit 63). max_depth : int or None Maximum branching depth. ``None`` means full recursion. Default is 4. @@ -308,7 +319,7 @@ def morton_polygon_from_array(morton_array, n_cells, max_depth=None): Parameters ---------- morton_array : array-like of int - Morton indices (signed integers). + Morton indices (packed ``uint64`` words; base cells 7-11 set bit 63). n_cells : int Maximum number of cells in the returned list. max_depth : int or None diff --git a/mortie/tests/test_main_api.py b/mortie/tests/test_main_api.py index 639d788..f5e1480 100644 --- a/mortie/tests/test_main_api.py +++ b/mortie/tests/test_main_api.py @@ -62,7 +62,6 @@ def test_all_main_imports(self): clip2order, geo2mort, geo2uniq, - heal_norm, norm2mort, order2res, res2display, @@ -74,7 +73,6 @@ def test_all_main_imports(self): assert callable(geo2uniq) assert callable(clip2order) assert callable(unique2parent) - assert callable(heal_norm) assert callable(norm2mort) assert callable(order2res) assert callable(res2display) diff --git a/mortie/tests/test_tools.py b/mortie/tests/test_tools.py index 991c1a7..f08aa65 100644 --- a/mortie/tests/test_tools.py +++ b/mortie/tests/test_tools.py @@ -53,47 +53,46 @@ def test_order2res_decreasing(self): class TestRes2Display: - """Test the resolution-ladder printer""" - - def test_prints_all_orders_through_max(self, capsys): - """One line per order 0..MAX_ORDER -- no silent drop above 19""" - tools.res2display() - lines = capsys.readouterr().out.strip().split('\n') - assert len(lines) == tools.MAX_ORDER + 1 - # every order 0..29 appears with its trailing phrasing - for order in range(tools.MAX_ORDER + 1): - assert any( - line.endswith('at tessellation order ' + str(order)) - for line in lines - ) - - def test_max_order_argument(self, capsys): - """max_order bounds the printed range inclusively""" - tools.res2display(max_order=5) - lines = capsys.readouterr().out.strip().split('\n') - assert len(lines) == 6 - assert lines[-1].endswith('at tessellation order 5') - - def test_unit_ladder_km_m_cm(self, capsys): + """Test the resolution ladder (returns records; issue #68)""" + + def test_returns_one_record_per_order_through_max(self): + """One record per order 0..MAX_ORDER -- no silent drop above 19""" + levels = tools.res2display() + assert len(levels) == tools.MAX_ORDER + 1 + assert [lvl.order for lvl in levels] == list(range(tools.MAX_ORDER + 1)) + + def test_returns_data_not_none(self, capsys): + """The ladder is returned, and nothing is printed (issue #68)""" + levels = tools.res2display(max_order=3) + assert capsys.readouterr().out == "" + assert isinstance(levels, list) + assert levels[0]._fields == ("order", "value", "unit", "km") + + def test_max_order_argument(self): + """max_order bounds the range inclusively""" + levels = tools.res2display(max_order=5) + assert len(levels) == 6 + assert levels[-1].order == 5 + + def test_unit_ladder_km_m_cm(self): """Coarse orders read in km, sub-km in m, sub-m in cm""" - tools.res2display() - lines = capsys.readouterr().out.strip().split('\n') - by_order = {int(line.rsplit(' ', 1)[1]): line for line in lines} + by_order = {lvl.order: lvl for lvl in tools.res2display()} # order 12 = 1.592 km, order 13 = 795.852 m (unified sphere, issue #119) - assert by_order[12] == '1.592 km at tessellation order 12' - assert by_order[13] == '795.852 m at tessellation order 13' + assert (by_order[12].value, by_order[12].unit) == (1.592, 'km') + assert (by_order[13].value, by_order[13].unit) == (795.852, 'm') # finest orders drop to cm rather than tiny km/m fractions - assert by_order[25] == '19.43 cm at tessellation order 25' - assert by_order[29] == '1.214 cm at tessellation order 29' + assert (by_order[25].value, by_order[25].unit) == (19.43, 'cm') + assert (by_order[29].value, by_order[29].unit) == (1.214, 'cm') - def test_rounds_within_bracket(self, capsys): + def test_rounds_within_bracket(self): """Values are rounded to three decimals inside the chosen unit""" - tools.res2display() - lines = capsys.readouterr().out.strip().split('\n') - for line in lines: - number = line.split(' ', 1)[0] - # at most three digits after the decimal point - assert '.' in number and len(number.split('.')[1]) <= 3 + for lvl in tools.res2display(): + assert round(lvl.value, 3) == lvl.value + + def test_km_field_is_unrounded_order2res(self): + """The km field is the raw resolution, for callers doing arithmetic""" + for lvl in tools.res2display(max_order=6): + assert lvl.km == tools.order2res(lvl.order) def test_out_of_range_raises(self): """max_order outside 0..MAX_ORDER is rejected""" @@ -147,46 +146,6 @@ def test_unique2parent_mixed_resolution_raises(self): tools.unique2parent(uniq_mixed) -class TestHealNorm: - """Test HEALPix address normalization""" - - def test_heal_norm_basic(self): - """Test basic normalization""" - order = 6 - nside = 2**order - N_pix = nside**2 - - base = 2 - addr_nest = np.array([2*N_pix + 100, 2*N_pix + 200]) - - normed = tools.heal_norm(base, order, addr_nest) - - # Should be offset by base * N_pix - expected = addr_nest - (base * N_pix) - assert_array_equal(normed, expected) - - def test_heal_norm_zero_offset(self): - """Test normalization with base 0""" - order = 6 - addr_nest = np.array([100, 200, 300]) - - normed = tools.heal_norm(0, order, addr_nest) - - # With base=0, should be unchanged - assert_array_equal(normed, addr_nest) - - def test_heal_norm_deterministic(self): - """Test determinism""" - order = 8 - base = 5 - addr_nest = np.array([1000, 2000, 3000]) - - result1 = tools.heal_norm(base, order, addr_nest) - result2 = tools.heal_norm(base, order, addr_nest) - - assert_array_equal(result1, result2) - - class TestGeo2Uniq: """Test geographic to UNIQ conversion""" @@ -433,9 +392,20 @@ def test_norm2mort_order29_native(self): class TestClip2Order: """Test resolution clipping (kernel coarsen).""" - def test_clip2order_factor(self): - """print_factor returns the level count dropped from order 18.""" - assert tools.clip2order(12, print_factor=True) == 18 - 12 + def test_clip2order_rejects_removed_print_factor(self): + """The order-18-anchored print_factor flag is gone (issue #68). + + It returned ``18 - clip_order``, which went negative for the + order-19..29 words this package now encodes. Pinned so the flag + cannot quietly return. + """ + with pytest.raises(TypeError): + tools.clip2order(12, print_factor=True) + + def test_clip2order_requires_words(self): + """midx is now required -- there is no word-less call form.""" + with pytest.raises(TypeError): + tools.clip2order(12) def test_clip2order_clipping(self): """Clipping coarsens packed words to the target order.""" diff --git a/mortie/tools.py b/mortie/tools.py index b3029d3..15fe1a5 100644 --- a/mortie/tools.py +++ b/mortie/tools.py @@ -1,12 +1,17 @@ -""" -functions for morton indexing -""" +"""Functions for morton indexing.""" + +from collections import namedtuple import numpy as np from . import _healpix as hp from . import _rustie +# One row of the res2display resolution ladder (issue #68): the display pair +# (value + unit, rounded within its bracket) alongside the unrounded km, so +# callers can format or compute without re-deriving either. +ResolutionLevel = namedtuple("ResolutionLevel", "order value unit km") + # Mean Earth radius (km), the exact HEALPix sphere the spec page's resolution # table (docs/specification.md §3) derives from. order2res is the RMS cell # spacing on this sphere; unified with the page in issue #119. @@ -45,22 +50,47 @@ def order2res(order): def res2display(max_order=MAX_ORDER): - '''prints resolution levels + """Resolution ladder for tessellation orders 0 through ``max_order``. - Prints one line per tessellation order from 0 through ``max_order`` - (default MAX_ORDER = 29, the finest order the packed-u64 kernel reaches). - Each resolution is rendered in the largest sensible unit -- km at coarse - orders, m once it drops below 1 km, cm once it drops below 1 m -- and + Returns one record per order rather than printing (issue #68): each + resolution is expressed in the largest sensible unit -- km at coarse + orders, m once it drops below 1 km, cm once it drops below 1 m -- rounded to three decimals within that bracket, so fine orders read - naturally (e.g. order 12 -> ``1.592 km``, order 13 -> ``795.852 m``) - rather than as tiny km fractions. + naturally (order 12 -> ``1.592 km``, order 13 -> ``795.852 m``) rather + than as tiny km fractions. - ``max_order`` must lie in 0..MAX_ORDER, the order range the packed-u64 - kernel encodes. - ''' + Parameters + ---------- + max_order : int, optional + Highest order to include, inclusive. Must lie in ``0..MAX_ORDER`` + (default ``MAX_ORDER`` = 29, the finest order the packed-u64 + kernel encodes). + + Returns + ------- + list of ResolutionLevel + One named tuple ``(order, value, unit, km)`` per order, in + ascending order. ``value``/``unit`` are the display pair; ``km`` + is the unrounded resolution in kilometres for further arithmetic. + + Examples + -------- + >>> from mortie import res2display + >>> levels = res2display(max_order=3) + >>> levels[0].order, levels[0].unit + (0, 'km') + >>> for lvl in res2display(max_order=2): + ... print(f"{lvl.value} {lvl.unit} at tessellation order {lvl.order}") + ... # doctest: +SKIP + + See Also + -------- + order2res : the raw kilometres for a single order. + """ if not 0 <= max_order <= MAX_ORDER: raise ValueError( f"max_order must be between 0 and {MAX_ORDER}, got {max_order!r}") + levels = [] for res in range(max_order + 1): km = order2res(res) if km >= 1.0: @@ -69,8 +99,8 @@ def res2display(max_order=MAX_ORDER): value, unit = km * 1e3, 'm' else: value, unit = km * 1e5, 'cm' - print(str(round(value, 3)) + ' ' + unit + - ' at tessellation order ' + str(res)) + levels.append(ResolutionLevel(res, round(value, 3), unit, km)) + return levels def unique2parent(unique): @@ -91,12 +121,6 @@ def unique2parent(unique): return parent -def heal_norm(base, order, addr_nest): - N_pix = hp.order2nside(order)**2 - addr_norm = addr_nest - (base * N_pix) - return addr_norm - - # Public API - uses Rust (the packed-u64 kernel) def norm2mort(normed, parent, order): """Convert a normalized HEALPix address + base cell to a packed morton word. @@ -798,7 +822,7 @@ def mort2polygon(morton, step=1): return polygons -def clip2order(clip_order, midx=None, print_factor=False): +def clip2order(clip_order, midx): """Coarsen packed morton words to a lower resolution. Degrades each packed word to ``clip_order`` by coarsening it through the @@ -806,25 +830,25 @@ def clip2order(clip_order, midx=None, print_factor=False): tuples are kept, finer detail is dropped, and the suffix is rewritten. Words already at or below ``clip_order`` are returned unchanged. + The ``print_factor`` flag was removed for the 1.x freeze (issue #68). It + returned ``18 - clip_order``, a level count anchored to the retired + decimal encoding's order-18 ceiling, so it went negative for the + order-19..29 words this package now encodes. There is no replacement: the + levels a word actually drops is ``order - clip_order`` for its own decoded + order, which :func:`orders_of` gives directly. + Parameters ---------- clip_order : int HEALPix order to degrade to. midx : array-like of int Packed morton words (see :func:`res2display` for approximate resolutions). - print_factor : bool, optional - If True, return the number of levels dropped from order 18 - (``18 - clip_order``) instead of clipping. Retained for backwards - compatibility; the value is now a level count, not a decimal factor. Returns ------- - ndarray or int - Coarsened packed words, or the level count when ``print_factor`` is True. + ndarray + Coarsened packed words, one per input word. """ - if print_factor: - return 18 - clip_order - midx = np.ascontiguousarray(np.asarray(midx, dtype=np.uint64).ravel()) return _rustie.rust_mi_coarsen(midx, int(clip_order))