lunar-toolkit 0.1.0.dev2__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.
Files changed (55) hide show
  1. lunar_toolkit/__init__.py +6 -0
  2. lunar_toolkit/asp.py +432 -0
  3. lunar_toolkit/cli/__init__.py +88 -0
  4. lunar_toolkit/cli/_jobs.py +437 -0
  5. lunar_toolkit/cli/_status.py +368 -0
  6. lunar_toolkit/cli/_utils.py +128 -0
  7. lunar_toolkit/cli/app.py +205 -0
  8. lunar_toolkit/cli/build_db.py +130 -0
  9. lunar_toolkit/cli/download.py +111 -0
  10. lunar_toolkit/cli/kaguya.py +253 -0
  11. lunar_toolkit/cli/ode_search.py +144 -0
  12. lunar_toolkit/cli/pairs.py +236 -0
  13. lunar_toolkit/cli/process.py +138 -0
  14. lunar_toolkit/cli/query.py +70 -0
  15. lunar_toolkit/cli/reference.py +233 -0
  16. lunar_toolkit/cli/search.py +90 -0
  17. lunar_toolkit/cli/serve.py +706 -0
  18. lunar_toolkit/cli/sfs.py +344 -0
  19. lunar_toolkit/cli/sfs_report.py +148 -0
  20. lunar_toolkit/cli/shadowcam.py +246 -0
  21. lunar_toolkit/cli/stereo.py +85 -0
  22. lunar_toolkit/cli/workspace.py +84 -0
  23. lunar_toolkit/config.py +345 -0
  24. lunar_toolkit/constants.py +217 -0
  25. lunar_toolkit/csm.py +340 -0
  26. lunar_toolkit/downloader.py +447 -0
  27. lunar_toolkit/geometry.py +556 -0
  28. lunar_toolkit/isis.py +222 -0
  29. lunar_toolkit/kaguya/__init__.py +45 -0
  30. lunar_toolkit/kaguya/mi.py +274 -0
  31. lunar_toolkit/kaguya/tc.py +370 -0
  32. lunar_toolkit/lroc/__init__.py +6 -0
  33. lunar_toolkit/lroc/cumindex.py +1197 -0
  34. lunar_toolkit/lroc/nac.py +702 -0
  35. lunar_toolkit/observation.py +127 -0
  36. lunar_toolkit/ode.py +485 -0
  37. lunar_toolkit/provenance.py +312 -0
  38. lunar_toolkit/reference.py +1111 -0
  39. lunar_toolkit/selection.py +714 -0
  40. lunar_toolkit/sfs.py +1534 -0
  41. lunar_toolkit/sfs_products.py +676 -0
  42. lunar_toolkit/sfs_report.py +1328 -0
  43. lunar_toolkit/shadowcam/__init__.py +29 -0
  44. lunar_toolkit/shadowcam/sc.py +392 -0
  45. lunar_toolkit/static/index.html +3259 -0
  46. lunar_toolkit/stereo.py +519 -0
  47. lunar_toolkit/stereo_dem.py +145 -0
  48. lunar_toolkit/tools.py +264 -0
  49. lunar_toolkit/workspace.py +1173 -0
  50. lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
  51. lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
  52. lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
  53. lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  54. lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
  55. lunar_toolkit-0.1.0.dev2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,556 @@
1
+ """Search regions and AOI polygons (lon/lat, 0–360 east; polar stereographic past ±60°)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, TypeAlias
12
+
13
+ import shapely.geometry
14
+ import shapely.wkt
15
+ from shapely.affinity import translate
16
+ from shapely.geometry import MultiPoint, Polygon
17
+ from shapely.ops import unary_union
18
+ from shapely.prepared import prep
19
+ from shapely.validation import make_valid
20
+
21
+ from .constants import FOOTPRINT_CORNERS, MOON_RADIUS_KM, POLAR_LAT_THRESHOLD
22
+
23
+ if TYPE_CHECKING: # pragma: no cover
24
+ import pandas as pd
25
+
26
+
27
+ def ps_xy(lat: float, lon: float, hemi: str) -> tuple[float, float]:
28
+ """Polar stereographic (x, y) in metres; mirrors the SQL ``_ps_*`` macros."""
29
+ sgn = 1.0 if hemi == "S" else -1.0
30
+ rho = 2.0 * MOON_RADIUS_KM * 1000.0 * math.tan(math.pi / 4 + sgn * math.radians(lat) / 2)
31
+ return rho * math.sin(math.radians(lon)), sgn * rho * math.cos(math.radians(lon))
32
+
33
+
34
+ def _ps_hemis(s: float, n: float) -> list[str]:
35
+ """Hemispheres (``'S'`` / ``'N'``) a latitude range reaches poleward of ±60°."""
36
+ return [h for h, past in (("S", s < -POLAR_LAT_THRESHOLD), ("N", n > POLAR_LAT_THRESHOLD)) if past]
37
+
38
+
39
+ def query_region(
40
+ lat: tuple[float, float] | None = None,
41
+ lon: tuple[float, float] | None = None,
42
+ point: tuple[float, float] | None = None,
43
+ radius_km: float = 0.0,
44
+ ) -> dict:
45
+ """Normalise a bbox or point+radius into lat range, lon arc and PS boxes."""
46
+ if point is not None:
47
+ plat, plon = float(point[0]), float(point[1])
48
+ delta = math.degrees(radius_km / MOON_RADIUS_KM)
49
+ s, n = plat - delta, plat + delta
50
+ if n >= 90 or s <= -90 or radius_km <= 0 and abs(plat) >= 90:
51
+ west, span = 0.0, 360.0
52
+ else:
53
+ ratio = math.sin(math.radians(delta)) / math.cos(math.radians(plat))
54
+ dlon = 90.0 if ratio >= 1 else math.degrees(math.asin(ratio))
55
+ west, span = (plon - dlon) % 360.0, min(2 * dlon, 360.0)
56
+ s, n = max(s, -90.0), min(n, 90.0)
57
+ ps: dict[str, tuple[float, float, float, float]] = {}
58
+ for hemi in _ps_hemis(s, n):
59
+ x, y = ps_xy(plat, plon, hemi)
60
+ # PS scale k = 2 / (1 + sin|φ|); use the largest k in the circle
61
+ edge = min(abs(s), abs(n)) if s * n > 0 else 0.0
62
+ k = 2.0 / (1.0 + math.sin(math.radians(edge)))
63
+ r = radius_km * 1000.0 * k * 1.01 + 1.0
64
+ ps[hemi] = (x - r, x + r, y - r, y + r)
65
+ return {"lat": (s, n), "west": west, "span": span, "ps": ps}
66
+
67
+ s, n = (float(lat[0]), float(lat[1])) if lat is not None else (-90.0, 90.0)
68
+ if s > n:
69
+ s, n = n, s
70
+ if lon is None or float(lon[1]) - float(lon[0]) >= 360.0:
71
+ west, span = 0.0, 360.0
72
+ else:
73
+ west = float(lon[0]) % 360.0
74
+ span = (float(lon[1]) - float(lon[0])) % 360.0
75
+ ps = {hemi: _ps_bbox_of_box(s, n, west, span, hemi) for hemi in _ps_hemis(s, n)}
76
+ return {"lat": (s, n), "west": west, "span": span, "ps": ps}
77
+
78
+
79
+ def _ps_bbox_of_box(
80
+ s: float, n: float, west: float, span: float, hemi: str, samples: int = 181
81
+ ) -> tuple[float, float, float, float]:
82
+ """Projected bbox of a lat/lon box by sampling its boundary."""
83
+ # Only the part of the box on this hemisphere's side of ±60° matters for
84
+ # rows that carry PS coordinates, but clipping at the equator is enough.
85
+ if hemi == "S":
86
+ s2, n2 = s, min(n, 0.0)
87
+ else:
88
+ s2, n2 = max(s, 0.0), n
89
+ pts = []
90
+ for i in range(samples):
91
+ f = i / (samples - 1)
92
+ lo = west + span * f
93
+ la = s2 + (n2 - s2) * f
94
+ pts += [(s2, lo), (n2, lo), (la, west), (la, west + span)]
95
+ xy = [ps_xy(a, b, hemi) for a, b in pts]
96
+ xs, ys = [p[0] for p in xy], [p[1] for p in xy]
97
+ pole_lat = -90.0 if hemi == "S" else 90.0
98
+ if span >= 360.0 and s2 <= pole_lat <= n2:
99
+ xs.append(0.0)
100
+ ys.append(0.0)
101
+ pad = 0.005 * max(max(xs) - min(xs), max(ys) - min(ys), 1.0)
102
+ return min(xs) - pad, max(xs) + pad, min(ys) - pad, max(ys) + pad
103
+
104
+
105
+ def region_bbox(region: dict) -> tuple[float, float, float, float]:
106
+ """``(west, south, east, north)`` of a :func:`query_region` (east may be < west)."""
107
+ s, n = region["lat"]
108
+ if region["span"] >= 360.0:
109
+ return 0.0, s, 360.0, n
110
+ west = region["west"]
111
+ east = (west + region["span"]) % 360.0
112
+ return west, s, east, n
113
+
114
+
115
+ Ring = list[tuple[float, float]] # open ring of (lon, lat) vertices, degrees
116
+ AoiLike: TypeAlias = "Aoi | dict | str | Path" # anything load_aoi accepts
117
+ Projection = Callable[[Ring], list[tuple[float, float]]] # lon/lat ring -> planar (x, y) ring
118
+
119
+ _WKT_COLUMNS = ("FP_WKT", "footprint_wkt")
120
+
121
+
122
+ @dataclass
123
+ class Aoi:
124
+ """Area of interest: one or more lon/lat polygons (degrees, positive east).
125
+
126
+ ``polygons`` holds, per polygon, its exterior ring followed by any holes.
127
+ Rings are open (no repeated closing vertex), densified and longitude
128
+ unwrapped so consecutive vertices never jump across the 0/360 seam
129
+ (values may lie outside 0..360). A ring whose longitudes wind once
130
+ around the globe encloses a pole.
131
+ """
132
+
133
+ polygons: list[list[Ring]]
134
+ source: str = ""
135
+
136
+ @property
137
+ def wkt(self) -> str:
138
+ """``MULTIPOLYGON`` WKT of the (unwrapped) rings."""
139
+
140
+ def ring_wkt(r: Ring) -> str:
141
+ return "(" + ", ".join(f"{lo:.6f} {la:.6f}" for lo, la in r + r[:1]) + ")"
142
+
143
+ return (
144
+ "MULTIPOLYGON("
145
+ + ", ".join("(" + ", ".join(ring_wkt(r) for r in poly) + ")" for poly in self.polygons)
146
+ + ")"
147
+ )
148
+
149
+
150
+ _WKT_RE = re.compile(
151
+ r"^\s*((MULTI)?(POINT|LINESTRING|POLYGON)|GEOMETRYCOLLECTION)\s*(Z|M|ZM)?\s*(\(|EMPTY)", re.IGNORECASE
152
+ )
153
+
154
+
155
+ def _is_file(text: str) -> bool:
156
+ """True when *text* names an existing file (WKT / GeoJSON strings never do)."""
157
+ if not text or "\n" in text or text.startswith("{") or _WKT_RE.match(text):
158
+ return False
159
+ try:
160
+ return Path(text).is_file()
161
+ except (OSError, ValueError):
162
+ return False
163
+
164
+
165
+ def load_aoi(source: AoiLike, densify_deg: float | None = 1.0) -> Aoi:
166
+ """Load an area of interest from GeoJSON, WKT or a box / circle region.
167
+
168
+ Parameters
169
+ ----------
170
+ source : str, Path, dict or Aoi
171
+ A ``.geojson`` / ``.json`` file (Feature, FeatureCollection or bare
172
+ Geometry), a ``.wkt`` (or any text) file, a raw WKT string, a GeoJSON
173
+ mapping, a region dict ``{"point": (lat, lon), "radius_km": r}``
174
+ (spherical cap) or ``{"lat": (south, north), "lon": (west, east)}``
175
+ (``west > east`` crosses 0/360; omit ``lon`` for all longitudes), or
176
+ an existing :class:`Aoi` (returned unchanged). Only
177
+ Polygon / MultiPolygon parts are used (also inside
178
+ GeometryCollections); coordinates are lon/lat degrees, positive east,
179
+ in either −180..180 or 0..360. Polygons may cross the 0/360 (or
180
+ ±180) seam or wind around a pole.
181
+ densify_deg : float, optional
182
+ Insert vertices so no edge spans more than this many degrees of
183
+ latitude or longitude. Edges are taken as straight in lon/lat (as in
184
+ GeoJSON); densifying keeps them that way once projected to polar
185
+ stereographic. ``None`` keeps the input vertices.
186
+
187
+ Returns
188
+ -------
189
+ Aoi
190
+ """
191
+ if isinstance(source, Aoi):
192
+ return source
193
+ label = ""
194
+ if isinstance(source, dict) and "type" not in source:
195
+ raw = [[_region_ring(source)]]
196
+ label = repr(source)
197
+ elif isinstance(source, dict):
198
+ raw = _shapely_rings(_geojson_geometries(source))
199
+ else:
200
+ text = str(source).strip()
201
+ if _is_file(text):
202
+ label = text
203
+ text = Path(text).read_text(encoding="utf-8").strip()
204
+ if text.startswith("{"):
205
+ geoms = _geojson_geometries(json.loads(text))
206
+ else:
207
+ try:
208
+ geoms = [shapely.wkt.loads(text)]
209
+ except Exception as exc:
210
+ raise ValueError(f"Not a GeoJSON/WKT file or WKT string: {text[:80]!r}") from exc
211
+ raw = _shapely_rings(geoms)
212
+ label = label or text[:80]
213
+
214
+ polygons: list[list[Ring]] = []
215
+ for rings in raw:
216
+ rings = [r for r in (_open_ring(r) for r in rings) if len(r) >= 3]
217
+ if not rings:
218
+ continue
219
+ if densify_deg:
220
+ rings = [_densify_ring(r, densify_deg) for r in rings]
221
+ rings = [_unwrap_ring(r) for r in rings]
222
+ out = _shift_to_0_360(rings[:1])
223
+ ext_mid = _lon_mid(out[0])
224
+ for h in rings[1:]:
225
+ kh = round((_lon_mid(h) - ext_mid) / 360.0)
226
+ out.append([(lo - 360.0 * kh, la) for lo, la in h])
227
+ polygons.append(out)
228
+ if not polygons:
229
+ raise ValueError("AOI contains no Polygon or MultiPolygon geometry")
230
+ for poly in polygons:
231
+ for r in poly:
232
+ if any(abs(la) > 90.0 for _, la in r):
233
+ raise ValueError("AOI latitude outside ±90° — coordinates must be (lon, lat)")
234
+ return Aoi(polygons=polygons, source=label)
235
+
236
+
237
+ def _region_ring(region: dict, n: int = 72) -> Ring:
238
+ """Boundary ring of a ``{"point", "radius_km"}`` cap or ``{"lat", "lon"}`` box."""
239
+ if "point" in region:
240
+ lat, lon = map(math.radians, region["point"])
241
+ d = float(region.get("radius_km", 0.0)) / MOON_RADIUS_KM
242
+ if d <= 0:
243
+ raise ValueError("point AOI needs radius_km > 0")
244
+ ring = []
245
+ for k in range(n):
246
+ b = 2 * math.pi * k / n
247
+ la = math.asin(math.sin(lat) * math.cos(d) + math.cos(lat) * math.sin(d) * math.cos(b))
248
+ dlo = math.atan2(
249
+ math.sin(b) * math.sin(d) * math.cos(lat), math.cos(d) - math.sin(lat) * math.sin(la)
250
+ )
251
+ ring.append((math.degrees(lon + dlo), math.degrees(la)))
252
+ return ring
253
+ if "lat" not in region and "lon" not in region:
254
+ raise ValueError(f"Region dict needs 'point' + 'radius_km' or 'lat' / 'lon', got {sorted(region)}")
255
+ r = query_region(lat=region.get("lat"), lon=region.get("lon"))
256
+ (s, n), west, span = r["lat"], r["west"], r["span"]
257
+ return _box_ring(s, n, west, span)
258
+
259
+
260
+ def _shapely_rings(geoms: list) -> list[list[Ring]]:
261
+ """Per polygon part of *geoms*: exterior then hole rings, as (lon, lat) lists."""
262
+ return [
263
+ [[(float(p[0]), float(p[1])) for p in r.coords] for r in (poly.exterior, *poly.interiors)]
264
+ for g in geoms
265
+ for poly in _polygon_parts(g)
266
+ ]
267
+
268
+
269
+ def _geojson_geometries(obj: dict) -> list:
270
+ kind = obj.get("type")
271
+ if kind == "FeatureCollection":
272
+ return [g for f in obj.get("features", []) for g in _geojson_geometries(f)]
273
+ if kind == "Feature":
274
+ return _geojson_geometries(obj["geometry"]) if obj.get("geometry") else []
275
+ if kind == "GeometryCollection":
276
+ return [g for sub in obj.get("geometries", []) for g in _geojson_geometries(sub)]
277
+ return [shapely.geometry.shape(obj)]
278
+
279
+
280
+ def _polygon_parts(geom) -> list:
281
+ kind = geom.geom_type
282
+ if kind == "Polygon":
283
+ return [] if geom.is_empty else [geom]
284
+ if kind in ("MultiPolygon", "GeometryCollection"):
285
+ return [p for g in geom.geoms for p in _polygon_parts(g)]
286
+ return []
287
+
288
+
289
+ def _wrap180(d: float) -> float:
290
+ return (d + 180.0) % 360.0 - 180.0
291
+
292
+
293
+ def _open_ring(ring: Ring) -> Ring:
294
+ if len(ring) > 1 and ring[0] == ring[-1]:
295
+ return ring[:-1]
296
+ return ring
297
+
298
+
299
+ def _densify_ring(ring: Ring, step: float) -> Ring:
300
+ """Subdivide edges (seam-aware) so none exceeds ``step`` degrees."""
301
+ out: Ring = []
302
+ for i, (lo0, la0) in enumerate(ring):
303
+ lo1, la1 = ring[(i + 1) % len(ring)]
304
+ dlo, dla = _wrap180(lo1 - lo0), la1 - la0
305
+ n = max(1, math.ceil(max(abs(dlo), abs(dla)) / step))
306
+ out += [(lo0 + dlo * j / n, la0 + dla * j / n) for j in range(n)]
307
+ return out
308
+
309
+
310
+ def _unwrap_ring(ring: Ring) -> Ring:
311
+ """Make longitudes continuous along the ring (no ±360 jumps)."""
312
+ out = [ring[0]]
313
+ for lo, la in ring[1:]:
314
+ out.append((out[-1][0] + _wrap180(lo - out[-1][0]), la))
315
+ return out
316
+
317
+
318
+ def _winding(ring: Ring) -> float:
319
+ """Net longitude swept by the closed ring (≈ ±360 if it encloses a pole)."""
320
+ return sum(_wrap180(ring[(i + 1) % len(ring)][0] - ring[i][0]) for i in range(len(ring)))
321
+
322
+
323
+ def _encloses_pole(ring: Ring) -> bool:
324
+ return abs(_winding(ring)) > 180.0
325
+
326
+
327
+ def _shift_to_0_360(rings: list[Ring]) -> list[Ring]:
328
+ """Shift all rings by the multiple of 360° that puts the first one's west edge in [0, 360)."""
329
+ k = math.floor(min(lo for lo, _ in rings[0]) / 360.0)
330
+ return [[(lo - 360.0 * k, la) for lo, la in r] for r in rings]
331
+
332
+
333
+ def _lon_mid(ring: Ring) -> float:
334
+ los = [lo for lo, _ in ring]
335
+ return (min(los) + max(los)) / 2.0
336
+
337
+
338
+ def _covering_arc(arcs: list[tuple[float, float]]) -> tuple[float, float]:
339
+ """Smallest ``(west, span)`` arc covering all ``(west, span)`` arcs."""
340
+ pieces = []
341
+ for w, sp in arcs:
342
+ if sp >= 360.0:
343
+ return 0.0, 360.0
344
+ w %= 360.0
345
+ if w + sp > 360.0:
346
+ pieces += [(w, 360.0), (0.0, w + sp - 360.0)]
347
+ else:
348
+ pieces.append((w, w + sp))
349
+ pieces.sort()
350
+ merged = [list(pieces[0])]
351
+ for a, b in pieces[1:]:
352
+ if a <= merged[-1][1]:
353
+ merged[-1][1] = max(merged[-1][1], b)
354
+ else:
355
+ merged.append([a, b])
356
+ # Largest uncovered gap (including the one wrapping past 360)
357
+ gaps = [(merged[i + 1][0] - merged[i][1], merged[i + 1][0]) for i in range(len(merged) - 1)]
358
+ gaps.append((merged[0][0] + 360.0 - merged[-1][1], merged[0][0]))
359
+ size, east_of_gap = max(gaps)
360
+ if size <= 0.0:
361
+ return 0.0, 360.0
362
+ return east_of_gap % 360.0, 360.0 - size
363
+
364
+
365
+ def aoi_region(aoi: AoiLike) -> dict:
366
+ """Bounding region of an AOI, in :func:`query_region` form.
367
+
368
+ Returns ``{"lat": (s, n), "west", "span", "ps"}`` so the bbox prefilter
369
+ used for boxes and circles applies unchanged; refine the prefiltered
370
+ rows with :func:`filter_aoi`.
371
+ """
372
+ aoi = load_aoi(aoi)
373
+ lats = [la for poly in aoi.polygons for la in (p[1] for p in poly[0])]
374
+ s, n = min(lats), max(lats)
375
+ arcs = []
376
+ for poly in aoi.polygons:
377
+ ext = poly[0]
378
+ if _encloses_pole(ext):
379
+ if sum(la for _, la in ext) < 0:
380
+ s = -90.0
381
+ else:
382
+ n = 90.0
383
+ arcs.append((0.0, 360.0))
384
+ else:
385
+ los = [lo for lo, _ in ext]
386
+ arcs.append((min(los), max(los) - min(los)))
387
+ west, span = _covering_arc(arcs)
388
+ ps = {}
389
+ for hemi in _ps_hemis(s, n):
390
+ same_side = all((la <= 0.0) if hemi == "S" else (la >= 0.0) for la in lats)
391
+ if not same_side:
392
+ ps[hemi] = _ps_bbox_of_box(s, n, west, span, hemi)
393
+ continue
394
+ xy = [ps_xy(la, lo, hemi) for poly in aoi.polygons for lo, la in poly[0]]
395
+ xs, ys = [p[0] for p in xy], [p[1] for p in xy]
396
+ pad = 0.005 * max(max(xs) - min(xs), max(ys) - min(ys)) + 1.0
397
+ ps[hemi] = (min(xs) - pad, max(xs) + pad, min(ys) - pad, max(ys) + pad)
398
+ return {"lat": (s, n), "west": west, "span": span, "ps": ps}
399
+
400
+
401
+ def _polar_hemi(region: dict) -> str | None:
402
+ """``'S'`` / ``'N'`` when the whole region is poleward of ±60°."""
403
+ s, n = region["lat"]
404
+ if n < -POLAR_LAT_THRESHOLD:
405
+ return "S"
406
+ if s > POLAR_LAT_THRESHOLD:
407
+ return "N"
408
+ return None
409
+
410
+
411
+ def ps_projection(hemi: str) -> Projection:
412
+ """Ring projection to polar stereographic (:func:`ps_xy`) for hemisphere *hemi*."""
413
+ # Clamp away from the opposite pole, where the projection diverges
414
+ lim = 89.0
415
+ return lambda ring: [ps_xy(min(la, lim) if hemi == "S" else max(la, -lim), lo, hemi) for lo, la in ring]
416
+
417
+
418
+ def _ll_ring(ring: Ring) -> Ring:
419
+ """Unwrapped lon/lat ring; pole-enclosing rings are closed via the pole."""
420
+ r = _unwrap_ring(ring)
421
+ if _encloses_pole(ring):
422
+ pole = -90.0 if sum(la for _, la in r) < 0 else 90.0
423
+ lo_end = r[0][0] + _winding(ring)
424
+ r = r + [(lo_end, r[0][1]), (lo_end, pole), (r[0][0], pole)]
425
+ return r
426
+
427
+
428
+ def _make_polygon(rings: list[list[tuple[float, float]]]):
429
+ """Valid shapely geometry from rings (degenerate rings become hulls)."""
430
+ if len(rings[0]) < 3:
431
+ return MultiPoint(rings[0]).convex_hull
432
+ poly = Polygon(rings[0], [h for h in rings[1:] if len(h) >= 3])
433
+ if not poly.is_valid:
434
+ poly = make_valid(poly)
435
+ if poly.is_empty or poly.area == 0.0:
436
+ return MultiPoint(rings[0]).convex_hull
437
+ return poly
438
+
439
+
440
+ def aoi_geometry(aoi: AoiLike, proj: Projection | None = None):
441
+ """AOI as one shapely geometry: rings projected by *proj*, else seam-replicated lon/lat."""
442
+ aoi = load_aoi(aoi)
443
+ if proj is not None:
444
+ return unary_union([_make_polygon([proj(r) for r in poly]) for poly in aoi.polygons])
445
+ g = unary_union([_make_polygon(_shift_to_0_360([_ll_ring(r) for r in poly])) for poly in aoi.polygons])
446
+ return unary_union([g, translate(g, xoff=360.0), translate(g, xoff=-360.0)])
447
+
448
+
449
+ def footprint_geometry(rings_list: list[list[Ring]], proj: Projection | None):
450
+ """Footprint polygons (per polygon: lon/lat exterior, *holes) as shapely geometry.
451
+
452
+ Rings are projected by *proj* (e.g. :func:`ps_projection`), else kept in
453
+ lon/lat (pole-enclosing rings closed via the pole) starting in 0..360.
454
+ """
455
+ parts = []
456
+ for rings in rings_list:
457
+ rings = [_open_ring(r) for r in rings]
458
+ if not rings or len(rings[0]) < 1:
459
+ continue
460
+ if proj is not None:
461
+ parts.append(_make_polygon([proj(r) for r in rings]))
462
+ continue
463
+ parts.append(_make_polygon(_shift_to_0_360([_ll_ring(r) if len(r) >= 3 else r for r in rings])))
464
+ return unary_union(parts) if parts else None
465
+
466
+
467
+ def _box_ring(s: float, n: float, west: float, span: float, step: float = 1.0) -> Ring:
468
+ """Densified lon/lat box ring (already unwrapped; span may be 360)."""
469
+ m = max(1, math.ceil(span / step))
470
+ k = max(1, math.ceil((n - s) / step))
471
+ bottom = [(west + span * i / m, s) for i in range(m + 1)]
472
+ right = [(west + span, s + (n - s) * j / k) for j in range(1, k + 1)]
473
+ top = [(west + span * i / m, n) for i in range(m - 1, -1, -1)]
474
+ left = [(west, n - (n - s) * j / k) for j in range(1, k)]
475
+ return bottom + right + top + left
476
+
477
+
478
+ def _num(v) -> float | None:
479
+ try:
480
+ f = float(v)
481
+ except (TypeError, ValueError):
482
+ return None
483
+ return None if math.isnan(f) else f
484
+
485
+
486
+ def footprint_rings(row: dict, wkt_column: str | None = None) -> list[list[Ring]] | None:
487
+ """Footprint of a result row as lon/lat rings (per polygon: exterior, *holes).
488
+
489
+ Source: the *wkt_column* WKT, else the four corner lat/lon columns, else
490
+ the ``FP_*`` / ``min_lat``…``lon_east`` bounding box; ``None`` if none.
491
+ """
492
+ wkt = row.get(wkt_column) if wkt_column else None
493
+ if isinstance(wkt, str) and wkt.strip():
494
+ try:
495
+ rings = _shapely_rings([shapely.wkt.loads(wkt)])
496
+ except Exception:
497
+ rings = [] # unparsable WKT: fall back to corners / bbox
498
+ if rings:
499
+ return rings
500
+ corners = [(_num(row.get(f"{c}_LONGITUDE")), _num(row.get(f"{c}_LATITUDE"))) for c in FOOTPRINT_CORNERS]
501
+ ring_ll = [(lo, la) for lo, la in corners if lo is not None and la is not None]
502
+ if len(ring_ll) == len(corners):
503
+ return [[ring_ll]]
504
+
505
+ s, n = _num(row.get("FP_MIN_LAT")), _num(row.get("FP_MAX_LAT"))
506
+ west, span = _num(row.get("FP_LON_WEST")), _num(row.get("FP_LON_SPAN"))
507
+ if s is None or n is None or west is None or span is None:
508
+ s, n = _num(row.get("min_lat")), _num(row.get("max_lat"))
509
+ west, east = _num(row.get("lon_west")), _num(row.get("lon_east"))
510
+ if s is None or n is None or west is None or east is None:
511
+ return None
512
+ span = east - west if east >= west else east - west + 360.0
513
+ span = min(span, 360.0)
514
+ return [[_box_ring(s, n, west, span)]]
515
+
516
+
517
+ def row_footprint(row: dict, wkt_column: str | None = None, proj: Projection | None = None):
518
+ """Shapely footprint (:func:`footprint_geometry`) of a result row, or ``None``."""
519
+ rings = footprint_rings(row, wkt_column)
520
+ return None if rings is None else footprint_geometry(rings, proj)
521
+
522
+
523
+ def aoi_mask(df: pd.DataFrame, aoi: AoiLike, wkt_column: str | None = None) -> list[bool]:
524
+ """Per-row flag: does the row's footprint intersect the AOI exactly?
525
+
526
+ Footprint source, per row: the WKT column (``FP_WKT`` for CumIndex,
527
+ ``footprint_wkt`` for observations, or ``wkt_column``), else the four
528
+ corner lat/lon columns, else the ``FP_*`` / ``min_lat``…``lon_east``
529
+ bounding box. Rows with no geometry at all are kept.
530
+
531
+ *aoi* is anything :func:`load_aoi` accepts. Regions entirely poleward
532
+ of ±60° (``POLAR_LAT_THRESHOLD``) are intersected in polar
533
+ stereographic (:func:`ps_xy`, footprint edges straight in the
534
+ projection); others in lon/lat with the AOI replicated at ±360° so
535
+ footprints across the 0/360 seam match.
536
+ """
537
+ aoi = load_aoi(aoi)
538
+ if df is None or len(df) == 0:
539
+ return []
540
+ hemi = _polar_hemi(aoi_region(aoi))
541
+ proj = ps_projection(hemi) if hemi else None
542
+ target = prep(aoi_geometry(aoi, proj))
543
+ if wkt_column is None:
544
+ wkt_column = next((c for c in _WKT_COLUMNS if c in df.columns), None)
545
+ mask = []
546
+ for row in df.to_dict("records"):
547
+ geom = row_footprint(row, wkt_column, proj)
548
+ mask.append(True if geom is None or geom.is_empty else bool(target.intersects(geom)))
549
+ return mask
550
+
551
+
552
+ def filter_aoi(df: pd.DataFrame, aoi: AoiLike, wkt_column: str | None = None) -> pd.DataFrame:
553
+ """Keep only rows whose footprint intersects the AOI (see :func:`aoi_mask`)."""
554
+ if df is None or len(df) == 0:
555
+ return df
556
+ return df[aoi_mask(df, aoi, wkt_column=wkt_column)].reset_index(drop=True)