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,1111 @@
1
+ """Reference data for the Artemis III candidate regions: PGDA LOLA 5 m site DEMs and Bertone et al. SfS DEMs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import io
7
+ import json
8
+ import logging
9
+ import math
10
+ import re
11
+ import shutil
12
+ import time
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ import zipfile
17
+ from collections.abc import Iterable, Sequence
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import IO, Any
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from .constants import APP_NAME, APP_VERSION, DEFAULT_CACHE_DIR, MOON_RADIUS_KM
26
+ from .csm import CsmCamera, read_camera, transform_camera
27
+ from .downloader import download_file, url_filename
28
+ from .geometry import Aoi, load_aoi, ps_xy
29
+ from .tools import reuse_existing
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ PS_SRS = "+proj=stere +lat_0=-90 +lat_ts=-90 +lon_0=0 +x_0=0 +y_0=0 +R=1737400 +units=m +no_defs"
34
+ """South polar stereographic on the 1737.4 km sphere (PGDA and Bertone et al. rasters)."""
35
+
36
+ ASP_CSV_FORMAT = "1:easting 2:northing 3:height_above_datum"
37
+ """``--csv-format`` for :func:`write_asp_csv` output (use with ``--csv-srs`` :data:`PS_SRS`)."""
38
+
39
+ ZENODO_RECORD = 17954508
40
+ ZENODO_API_URL = f"https://zenodo.org/api/records/{ZENODO_RECORD}"
41
+ ZENODO_FILE_URL = f"https://zenodo.org/records/{ZENODO_RECORD}/files/{{name}}?download=1"
42
+
43
+ PGDA_PRODUCT_PAGE = "https://pgda.gsfc.nasa.gov/products/78"
44
+ PGDA_ROOT = "https://pgda.gsfc.nasa.gov/data/LOLA_5mpp/"
45
+ PGDA_KINDS = ("surf", "ldec", "toterr", "slp", "slperr", "xyzi", "clones", "rois")
46
+
47
+ REFERENCE_CACHE_DIR = DEFAULT_CACHE_DIR / "reference"
48
+
49
+ _USER_AGENT = f"{APP_NAME}/{APP_VERSION} (python-urllib)"
50
+ _RETRY_CODES = (403, 429, 500, 502, 503, 504) # PGDA answers 403 to bursts of requests
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class PsBox:
55
+ """Axis-aligned box in south polar stereographic metres (:data:`PS_SRS`).
56
+
57
+ Parameters
58
+ ----------
59
+ x0, y0, x1, y1 : float
60
+ West, south, east and north edges (``x0 < x1``, ``y0 < y1``).
61
+ """
62
+
63
+ x0: float
64
+ y0: float
65
+ x1: float
66
+ y1: float
67
+
68
+ @property
69
+ def bounds(self) -> tuple[float, float, float, float]:
70
+ """``(xmin, ymin, xmax, ymax)``."""
71
+ return self.x0, self.y0, self.x1, self.y1
72
+
73
+ @property
74
+ def projwin(self) -> tuple[float, float, float, float]:
75
+ """GDAL / ASP ``projwin`` order: ``(ulx, uly, lrx, lry)``."""
76
+ return self.x0, self.y1, self.x1, self.y0
77
+
78
+ @property
79
+ def width(self) -> float:
80
+ return self.x1 - self.x0
81
+
82
+ @property
83
+ def height(self) -> float:
84
+ return self.y1 - self.y0
85
+
86
+ @property
87
+ def center_xy(self) -> tuple[float, float]:
88
+ return (self.x0 + self.x1) / 2.0, (self.y0 + self.y1) / 2.0
89
+
90
+ @property
91
+ def center_latlon(self) -> tuple[float, float]:
92
+ """Centre as ``(lat, lon)`` degrees, lon in −180..180."""
93
+ return ps_latlon(*self.center_xy)
94
+
95
+ def contains(self, other: PsBox) -> bool:
96
+ return self.x0 <= other.x0 and self.y0 <= other.y0 and other.x1 <= self.x1 and other.y1 <= self.y1
97
+
98
+ def intersects(self, other: PsBox) -> bool:
99
+ return self.x0 < other.x1 and other.x0 < self.x1 and self.y0 < other.y1 and other.y0 < self.y1
100
+
101
+ def ring_lonlat(self, step_m: float = 100.0) -> list[tuple[float, float]]:
102
+ """Closed ``(lon, lat)`` ring of the box edges, sampled every *step_m* metres."""
103
+ if self.x0 < 0.0 < self.x1 and self.y0 < 0.0 < self.y1:
104
+ raise ValueError("Box contains the pole; it has no simple lon/lat outline")
105
+ corners = [(self.x0, self.y0), (self.x1, self.y0), (self.x1, self.y1), (self.x0, self.y1)]
106
+ pts: list[tuple[float, float]] = []
107
+ for (xa, ya), (xb, yb) in zip(corners, corners[1:] + corners[:1]):
108
+ n = max(1, math.ceil(math.hypot(xb - xa, yb - ya) / step_m))
109
+ pts += [(xa + (xb - xa) * i / n, ya + (yb - ya) * i / n) for i in range(n)]
110
+ ring = [ps_latlon(x, y)[::-1] for x, y in pts]
111
+ return ring + ring[:1]
112
+
113
+ def aoi(self, step_m: float = 100.0) -> Aoi:
114
+ """The box as a lon/lat :class:`~lunar_toolkit.geometry.Aoi` (edges straight in PS).
115
+
116
+ Parameters
117
+ ----------
118
+ step_m : float
119
+ Vertex spacing along the edges; the AOI matches the box to well
120
+ under a metre once :mod:`~lunar_toolkit.geometry` projects it back.
121
+ """
122
+ geom = {"type": "Polygon", "coordinates": [self.ring_lonlat(step_m)]}
123
+ aoi = load_aoi(geom, densify_deg=None)
124
+ aoi.source = f"PS box {self.bounds}"
125
+ return aoi
126
+
127
+ def wkt(self, step_m: float = 100.0) -> str:
128
+ """``POLYGON`` WKT of :meth:`ring_lonlat` (lon/lat degrees)."""
129
+ return "POLYGON((" + ", ".join(f"{lo:.7f} {la:.7f}" for lo, la in self.ring_lonlat(step_m)) + "))"
130
+
131
+ def wkt_ps(self) -> str:
132
+ """``POLYGON`` WKT of the corners in PS metres."""
133
+ x0, y0, x1, y1 = self.bounds
134
+ return f"POLYGON(({x0:g} {y0:g}, {x1:g} {y0:g}, {x1:g} {y1:g}, {x0:g} {y1:g}, {x0:g} {y0:g}))"
135
+
136
+
137
+ def ps_latlon(x: float, y: float) -> tuple[float, float]:
138
+ """Inverse of south-hemisphere :func:`~lunar_toolkit.geometry.ps_xy`: ``(lat, lon)`` degrees."""
139
+ rho = math.hypot(x, y)
140
+ lat = math.degrees(2.0 * math.atan(rho / (2.0 * MOON_RADIUS_KM * 1000.0))) - 90.0
141
+ return lat, math.degrees(math.atan2(x, y))
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class PgdaSite:
146
+ """A PGDA LOLA 5 m/px site DEM (Barker et al. 2021): name, description and PS extent."""
147
+
148
+ name: str
149
+ description: str
150
+ extent: PsBox
151
+
152
+ @property
153
+ def url(self) -> str:
154
+ return f"{PGDA_ROOT}{self.name}/"
155
+
156
+
157
+ # Extents read from each site's *_surf.tif (all 5 m/px, PS_SRS, float32, NaN nodata).
158
+ PGDA_SITES: dict[str, PgdaSite] = {
159
+ s[0]: PgdaSite(s[0], s[1], PsBox(*s[2]))
160
+ for s in [
161
+ ("Site01", "Connecting ridge", (-19000, -20000, -3000, -4000)),
162
+ ("Site04", "Shackleton rim", (-9000, -15000, 7000, 1000)),
163
+ ("Site06", "Nobile rim 1", (74000, 100000, 94000, 120000)),
164
+ ("Site07", "Peak near Shackleton", (22000, -28000, 38000, -12000)),
165
+ ("Site11", "de Gerlache rim", (-45000, 7000, -29000, 23000)),
166
+ ("Site20", "Leibnitz beta plateau", (65000, 110000, 81000, 126000)),
167
+ ("Site20v2", "Leibnitz beta plateau, extended boundaries", (60000, 105000, 86000, 131000)),
168
+ ("Site23", "Malapert massif", (-11000, 111000, 10000, 132000)),
169
+ ("Site42", "de Gerlache-Kocher massif", (-123400, -66100, -103400, -46100)),
170
+ ("Haworth", "Haworth", (-52900, 75600, -23100, 105400)),
171
+ ("Shoemaker", "Shoemaker", (66000, 29000, 86000, 49000)),
172
+ ("DM1", "Amundsen rim", (154000, 51500, 174000, 71500)),
173
+ ("DM2", "Nobile rim 2", (147000, 85000, 167000, 105000)),
174
+ ("SL2", "de Gerlache rim 2", (-58500, 13000, -38500, 33000)),
175
+ ("SL3", "Connecting ridge extension", (-38700, -16100, -18700, 3900)),
176
+ ("NPA", "Cabeus exterior wall 1", (-98500, 19500, -78500, 39500)),
177
+ ("NPB", "Amundsen 1", (175000, -8500, 195000, 11500)),
178
+ ("NPC", "Idel'son L crater 1", (122500, -109500, 142500, -89500)),
179
+ ("NPD", "Malapert crater 1", (13000, 146000, 33000, 166000)),
180
+ ("LM1", "Shackleton Rim B", (2000, -2000, 22000, 18000)),
181
+ ("LM2", "Shoemaker Rim A", (2000, 47000, 22000, 67000)),
182
+ ("LM3", "Shoemaker Rim B", (4000, 37000, 24000, 57000)),
183
+ ("LM4", "Shoemaker Rim C", (9000, 15000, 29000, 35000)),
184
+ ("LM5", "Shoemaker Rim D", (30000, 1000, 50000, 21000)),
185
+ ("LM6", "Shoemaker Rim E", (49000, 10000, 69000, 30000)),
186
+ ("LM7", "Faustini Rim A", (54000, -10000, 74000, 10000)),
187
+ ("LM8", "Shoemaker Rim F", (60000, 30000, 80000, 50000)),
188
+ ]
189
+ }
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class Region:
194
+ """A 2022 Artemis III candidate landing region as processed by Bertone et al. (2026).
195
+
196
+ ``bbox`` (``min_lon, min_lat, max_lon, max_lat``) and ``center``
197
+ (``lon, lat``, the bbox midpoint) come from the Zenodo README; ``roi`` is
198
+ the polar stereographic square the SfS DEM covers, which is the extent of
199
+ the matching PGDA LOLA 5 m site DEM (``pgda_site``).
200
+ """
201
+
202
+ id: int
203
+ name: str
204
+ bbox: tuple[float, float, float, float]
205
+ center: tuple[float, float]
206
+ pgda_site: str | None
207
+ zenodo_file: str
208
+ zenodo_size: int
209
+ zenodo_md5: str
210
+
211
+ @property
212
+ def slug(self) -> str:
213
+ return re.sub(r"[^a-z0-9]+", "-", self.name.lower()).strip("-")
214
+
215
+ @property
216
+ def roi(self) -> PsBox:
217
+ if self.pgda_site is None:
218
+ return bbox_ps_box(self.bbox)
219
+ return PGDA_SITES[self.pgda_site].extent
220
+
221
+ @property
222
+ def projwin(self) -> tuple[float, float, float, float]:
223
+ """``(ulx, uly, lrx, lry)`` of :attr:`roi` in PS metres."""
224
+ return self.roi.projwin
225
+
226
+ @property
227
+ def bbox_projwin(self) -> tuple[float, float, float, float]:
228
+ """``projwin`` of the PS envelope of the lon/lat :attr:`bbox` (a wedge; larger than :attr:`roi`)."""
229
+ return bbox_ps_box(self.bbox).projwin
230
+
231
+ @property
232
+ def zenodo_url(self) -> str:
233
+ return ZENODO_FILE_URL.format(name=self.zenodo_file)
234
+
235
+ def aoi(self, step_m: float = 100.0) -> Aoi:
236
+ """:attr:`roi` as an :class:`~lunar_toolkit.geometry.Aoi`."""
237
+ aoi = self.roi.aoi(step_m)
238
+ aoi.source = f"A3CLR22 #{self.id} {self.name}"
239
+ return aoi
240
+
241
+ def bbox_aoi(self, densify_deg: float = 0.05) -> Aoi:
242
+ """The lon/lat :attr:`bbox` as an AOI, densified so it stays a wedge in PS."""
243
+ w, s, e, n = self.bbox
244
+ aoi = load_aoi(f"POLYGON(({w} {s}, {e} {s}, {e} {n}, {w} {n}, {w} {s}))", densify_deg=densify_deg)
245
+ aoi.source = f"A3CLR22 #{self.id} {self.name} bbox"
246
+ return aoi
247
+
248
+
249
+ # Zenodo README table + file sizes / MD5s from the record API (id, label, center lon/lat,
250
+ # bbox min lon/lat max lon/lat, PGDA site, zip, size, md5).
251
+ _REGION_ROWS = [
252
+ (0, "Amundsen Rim", (69.303844, -84.225586), (65.095231, -84.648844, 73.512457, -83.802328), "DM1",
253
+ "A3CLR22_0_Amundsen_Rim.zip", 1775016989, "d1e12877277cf9715381b8795c0873cb"),
254
+ (1, "Nobile Rim 2", (58.743523, -83.953082), (54.462322, -84.404616, 63.024724, -83.501549), "DM2",
255
+ "A3CLR22_1_Nobile_Rim_2.zip", 1839070131, "964310cb7f2a5033e2e12cd34e7aa7b1"),
256
+ (2, "Haworth", (-23.671814, -86.752966), (-34.981846, -87.393534, -12.361782, -86.112398), "Haworth",
257
+ "A3CLR22_2_Haworth.zip", 4353812518, "e83a923c41c00be51396d06ab7cf6e9e"),
258
+ (3, "Faustini Rim A", (90.0, -87.863451), (79.508523, -88.189067, 100.491477, -87.537834), "LM7",
259
+ "A3CLR22_3_Faustini_Rim_A.zip", 1936729230, "161db71236036f7c9b98b501d353482a"),
260
+ (4, "de Gerlache Rim 2", (-63.434949, -88.222637), (-77.471192, -88.659986, -49.398705, -87.785288), "SL2",
261
+ "A3CLR22_4_deGerlache_Rim_2.zip", 2187384204, "52921a134793a6b35b0eea6f98ad79d8"),
262
+ (5, "Connecting Ridge Extension", (-104.473348, -88.993919), (-130.727219, -89.370051, -78.219476, -88.617786),
263
+ "SL3", "A3CLR22_5_Connecting_Ridge_Extension.zip", 2225897760, "9141ec5d298a3aebab4d3671f7f38a79"),
264
+ (6, "Connecting Ridge", (-136.678946, -89.462697), (-171.469234, -89.835111, -101.888658, -89.090284),
265
+ "Site01", "A3CLR22_6_Connecting_Ridge.zip", 2206485514, "0ad982cf42319a300f2a2cff69d39322"),
266
+ (7, "Nobile Rim 1", (37.444641, -85.43775), (31.660751, -85.899218, 43.22853, -84.976283), "Site06",
267
+ "A3CLR22_7_Nobile_Rim_1.zip", 1962014939, "a8bcb60f432253f5fa97ff07899bd02a"),
268
+ (8, "Peak Near Shackleton", (124.684171, -88.808537), (107.525568, -89.173591, 141.842773, -88.443484),
269
+ "Site07", "A3CLR22_8_Peak_Near_Shackleton.zip", 1360387336, "9ac25cc18b1290e679dfad9b1eaabad7"),
270
+ (9, "de Gerlache Rim", (-66.370065, -88.674855), (-81.158185, -89.016199, -51.581945, -88.33351), "Site11",
271
+ "A3CLR22_9_deGerlache_Rim.zip", 1340667387, "a34e603e9d2c2c9a2796fc237d24b1b4"),
272
+ (10, "Leibnitz Beta Plateau", (31.827284, -85.425819), (27.287988, -85.788335, 36.36658, -85.063304),
273
+ "Site20", "A3CLR22_10_Leibnitz_Beta_Plateau.zip", 1050441742, "b0a606e6a4ce73faf2ea68c91ce0b93e"),
274
+ (11, "Malapert Massif", (-0.255799, -85.979917), (-5.659482, -86.325889, 5.147885, -85.633944), "Site23",
275
+ "A3CLR22_11_Malapert_Massif.zip", 2213137537, "772a578b8f2ebdb078c5a04a3c07182a"),
276
+ (12, "de Gerlache-Kocher Massif", (-116.537066, -85.826913), (-122.589357, -86.267855, -110.484776, -85.385972),
277
+ "Site42", "A3CLR22_12_deGerlache_Kocher_Massif.zip", 2108633565, "78b8a042d0c6be2923abdd288e436c23"),
278
+ ] # fmt: skip
279
+
280
+ REGIONS: tuple[Region, ...] = tuple(
281
+ Region(id=i, name=name, center=c, bbox=b, pgda_site=site, zenodo_file=f, zenodo_size=size, zenodo_md5=md5)
282
+ for i, name, c, b, site, f, size, md5 in _REGION_ROWS
283
+ )
284
+
285
+
286
+ def _norm(s: str) -> str:
287
+ return re.sub(r"[^a-z0-9]", "", s.lower())
288
+
289
+
290
+ def get_region(key: Region | int | str) -> Region:
291
+ """Look up a region by id, name, slug or PGDA site (case-insensitive).
292
+
293
+ ``11``, ``"11"``, ``"Malapert Massif"``, ``"malapert-massif"``,
294
+ ``"malapert"`` and ``"Site23"`` all give region 11. A partial name must
295
+ match exactly one region.
296
+ """
297
+ if isinstance(key, Region):
298
+ return key
299
+ if isinstance(key, int) or str(key).strip().isdigit():
300
+ i = int(key)
301
+ for r in REGIONS:
302
+ if r.id == i:
303
+ return r
304
+ raise KeyError(f"No region with id {i} (0–{len(REGIONS) - 1})")
305
+ k = _norm(str(key))
306
+ if not k:
307
+ raise KeyError("Empty region key")
308
+ for r in REGIONS:
309
+ if k in (
310
+ _norm(r.name),
311
+ _norm(r.slug),
312
+ _norm(r.pgda_site or ""),
313
+ _norm(r.zenodo_file.removesuffix(".zip")),
314
+ ):
315
+ return r
316
+ hits = [r for r in REGIONS if k and k in _norm(r.name)]
317
+ if len(hits) == 1:
318
+ return hits[0]
319
+ if hits:
320
+ raise KeyError(f"{key!r} is ambiguous: " + ", ".join(f"{r.id} {r.name}" for r in hits))
321
+ raise KeyError(f"No region matches {key!r}; known: " + ", ".join(r.name for r in REGIONS))
322
+
323
+
324
+ def regions_table() -> pd.DataFrame:
325
+ """All regions as a table (one row per region)."""
326
+ rows = []
327
+ for r in REGIONS:
328
+ rows.append(
329
+ {
330
+ "id": r.id,
331
+ "name": r.name,
332
+ "slug": r.slug,
333
+ "center_lon": r.center[0],
334
+ "center_lat": r.center[1],
335
+ "min_lon": r.bbox[0],
336
+ "min_lat": r.bbox[1],
337
+ "max_lon": r.bbox[2],
338
+ "max_lat": r.bbox[3],
339
+ "pgda_site": r.pgda_site,
340
+ "roi_projwin": r.projwin,
341
+ "zenodo_file": r.zenodo_file,
342
+ "zenodo_size": r.zenodo_size,
343
+ "zenodo_md5": r.zenodo_md5,
344
+ }
345
+ )
346
+ return pd.DataFrame(rows)
347
+
348
+
349
+ def bbox_ps_box(bbox: Sequence[float], samples: int = 200) -> PsBox:
350
+ """PS envelope of a lon/lat ``(min_lon, min_lat, max_lon, max_lat)`` box (south)."""
351
+ w, s, e, n = bbox
352
+ pts = []
353
+ for i in range(samples + 1):
354
+ f = i / samples
355
+ lo, la = w + (e - w) * f, s + (n - s) * f
356
+ pts += [ps_xy(s, lo, "S"), ps_xy(n, lo, "S"), ps_xy(la, w, "S"), ps_xy(la, e, "S")]
357
+ xs, ys = [p[0] for p in pts], [p[1] for p in pts]
358
+ return PsBox(min(xs), min(ys), max(xs), max(ys))
359
+
360
+
361
+ def crop_window(
362
+ region: Region | int | str,
363
+ size_km: float = 2.5,
364
+ center: tuple[float, float] | None = None,
365
+ snap_m: float | None = 5.0,
366
+ ) -> PsBox:
367
+ """Square PS window of *size_km* centred on a region.
368
+
369
+ Parameters
370
+ ----------
371
+ region : Region, int or str
372
+ Anything :func:`get_region` accepts.
373
+ size_km : float
374
+ Side length (2.5 km is the Bertone et al. uncertainty-ensemble crop).
375
+ center : (lat, lon), optional
376
+ Centre in degrees; default the centre of the region's :attr:`Region.roi`.
377
+ snap_m : float, optional
378
+ Snap the edges to multiples of this many metres so the window lines
379
+ up with the 5 m PGDA / SfS pixel grid; ``None`` keeps them exact.
380
+
381
+ Returns
382
+ -------
383
+ PsBox
384
+ Use ``.projwin`` for ASP / GDAL and ``.aoi()`` for searches.
385
+ """
386
+ reg = get_region(region)
387
+ cx, cy = ps_xy(center[0], center[1], "S") if center is not None else reg.roi.center_xy
388
+ side = size_km * 1000.0
389
+ x0, y0 = cx - side / 2.0, cy - side / 2.0
390
+ if snap_m:
391
+ x0, y0 = round(x0 / snap_m) * snap_m, round(y0 / snap_m) * snap_m
392
+ box = PsBox(x0, y0, x0 + side, y0 + side)
393
+ if not reg.roi.contains(box):
394
+ log.warning("Crop window %s extends outside the %s ROI %s", box.bounds, reg.name, reg.roi.bounds)
395
+ return box
396
+
397
+
398
+ def _site_name(site_or_region: Region | int | str) -> str:
399
+ if isinstance(site_or_region, str) and site_or_region in PGDA_SITES:
400
+ return site_or_region
401
+ reg = get_region(site_or_region)
402
+ if reg.pgda_site is None:
403
+ raise KeyError(f"Region {reg.id} {reg.name} has no PGDA LOLA 5 m site")
404
+ return reg.pgda_site
405
+
406
+
407
+ def _classify_pgda(path: str) -> str | None:
408
+ name = path.rsplit("/", 1)[-1]
409
+ if "/Clones/" in path:
410
+ return "clones"
411
+ if name.endswith("_ROIs.tgz"):
412
+ return "rois"
413
+ if name.endswith(".xyzi"):
414
+ return "xyzi"
415
+ m = re.search(r"_(surf|ldec|toterr|slp|slperr)\.tif$", name)
416
+ return m.group(1) if m else None
417
+
418
+
419
+ def parse_pgda_index(html: str) -> dict[str, dict[str, list[str]]]:
420
+ """``site → kind → [url, ...]`` from the PGDA product page HTML.
421
+
422
+ Links may be absolute, root-relative or relative to :data:`PGDA_ROOT`
423
+ (``DM2/Clones/DM2_final_adj_5mpp_0001_err.tif``); clones are sorted by
424
+ number.
425
+ """
426
+ index: dict[str, dict[str, list[str]]] = {}
427
+ host = "{0.scheme}://{0.netloc}".format(urllib.parse.urlparse(PGDA_ROOT))
428
+ for href in re.findall(r"""href\s*=\s*["']?([^"'\s>]+)""", html, flags=re.I):
429
+ if href.startswith(("http://", "https://")):
430
+ url = href.replace("http://", "https://", 1)
431
+ elif href.startswith("/"):
432
+ url = host + href
433
+ else:
434
+ url = PGDA_ROOT + href
435
+ if not url.startswith(PGDA_ROOT):
436
+ continue
437
+ rel = url[len(PGDA_ROOT) :]
438
+ kind = _classify_pgda(rel)
439
+ if kind is None or "/" not in rel:
440
+ continue
441
+ urls = index.setdefault(rel.split("/", 1)[0], {}).setdefault(kind, [])
442
+ if url not in urls:
443
+ urls.append(url)
444
+ for kinds in index.values():
445
+ kinds.get("clones", []).sort()
446
+ return index
447
+
448
+
449
+ def _get(url: str, headers: dict[str, str] | None = None, timeout: float = 60.0, retries: int = 4) -> bytes:
450
+ """GET *url* with retries on throttling / server errors."""
451
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT, **(headers or {})})
452
+ for attempt in range(retries + 1):
453
+ try:
454
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
455
+ return resp.read()
456
+ except urllib.error.HTTPError as exc:
457
+ if exc.code not in _RETRY_CODES or attempt == retries:
458
+ raise
459
+ except urllib.error.URLError:
460
+ if attempt == retries:
461
+ raise
462
+ time.sleep(2.0 * 2**attempt)
463
+ raise AssertionError("unreachable")
464
+
465
+
466
+ def pgda_index(
467
+ refresh: bool = False, cache_dir: Path = REFERENCE_CACHE_DIR, html: str | None = None
468
+ ) -> dict[str, dict[str, list[str]]]:
469
+ """PGDA LOLA 5 m file index, parsed from the product page and cached.
470
+
471
+ Parameters
472
+ ----------
473
+ refresh : bool
474
+ Re-fetch :data:`PGDA_PRODUCT_PAGE` even when a cached index exists.
475
+ cache_dir : Path
476
+ Where ``pgda_lola_5mpp_index.json`` is kept.
477
+ html : str, optional
478
+ Parse this page text instead of fetching it (nothing is cached).
479
+ """
480
+ if html is not None:
481
+ return parse_pgda_index(html)
482
+ cache = Path(cache_dir) / "pgda_lola_5mpp_index.json"
483
+ if cache.exists() and not refresh:
484
+ return json.loads(cache.read_text())
485
+ index = parse_pgda_index(_get(PGDA_PRODUCT_PAGE).decode("utf-8", "replace"))
486
+ if not index:
487
+ raise RuntimeError(f"No LOLA 5 m files found on {PGDA_PRODUCT_PAGE}")
488
+ cache.parent.mkdir(parents=True, exist_ok=True)
489
+ cache.write_text(json.dumps(index, indent=1))
490
+ return index
491
+
492
+
493
+ def _clone_number(url: str) -> int:
494
+ m = re.search(r"_(\d{4})_err\.tif$", url)
495
+ return int(m.group(1)) if m else 0
496
+
497
+
498
+ def pgda_urls(
499
+ site_or_region: Region | int | str,
500
+ kinds: Iterable[str] = ("surf",),
501
+ clones: int | Iterable[int] | None = None,
502
+ index: dict[str, dict[str, list[str]]] | None = None,
503
+ ) -> dict[str, list[str]]:
504
+ """``kind → [url, ...]`` for one PGDA site.
505
+
506
+ Parameters
507
+ ----------
508
+ site_or_region : Region, int or str
509
+ A PGDA site name (``"Site23"``) or anything :func:`get_region` accepts.
510
+ kinds : iterable of str
511
+ Members of :data:`PGDA_KINDS`.
512
+ clones : int or iterable of int, optional
513
+ With ``"clones"``: the first *n* clones, or these clone numbers
514
+ (1-based); default all 100.
515
+ index : dict, optional
516
+ A :func:`pgda_index` result (default: the cached index).
517
+ """
518
+ site = _site_name(site_or_region)
519
+ kinds = list(kinds)
520
+ bad = [k for k in kinds if k not in PGDA_KINDS]
521
+ if bad:
522
+ raise ValueError(f"Unknown PGDA kind(s) {bad}; choose from {', '.join(PGDA_KINDS)}")
523
+ files = (index if index is not None else pgda_index()).get(site)
524
+ if not files:
525
+ raise KeyError(f"PGDA site {site} not in the file index")
526
+ out: dict[str, list[str]] = {}
527
+ for kind in kinds:
528
+ urls = list(files.get(kind, []))
529
+ if kind == "clones" and clones is not None:
530
+ if isinstance(clones, int):
531
+ urls = urls[:clones]
532
+ else:
533
+ wanted = set(clones)
534
+ urls = [u for u in urls if _clone_number(u) in wanted]
535
+ if not urls:
536
+ log.warning("PGDA site %s has no %s files", site, kind)
537
+ out[kind] = urls
538
+ return out
539
+
540
+
541
+ def fetch(
542
+ url: str,
543
+ dest: Path,
544
+ expected_md5: str | None = None,
545
+ force: bool = False,
546
+ show_progress: bool = True,
547
+ retries: int = 4,
548
+ ) -> Path:
549
+ """Resumable download of *url* to *dest*, skipped when *dest* exists.
550
+
551
+ Throttling replies (403 / 429) and server errors are retried with
552
+ back-off; the ``.part`` file is resumed each time.
553
+ """
554
+ dest = Path(dest)
555
+ if reuse_existing(dest, force):
556
+ return dest
557
+ part = dest.with_suffix(dest.suffix + ".part")
558
+ if force and part.exists():
559
+ part.unlink()
560
+ for attempt in range(retries + 1):
561
+ try:
562
+ return download_file(url, dest, show_progress=show_progress, expected_md5=expected_md5)
563
+ except urllib.error.HTTPError as exc:
564
+ if exc.code not in _RETRY_CODES or attempt == retries:
565
+ raise
566
+ except (urllib.error.URLError, TimeoutError, ConnectionError):
567
+ if attempt == retries:
568
+ raise
569
+ log.info("Retrying %s", url)
570
+ time.sleep(2.0 * 2**attempt)
571
+ raise AssertionError("unreachable")
572
+
573
+
574
+ def download_pgda(
575
+ site_or_region: Region | int | str,
576
+ kinds: Iterable[str] = ("surf",),
577
+ out_dir: Path = Path("."),
578
+ clones: int | Iterable[int] | None = None,
579
+ force: bool = False,
580
+ show_progress: bool = True,
581
+ index: dict[str, dict[str, list[str]]] | None = None,
582
+ ) -> dict[str, list[Path]]:
583
+ """Download PGDA LOLA 5 m files of one site by kind (sequentially; resumable).
584
+
585
+ Files land in ``out_dir/<site>/`` (clones in ``out_dir/<site>/Clones/``);
586
+ existing ones are kept unless *force*. Arguments as in :func:`pgda_urls`.
587
+
588
+ Returns
589
+ -------
590
+ dict
591
+ ``kind → [local path, ...]``.
592
+ """
593
+ site = _site_name(site_or_region)
594
+ out: dict[str, list[Path]] = {}
595
+ for kind, urls in pgda_urls(site, kinds, clones, index).items():
596
+ sub = Path(out_dir) / site / ("Clones" if kind == "clones" else "")
597
+ out[kind] = [fetch(u, sub / url_filename(u), force=force, show_progress=show_progress) for u in urls]
598
+ return out
599
+
600
+
601
+ def read_xyzi(path: Path, box: PsBox | None = None) -> pd.DataFrame:
602
+ """Read a PGDA ``.xyzi`` LOLA point cloud.
603
+
604
+ The file is little-endian float64 rows of ``X, Y, Z, RDRid`` with X/Y the
605
+ polar stereographic coordinates and Z the height above the 1737.4 km
606
+ sphere, all in km (the same Z as the ``*_surf.tif`` metres), and RDRid the
607
+ LOLA RDR ``YYDOYHHMM``.
608
+
609
+ Parameters
610
+ ----------
611
+ path : Path
612
+ The ``.xyzi`` file.
613
+ box : PsBox, optional
614
+ Keep only points inside this window.
615
+
616
+ Returns
617
+ -------
618
+ pandas.DataFrame
619
+ Columns ``x``, ``y``, ``height`` (metres) and ``rdr_id`` (int64).
620
+ """
621
+ raw = np.fromfile(path, dtype="<f8")
622
+ if raw.size % 4:
623
+ raise ValueError(f"{path}: size is not a multiple of 4 float64 values")
624
+ pts = raw.reshape(-1, 4)
625
+ if box is not None:
626
+ x, y = pts[:, 0] * 1000.0, pts[:, 1] * 1000.0
627
+ pts = pts[(x >= box.x0) & (x <= box.x1) & (y >= box.y0) & (y <= box.y1)]
628
+ return pd.DataFrame(
629
+ {
630
+ "x": pts[:, 0] * 1000.0,
631
+ "y": pts[:, 1] * 1000.0,
632
+ "height": pts[:, 2] * 1000.0,
633
+ "rdr_id": pts[:, 3].astype(np.int64),
634
+ }
635
+ )
636
+
637
+
638
+ def write_asp_csv(points: pd.DataFrame | Path, out: Path, box: PsBox | None = None) -> Path:
639
+ """Write LOLA points as an ASP CSV of ``easting, northing, height_above_datum``.
640
+
641
+ Use with ``--csv-format`` :data:`ASP_CSV_FORMAT` and ``--csv-srs``
642
+ :data:`PS_SRS` (the 1737.4 km sphere is then the datum), e.g.
643
+ ``pc_align --csv-format '1:easting 2:northing 3:height_above_datum'
644
+ --csv-srs '+proj=stere +lat_0=-90 +lon_0=0 +R=1737400 +units=m' dem.tif lola.csv``.
645
+
646
+ Parameters
647
+ ----------
648
+ points : DataFrame or Path
649
+ A :func:`read_xyzi` table or an ``.xyzi`` path.
650
+ out : Path
651
+ CSV to write; a ``#`` header line (ignored by ASP) names the columns.
652
+ box : PsBox, optional
653
+ Keep only points inside this window.
654
+ """
655
+ df = read_xyzi(Path(points), box) if not isinstance(points, pd.DataFrame) else points
656
+ if box is not None and isinstance(points, pd.DataFrame):
657
+ df = df[(df.x >= box.x0) & (df.x <= box.x1) & (df.y >= box.y0) & (df.y <= box.y1)]
658
+ out = Path(out)
659
+ out.parent.mkdir(parents=True, exist_ok=True)
660
+ with open(out, "w") as f:
661
+ f.write(f"# easting_m,northing_m,height_above_datum_m,rdr_id csv-srs: {PS_SRS}\n")
662
+ np.savetxt(
663
+ f,
664
+ df[["x", "y", "height", "rdr_id"]].to_numpy(),
665
+ fmt=["%.3f", "%.3f", "%.3f", "%d"],
666
+ delimiter=",",
667
+ )
668
+ return out
669
+
670
+
671
+ def parse_zenodo_files(record: dict[str, Any]) -> list[dict[str, Any]]:
672
+ """``[{"key", "size", "md5", "url"}, ...]`` from a Zenodo record API response."""
673
+ files = []
674
+ for f in record.get("files", []):
675
+ algo, _, digest = str(f.get("checksum", "")).partition(":")
676
+ files.append(
677
+ {
678
+ "key": f["key"],
679
+ "size": int(f.get("size", 0)),
680
+ "md5": digest if algo == "md5" else None,
681
+ "url": ZENODO_FILE_URL.format(name=f["key"]),
682
+ }
683
+ )
684
+ return sorted(files, key=lambda f: f["key"])
685
+
686
+
687
+ def zenodo_files(refresh: bool = False, cache_dir: Path = REFERENCE_CACHE_DIR) -> list[dict[str, Any]]:
688
+ """Files of the Bertone et al. Zenodo record (fetched once, then cached)."""
689
+ cache = Path(cache_dir) / f"zenodo_{ZENODO_RECORD}.json"
690
+ if cache.exists() and not refresh:
691
+ record = json.loads(cache.read_text())
692
+ else:
693
+ record = json.loads(_get(ZENODO_API_URL))
694
+ cache.parent.mkdir(parents=True, exist_ok=True)
695
+ cache.write_text(json.dumps({"id": record.get("id"), "files": record.get("files", [])}, indent=1))
696
+ return parse_zenodo_files(record)
697
+
698
+
699
+ def download_sdem(
700
+ region: Region | int | str,
701
+ out_dir: Path = Path("."),
702
+ force: bool = False,
703
+ show_progress: bool = True,
704
+ ) -> Path:
705
+ """Download a region's Bertone et al. zip (1–4 GB; resumable, MD5-checked)."""
706
+ reg = get_region(region)
707
+ return fetch(
708
+ reg.zenodo_url,
709
+ Path(out_dir) / reg.zenodo_file,
710
+ expected_md5=reg.zenodo_md5,
711
+ force=force,
712
+ show_progress=show_progress,
713
+ )
714
+
715
+
716
+ class HttpRangeFile(io.RawIOBase):
717
+ """Read-only, seekable view of a remote file through HTTP Range requests.
718
+
719
+ Lets :class:`zipfile.ZipFile` read a remote zip's central directory or a
720
+ single member without downloading the archive.
721
+ """
722
+
723
+ def __init__(self, url: str, timeout: float = 60.0) -> None:
724
+ self.url, self.timeout, self.pos = url, timeout, 0
725
+ self.requests = 0
726
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT, "Range": "bytes=0-0"})
727
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
728
+ rng = resp.headers.get("Content-Range", "")
729
+ if resp.status != 206 or "/" not in rng:
730
+ raise OSError(f"{url} does not support HTTP Range requests")
731
+ self.size = int(rng.rsplit("/", 1)[1])
732
+
733
+ def readable(self) -> bool:
734
+ return True
735
+
736
+ def seekable(self) -> bool:
737
+ return True
738
+
739
+ def tell(self) -> int:
740
+ return self.pos
741
+
742
+ def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
743
+ base = {io.SEEK_SET: 0, io.SEEK_CUR: self.pos, io.SEEK_END: self.size}[whence]
744
+ self.pos = max(0, base + offset)
745
+ return self.pos
746
+
747
+ def readinto(self, b: Any) -> int:
748
+ n = min(len(b), self.size - self.pos)
749
+ if n <= 0:
750
+ return 0
751
+ data = _get(self.url, {"Range": f"bytes={self.pos}-{self.pos + n - 1}"}, timeout=self.timeout)[:n]
752
+ self.requests += 1
753
+ b[: len(data)] = data
754
+ self.pos += len(data)
755
+ return len(data)
756
+
757
+
758
+ def _open_zip(source: Region | int | str | Path, buffer_size: int = 4 << 20) -> zipfile.ZipFile:
759
+ """Local zip path, or a region's remote Zenodo zip (read via HTTP Range, *buffer_size* per request)."""
760
+ if isinstance(source, Path) or (isinstance(source, str) and Path(source).is_file()):
761
+ return zipfile.ZipFile(source)
762
+ fh: IO[bytes] = io.BufferedReader(HttpRangeFile(get_region(source).zenodo_url), buffer_size=buffer_size)
763
+ return zipfile.ZipFile(fh)
764
+
765
+
766
+ def _is_junk(name: str) -> bool:
767
+ return name.startswith("__MACOSX/") or name.endswith((".DS_Store", "/"))
768
+
769
+
770
+ def sdem_members(source: Region | int | str | Path) -> pd.DataFrame:
771
+ """Members of a Bertone et al. zip: a local path, or a region (remote, central directory only).
772
+
773
+ Returns
774
+ -------
775
+ pandas.DataFrame
776
+ ``name`` (path inside the zip), ``size`` and ``compressed_size``
777
+ (bytes); macOS metadata and directories are left out.
778
+ """
779
+ with _open_zip(source) as zf:
780
+ rows = [
781
+ {"name": i.filename, "size": i.file_size, "compressed_size": i.compress_size}
782
+ for i in zf.infolist()
783
+ if not _is_junk(i.filename)
784
+ ]
785
+ return pd.DataFrame(rows, columns=["name", "size", "compressed_size"])
786
+
787
+
788
+ def select_members(names: Iterable[str], patterns: Iterable[str]) -> list[str]:
789
+ """Zip member names matching any pattern.
790
+
791
+ A pattern with ``*``, ``?`` or ``[`` is a glob against the full name and
792
+ the base name (``"SLOPE_MAPS/*_100.tif"``); otherwise it is a
793
+ case-insensitive substring of the base name (``"GLDELEV"``, ``"README"``).
794
+ """
795
+ pats = list(patterns)
796
+ out = []
797
+ for name in names:
798
+ if _is_junk(name):
799
+ continue
800
+ base = name.rsplit("/", 1)[-1]
801
+ for p in pats:
802
+ if any(c in p for c in "*?["):
803
+ hit = fnmatch.fnmatch(name, p) or fnmatch.fnmatch(base, p) or fnmatch.fnmatch(name, f"*/{p}")
804
+ else:
805
+ hit = p.lower() in base.lower()
806
+ if hit:
807
+ out.append(name)
808
+ break
809
+ return out
810
+
811
+
812
+ def extract_sdem(
813
+ source: Region | int | str | Path,
814
+ members: Iterable[str] = ("GLDELEV",),
815
+ out_dir: Path = Path("."),
816
+ force: bool = False,
817
+ ) -> list[Path]:
818
+ """Extract selected members of a Bertone et al. zip.
819
+
820
+ Parameters
821
+ ----------
822
+ source : Path, Region, int or str
823
+ A downloaded zip, or a region whose Zenodo zip is then read remotely
824
+ member by member (HTTP Range; only the selected members are fetched).
825
+ members : iterable of str
826
+ :func:`select_members` patterns, e.g. ``("GLDELEV", "GLDSIGM", "README")``.
827
+ out_dir : Path
828
+ Destination; paths below the zip's top-level folder are kept
829
+ (``SLOPE_MAPS/A311_GLDTSLP_005_100.tif``).
830
+ force : bool
831
+ Re-extract members that already exist.
832
+
833
+ Returns
834
+ -------
835
+ list of Path
836
+ """
837
+ out_dir = Path(out_dir)
838
+ paths = []
839
+ with _open_zip(source) as zf:
840
+ names = select_members(zf.namelist(), members)
841
+ if not names:
842
+ raise KeyError(f"No zip member matches {list(members)}")
843
+ for name in names:
844
+ rel = name.split("/", 1)[1] if "/" in name else name
845
+ dest = out_dir / rel
846
+ if not reuse_existing(dest, force):
847
+ dest.parent.mkdir(parents=True, exist_ok=True)
848
+ tmp = dest.with_suffix(dest.suffix + ".part")
849
+ with zf.open(name) as src, open(tmp, "wb") as dst:
850
+ shutil.copyfileobj(src, dst, 1 << 20)
851
+ tmp.replace(dest)
852
+ log.info("Extracted %s", dest)
853
+ paths.append(dest)
854
+ return paths
855
+
856
+
857
+ NAC_FOCAL_MM = {"LEFT": 699.62, "RIGHT": 701.57}
858
+ """LROC NAC focal lengths (mm) of the ISIS / ALE camera models, by ``name_sensor`` suffix."""
859
+
860
+ _PID_RE = re.compile(r"_(M\d+[LR]E)\.(?:json|tif)$")
861
+
862
+
863
+ def _member_pid(name: str) -> str | None:
864
+ m = _PID_RE.search(name)
865
+ return m.group(1) if m else None
866
+
867
+
868
+ def published_members(source: Region | int | str | Path, kind: str = "GLDACAM") -> pd.DataFrame:
869
+ """Per-image members of a Bertone et al. zip: cameras (``GLDACAM``) or mapprojected images (``GLDIMGP``).
870
+
871
+ Returns
872
+ -------
873
+ pandas.DataFrame
874
+ ``product_id``, ``name`` and ``size``; regions 0, 7, 8 and 10 have
875
+ no ``LROC-NAC-adjust/`` cameras (the table is then empty).
876
+ """
877
+ members = sdem_members(source)
878
+ hit = members[members["name"].str.contains(f"_{kind}_", regex=False)].copy()
879
+ hit.insert(0, "product_id", hit["name"].map(_member_pid))
880
+ return hit.dropna(subset=["product_id"])[["product_id", "name", "size"]].reset_index(drop=True)
881
+
882
+
883
+ def parse_image_status(text: str) -> pd.DataFrame:
884
+ """Parse ``extra/image_status_*.csv``: ``product_id`` and ``status``.
885
+
886
+ Statuses are ``used_for_sfs`` (the SfS inputs, one ``GLDACAM`` camera
887
+ each), ``not_aligned`` (dropped by bundle adjustment) and
888
+ ``available_not_selected``. The ``# key: value`` header lines go to
889
+ ``attrs["summary"]`` (counts as ints).
890
+ """
891
+ summary: dict[str, int] = {}
892
+ rows = []
893
+ for line in text.splitlines():
894
+ line = line.strip()
895
+ if not line:
896
+ continue
897
+ if line.startswith("#"):
898
+ key, sep, val = line[1:].partition(":")
899
+ if sep and val.strip().isdigit():
900
+ summary[key.strip()] = int(val)
901
+ continue
902
+ pid, _, status = line.partition(",")
903
+ if pid != "img_name":
904
+ rows.append((pid.strip(), status.strip()))
905
+ df = pd.DataFrame(rows, columns=["product_id", "status"])
906
+ df.attrs["summary"] = summary
907
+ return df
908
+
909
+
910
+ def image_status(
911
+ source: Region | int | str | Path, out_dir: Path = REFERENCE_CACHE_DIR / "published", force: bool = False
912
+ ) -> pd.DataFrame:
913
+ """A region's image status table (:func:`parse_image_status`), extracted once to *out_dir*."""
914
+ (path,) = extract_sdem(source, ["extra/image_status_*.csv"], Path(out_dir), force=force)
915
+ return parse_image_status(path.read_text())
916
+
917
+
918
+ def parse_tiff_extent(head: bytes) -> PsBox:
919
+ """Extent of a north-up GeoTIFF from its first bytes (header, IFD and georeferencing tags).
920
+
921
+ Reads ``ImageWidth`` / ``ImageLength`` with ``ModelPixelScale`` and
922
+ ``ModelTiepoint`` (or ``ModelTransformation``) from a classic TIFF whose
923
+ IFD and tag values lie inside *head* (GDAL writes them first, so 16 KB
924
+ is plenty for the Bertone et al. ``GLDIMGP`` maps).
925
+ """
926
+ import struct
927
+
928
+ bo = {b"II": "<", b"MM": ">"}.get(head[:2])
929
+ if bo is None or struct.unpack(bo + "H", head[2:4])[0] != 42:
930
+ raise ValueError("Not a classic TIFF")
931
+ sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8}
932
+ codes = {3: "H", 4: "I", 8: "h", 9: "i", 11: "f", 12: "d"}
933
+ (ifd,) = struct.unpack(bo + "I", head[4:8])
934
+ (n,) = struct.unpack(bo + "H", head[ifd : ifd + 2])
935
+ tags: dict[int, tuple[Any, ...]] = {}
936
+ for i in range(n):
937
+ tag, typ, cnt = struct.unpack(bo + "HHI", head[ifd + 2 + 12 * i : ifd + 10 + 12 * i])
938
+ if typ not in codes:
939
+ continue
940
+ size = sizes[typ] * cnt
941
+ raw = head[ifd + 10 + 12 * i : ifd + 14 + 12 * i]
942
+ if size > 4:
943
+ (off,) = struct.unpack(bo + "I", raw)
944
+ if off + size > len(head):
945
+ raise ValueError(f"TIFF tag {tag} lies beyond the first {len(head)} bytes")
946
+ raw = head[off : off + size]
947
+ tags[tag] = struct.unpack(bo + codes[typ] * cnt, raw[:size])
948
+ w, h = tags[256][0], tags[257][0]
949
+ if 34264 in tags:
950
+ t = tags[34264]
951
+ sx, sy, x0, y0 = t[0], -t[5], t[3], t[7]
952
+ elif 33550 in tags and 33922 in tags:
953
+ sx, sy = tags[33550][:2]
954
+ i, j, _, x, y, _ = tags[33922][:6]
955
+ x0, y0 = x - i * sx, y + j * sy
956
+ else:
957
+ raise ValueError("TIFF has no GeoTIFF georeferencing tags")
958
+ return PsBox(x0, y0 - h * sy, x0 + w * sx, y0)
959
+
960
+
961
+ def _member_head(zf: zipfile.ZipFile, info: zipfile.ZipInfo, n: int) -> bytes:
962
+ """First *n* uncompressed bytes of a stored or deflated member, in one read of the archive."""
963
+ import struct
964
+ import zlib
965
+
966
+ fp = zf.fp
967
+ assert fp is not None
968
+ fp.seek(info.header_offset)
969
+ raw = fp.read(30 + len(info.filename.encode()) + 1024 + 2 * n)
970
+ if raw[:4] != b"PK\x03\x04":
971
+ raise zipfile.BadZipFile(f"Bad local header for {info.filename}")
972
+ name_len, extra_len = struct.unpack("<HH", raw[26:30])
973
+ data = raw[30 + name_len + extra_len :][: info.compress_size]
974
+ if info.compress_type == zipfile.ZIP_STORED:
975
+ return data[:n]
976
+ if info.compress_type != zipfile.ZIP_DEFLATED:
977
+ raise NotImplementedError(f"Compression {info.compress_type} of {info.filename}")
978
+ return zlib.decompressobj(-15).decompress(data, n)
979
+
980
+
981
+ def published_extents(
982
+ source: Region | int | str | Path,
983
+ cache: Path | None = None,
984
+ head_bytes: int = 16384,
985
+ ) -> pd.DataFrame:
986
+ """PS extents of a region's mapprojected input images (``LROC-NAC-maps/*_GLDIMGP_*.tif``).
987
+
988
+ Only the start of each member is read (remotely: about 2 small HTTP
989
+ Range requests per image), so this is a cheap way to find the images
990
+ over a crop window. The maps are cropped to the valid pixels' bounding
991
+ box within the ROI, so a diagonal strip's extent over-covers.
992
+
993
+ Parameters
994
+ ----------
995
+ source : Path, Region, int or str
996
+ A downloaded zip or a region (remote).
997
+ cache : Path, optional
998
+ JSON file of the result, written once and reused.
999
+ head_bytes : int
1000
+ Bytes decompressed from each member.
1001
+
1002
+ Returns
1003
+ -------
1004
+ pandas.DataFrame
1005
+ ``product_id``, ``x0``, ``y0``, ``x1``, ``y1`` (PS metres).
1006
+ """
1007
+ cols = ["product_id", "x0", "y0", "x1", "y1"]
1008
+ if cache is not None and Path(cache).is_file():
1009
+ return pd.DataFrame(json.loads(Path(cache).read_text()), columns=cols)
1010
+ rows = []
1011
+ with _open_zip(source, buffer_size=4 << 10) as zf:
1012
+ for info in zf.infolist():
1013
+ pid = _member_pid(info.filename)
1014
+ if pid is None or "_GLDIMGP_" not in info.filename or _is_junk(info.filename):
1015
+ continue
1016
+ rows.append([pid, *parse_tiff_extent(_member_head(zf, info, head_bytes)).bounds])
1017
+ if cache is not None:
1018
+ Path(cache).parent.mkdir(parents=True, exist_ok=True)
1019
+ Path(cache).write_text(json.dumps(rows))
1020
+ return pd.DataFrame(rows, columns=cols)
1021
+
1022
+
1023
+ def images_covering(extents: pd.DataFrame, box: PsBox, min_fraction: float = 0.0) -> list[str]:
1024
+ """Product IDs whose extent overlaps *box* by more than *min_fraction* of the box area."""
1025
+ ix = np.clip(np.minimum(extents.x1, box.x1) - np.maximum(extents.x0, box.x0), 0, None)
1026
+ iy = np.clip(np.minimum(extents.y1, box.y1) - np.maximum(extents.y0, box.y0), 0, None)
1027
+ frac = ix * iy / (box.width * box.height)
1028
+ return list(extents.product_id[(frac > min_fraction) & (ix * iy > 0)])
1029
+
1030
+
1031
+ def published_cameras(
1032
+ region: Region | int | str,
1033
+ out_dir: Path,
1034
+ images: Iterable[str] | None = None,
1035
+ window: PsBox | None = None,
1036
+ source: Path | None = None,
1037
+ force: bool = False,
1038
+ ) -> dict[str, Path]:
1039
+ """Extract the Bertone et al. bundle-adjusted NAC cameras (``LROC-NAC-adjust/*_GLDACAM_*.json``).
1040
+
1041
+ Each file is an ASP / USGS CSM linescan model state (model-name line,
1042
+ then JSON) with the bundle adjustment and LOLA registration baked in.
1043
+ It describes the *full* image ISIS-``reduce``-d by an integer factor to
1044
+ about 5 m/px (see :func:`published_reduce_factor` and
1045
+ :func:`published_camera_native`).
1046
+
1047
+ Parameters
1048
+ ----------
1049
+ region : Region, int or str
1050
+ Anything :func:`get_region` accepts.
1051
+ out_dir : Path
1052
+ Destination; files land in ``out_dir/LROC-NAC-adjust/``.
1053
+ images : iterable of str, optional
1054
+ Only these product IDs.
1055
+ window : PsBox, optional
1056
+ Only images whose mapprojected extent overlaps this box
1057
+ (:func:`published_extents`, cached in ``out_dir``).
1058
+ source : Path, optional
1059
+ A downloaded zip instead of the remote Zenodo file.
1060
+ force : bool
1061
+ Re-extract existing files.
1062
+
1063
+ Returns
1064
+ -------
1065
+ dict
1066
+ ``product_id -> camera path`` (sorted by product ID).
1067
+ """
1068
+ reg = get_region(region)
1069
+ src: Region | Path = Path(source) if source is not None else reg
1070
+ cams = published_members(src)
1071
+ if cams.empty:
1072
+ raise KeyError(f"Region {reg.id} {reg.name} has no published cameras (LROC-NAC-adjust/)")
1073
+ wanted = set(cams.product_id)
1074
+ if images is not None:
1075
+ wanted &= {str(p).upper() for p in images}
1076
+ if window is not None:
1077
+ ext = published_extents(src, cache=Path(out_dir) / f"gldimgp_extents_{reg.id}.json")
1078
+ wanted &= set(images_covering(ext, window))
1079
+ names = sorted(n.rsplit("/", 1)[-1] for n in cams.name[cams.product_id.isin(wanted)])
1080
+ if not names:
1081
+ return {}
1082
+ paths = extract_sdem(src, names, Path(out_dir), force=force)
1083
+ return dict(sorted((p.name.rsplit("_", 1)[-1].removesuffix(".json"), p) for p in paths))
1084
+
1085
+
1086
+ def published_reduce_factor(camera: CsmCamera | Path) -> int:
1087
+ """``reduce`` factor of a published camera: NAC focal length / its (scaled) ``m_focalLength``."""
1088
+ cam = camera if isinstance(camera, CsmCamera) else read_camera(camera)
1089
+ side = "LEFT" if str(cam.data.get("m_sensorName", "")).upper().endswith("LEFT") else "RIGHT"
1090
+ return max(1, round(NAC_FOCAL_MM[side] / float(cam.data["m_focalLength"])))
1091
+
1092
+
1093
+ def published_camera_native(camera: CsmCamera | Path, native_size: tuple[int, int]) -> CsmCamera:
1094
+ """Published camera re-mapped to the full-resolution image (``(samples, lines)`` of the EDR / cube).
1095
+
1096
+ The published states keep the detector summing and divide the focal
1097
+ length and detector-sample origin by the reduce factor *f*; their
1098
+ ``m_nLines`` / ``m_nSamples`` are the native size divided by ``f**2``
1099
+ (not ``f``), which only affects usgscsm's initial ground-to-image guess.
1100
+ The result is a state for the native image with consistent sizes; use
1101
+ :func:`lunar_toolkit.csm.camera_for_cube` or
1102
+ :func:`lunar_toolkit.csm.isis_reduce_with_csm` to get any crop or
1103
+ reduction of it.
1104
+ """
1105
+ cam = camera if isinstance(camera, CsmCamera) else read_camera(camera)
1106
+ f = published_reduce_factor(cam)
1107
+ out = transform_camera(cam, 1.0 / f, 0.0, 1.0 / f, 0.0, native_size)
1108
+ if "m_gsd" in cam.data: # already the native ground sample distance
1109
+ out.data["m_gsd"] = cam.data["m_gsd"]
1110
+ out.data["m_halfSwath"] = cam.data["m_gsd"] * native_size[0] / 2.0
1111
+ return out