moczarr 0.1.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.
- moczarr/__init__.py +101 -0
- moczarr/_version.py +24 -0
- moczarr/convention.py +385 -0
- moczarr/coverage.py +232 -0
- moczarr/dggs.py +331 -0
- moczarr/exceptions.py +24 -0
- moczarr/fabricate.py +118 -0
- moczarr/join.py +210 -0
- moczarr/moc_index.py +432 -0
- moczarr/open.py +447 -0
- moczarr/ranges.py +393 -0
- moczarr/store.py +439 -0
- moczarr-0.1.0.dist-info/METADATA +133 -0
- moczarr-0.1.0.dist-info/RECORD +16 -0
- moczarr-0.1.0.dist-info/WHEEL +4 -0
- moczarr-0.1.0.dist-info/licenses/LICENSE +21 -0
moczarr/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""moczarr: sparse-DGGS xarray reader for morton-hive zarr stores."""
|
|
2
|
+
|
|
3
|
+
from moczarr.convention import (
|
|
4
|
+
COMMIT_ATTR,
|
|
5
|
+
COVERAGE_SIDECAR,
|
|
6
|
+
HIVE_SPEC,
|
|
7
|
+
HIVE_SPEC_V2,
|
|
8
|
+
MANIFEST_NAME,
|
|
9
|
+
MORTON_CONVENTION_ENTRY,
|
|
10
|
+
MORTON_CONVENTION_UUID,
|
|
11
|
+
ROOT_COVERAGE_NAME,
|
|
12
|
+
check_node_invariant,
|
|
13
|
+
is_point_word,
|
|
14
|
+
leaf_path,
|
|
15
|
+
morton_decimal,
|
|
16
|
+
morton_word,
|
|
17
|
+
parse_manifest,
|
|
18
|
+
split_leaf_name,
|
|
19
|
+
)
|
|
20
|
+
from moczarr.coverage import (
|
|
21
|
+
COVERAGE_SPEC,
|
|
22
|
+
aoi_mask,
|
|
23
|
+
box_and,
|
|
24
|
+
box_words,
|
|
25
|
+
decode_bitmap,
|
|
26
|
+
parse_leaf_coverage,
|
|
27
|
+
parse_root_coverage,
|
|
28
|
+
ranges_contain,
|
|
29
|
+
ranges_words,
|
|
30
|
+
root_coverage_and,
|
|
31
|
+
)
|
|
32
|
+
from moczarr.exceptions import NoCoverageError
|
|
33
|
+
from moczarr.fabricate import FLOAT64_EXACT_MAX_ORDER, fabricate_cell_ids
|
|
34
|
+
from moczarr.join import join_coarse, parent_cells
|
|
35
|
+
from moczarr.open import open_hive
|
|
36
|
+
from moczarr.ranges import MortonRanges
|
|
37
|
+
|
|
38
|
+
# moczarr.moc_index (MortonMocIndex) is imported by module path, not here:
|
|
39
|
+
# the package root stays xarray-import-free (the repo's lazy-import posture),
|
|
40
|
+
# and the index reaches most users through open_hive(index_kind="moc").
|
|
41
|
+
from moczarr.store import (
|
|
42
|
+
bitmap_and,
|
|
43
|
+
load_root_coverage,
|
|
44
|
+
open_object_store,
|
|
45
|
+
read_commit,
|
|
46
|
+
read_coverage_bitmap,
|
|
47
|
+
read_leaf_coverage,
|
|
48
|
+
read_manifest,
|
|
49
|
+
walk_leaves,
|
|
50
|
+
warn_if_stale,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
from moczarr._version import __version__
|
|
55
|
+
except ImportError: # pragma: no cover - version file is generated at build time
|
|
56
|
+
__version__ = "0.0.0+unknown"
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"COMMIT_ATTR",
|
|
60
|
+
"COVERAGE_SIDECAR",
|
|
61
|
+
"COVERAGE_SPEC",
|
|
62
|
+
"FLOAT64_EXACT_MAX_ORDER",
|
|
63
|
+
"HIVE_SPEC",
|
|
64
|
+
"HIVE_SPEC_V2",
|
|
65
|
+
"MANIFEST_NAME",
|
|
66
|
+
"MORTON_CONVENTION_ENTRY",
|
|
67
|
+
"MORTON_CONVENTION_UUID",
|
|
68
|
+
"MortonRanges",
|
|
69
|
+
"NoCoverageError",
|
|
70
|
+
"ROOT_COVERAGE_NAME",
|
|
71
|
+
"__version__",
|
|
72
|
+
"aoi_mask",
|
|
73
|
+
"bitmap_and",
|
|
74
|
+
"box_and",
|
|
75
|
+
"box_words",
|
|
76
|
+
"check_node_invariant",
|
|
77
|
+
"decode_bitmap",
|
|
78
|
+
"fabricate_cell_ids",
|
|
79
|
+
"is_point_word",
|
|
80
|
+
"join_coarse",
|
|
81
|
+
"leaf_path",
|
|
82
|
+
"load_root_coverage",
|
|
83
|
+
"morton_decimal",
|
|
84
|
+
"morton_word",
|
|
85
|
+
"open_hive",
|
|
86
|
+
"open_object_store",
|
|
87
|
+
"parent_cells",
|
|
88
|
+
"parse_leaf_coverage",
|
|
89
|
+
"parse_manifest",
|
|
90
|
+
"parse_root_coverage",
|
|
91
|
+
"ranges_contain",
|
|
92
|
+
"ranges_words",
|
|
93
|
+
"read_commit",
|
|
94
|
+
"read_coverage_bitmap",
|
|
95
|
+
"read_leaf_coverage",
|
|
96
|
+
"read_manifest",
|
|
97
|
+
"root_coverage_and",
|
|
98
|
+
"split_leaf_name",
|
|
99
|
+
"walk_leaves",
|
|
100
|
+
"warn_if_stale",
|
|
101
|
+
]
|
moczarr/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
moczarr/convention.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""The morton-hive layout convention, read side (pure functions, no I/O).
|
|
2
|
+
|
|
3
|
+
The layout is owned by the mortie spec (espg/mortie#62); zagg writes it
|
|
4
|
+
(``zagg.hive``); moczarr reads it. Summary::
|
|
5
|
+
|
|
6
|
+
{store_root}/
|
|
7
|
+
morton_hive.json <- static manifest; root-only exception
|
|
8
|
+
coverage.moc <- root ranges MOC; root-only exception
|
|
9
|
+
{sign+base}/{d1}/.../{d_n}/ <- digit component(s) per level
|
|
10
|
+
{full_id}.zarr/ <- vanilla zarr v3 leaf
|
|
11
|
+
{full_id}_{window}.zarr/ <- time-windowed leaf (morton-hive/2)
|
|
12
|
+
|
|
13
|
+
- Ids are morton decimal strings: sign + base digit (``1..6``), then one
|
|
14
|
+
digit ``1..4`` per order. A string prefix is a spatial ancestor.
|
|
15
|
+
- Path components chunk the digit tail per the manifest's ``path_grouping``
|
|
16
|
+
(spec §6.1; zagg D21): ``path_grouping`` digits per component, the LAST
|
|
17
|
+
component carrying the remainder when the order does not divide evenly
|
|
18
|
+
(leading components stay full-width, so component boundaries are ancestor
|
|
19
|
+
prefixes shared by deeper shards regardless of their order). ``1`` — the
|
|
20
|
+
default, and every existing store retroactively — is a value of the one
|
|
21
|
+
generic chunking, never a separate code path; readers chunk per the
|
|
22
|
+
manifest, never by assumption.
|
|
23
|
+
- Below the root a node holds only digit children and ``*.zarr`` objects
|
|
24
|
+
(the node invariant); the manifest and root ``coverage.moc`` are the two
|
|
25
|
+
root-only exceptions.
|
|
26
|
+
- A leaf is complete iff its root zarr attrs carry the commit stamp
|
|
27
|
+
(``morton_hive_commit``); an unstamped ``.zarr/`` prefix is debris.
|
|
28
|
+
- Windowed leaf names split on the FIRST ``_`` (morton ids and window labels
|
|
29
|
+
never contain one); labels use the frozen charset ``[0-9A-Za-z-]{1,32}``.
|
|
30
|
+
|
|
31
|
+
Everything here is arithmetic on ids and dict validation — the store layer
|
|
32
|
+
(phase 2) supplies the bytes. Golden vectors in ``tests/`` pin this
|
|
33
|
+
implementation against zagg's writer so the two cannot drift silently.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
#: Manifest convention versions (``spec`` field). A ``/1`` store is a ``/2``
|
|
43
|
+
#: store with ``schedule: none``; ``/2`` adds the temporal block.
|
|
44
|
+
HIVE_SPEC = "morton-hive/1"
|
|
45
|
+
HIVE_SPEC_V2 = "morton-hive/2"
|
|
46
|
+
#: Root manifest object name.
|
|
47
|
+
MANIFEST_NAME = "morton_hive.json"
|
|
48
|
+
#: Root-group attrs key carrying the commit stamp.
|
|
49
|
+
COMMIT_ATTR = "morton_hive_commit"
|
|
50
|
+
#: In-leaf occupancy-bitmap sidecar object name; same name at the store root
|
|
51
|
+
#: holds the shard-order ranges MOC (different location, different encoding).
|
|
52
|
+
COVERAGE_SIDECAR = "coverage.moc"
|
|
53
|
+
ROOT_COVERAGE_NAME = "coverage.moc"
|
|
54
|
+
|
|
55
|
+
#: Spec §5: the permanent, self-declared zarr-convention UUID for
|
|
56
|
+
#: morton-declared stores (minted once; readers may key on it).
|
|
57
|
+
MORTON_CONVENTION_UUID = "3e22156d-ea9e-4e01-95fe-e3809a4b41e7"
|
|
58
|
+
#: Spec §5: the self-declared ``zarr_conventions`` entry, verbatim. The list
|
|
59
|
+
#: may carry other entries alongside (e.g. the generic dggs registry one).
|
|
60
|
+
MORTON_CONVENTION_ENTRY = {
|
|
61
|
+
"schema_url": "https://github.com/espg/mortie/blob/main/docs/specification.md#dggs-attrs",
|
|
62
|
+
"spec_url": "https://github.com/espg/mortie/blob/main/docs/specification.md",
|
|
63
|
+
"uuid": MORTON_CONVENTION_UUID,
|
|
64
|
+
"name": "morton-dggs",
|
|
65
|
+
"description": "Packed-u64 morton (HEALPix) DGGS convention",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#: Frozen window-label charset (no ``_``, so leaf names split unambiguously).
|
|
69
|
+
_LABEL_RE = re.compile(r"^[0-9A-Za-z-]{1,32}$")
|
|
70
|
+
|
|
71
|
+
#: Spec §1 suffix bands: ``0..=27`` area (order == suffix), ``28..=47``
|
|
72
|
+
#: order-28/29 area preorder, ``48..=63`` order-29 POINT (no area claim).
|
|
73
|
+
_POINT_SUFFIX_MIN = 48
|
|
74
|
+
_SUFFIX_MASK = np.uint64(0x3F)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_point_word(word) -> bool | np.ndarray:
|
|
78
|
+
"""Whether packed word(s) encode an order-29 POINT (spec §1/§4).
|
|
79
|
+
|
|
80
|
+
Kind is carried by the word's 6-bit suffix — ``48..=63`` is the point
|
|
81
|
+
band; everything below (``0..=47``) is an AREA element, exact at its
|
|
82
|
+
encoded order — never by store or array metadata (§4). Suffix-mask
|
|
83
|
+
implementation, golden-tested against the spec §1 table; swaps to
|
|
84
|
+
mortie's public kind predicate once released (espg/mortie#116 —
|
|
85
|
+
mortie 0.9.0 has none). Scalar in -> bool; array in -> bool array.
|
|
86
|
+
"""
|
|
87
|
+
words = np.asarray(word, dtype=np.uint64)
|
|
88
|
+
mask = (words & _SUFFIX_MASK) >= np.uint64(_POINT_SUFFIX_MIN)
|
|
89
|
+
return bool(mask) if words.ndim == 0 else mask
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def point_to_area29(word):
|
|
93
|
+
"""Order-29 AREA twin(s) of point word(s); area words pass through.
|
|
94
|
+
|
|
95
|
+
Spec §1 suffix arithmetic on the same body: point ``48 + t28*4 + t29``
|
|
96
|
+
-> area ``28 + t28*5 + (t29 + 1)``. The twin shares the point's full
|
|
97
|
+
path, so containment arithmetic (§4: membership of a point at a coarser
|
|
98
|
+
level is ordinary truncation) runs uniformly in area space — the
|
|
99
|
+
normalization :func:`moczarr.coverage.aoi_mask` applies. Scalar in ->
|
|
100
|
+
int; array in -> ``uint64`` array.
|
|
101
|
+
"""
|
|
102
|
+
words = np.asarray(word, dtype=np.uint64)
|
|
103
|
+
# Suffix arithmetic in int64 (values <= 63): negative intermediates for
|
|
104
|
+
# area words are discarded by the where, without uint64 wrap warnings.
|
|
105
|
+
suffix = (words & _SUFFIX_MASK).astype(np.int64)
|
|
106
|
+
r2 = suffix - _POINT_SUFFIX_MIN
|
|
107
|
+
area_suffix = (29 + (r2 >> 2) * 5 + (r2 & 3)).astype(np.uint64)
|
|
108
|
+
twins = np.where(suffix >= _POINT_SUFFIX_MIN, (words & ~_SUFFIX_MASK) | area_suffix, words)
|
|
109
|
+
return int(twins) if twins.ndim == 0 else twins.astype(np.uint64)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def area29_to_point(word):
|
|
113
|
+
"""Max-encoded POINT twin(s) of order-29 AREA word(s) — the ``p`` parse.
|
|
114
|
+
|
|
115
|
+
Inverse of :func:`point_to_area29`: area ``28 + t28*5 + (t29 + 1)`` ->
|
|
116
|
+
point ``48 + t28*4 + t29`` (spec §1). Raises on any word that is not an
|
|
117
|
+
order-29 area encoding — points exist only at order 29 (§2/§4).
|
|
118
|
+
"""
|
|
119
|
+
words = np.asarray(word, dtype=np.uint64)
|
|
120
|
+
suffix = (words & _SUFFIX_MASK).astype(np.int64) # int64: no wrap on invalid input
|
|
121
|
+
r = suffix - 28
|
|
122
|
+
# Order-29 area suffixes are 28 + t28*5 + (t29+1), t29 in 0..3 -> r % 5 != 0.
|
|
123
|
+
ok = (suffix > 27) & (suffix < _POINT_SUFFIX_MIN) & (r % 5 != 0)
|
|
124
|
+
if not np.all(ok):
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"not an order-29 area word: the point twin exists only at order 29 (spec §1/§4)"
|
|
127
|
+
)
|
|
128
|
+
point_suffix = (_POINT_SUFFIX_MIN + (r // 5) * 4 + (r % 5 - 1)).astype(np.uint64)
|
|
129
|
+
points = (words & ~_SUFFIX_MASK) | point_suffix
|
|
130
|
+
return int(points) if points.ndim == 0 else points.astype(np.uint64)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def morton_word(label: str | int) -> int:
|
|
134
|
+
"""Packed ``uint64`` morton word of a decimal id (pass-through for ints).
|
|
135
|
+
|
|
136
|
+
Accepts the §2 ``p`` kind-suffix on full order-29 POINT ids: the stem
|
|
137
|
+
parses as the area word and the §1 point suffix is restored moczarr-side
|
|
138
|
+
(:func:`area29_to_point`) — mortie 0.9.0 predates the ``p`` grammar
|
|
139
|
+
(espg/mortie#120/#121). An UNMARKED order-29 string parses as the AREA
|
|
140
|
+
word, the normative §4 tie-break. Rides mortie's private-but-documented
|
|
141
|
+
``_decimal_to_word`` (numpy-only; the public array classes require
|
|
142
|
+
pandas — upstream ask for a public export stands, same note as zagg's
|
|
143
|
+
boundary helper).
|
|
144
|
+
"""
|
|
145
|
+
if isinstance(label, (int, np.integer)):
|
|
146
|
+
return int(label)
|
|
147
|
+
from mortie.morton_index import _decimal_to_word
|
|
148
|
+
|
|
149
|
+
text = str(label)
|
|
150
|
+
if text.endswith("p"):
|
|
151
|
+
stem = text[:-1]
|
|
152
|
+
if decimal_order(stem) != 29:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"{label!r}: the 'p' kind-suffix is legal only on a full order-29 "
|
|
155
|
+
f"POINT id (points exist only at order 29; spec §2)"
|
|
156
|
+
)
|
|
157
|
+
return int(area29_to_point(int(_decimal_to_word(stem))))
|
|
158
|
+
return int(_decimal_to_word(text))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def morton_decimal(word: str | int) -> str:
|
|
162
|
+
"""Decimal morton string of a packed word (pass-through for strings).
|
|
163
|
+
|
|
164
|
+
POINT words (§1 suffix ``48..=63``) render with the terminal ``p`` kind
|
|
165
|
+
marker — the §2 render/interchange form — via the order-29 area twin's
|
|
166
|
+
digits (same path; kind restored by the marker, so the round-trip is
|
|
167
|
+
lossless for BOTH kinds, pinned by goldens).
|
|
168
|
+
"""
|
|
169
|
+
if isinstance(word, str):
|
|
170
|
+
return word
|
|
171
|
+
from mortie import MortonIndexArray
|
|
172
|
+
|
|
173
|
+
value, marker = int(word), ""
|
|
174
|
+
if is_point_word(value):
|
|
175
|
+
value, marker = point_to_area29(value), "p"
|
|
176
|
+
rendered = MortonIndexArray.from_words(np.asarray([value], dtype=np.uint64)).decimal_repr()[0]
|
|
177
|
+
return rendered + marker
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def decimal_order(decimal: str) -> int:
|
|
181
|
+
"""HEALPix order of a decimal id (one digit per level past the base)."""
|
|
182
|
+
return len(decimal) - (2 if decimal.startswith("-") else 1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def decimal_base(decimal: str) -> str:
|
|
186
|
+
"""The ``{sign+base}`` component of a decimal id."""
|
|
187
|
+
return decimal[:2] if decimal.startswith("-") else decimal[:1]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def decimal_rank(decimal: str) -> int:
|
|
191
|
+
"""Base-4 value of a decimal id's digit tail (digits ``1..4`` -> ``0..3``).
|
|
192
|
+
|
|
193
|
+
The bit/rank convention of the coverage encodings: ascending packed-word
|
|
194
|
+
(Z-)order within one base cell at a fixed order.
|
|
195
|
+
"""
|
|
196
|
+
rank = 0
|
|
197
|
+
for ch in decimal[len(decimal_base(decimal)) :]:
|
|
198
|
+
rank = rank * 4 + (int(ch) - 1)
|
|
199
|
+
return rank
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def rank_tail(rank: int, depth: int) -> str:
|
|
203
|
+
"""Inverse of :func:`decimal_rank`: the width-``depth`` digit tail."""
|
|
204
|
+
digits = []
|
|
205
|
+
for _ in range(depth):
|
|
206
|
+
digits.append(str(rank % 4 + 1))
|
|
207
|
+
rank //= 4
|
|
208
|
+
return "".join(reversed(digits))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def is_base_component(name: str) -> bool:
|
|
212
|
+
"""Whether ``name`` is a ``{sign+base}``-shaped hive root child."""
|
|
213
|
+
base = name[1:] if name.startswith("-") else name
|
|
214
|
+
return len(base) == 1 and base in "123456"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def group_digits(digits: str, path_grouping: int) -> list[str]:
|
|
218
|
+
"""Chunk a digit tail into hive path components (spec §6.1, zagg D21).
|
|
219
|
+
|
|
220
|
+
Left to right, ``path_grouping`` digits per component; the LAST component
|
|
221
|
+
carries the remainder when ``len(digits) % path_grouping != 0``. Leading
|
|
222
|
+
components stay full-width so every component boundary is a spatial
|
|
223
|
+
ancestor prefix (§2) shared by deeper shards regardless of their order —
|
|
224
|
+
mixed-order shards share ancestor nodes, the property the digit tree
|
|
225
|
+
exists for.
|
|
226
|
+
"""
|
|
227
|
+
return [digits[i : i + path_grouping] for i in range(0, len(digits), path_grouping)]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def manifest_path_grouping(manifest: dict) -> int:
|
|
231
|
+
"""The manifest's ``path_grouping`` (D21: absent reads as ``1``).
|
|
232
|
+
|
|
233
|
+
Assumes a :func:`parse_manifest`-validated manifest; the single accessor
|
|
234
|
+
keeps the absent->1 normalization in one place.
|
|
235
|
+
"""
|
|
236
|
+
return int(manifest.get("path_grouping", 1))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def validate_label(label: str) -> str:
|
|
240
|
+
"""Validate a window label against the frozen charset; returns it."""
|
|
241
|
+
if not isinstance(label, str) or not _LABEL_RE.match(label):
|
|
242
|
+
raise ValueError(
|
|
243
|
+
f"window label {label!r} does not match the frozen grammar "
|
|
244
|
+
f"({_LABEL_RE.pattern}; morton-hive/2, mortie#62)"
|
|
245
|
+
)
|
|
246
|
+
return label
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def leaf_name(full_id: str, window: str | None = None) -> str:
|
|
250
|
+
"""The leaf zarr basename: ``{full_id}_{window}.zarr``, or bare."""
|
|
251
|
+
if window is None:
|
|
252
|
+
return f"{full_id}.zarr"
|
|
253
|
+
validate_label(window)
|
|
254
|
+
return f"{full_id}_{window}.zarr"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def split_leaf_name(name: str) -> tuple[str, str | None]:
|
|
258
|
+
"""``(full_id, window-or-None)`` from a leaf basename — split on the FIRST ``_``.
|
|
259
|
+
|
|
260
|
+
Morton decimal ids never contain ``_`` and window labels cannot
|
|
261
|
+
(charset), so the first underscore is the one separator. Raises on a
|
|
262
|
+
non-``.zarr`` name or a malformed window label.
|
|
263
|
+
"""
|
|
264
|
+
if not name.endswith(".zarr"):
|
|
265
|
+
raise ValueError(f"{name!r} is not a leaf zarr name")
|
|
266
|
+
stem = name.removesuffix(".zarr")
|
|
267
|
+
if "_" not in stem:
|
|
268
|
+
return stem, None
|
|
269
|
+
full_id, window = stem.split("_", 1)
|
|
270
|
+
validate_label(window)
|
|
271
|
+
return full_id, window
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def leaf_path(shard: str | int, window: str | None = None, *, path_grouping: int = 1) -> str:
|
|
275
|
+
"""Store-relative hive path of a shard's leaf zarr.
|
|
276
|
+
|
|
277
|
+
``path_grouping`` is the manifest's digit-chunking (spec §6.1; default
|
|
278
|
+
``1`` — every pre-D21 store). At ``1`` the path is computed by mortie's
|
|
279
|
+
``hive_path`` (the convention owner) and re-checked against the node
|
|
280
|
+
invariant so drift on either side fails loudly; the grouped form chunks
|
|
281
|
+
the decimal here (:func:`group_digits`) until mortie grows the grouped
|
|
282
|
+
``hive_path`` (espg/mortie#62). ``window`` selects the time-windowed
|
|
283
|
+
leaf at the same node.
|
|
284
|
+
"""
|
|
285
|
+
from mortie import MortonIndexArray
|
|
286
|
+
|
|
287
|
+
word = morton_word(shard)
|
|
288
|
+
if word < 0:
|
|
289
|
+
raise ValueError(
|
|
290
|
+
f"shard {shard!r} is a negative int, not a packed morton word; a "
|
|
291
|
+
f"packed morton word is required. Parse a decimal id by passing it "
|
|
292
|
+
f"as a string (e.g. morton_word('-5112333')) instead."
|
|
293
|
+
)
|
|
294
|
+
if is_point_word(word):
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"shard {shard!r} is an order-29 POINT word: points never live in "
|
|
297
|
+
f"hive paths (spec §2/§6.6)"
|
|
298
|
+
)
|
|
299
|
+
if path_grouping == 1:
|
|
300
|
+
rel = MortonIndexArray.from_words(np.asarray([word], dtype=np.uint64)).hive_path()[0]
|
|
301
|
+
if window is not None:
|
|
302
|
+
node, _sep, bare = rel.rpartition("/")
|
|
303
|
+
rel = f"{node}/{leaf_name(bare.removesuffix('.zarr'), window)}"
|
|
304
|
+
else:
|
|
305
|
+
decimal = morton_decimal(word)
|
|
306
|
+
base = decimal_base(decimal)
|
|
307
|
+
components = [base, *group_digits(decimal[len(base) :], path_grouping)]
|
|
308
|
+
rel = f"{'/'.join(components)}/{leaf_name(decimal, window)}"
|
|
309
|
+
check_node_invariant(rel, path_grouping=path_grouping)
|
|
310
|
+
return rel
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def check_node_invariant(rel_path: str, *, path_grouping: int = 1) -> None:
|
|
314
|
+
"""Raise unless ``rel_path`` is a legal hive leaf path.
|
|
315
|
+
|
|
316
|
+
Below the root only digit components are allowed — ``{sign+base}``
|
|
317
|
+
(optional ``-``, one digit ``1..6``) at the first level, then digit
|
|
318
|
+
(``1..4``) components chunked per ``path_grouping`` (spec §6.1): every
|
|
319
|
+
component full-width except the last, which carries the remainder —
|
|
320
|
+
terminating in ``{full_id}.zarr`` (or the windowed
|
|
321
|
+
``{full_id}_{window}.zarr``) whose id equals the concatenated
|
|
322
|
+
components. This is the walker's contract: any other name under the root
|
|
323
|
+
(bar the manifest and the root ``coverage.moc``) breaks child
|
|
324
|
+
classification.
|
|
325
|
+
"""
|
|
326
|
+
parts = rel_path.strip("/").split("/")
|
|
327
|
+
leaf = parts[-1]
|
|
328
|
+
ok = len(parts) >= 2 and leaf.endswith(".zarr")
|
|
329
|
+
if ok:
|
|
330
|
+
head, digits = parts[0], parts[1:-1]
|
|
331
|
+
try:
|
|
332
|
+
full_id, _window = split_leaf_name(leaf)
|
|
333
|
+
except ValueError:
|
|
334
|
+
full_id = None # malformed window label -> not a legal leaf
|
|
335
|
+
if full_id is not None and full_id.endswith("p"):
|
|
336
|
+
raise ValueError(
|
|
337
|
+
f"path {rel_path!r} carries the 'p' point kind-suffix: paths never "
|
|
338
|
+
f"do (spec §2/§6.6 — an order-29 path component reads as AREA per "
|
|
339
|
+
f"the §4 tie-break; kind rides the packed words)"
|
|
340
|
+
)
|
|
341
|
+
ok = is_base_component(head)
|
|
342
|
+
ok = ok and all(1 <= len(d) <= path_grouping and set(d) <= set("1234") for d in digits)
|
|
343
|
+
# Only the LAST component may be short (the remainder rides last).
|
|
344
|
+
ok = ok and all(len(d) == path_grouping for d in digits[:-1])
|
|
345
|
+
ok = ok and full_id == head + "".join(digits)
|
|
346
|
+
if not ok:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"path {rel_path!r} violates the hive node invariant (path_grouping={path_grouping})"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def parse_manifest(payload: object) -> dict:
|
|
353
|
+
"""Validate a ``morton_hive.json`` payload; returns it as a dict.
|
|
354
|
+
|
|
355
|
+
Loud on malformed input (a manifest is the reader's bootstrap — there is
|
|
356
|
+
no degraded mode without it): unknown ``spec``, missing/non-integer
|
|
357
|
+
orders, a ``/2`` manifest without its temporal block, or a temporal block
|
|
358
|
+
without a schedule all raise ``ValueError``.
|
|
359
|
+
"""
|
|
360
|
+
if not isinstance(payload, dict):
|
|
361
|
+
raise ValueError(f"manifest is not a mapping: {type(payload).__name__}")
|
|
362
|
+
spec = payload.get("spec")
|
|
363
|
+
if spec not in (HIVE_SPEC, HIVE_SPEC_V2):
|
|
364
|
+
raise ValueError(f"unknown manifest spec {spec!r} (expected {HIVE_SPEC} or {HIVE_SPEC_V2})")
|
|
365
|
+
for key in ("cell_order", "shard_order"):
|
|
366
|
+
value = payload.get(key)
|
|
367
|
+
if not isinstance(value, int):
|
|
368
|
+
raise ValueError(f"manifest {key} must be an integer (got {value!r})")
|
|
369
|
+
if payload["cell_order"] < payload["shard_order"]:
|
|
370
|
+
raise ValueError(
|
|
371
|
+
f"manifest cell_order {payload['cell_order']} is above shard_order "
|
|
372
|
+
f"{payload['shard_order']} (cells nest inside shards)"
|
|
373
|
+
)
|
|
374
|
+
grouping = payload.get("path_grouping", 1)
|
|
375
|
+
if isinstance(grouping, bool) or not isinstance(grouping, int) or grouping < 1:
|
|
376
|
+
# Spec §6.1 defines path_grouping as an integer digit count (absent
|
|
377
|
+
# reads as 1, D21); no list form exists in the spec grammar.
|
|
378
|
+
raise ValueError(f"manifest path_grouping must be an integer >= 1 (got {grouping!r})")
|
|
379
|
+
temporal = payload.get("temporal")
|
|
380
|
+
if spec == HIVE_SPEC_V2:
|
|
381
|
+
if not isinstance(temporal, dict) or not temporal.get("schedule"):
|
|
382
|
+
raise ValueError(f"a {HIVE_SPEC_V2} manifest requires a temporal block with a schedule")
|
|
383
|
+
elif temporal is not None:
|
|
384
|
+
raise ValueError(f"a {HIVE_SPEC} manifest must not carry a temporal block")
|
|
385
|
+
return payload
|