Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 33 additions & 14 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)`

Expand All @@ -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)`

Expand Down
2 changes: 1 addition & 1 deletion examples/ExampleUsage.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 0 additions & 2 deletions mortie/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@
generate_morton_children,
geo2mort,
geo2uniq,
heal_norm,
# Inverse functions
infer_order_from_morton,
is_point,
Expand Down Expand Up @@ -86,7 +85,6 @@
'order2res',
'res2display',
'unique2parent',
'heal_norm',
'norm2mort',
'geo2uniq',
'clip2order',
Expand Down
15 changes: 13 additions & 2 deletions mortie/prefix_trie.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions mortie/tests/test_main_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ def test_all_main_imports(self):
clip2order,
geo2mort,
geo2uniq,
heal_norm,
norm2mort,
order2res,
res2display,
Expand All @@ -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)
Expand Down
128 changes: 49 additions & 79 deletions mortie/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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"""

Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading