The morton_index datatype
mortie ships a first-class datatype for its packed 64-bit decimal-Morton MOC
words: a pandas ExtensionArray (MortonIndexArray / MortonIndexDtype,
registered as "morton_index") and a matching pyarrow ExtensionType. Both
are thin skins over the same Rust kernel — storage is the raw uint64 packed
word (bit layout [4-bit prefix | 54-bit body | 6-bit suffix]), so wrapping a
column in the datatype is zero-copy and every domain operation delegates to the
vectorized Rust bindings. The datatype gives a column three things a bare
uint64 array does not: a self-describing dtype that survives a DataFrame /
parquet round-trip, a decimal-Morton repr (-31123 rather than the raw
word), and order-aware accessors (.order(), .coarsen(k), …).
Optional dependencies — numpy stays the only runtime dep
The library's sole hard runtime dependency is numpy. pandas and pyarrow are
optional extras: import mortie succeeds with neither installed, and the
extension classes are reached lazily on first use.
pip install "mortie[pandas]" # the MortonIndexArray ExtensionArray
pip install "mortie[pyarrow]" # the pyarrow ExtensionType
pip install "mortie[pandas,pyarrow]" # both
The names are exposed through a module-level __getattr__, so touching one
without its extra installed raises a clear ImportError rather than failing at
import time:
# in a numpy-only environment:
import mortie
mortie.MortonIndexArray # -> ImportError:
# "the morton_index ExtensionArray requires pandas, which is not installed.
# Install it with `pip install pandas`, or with `pip install mortie[pandas]`
# to declare it as a mortie extra so it is installed alongside mortie in
# future environments."
from mortie import arrow
arrow.morton_index_type() # -> ImportError:
# "the morton_index Arrow extension type requires pyarrow; install it with
# `pip install mortie[pyarrow]` (or `pip install pyarrow`)"
The pandas classes themselves live in the mortie.pandas submodule (issue
#135) — importing that module is
what pulls pandas in, which is why import mortie stays numpy-only. A direct
from mortie.pandas import MortonIndexArray raises the same message. Note that
mortie.pandas is mortie's pandas extension, not pandas itself; it does not
shadow the real pandas for import pandas.
Every example below therefore requires the relevant extra. The pandas examples
assume mortie[pandas]; the Arrow examples assume mortie[pyarrow].
The pandas ExtensionArray
Requires mortie[pandas].
Construction
MortonIndexArray builds from HEALPix NESTED ids, from lat/lon, or from
already-packed uint64 words:
import numpy as np
import pandas as pd
from mortie import MortonIndexArray
# from NESTED ids at a fixed HEALPix order (depth)
arr = MortonIndexArray.from_nested([0, 1, 2, 3], depth=1)
print(repr(arr))
# MortonIndexArray([11, 12, 13, 14], len=4, order=1)
# from lat/lon in degrees (default order=29; here order=6)
pts = MortonIndexArray.from_latlon([40.0, -33.9], [-105.0, 151.2], order=6)
print(repr(pts))
# MortonIndexArray([3223213, -4243113], len=2, order=6)
The raw constructor (and from_words) expects the packed uint64 word, not
the human-readable decimal id — pull the storage back out with np.asarray(...,
dtype=np.uint64) and it round-trips exactly:
words = np.asarray(arr, dtype=np.uint64) # the packed uint64 storage
print(words)
# [1152921504606846977 1441151880758558721 1729382256910270465 2017612633061982209]
back = MortonIndexArray.from_words(words)
print(repr(back))
# MortonIndexArray([11, 12, 13, 14], len=4, order=1)
Wrap it in a Series either from an existing MortonIndexArray or by naming the
registered dtype (packed words in, "morton_index" out):
s = pd.Series(arr)
print(s.dtype, s.dtype.name) # morton_index morton_index
s = pd.Series(words, dtype="morton_index")
print(s)
# 0 11
# 1 12
# 2 13
# 3 14
# dtype: morton_index
What you see — the decimal repr
The array and its elements display as the decimal-Morton id, while the stored
value stays the packed word. Indexing yields a MortonIndexScalar (a
numpy.uint64 subclass): it compares, hashes, and int()s as the raw word, but
str/repr/format render the decimal id. This is what makes an
f"{shard_key}" print 11 instead of 1152921504606846977:
elt = arr[0]
print(type(elt).__name__) # MortonIndexScalar
print(elt) # 11 (decimal-Morton id)
print(int(elt)) # 1152921504606846977 (raw packed word)
To materialize the decimal ids as plain strings, use decimal_repr() (a list)
or to_decimal() (a fixed-width "<U32" numpy array):
print(arr.decimal_repr()) # ['11', '12', '13', '14']
print(arr.to_decimal()) # ['11' '12' '13' '14'] dtype='<U32'
Parsing decimal ids back — the other direction
The inverse of the emit surface. Two of the three entry points are
numpy-only: they import no pandas and build no ExtensionArray, so they
suit hot per-key parse paths (parsing shard ids out of store paths, status
keys, CLI arguments):
import mortie
mortie.decimal_to_word("-31123") # np.uint64, the packed word
mortie.decimal_to_word("-31123", dtype=int) # a Python int
mortie.decimal_to_word("-31123", dtype=mortie.morton_index.MortonIndexScalar)
mortie.decimals_to_words(["11", "12", "13", "14"]) # vectorized, uint64 array
decimals_to_words is the exact inverse of to_decimal(), so the array
round-trips:
from mortie import MortonIndexArray
assert (MortonIndexArray.from_decimal(arr.to_decimal())._data == arr._data).all()
MortonIndexArray.from_decimal is the pandas-side sugar for the same parse.
All three raise ValueError naming the first malformed id, in input order.
One caveat, at order 29 only. The decimal string carries the path, not
the kind, so an order-29 id is ambiguous between the area cell and the
max-encoded point on that path. The rule (spec §4) is that a p-marked id
parses to the point word and an unmarked id always parses to the area
word. Emit marks point words, so to_decimal → from_decimal is lossless
end to end — but any channel that strips the marker (path components above
all, which never carry it) hands back the area word for what may have been a
point. Orders 0–28 have no ambiguity: points exist only at order 29.
Order-aware accessors
Because each word self-encodes its HEALPix order, the array exposes order and
base-cell accessors that delegate to the kernel. The singular forms (.order(),
.base_cell()) return one value and raise on a mixed array, steering you to
the plural forms:
print(arr.orders()) # [1 1 1 1] per-element order (uint8)
print(arr.order()) # 1 single shared order
print(arr.is_fixed_order()) # True
print(arr.base_cells()) # [0 0 0 0]
base_cells() returns the native HEALPix base pixel (0–11, uint8) — the
same index mort2norm, cdshealpix, and healpy use — not the leading digit
of the decimal repr. The decimal-Morton id renders the base cell in its signed
form (specification.md §2): base pixels 0–5 lead with
1–6, and southern base pixels 6–11 lead with -1 to -6. So base pixel 0
displays as decimal 1…, which is why from_nested([0, 1, 2, 3], depth=1) above
shows repr [11, 12, 13, 14] while base_cells() is [0, 0, 0, 0]. A spread
across the range makes both forms visible at once:
b = MortonIndexArray.from_nested([0, 4, 40, 44], depth=1) # base pixels 0, 1, 10, 11
print(b.base_cells()) # [ 0 1 10 11] native HEALPix base pixel (0–11)
print(b.decimal_repr()) # ['11', '21', '-51', '-61'] signed decimal-Morton form
mixed = MortonIndexArray(np.concatenate(
[np.asarray(arr, dtype=np.uint64), np.asarray(pts, dtype=np.uint64)]))
print(repr(mixed))
# MortonIndexArray([11, 12, 13, 14, 3223213, -4243113], len=6, order=mixed)
print(mixed.is_fixed_order()) # False
mixed.order()
# ValueError: array holds mixed orders; use .orders() for the per-element
# orders or .coarsen(k) to cast to a fixed order
coarsen(k) rewrites every word to order k (a new array; elements already at
or below k are unchanged) — the way to cast a mixed array to a fixed order:
fine = MortonIndexArray.from_nested([100, 101, 102, 103], depth=4)
print(repr(fine)) # MortonIndexArray([12321, 12322, 12323, 12324], len=4, order=4)
print(repr(fine.coarsen(2))) # MortonIndexArray([123, 123, 123, 123], len=4, order=2)
hive_path(root="", suffix=".zarr") renders each word as its hive-layout path
(one decimal digit per directory level, the full id as the leaf — see the
hive-path convention in specification.md):
print(arr.hive_path())
# ['1/1/11.zarr', '1/2/12.zarr', '1/3/13.zarr', '1/4/14.zarr']
Comparisons and sorting are the Z-order
The packed word is unsigned, so the raw uint64 order is the Morton Z-order —
comparisons and argsort operate on the words directly, with no special casing
across the base-cell boundary. Equality is bit-identity. No arithmetic
operators are defined (see What is and isn't supported):
b = MortonIndexArray.from_nested([3, 0, 2, 1], depth=1)
print(repr(b)) # MortonIndexArray([14, 11, 13, 12], len=4, order=1)
print(np.argsort(b)) # [1 3 2 0] -> sorts to 11, 12, 13, 14 (Z-order)
print(b == arr) # [False False True False]
Missing values
The missing value is pd.NA, stored as the kernel's all-zero empty sentinel
word (prefix 0). NA-bearing construction and isna() round-trip through it,
and the empty sentinel renders as <NA>:
na = pd.array([int(words[0]), pd.NA, int(words[2])], dtype="morton_index")
print(repr(na)) # MortonIndexArray([11, <NA>, 13], len=3, order=mixed)
print(na.isna()) # [False True False]
The pyarrow ExtensionType
Requires mortie[pyarrow].
mortie.arrow wraps the same uint64 words in a pyarrow ExtensionType
(extension name mortie.morton_index) so a column keeps its morton_index
identity through IPC / parquet. Convert with from_morton_index /
to_morton_index:
import pyarrow as pa
from mortie import MortonIndexArray
from mortie import arrow as marrow
arr = MortonIndexArray.from_nested([0, 1, 2, 3], depth=1)
pa_arr = marrow.from_morton_index(arr)
print(pa_arr.type) # extension<mortie.morton_index<MortonIndexType>>
back = marrow.to_morton_index(pa_arr)
print(repr(back)) # MortonIndexArray([11, 12, 13, 14], len=4, order=1)
The pandas empty sentinel maps to an Arrow null (a real validity bitmap) and
back, so isna() survives the round-trip in both directions:
w = np.asarray(arr)
na = MortonIndexArray.from_words(np.array([int(w[0]), 0, int(w[2])], dtype=np.uint64))
pa_na = marrow.from_morton_index(na)
print(pa_na.null_count) # 1
print(marrow.to_morton_index(pa_na).isna()) # [False True False]
Parquet round-trip and table.to_pandas()
The extension name rides along through parquet, and pandas' __from_arrow__
hook lands the column back as a MortonIndexArray (not a bare int column):
import pyarrow.parquet as pq
tbl = pa.table({"cell": marrow.from_morton_index(arr)})
pq.write_table(tbl, "cells.parquet")
rt = pq.read_table("cells.parquet")
print(rt.schema.field("cell").type) # extension<mortie.morton_index<MortonIndexType>>
df = rt.to_pandas()
print(df["cell"].dtype) # morton_index
Because MortonIndexArray implements the Arrow C Data Interface
(__arrow_c_array__), you can also hand it straight to pa.array(...):
print(pa.array(arr).type) # extension<mortie.morton_index<MortonIndexType>>
Note the reverse convenience does not exist: pa.Table.from_pandas(df) on a
morton_index column raises an Arrow error (the pandas array does not
implement __arrow_array__). Convert explicitly with from_morton_index, or use
pa.array(series.array) via the C-Data path shown above.
For the library-agnostic, pyarrow-free C Data Interface surface
(export_c_array / import_c_array, for arro3-core / polars on workers without
pyarrow), see arrow_interchange.md.
numpy ↔ pandas ↔ arrow round-trip
The three representations are lossless conversions of the same packed words:
| from → to | how |
|---|---|
numpy uint64 → array |
MortonIndexArray.from_words(words) |
array → numpy uint64 |
np.asarray(arr, dtype=np.uint64) |
array → pandas Series |
pd.Series(arr) |
| array → Arrow | mortie.arrow.from_morton_index(arr) / pa.array(arr) |
| Arrow → array | mortie.arrow.to_morton_index(pa_arr) / MortonIndexArray.from_arrow(a) |
| Arrow table → pandas | table.to_pandas() (lands as morton_index) |
NESTED ids and lat/lon are one-way entry points (from_nested,
from_latlon); to_nested() returns (nested_ids, depths) back out.
What is and isn't supported in 1.x
The datatype deliberately ships a narrow, total surface for 1.x:
- Supported: construction, storage/round-trip (numpy ↔ pandas ↔ arrow ↔
parquet), NA/sentinel semantics, the decimal repr, ordering/equality
comparisons and
argsort(the Z-order), and the domain accessors (.orders()/.order(),.base_cells()/.base_cell(),.is_fixed_order(),.coarsen(k),.to_nested(),.hive_path()). - Not supported: arithmetic operators. Add/subtract/multiply and the like are not defined, because raw arithmetic on a packed word (prefix + body
- suffix bit-fields) is meaningless — it does not correspond to any spatial operation. Use the domain accessors instead.
A richer, NEP-42-style operator grammar for the datatype — spatial
predicates and set operations expressed through native operators / ufuncs, so
morton_index columns compose the way numpy dtypes do — is a deferred,
forward-looking design and is not part of 1.x. Nothing above will change
its meaning when that lands; the accessor methods are the stable 1.x contract.
The MOC set operations that back such a grammar already exist today as functions
(moc_and, moc_or, moc_minus, …) — see the coverage API.
See also
arrow_interchange.md— the two Arrow surfaces (pyarrowExtensionTypeand the pyarrow-free C Data Interface) in depth.specification.md— the packed-word bit layout, the decimal-Morton id grammar, and the hive-path convention.coverage_methods.md— polygon/MOC coverage that producesmorton_indexcell lists.