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,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
|