Skip to content

HEALPix-ecosystem interchange

A mortie packed 64-bit morton_index word is a HEALPix NESTED cell with its order carried intrinsically (see specification.md §1). This guide shows how to move a word to and from the two common HEALPix carriers — (order, nested-pixel-id) pairs as used by cdshealpix and healpy — with runnable, cross-checked snippets, then covers the three gotchas that bite at the boundary.

mortie does not reimplement the HEALPix geometry: its NESTED transforms (geo2mort, mort2geo, mort2healpix, norm2mort, and the coverage kernels) wrap the Rust healpix crate (matt-cornell/healpix-rs, pinned in Cargo.toml) — the same crate used in production for transforms to and from NESTED and the other wrapped HEALPix routines. It is the only HEALPix implementation in the runtime path; there is no C HEALPix library. cdshealpix and healpy appear below only as independent external oracles — a second, unrelated implementation to cross-check that the interchange is exact — not as runtime dependencies.

Only numpy is a mortie runtime dependency (the healpix crate above is compiled into the extension, not a Python dependency). cdshealpix (with its astropy dependency) ships in the test extra — it is the same oracle the test-suite cross-checks against (mortie/tests/test_morton_index.py, test_coverage_hemisphere.py). healpy is not in any mortie extra; install it separately (pip install healpy) to run the healpy snippets below.

The conversion API

direction function returns
word → HEALPix mort2healpix(morton) (nested_cell_id, order)
word → normalized mort2norm(morton) (normed, base_cell, order)
normalized → word norm2mort(normed, base_cell, order) packed uint64 word
order of a word infer_order_from_morton(morton) int

mort2healpix is the one-call bridge to the ecosystem: it returns the NESTED ipix and the order (the HEALPix depth) that every other library keys on. The nested id is the standard base_cell * nside**2 + normed composition with nside = 2**order, so mort2norm / norm2mort are just the split/join around that same relation.

import numpy as np
import mortie

# geo2mort takes (lat, lon) and returns a length-1 array for scalar input,
# so index [0] for a single word.
m = mortie.geo2mort(-80.0, 120.0, order=6)[0]
print(int(m))                        # 11570310392668225542

cell_id, order = mortie.mort2healpix(m)
print(cell_id, order)                # 37010 6

normed, base_cell, order = mortie.mort2norm(m)
print(normed, base_cell, order)      # 146 9 6

# nested id is base_cell * nside**2 + normed
nside = 2 ** order
print(cell_id == base_cell * nside**2 + normed)   # True

# round-trip back to the exact same word
m2 = mortie.norm2mort(normed, base_cell, order)
print(np.uint64(m2) == m)            # True

print(mortie.infer_order_from_morton(m))          # 6

For an array of words mort2healpix returns (cell_ids_array, order) with a single order when the words share one order (it raises on mixed orders — pass one order at a time).

Round-trip against cdshealpix

cdshealpix.nested.lonlat_to_healpix(lon, lat, depth) is the NESTED-ipix oracle; healpix_to_lonlat(ipix, depth) inverts it. The cell ids match mort2healpix exactly, and the centers agree to floating-point tolerance.

import numpy as np
import astropy.units as u
from cdshealpix.nested import lonlat_to_healpix, healpix_to_lonlat
import mortie

lats = np.array([0.0, 41.8, -41.8, 80.0, -80.0, 12.3, -67.9])
lons = np.array([0.0, 45.0, 135.0, 200.0, 305.0, 91.5, 270.2])
order = 14

words = mortie.geo2mort(lats, lons, order=order)
cell_ids, o = mortie.mort2healpix(words)

oracle = np.asarray(lonlat_to_healpix(lons * u.deg, lats * u.deg, depth=order),
                    dtype=np.int64)
print(np.array_equal(cell_ids, oracle))     # True
# cell_ids == [1275068416, 67108860, 2617245699, 800177021,
#              2962305922, 1556551876, 2997005193]

# centers, mortie vs cdshealpix
clon, clat = healpix_to_lonlat(cell_ids, depth=order)
mlat, mlon = mortie.mort2geo(words)
print(float(np.max(np.abs(mlat - clat.to_value(u.deg)))))   # ~1.98e-09
print(float(np.max(np.abs(mlon - clon.to_value(u.deg)))))   # 0.0

The cell_ids list and the tolerance figures above are captured from the current build — regenerate them by rerunning the snippet rather than editing them by hand.

Note the argument order: geo2mort takes (lat, lon), while the cdshealpix calls take (lon, lat) — a frequent source of transposed results.

Importing a foreign nested id

To build a mortie word from a NESTED id produced elsewhere, split it into (normed, base_cell) at that order and call norm2mort:

foreign = int(oracle[3])            # 800177021, a cdshealpix NESTED ipix at order 14
nside_sq = (2 ** order) ** 2
base_cell, normed = divmod(foreign, nside_sq)
word = mortie.norm2mort(normed, base_cell, order)
print(mortie.mort2healpix(word))    # (800177021, 14)

Round-trip against healpy

healpy keys on nside = 2**order (not the order itself) and needs nest=True; pass lonlat=True to give it degrees in (lon, lat) order.

import numpy as np
import healpy as hp
import mortie

lats = np.array([0.0, 41.8, -41.8, 80.0, -80.0, 12.3, -67.9])
lons = np.array([0.0, 45.0, 135.0, 200.0, 305.0, 91.5, 270.2])
order = 14
nside = 2 ** order

words = mortie.geo2mort(lats, lons, order=order)
cell_ids, _ = mortie.mort2healpix(words)

hpix = hp.ang2pix(nside, lons, lats, nest=True, lonlat=True)
print(np.array_equal(cell_ids, hpix))       # True

hlon, hlat = hp.pix2ang(nside, cell_ids, nest=True, lonlat=True)
mlat, mlon = mortie.mort2geo(words)
print(float(np.max(np.abs(mlat - hlat))))    # ~1.98e-09

Gotchas at the boundary

1. The 4-bit prefix is base_cell + 1, and the word is unsigned

The top nibble of a word stores the HEALPix base cell as base + 1 (so the 12 base cells occupy 1..=12, and 0 is the empty/null sentinel). Base cells 7–11 set bit 63, making the word a large unsigned value. Store and exchange the word as uint64; reinterpreting it as signed int64 makes base cells 7–11 read back negative and corrupts the id.

import numpy as np
import mortie

w = mortie.geo2mort(-80.0, 120.0, order=3)[0]   # lands in a southern base cell
print(int(w))                           # 11565243843087433731
print(int(w) >> 60)                     # 10   (prefix nibble == base_cell + 1)
_, base_cell, _ = mortie.mort2norm(w)
print(base_cell)                        # 9
print(bool(int(w) >> 63 & 1))           # True  (bit 63 set)
print(np.int64(w))                      # -6881500230622117885  <- WRONG if read signed

The "negative = southern" signed form only exists in the decimal string presentation (specification.md §2); it is never a storage form. Keep interchange columns uint64.

2. Order is self-encoded — don't carry it out of band

The order is intrinsic to the word (infer_order_from_morton reads it back), so a word never needs a companion order column. But every ecosystem call does need the order/depth/nside explicitly — always pass the order that mort2healpix returned, and never mix orders in a single mort2healpix array call (it raises on mixed orders).

3. Points decode at order 29

A bare geo2mort(lat, lon) (no order) encodes an order-29 point — a location with no area claim (specification.md §1). It still converts cleanly, but always lands at order 29:

p = mortie.geo2mort(-80.0, 120.0)[0]        # order-29 point word
print(mortie.mort2healpix(p))               # (2604365600141906640, 29)
print(mortie.infer_order_from_morton(p))    # 29

If you need an area cell at a specific resolution for interchange, pass an explicit order to geo2mort. The empty/null sentinel (geo2mort of a non-finite coordinate) is the all-zero word 0, which is not a valid HEALPix cell — filter it before handing ids to an ecosystem library.