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.
- lunar_toolkit/__init__.py +6 -0
- lunar_toolkit/asp.py +432 -0
- lunar_toolkit/cli/__init__.py +88 -0
- lunar_toolkit/cli/_jobs.py +437 -0
- lunar_toolkit/cli/_status.py +368 -0
- lunar_toolkit/cli/_utils.py +128 -0
- lunar_toolkit/cli/app.py +205 -0
- lunar_toolkit/cli/build_db.py +130 -0
- lunar_toolkit/cli/download.py +111 -0
- lunar_toolkit/cli/kaguya.py +253 -0
- lunar_toolkit/cli/ode_search.py +144 -0
- lunar_toolkit/cli/pairs.py +236 -0
- lunar_toolkit/cli/process.py +138 -0
- lunar_toolkit/cli/query.py +70 -0
- lunar_toolkit/cli/reference.py +233 -0
- lunar_toolkit/cli/search.py +90 -0
- lunar_toolkit/cli/serve.py +706 -0
- lunar_toolkit/cli/sfs.py +344 -0
- lunar_toolkit/cli/sfs_report.py +148 -0
- lunar_toolkit/cli/shadowcam.py +246 -0
- lunar_toolkit/cli/stereo.py +85 -0
- lunar_toolkit/cli/workspace.py +84 -0
- lunar_toolkit/config.py +345 -0
- lunar_toolkit/constants.py +217 -0
- lunar_toolkit/csm.py +340 -0
- lunar_toolkit/downloader.py +447 -0
- lunar_toolkit/geometry.py +556 -0
- lunar_toolkit/isis.py +222 -0
- lunar_toolkit/kaguya/__init__.py +45 -0
- lunar_toolkit/kaguya/mi.py +274 -0
- lunar_toolkit/kaguya/tc.py +370 -0
- lunar_toolkit/lroc/__init__.py +6 -0
- lunar_toolkit/lroc/cumindex.py +1197 -0
- lunar_toolkit/lroc/nac.py +702 -0
- lunar_toolkit/observation.py +127 -0
- lunar_toolkit/ode.py +485 -0
- lunar_toolkit/provenance.py +312 -0
- lunar_toolkit/reference.py +1111 -0
- lunar_toolkit/selection.py +714 -0
- lunar_toolkit/sfs.py +1534 -0
- lunar_toolkit/sfs_products.py +676 -0
- lunar_toolkit/sfs_report.py +1328 -0
- lunar_toolkit/shadowcam/__init__.py +29 -0
- lunar_toolkit/shadowcam/sc.py +392 -0
- lunar_toolkit/static/index.html +3259 -0
- lunar_toolkit/stereo.py +519 -0
- lunar_toolkit/stereo_dem.py +145 -0
- lunar_toolkit/tools.py +264 -0
- lunar_toolkit/workspace.py +1173 -0
- lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
- lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
- lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
- lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
- lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
- 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)
|