py4dggs 0.2.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.
py4dggs/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from py4dggs.grid import Grid
2
+ from py4dggs.zone import Zone
3
+ from py4dggs.registry import IGEO7, IVEA7H, RTEA7H, ISEA3H, IVEA3H, RTEA3H, get_grid
4
+ from py4dggs.types import GeoPoint, PlanarPoint, GridConfig, InvalidZoneError
5
+ from py4dggs import geojson
6
+ __all__ = ["Grid", "Zone", "IGEO7", "IVEA7H", "RTEA7H", "ISEA3H", "IVEA3H", "RTEA3H", "get_grid", "GeoPoint", "PlanarPoint", "GridConfig", "InvalidZoneError", "geojson"]
py4dggs/geojson.py ADDED
@@ -0,0 +1,276 @@
1
+ # src/py4dggs/geojson.py
2
+ """RFC 7946 GeoJSON output for cell boundaries.
3
+
4
+ `Grid.vertices` mirrors DGGAL's ``getZoneWGS84Vertices`` exactly: the raw
5
+ boundary points, longitudes in [-180, 180], in the engine's own order. That is
6
+ the library's correctness contract (the conformance suite asserts bit-equality
7
+ against pydggal), so **nothing here changes it**. This module sits above it and
8
+ solves the two problems that raw contract leaves to the caller:
9
+
10
+ * **Antimeridian.** A cell straddling +/-180 has vertices on both sides, so the
11
+ naive ring spans ~360 deg of longitude. Any planar consumer -- GeoJSON
12
+ readers, Shapely, Leaflet, PostGIS -- draws a sliver wrapped the wrong way
13
+ round the globe. Such a cell is cut at the antimeridian into a two-part
14
+ ``MultiPolygon``, per RFC 7946 s3.1.9.
15
+
16
+ * **Poles.** A cell enclosing a pole winds +/-360 deg in longitude but carries
17
+ no vertex at lat +/-90, so it cannot be closed as a planar ring at all. The
18
+ boundary is re-anchored to run from lon -180 to +180 and then closed over the
19
+ pole itself.
20
+
21
+ Every emitted exterior ring is closed (first point repeated) and wound
22
+ counterclockwise, per RFC 7946 s3.1.6.
23
+
24
+ The cut interpolates latitude linearly in longitude at the +/-180 meridian.
25
+ That is what mainstream antimeridian splitters do and is accurate to well under
26
+ a metre at any DGGS cell size; it is not a great-circle intersection.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from typing import Iterable
32
+
33
+ from py4dggs.types import InvalidZoneError
34
+
35
+ __all__ = ["zone_geometry", "zone_feature", "feature_collection"]
36
+
37
+ _FULL_TURN = 360.0
38
+ _HALF_TURN = 180.0
39
+ # A ring whose total longitude turn is within this of +/-360 deg encloses a pole.
40
+ _POLE_WINDING_TOLERANCE = 1.0
41
+
42
+
43
+ # --- longitude bookkeeping ------------------------------------------------- #
44
+
45
+ def _unwrap(ring: list[tuple[float, float]]) -> list[tuple[float, float]]:
46
+ """Make longitudes continuous, so consecutive steps are never > 180 deg.
47
+
48
+ The result may leave [-180, 180]; that is the point. It turns a ring that
49
+ "jumps" across the antimeridian into a single connected path that can be
50
+ reasoned about with ordinary planar geometry.
51
+ """
52
+ out = [ring[0]]
53
+ for lon, lat in ring[1:]:
54
+ prev = out[-1][0]
55
+ delta = lon - prev
56
+ delta -= _FULL_TURN * round(delta / _FULL_TURN)
57
+ out.append((prev + delta, lat))
58
+ return out
59
+
60
+
61
+ def _winding(unwrapped: list[tuple[float, float]]) -> float:
62
+ """Total longitude turn around the closed ring, including the closing edge.
63
+
64
+ ~0 for an ordinary cell; ~+/-360 when the ring encircles a pole.
65
+ """
66
+ first, last = unwrapped[0][0], unwrapped[-1][0]
67
+ closing = first - last
68
+ closing -= _FULL_TURN * round(closing / _FULL_TURN)
69
+ return (last - first) + closing
70
+
71
+
72
+ def _oriented(ring: list[tuple[float, float]]) -> tuple[list[tuple[float, float]], float]:
73
+ """Unwrap, and flip the traversal so the winding is non-negative."""
74
+ unwrapped = _unwrap(ring)
75
+ turn = _winding(unwrapped)
76
+ if turn < 0:
77
+ unwrapped = _unwrap(list(reversed(ring)))
78
+ turn = -turn
79
+ return unwrapped, turn
80
+
81
+
82
+ def _wrap_lon(lon: float) -> float:
83
+ """Fold a longitude into [-180, 180], leaving the poles' +/-180 intact."""
84
+ if -_HALF_TURN <= lon <= _HALF_TURN:
85
+ return lon
86
+ return lon - _FULL_TURN * round(lon / _FULL_TURN)
87
+
88
+
89
+ # --- ring hygiene ---------------------------------------------------------- #
90
+
91
+ def _signed_area(ring: list[tuple[float, float]]) -> float:
92
+ """Planar shoelace over a closed ring. Positive == counterclockwise."""
93
+ return 0.5 * sum(
94
+ ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]
95
+ for i in range(len(ring) - 1)
96
+ )
97
+
98
+
99
+ def _close_and_orient(ring: list[tuple[float, float]]) -> list[list[float]]:
100
+ """Repeat the first point and force counterclockwise winding (RFC 7946
101
+ s3.1.6). Applied to every emitted ring, so no earlier step has to reason
102
+ about traversal direction."""
103
+ closed = list(ring)
104
+ if closed[0] != closed[-1]:
105
+ closed.append(closed[0])
106
+ if _signed_area(closed) < 0:
107
+ closed.reverse()
108
+ return [[lon, lat] for lon, lat in closed]
109
+
110
+
111
+ def _is_degenerate(ring: list[tuple[float, float]]) -> bool:
112
+ """A clipped part that collapsed to a line or a point carries no area."""
113
+ if len(ring) < 3:
114
+ return True
115
+ distinct = {(round(lon, 9), round(lat, 9)) for lon, lat in ring}
116
+ if len(distinct) < 3:
117
+ return True
118
+ closed = list(ring) + [ring[0]]
119
+ return abs(_signed_area(closed)) < 1e-12
120
+
121
+
122
+ # --- the antimeridian cut -------------------------------------------------- #
123
+
124
+ def _clip_half_plane(
125
+ ring: list[tuple[float, float]], meridian: float, keep_below: bool
126
+ ) -> list[tuple[float, float]]:
127
+ """Sutherland-Hodgman clip of a ring against a vertical line in lon.
128
+
129
+ `keep_below` keeps the lon <= meridian side, else the lon >= side. Latitude
130
+ at the cut is interpolated linearly in longitude.
131
+ """
132
+ out: list[tuple[float, float]] = []
133
+ n = len(ring)
134
+ for i in range(n):
135
+ lon1, lat1 = ring[i]
136
+ lon2, lat2 = ring[(i + 1) % n]
137
+ in1 = lon1 <= meridian if keep_below else lon1 >= meridian
138
+ in2 = lon2 <= meridian if keep_below else lon2 >= meridian
139
+ if in1:
140
+ out.append((lon1, lat1))
141
+ if in1 != in2 and lon2 != lon1:
142
+ t = (meridian - lon1) / (lon2 - lon1)
143
+ out.append((meridian, lat1 + t * (lat2 - lat1)))
144
+ return out
145
+
146
+
147
+ def _split_at_antimeridian(
148
+ unwrapped: list[tuple[float, float]], meridian: float
149
+ ) -> list[list[tuple[float, float]]]:
150
+ """Cut at +180 or -180 and bring the far side back into [-180, 180]."""
151
+ below = _clip_half_plane(unwrapped, meridian, keep_below=True)
152
+ above = _clip_half_plane(unwrapped, meridian, keep_below=False)
153
+ # Exactly one side lies outside [-180, 180] and must be translated back.
154
+ shift_below, shift_above = (0.0, -_FULL_TURN) if meridian > 0 else (_FULL_TURN, 0.0)
155
+ parts = [
156
+ [(lon + shift_below, lat) for lon, lat in below],
157
+ [(lon + shift_above, lat) for lon, lat in above],
158
+ ]
159
+ return [p for p in parts if p and not _is_degenerate(p)]
160
+
161
+
162
+ # --- the polar cap --------------------------------------------------------- #
163
+
164
+ def _close_over_pole(
165
+ unwrapped: list[tuple[float, float]], turn: float
166
+ ) -> list[tuple[float, float]]:
167
+ """Re-anchor a pole-encircling boundary to run -180 -> +180, then close it
168
+ across the pole.
169
+
170
+ `unwrapped` is already oriented so longitude increases along the ring, and
171
+ `turn` is ~+360. The ring is re-cut at whichever antimeridian crossing it
172
+ contains, so the emitted boundary starts at lon -180 and ends at +180; the
173
+ two pole corners then seal it.
174
+ """
175
+ start_lon = unwrapped[0][0]
176
+ # The path spans [start_lon, start_lon + 360). Find the antimeridian inside it.
177
+ k = math.ceil((start_lon + _HALF_TURN) / _FULL_TURN)
178
+ cut = -_HALF_TURN + _FULL_TURN * k
179
+
180
+ # Walk the closed turn: the ring plus its own first point, one turn on.
181
+ path = unwrapped + [(start_lon + _FULL_TURN, unwrapped[0][1])]
182
+ lat_at_cut = None
183
+ for i in range(len(path) - 1):
184
+ lon1, lat1 = path[i]
185
+ lon2, lat2 = path[i + 1]
186
+ if lon1 <= cut <= lon2 and lon2 != lon1:
187
+ lat_at_cut = lat1 + (cut - lon1) / (lon2 - lon1) * (lat2 - lat1)
188
+ break
189
+ if lat_at_cut is None: # pragma: no cover - defensive
190
+ lat_at_cut = path[0][1]
191
+
192
+ # Rotate the cyclic ring to begin at the first vertex past the cut, lifting
193
+ # the wrapped-around tail by one turn so longitudes ascend across [cut,
194
+ # cut + 360). Rotating (rather than filtering by longitude) is what keeps
195
+ # every vertex: the cut rarely sits at the ring's own starting longitude.
196
+ count = len(unwrapped)
197
+ start = next((i for i, (lon, _) in enumerate(unwrapped) if lon >= cut), count)
198
+ ordered = []
199
+ for step in range(count):
200
+ index = start + step
201
+ lon, lat = unwrapped[index % count]
202
+ ordered.append((lon + (_FULL_TURN if index >= count else 0.0), lat))
203
+
204
+ shift = -_FULL_TURN * k # a whole number of turns: a true re-wrap
205
+ boundary = [(cut + shift, lat_at_cut)]
206
+ boundary += [(lon + shift, lat) for lon, lat in ordered]
207
+ boundary.append((cut + _FULL_TURN + shift, lat_at_cut))
208
+
209
+ pole_lat = 90.0 if sum(lat for _, lat in unwrapped) > 0 else -90.0
210
+ boundary.append((_HALF_TURN, pole_lat))
211
+ boundary.append((-_HALF_TURN, pole_lat))
212
+ return boundary
213
+
214
+
215
+ # --- public API ------------------------------------------------------------ #
216
+
217
+ def _rings_for(zone) -> list[list[list[float]]]:
218
+ vertices = zone.vertices
219
+ if not vertices or all(v.lat == 0.0 and v.lon == 0.0 for v in vertices):
220
+ raise InvalidZoneError(
221
+ f"zone {zone.value} has no geometry (DGGAL nullZone); nothing to render"
222
+ )
223
+
224
+ ring = [(v.lon, v.lat) for v in vertices]
225
+ unwrapped, turn = _oriented(ring)
226
+
227
+ if abs(turn - _FULL_TURN) < _POLE_WINDING_TOLERANCE:
228
+ return [_close_and_orient(_close_over_pole(unwrapped, turn))]
229
+
230
+ lons = [lon for lon, _ in unwrapped]
231
+ for meridian in (_HALF_TURN, -_HALF_TURN):
232
+ if min(lons) < meridian < max(lons):
233
+ parts = _split_at_antimeridian(unwrapped, meridian)
234
+ if len(parts) > 1:
235
+ return [_close_and_orient(p) for p in parts]
236
+ unwrapped = parts[0] if parts else unwrapped
237
+ break
238
+
239
+ return [_close_and_orient([(_wrap_lon(lon), lat) for lon, lat in unwrapped])]
240
+
241
+
242
+ def zone_geometry(zone) -> dict:
243
+ """The zone's boundary as an RFC 7946 geometry.
244
+
245
+ Returns a ``Polygon`` normally, or a two-part ``MultiPolygon`` when the cell
246
+ straddles the antimeridian. A cell enclosing a pole comes back as a
247
+ ``Polygon`` closed over the pole. Raises `InvalidZoneError` for a zone with
248
+ no geometry (DGGAL's nullZone), rather than emitting a degenerate shape.
249
+ """
250
+ rings = _rings_for(zone)
251
+ if len(rings) == 1:
252
+ return {"type": "Polygon", "coordinates": [rings[0]]}
253
+ return {"type": "MultiPolygon", "coordinates": [[r] for r in rings]}
254
+
255
+
256
+ def zone_feature(zone, properties: dict | None = None) -> dict:
257
+ """The zone as a GeoJSON Feature. Defaults to carrying its text id; pass
258
+ `properties` to replace that wholesale."""
259
+ return {
260
+ "type": "Feature",
261
+ "geometry": zone_geometry(zone),
262
+ "properties": {"zone": zone.text_id} if properties is None else properties,
263
+ }
264
+
265
+
266
+ def feature_collection(zones: Iterable, properties=None) -> dict:
267
+ """A FeatureCollection over any iterable of zones.
268
+
269
+ `properties` may be a dict applied to every feature, or a callable taking a
270
+ zone and returning its properties.
271
+ """
272
+ resolve = properties if callable(properties) else lambda _zone: properties
273
+ return {
274
+ "type": "FeatureCollection",
275
+ "features": [zone_feature(zone, resolve(zone)) for zone in zones],
276
+ }
py4dggs/grid.py ADDED
@@ -0,0 +1,253 @@
1
+ # src/py4dggs/grid.py
2
+ """A Grid is one DGGRS = Projection + Topology + Indexing. Operations needing
3
+ all three (notably the grid-agnostic geographic edge-crossing k-ring) live
4
+ here."""
5
+ from __future__ import annotations
6
+ import math
7
+ from dataclasses import dataclass
8
+ from py4dggs.interfaces import Projection, Topology, Indexing
9
+ from py4dggs.types import GeoPoint, GridConfig, InvalidZoneError
10
+
11
+ def _ll_to_xyz(lat, lon):
12
+ la, lo = math.radians(lat), math.radians(lon); cla = math.cos(la)
13
+ return [cla * math.cos(lo), cla * math.sin(lo), math.sin(la)]
14
+
15
+ def _xyz_to_ll(v):
16
+ n = math.sqrt(sum(k * k for k in v)); x, y, z = (k / n for k in v)
17
+ return math.degrees(math.asin(max(-1.0, min(1.0, z)))), math.degrees(math.atan2(y, x))
18
+
19
+
20
+ # DGGAL's geometry for a zone that has none: getZoneWGS84Centroid/Vertices on a
21
+ # nullZone give the zero point and six zero vertices (six regardless of whether
22
+ # the address is a pentagon path — verified against pydggal on all three
23
+ # aperture-7 grids). See `Topology.is_null_geometry` in interfaces.py.
24
+ _NULL_GEOPOINT = GeoPoint(0.0, 0.0)
25
+ _NULL_VERTICES = tuple(GeoPoint(0.0, 0.0) for _ in range(6))
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Grid:
30
+ projection: Projection
31
+ topology: Topology
32
+ indexing: Indexing
33
+ config: GridConfig
34
+ name: str
35
+
36
+ def __post_init__(self):
37
+ object.__setattr__(self, "_geom", self.projection.build_geometry(self.config))
38
+
39
+ # --- indexing entry points ---
40
+ def zone_from_geo(self, lat: float, lon: float, res: int):
41
+ if not 0 <= res <= self.indexing.max_resolution:
42
+ raise InvalidZoneError(f"resolution {res} out of range 0..{self.indexing.max_resolution}")
43
+ p = self.projection.forward(self._geom, lat, lon)
44
+ base, digits = self.topology.quantize(self._geom, p, res)
45
+ from py4dggs.zone import Zone
46
+ return Zone(self, self.indexing.encode(base, digits))
47
+
48
+ def zone_from_text(self, text: str):
49
+ """Construct a Zone from its canonical Z7 text id (validated via the indexing)."""
50
+ from py4dggs.zone import Zone
51
+ return Zone(self, self.indexing.from_text(text))
52
+
53
+ # --- geometry: planar (topology) -> geographic (projection) ---
54
+ def _is_null_geometry(self, base: int, digits: list[int]) -> bool:
55
+ """Whether the topology declares this address geometry-less (DGGAL's
56
+ `nullZone`). Optional capability — absent means "always has geometry",
57
+ which is right for every topology that has no degenerate level."""
58
+ fn = getattr(self.topology, "is_null_geometry", None)
59
+ return fn is not None and fn(self._geom, base, digits)
60
+
61
+ def centroid(self, value: int) -> GeoPoint:
62
+ base, digits = self.indexing.decode(value)
63
+ if self._is_null_geometry(base, digits):
64
+ return _NULL_GEOPOINT
65
+ p = self.topology.planar_centroid(self._geom, base, digits)
66
+ return self.projection.inverse(self._geom, p)
67
+
68
+ def vertices(self, value: int) -> tuple[GeoPoint, ...]:
69
+ base, digits = self.indexing.decode(value)
70
+ if self._is_null_geometry(base, digits):
71
+ return _NULL_VERTICES
72
+ ps = self.topology.planar_vertices(self._geom, base, digits)
73
+ return tuple(self.projection.inverse(self._geom, p) for p in ps)
74
+
75
+ # --- neighbours: exact topology override if provided, else grid-agnostic k-ring ---
76
+ def neighbors(self, value: int) -> tuple[int, ...]:
77
+ base, digits = self.indexing.decode(value)
78
+ # A zone with no geometry has no neighbours: pydggal's getZoneNeighbors
79
+ # returns [] for a Z7 level-20 zone. Checked before the k-ring below,
80
+ # which would otherwise reflect the degenerate (0,0) vertices back
81
+ # through quantize and raise a bare IndexError (it did so even before
82
+ # is_null_geometry existed, when the vertices were merely meaningless
83
+ # rather than zero).
84
+ if self._is_null_geometry(base, digits):
85
+ return ()
86
+ # A Topology MAY expose an exact `neighbors` (aperture-3 does — it ports
87
+ # DGGAL's exact adjacency); prefer it. Aperture-7 has none, so it falls
88
+ # through to the grid-agnostic edge k-ring below (proven exact for 7H).
89
+ nb_fn = getattr(self.topology, "neighbors", None)
90
+ if nb_fn is not None:
91
+ return tuple(self.indexing.encode(b, d) for (b, d) in nb_fn(self._geom, base, digits))
92
+ # --- grid-agnostic geographic edge-crossing k-ring ---
93
+ res = self.indexing.resolution(value)
94
+ c = self.centroid(value); verts = self.vertices(value)
95
+ c3 = _ll_to_xyz(c.lat, c.lon); n = len(verts)
96
+ origin, out, seen = value, [], set()
97
+ for i in range(n):
98
+ v0 = _ll_to_xyz(*verts[i]); v1 = _ll_to_xyz(*verts[(i + 1) % n])
99
+ t = [v0[k] + v1[k] - c3[k] for k in range(3)]
100
+ lat, lon = _xyz_to_ll(t)
101
+ p = self.projection.forward(self._geom, lat, lon)
102
+ base, digits = self.topology.quantize(self._geom, p, res)
103
+ nv = self.indexing.encode(base, digits)
104
+ if nv == origin or nv in seen:
105
+ continue
106
+ seen.add(nv); out.append(nv)
107
+ return tuple(out)
108
+
109
+ # --- hierarchy: exact topology override if provided, else congruent digit-path ---
110
+ def parents(self, value: int) -> tuple[int, ...]:
111
+ fn = getattr(self.topology, "parents", None)
112
+ if fn is not None:
113
+ base, digits = self.indexing.decode(value)
114
+ return tuple(self.indexing.encode(b, d) for (b, d) in fn(self._geom, base, digits))
115
+ p = self.indexing.parent(value)
116
+ return () if p is None else (p,)
117
+
118
+ def children(self, value: int) -> tuple[int, ...]:
119
+ fn = getattr(self.topology, "children", None)
120
+ base, digits = self.indexing.decode(value)
121
+ if fn is not None:
122
+ return tuple(self.indexing.encode(b, d) for (b, d) in fn(self._geom, base, digits))
123
+ # congruent digit-path default (Z7): a max-resolution zone has no
124
+ # children -- you cannot refine past max_resolution (a further digit
125
+ # does not fit the packing). Mirrors parent() -> () at resolution 0.
126
+ if self.indexing.resolution(value) >= self.indexing.max_resolution:
127
+ return ()
128
+ return tuple(self.indexing.encode(base, digits + [d]) for d in self.indexing.child_digits(value))
129
+
130
+ def centroid_parent(self, value: int) -> int | None:
131
+ fn = getattr(self.topology, "centroid_parent", None)
132
+ if fn is not None:
133
+ base, digits = self.indexing.decode(value)
134
+ r = fn(self._geom, base, digits)
135
+ return None if r is None else self.indexing.encode(*r)
136
+ return self.indexing.parent(value) # congruent: the one parent IS the centroid parent
137
+
138
+ def is_centroid_child(self, value: int) -> bool:
139
+ fn = getattr(self.topology, "is_centroid_child", None)
140
+ if fn is not None:
141
+ base, digits = self.indexing.decode(value)
142
+ return fn(self._geom, base, digits)
143
+ return True # congruent: every cell has exactly one parent
144
+
145
+ # --- sub-zones: required topology override (no congruent-digit default exists) ---
146
+ #: Ceiling on how many sub-zones :meth:`sub_zones` will materialise into a
147
+ #: tuple. `count_sub_zones` is a cheap closed form, but `sub_zones` builds
148
+ #: the whole sequence, and `relative_depth` was validated only against
149
+ #: `max_resolution` -- never against result *cardinality*. On an I3H grid at
150
+ #: resolution 0, depth 33 counts 4,632,550,579,746,406 sub-zones and passed
151
+ #: every guard, so a caller-supplied depth (the README's DGGS-as-tile-store
152
+ #: pattern) was a one-line denial of service. 4 million keeps every
153
+ #: realistic tiling request working -- an I3H cell has 6 sub-zones at depth
154
+ #: 1 and ~4.6M at depth 16 -- while refusing the unservable ones outright.
155
+ #: Callers who want the count without the cost still call `count_sub_zones`.
156
+ MAX_MATERIALISED_SUB_ZONES = 4_000_000
157
+
158
+ def _sub_zone_fn(self, name: str, value: int, relative_depth: int):
159
+ """Shared guard for count_sub_zones/first_sub_zone/sub_zones: resolve
160
+ the topology override (or raise), then reject the two invalid-input
161
+ cases that would otherwise reach the depth-dependent geometric
162
+ generators, which aren't designed for them and misbehave silently
163
+ (a negative depth turns `3**((depth-1)//2)`-style exponents into
164
+ floats; an over-deep depth produces a zone past `max_resolution`
165
+ with no error, since only `zone_from_geo` -- not the sub-zone path --
166
+ validates against it)."""
167
+ fn = getattr(self.topology, name, None)
168
+ if fn is None:
169
+ raise NotImplementedError("this grid has no sub-zone order")
170
+ if relative_depth < 0:
171
+ raise ValueError(f"relative_depth must be >= 0, got {relative_depth}")
172
+ target_res = self.indexing.resolution(value) + relative_depth
173
+ if target_res > self.indexing.max_resolution:
174
+ raise InvalidZoneError(
175
+ f"relative_depth {relative_depth} would reach resolution {target_res}, "
176
+ f"past max_resolution {self.indexing.max_resolution}"
177
+ )
178
+ return fn
179
+
180
+ def count_sub_zones(self, value: int, relative_depth: int) -> int:
181
+ fn = self._sub_zone_fn("count_sub_zones", value, relative_depth)
182
+ if relative_depth == 0:
183
+ # I3H's own override treats depth 0 as "the zone itself" (eC
184
+ # getI3HSubZoneCentroids, I3HSubZones.ec:1787-1790: "if(rDepth > 0)
185
+ # ... else centroids[0] = zone.centroid" -- the base DGGRS class's
186
+ # getSubZoneCRSCentroids is a null stub, not a generic dispatcher).
187
+ # We hoist that semantic to the Grid level (deliberate
188
+ # generalization, not a claim about where DGGAL implements it --
189
+ # "depth 0 = same level = the zone itself" is universal to any
190
+ # DGGS) because it's true regardless of grid/cell-class (verified
191
+ # against pydggal's getSubZones for interior hex, non-polar
192
+ # pentagon and polar pentagon -- all three return exactly [value]
193
+ # at depth 0, even though pydggal's own raw per-grid
194
+ # getFirstSubZone(zone, 0) is NOT self-consistent for the
195
+ # pentagon/pole cases, an oracle quirk this generic short-circuit
196
+ # deliberately does not replicate). Handled here, above the topology
197
+ # call, so it never reaches the depth-dependent geometric generators
198
+ # (which are not designed for depth 0 and would misbehave).
199
+ return 1
200
+ base, digits = self.indexing.decode(value)
201
+ return fn(self._geom, base, digits, relative_depth)
202
+
203
+ def first_sub_zone(self, value: int, relative_depth: int) -> int:
204
+ fn = self._sub_zone_fn("first_sub_zone", value, relative_depth)
205
+ if relative_depth == 0:
206
+ return value # see count_sub_zones' depth-0 note
207
+ base, digits = self.indexing.decode(value)
208
+ b, d = fn(self._geom, base, digits, relative_depth)
209
+ return self.indexing.encode(b, d)
210
+
211
+ def sub_zones(self, value: int, relative_depth: int) -> tuple:
212
+ fn = self._sub_zone_fn("sub_zones", value, relative_depth)
213
+ if relative_depth == 0:
214
+ return (value,) # see count_sub_zones' depth-0 note
215
+ n = self.count_sub_zones(value, relative_depth)
216
+ if n > self.MAX_MATERIALISED_SUB_ZONES:
217
+ raise InvalidZoneError(
218
+ f"relative_depth {relative_depth} yields {n} sub-zones, past the "
219
+ f"{self.MAX_MATERIALISED_SUB_ZONES} materialisation limit; use "
220
+ f"count_sub_zones() for the count, or refine in smaller steps"
221
+ )
222
+ base, digits = self.indexing.decode(value)
223
+ return tuple(self.indexing.encode(b, d) for (b, d) in fn(self._geom, base, digits, relative_depth))
224
+
225
+ def sub_zone_index(self, value: int, sub_zone_value: int) -> int:
226
+ """Generic (dggrs.ec:115-131): build the full ordered list, find the
227
+ position. Not the generators' internal index>=0 fast-forward path
228
+ (explicitly out of scope -- see the A3 design spec)."""
229
+ # caller must know the sub-zone's relative depth; derive it from level
230
+ sub_level = self.indexing.resolution(sub_zone_value)
231
+ parent_level = self.indexing.resolution(value)
232
+ relative_depth = sub_level - parent_level
233
+ if relative_depth == 0:
234
+ # depth 0 = "the zone itself": sub_zones(v, 0) == (v,),
235
+ # count_sub_zones(v, 0) == 1 and sub_zone_at_index(v, 0, 0) == v, so
236
+ # a zone IS its own sub-zone at index 0. Folding this into the
237
+ # `< 0` rejection reported a real sub-zone as not-a-descendant.
238
+ return 0 if sub_zone_value == value else -1
239
+ if relative_depth < 0:
240
+ return -1
241
+ subs = self.sub_zones(value, relative_depth)
242
+ try:
243
+ return subs.index(sub_zone_value)
244
+ except ValueError:
245
+ return -1
246
+
247
+ def sub_zone_at_index(self, value: int, relative_depth: int, index: int) -> int:
248
+ """Generic (dggrs.ec:133-149)."""
249
+ if index < 0 or index >= self.count_sub_zones(value, relative_depth):
250
+ raise IndexError(f"index {index} out of range for relative_depth {relative_depth}")
251
+ if index == 0:
252
+ return self.first_sub_zone(value, relative_depth)
253
+ return self.sub_zones(value, relative_depth)[index]
@@ -0,0 +1,2 @@
1
+ # src/py4dggs/indexings/__init__.py
2
+ """Concrete Indexing implementations for each supported DGGRS."""