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 +6 -0
- py4dggs/geojson.py +276 -0
- py4dggs/grid.py +253 -0
- py4dggs/indexings/__init__.py +2 -0
- py4dggs/indexings/i3h.py +217 -0
- py4dggs/indexings/z7.py +218 -0
- py4dggs/interfaces.py +112 -0
- py4dggs/projections/__init__.py +6 -0
- py4dggs/projections/icovertex.py +789 -0
- py4dggs/projections/isea.py +10 -0
- py4dggs/projections/ivea.py +9 -0
- py4dggs/projections/rtea.py +9 -0
- py4dggs/registry.py +24 -0
- py4dggs/topologies/__init__.py +6 -0
- py4dggs/topologies/hex_a3.py +2558 -0
- py4dggs/topologies/hex_a7.py +1315 -0
- py4dggs/types.py +52 -0
- py4dggs/zone.py +120 -0
- py4dggs-0.2.0.dist-info/METADATA +334 -0
- py4dggs-0.2.0.dist-info/RECORD +21 -0
- py4dggs-0.2.0.dist-info/WHEEL +4 -0
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]
|