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,702 @@
|
|
|
1
|
+
"""LROC NAC images: metadata, download and the ISIS + GDAL processing pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Generic, TypeVar, overload
|
|
10
|
+
|
|
11
|
+
import matplotlib.image as mpimg
|
|
12
|
+
import matplotlib.pyplot as plt
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from ..constants import DEFAULT_PROJECTION, GEOTIFF_OPTIONS, nac_id, parse_nac_id
|
|
16
|
+
from ..downloader import download_file, url_filename
|
|
17
|
+
from ..isis import calibrate_lronac, map_project
|
|
18
|
+
from ..tools import is_dry_run, reuse_existing, run
|
|
19
|
+
from .cumindex import CumIndex
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_T = TypeVar("_T")
|
|
24
|
+
_V = TypeVar("_V")
|
|
25
|
+
|
|
26
|
+
# Ordered (column_name, display_label) pairs for metadata display.
|
|
27
|
+
# Used by metadata_items(), the CLI query command, and the GUI.
|
|
28
|
+
METADATA_LABELS: list[tuple[str, str]] = [
|
|
29
|
+
("PRODUCT_ID", "Product"),
|
|
30
|
+
("DATA_SET_ID", "PDS dataset name"),
|
|
31
|
+
("VOLUME_ID", "PDS volume name"),
|
|
32
|
+
("INSTRUMENT_HOST_ID", "Instrument host"),
|
|
33
|
+
("INSTRUMENT_ID", "Instrument"),
|
|
34
|
+
("ORIGINAL_PRODUCT_ID", "Original product"),
|
|
35
|
+
("PRODUCT_VERSION_ID", "Product version"),
|
|
36
|
+
("MISSION_PHASE_NAME", "Mission phase name"),
|
|
37
|
+
("RATIONALE_DESC", "Rationale desc"),
|
|
38
|
+
("DATA_QUALITY_ID", "Data quality"),
|
|
39
|
+
("NAC_PREROLL_START_TIME", "NAC preroll start time"),
|
|
40
|
+
("START_TIME", "Start time"),
|
|
41
|
+
("STOP_TIME", "Stop time"),
|
|
42
|
+
("SPACECRAFT_CLOCK_PARTITION", "SC clock partition"),
|
|
43
|
+
("NAC_SPACECRAFT_CLOCK_PREROLL_COUNT", "NAC SC clock preroll count"),
|
|
44
|
+
("SPACECRAFT_CLOCK_START_COUNT", "SC clock start count"),
|
|
45
|
+
("SPACECRAFT_CLOCK_STOP_COUNT", "SC clock stop count"),
|
|
46
|
+
("TARGET_NAME", "Target name"),
|
|
47
|
+
("ORBIT_NUMBER", "Orbit number"),
|
|
48
|
+
("SLEW_ANGLE", "Slew angle"),
|
|
49
|
+
("ORBIT_NODE", "LRO node crossing"),
|
|
50
|
+
("LRO_FLIGHT_DIRECTION", "LRO flight direction"),
|
|
51
|
+
("NAC_LINE_EXPOSURE_DURATION", "NAC line exposure duration"),
|
|
52
|
+
("NAC_FRAME_ID", "NAC frame"),
|
|
53
|
+
("NAC_DAC_RESET", "NAC DAC reset"),
|
|
54
|
+
("NAC_CHANNEL_A_OFFSET", "NAC channel A offset"),
|
|
55
|
+
("NAC_CHANNEL_B_OFFSET", "NAC channel B offset"),
|
|
56
|
+
("INSTRUMENT_MODE_CODE", "Instrument mode code"),
|
|
57
|
+
("COMPAND_SELECT_CODE", "Compand select code"),
|
|
58
|
+
("MODE_COMPRESSION", "Mode compression"),
|
|
59
|
+
("MODE_TEST", "Mode test"),
|
|
60
|
+
("NAC_TEMPERATURE_SCS", "NAC temperature SCS"),
|
|
61
|
+
("NAC_TEMPERATURE_FPA", "NAC temperature FPA"),
|
|
62
|
+
("NAC_TEMPERATURE_FPGA", "NAC temperature FPGA"),
|
|
63
|
+
("NAC_TEMPERATURE_TELESCOPE", "NAC temperature telescope"),
|
|
64
|
+
("IMAGE_LINES", "Image lines"),
|
|
65
|
+
("LINE_SAMPLES", "Line samples"),
|
|
66
|
+
("SAMPLE_BITS", "Sample bits"),
|
|
67
|
+
("SCALED_PIXEL_WIDTH", "Scaled pixel width"),
|
|
68
|
+
("SCALED_PIXEL_HEIGHT", "Scaled pixel height"),
|
|
69
|
+
("RESOLUTION", "Resolution"),
|
|
70
|
+
("EMISSION_ANGLE", "Emission angle"),
|
|
71
|
+
("INCIDENCE_ANGLE", "Incidence angle"),
|
|
72
|
+
("PHASE_ANGLE", "Phase angle"),
|
|
73
|
+
("NORTH_AZIMUTH", "North azimuth"),
|
|
74
|
+
("SUB_SOLAR_AZIMUTH", "Sub solar azimuth"),
|
|
75
|
+
("SUB_SOLAR_LATITUDE", "Sub solar latitude"),
|
|
76
|
+
("SUB_SOLAR_LONGITUDE", "Sub solar longitude"),
|
|
77
|
+
("SUB_SPACECRAFT_LATITUDE", "Sub spacecraft latitude"),
|
|
78
|
+
("SUB_SPACECRAFT_LONGITUDE", "Sub spacecraft longitude"),
|
|
79
|
+
("SOLAR_DISTANCE", "Solar distance"),
|
|
80
|
+
("SOLAR_LONGITUDE", "Solar longitude"),
|
|
81
|
+
("CENTER_LATITUDE", "Center latitude"),
|
|
82
|
+
("CENTER_LONGITUDE", "Center longitude"),
|
|
83
|
+
("UPPER_RIGHT_LATITUDE", "Upper right latitude"),
|
|
84
|
+
("UPPER_RIGHT_LONGITUDE", "Upper right longitude"),
|
|
85
|
+
("LOWER_RIGHT_LATITUDE", "Lower right latitude"),
|
|
86
|
+
("LOWER_RIGHT_LONGITUDE", "Lower right longitude"),
|
|
87
|
+
("LOWER_LEFT_LATITUDE", "Lower left latitude"),
|
|
88
|
+
("LOWER_LEFT_LONGITUDE", "Lower left longitude"),
|
|
89
|
+
("UPPER_LEFT_LATITUDE", "Upper left latitude"),
|
|
90
|
+
("UPPER_LEFT_LONGITUDE", "Upper left longitude"),
|
|
91
|
+
("SPACECRAFT_ALTITUDE", "Spacecraft altitude"),
|
|
92
|
+
("TARGET_CENTER_DISTANCE", "Target center distance"),
|
|
93
|
+
("PDS_URL", "PDS URL"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _normalize_nac_id(product_id: str) -> str:
|
|
98
|
+
"""Full, upper-case NAC ID (short ``M…L`` → EDR ``M…LE``); other IDs just upper-cased."""
|
|
99
|
+
return nac_id(product_id) or product_id.strip().upper()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _raw(value: Any) -> Any:
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class IndexColumn(Generic[_V]):
|
|
107
|
+
"""Read-only :class:`NacImage` attribute holding one CUMINDEX column.
|
|
108
|
+
|
|
109
|
+
The value is converted by *cast*; ``None`` when the column is missing
|
|
110
|
+
or null.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, column: str, cast: Callable[[Any], _V]) -> None:
|
|
114
|
+
self.column = column
|
|
115
|
+
self.cast = cast
|
|
116
|
+
|
|
117
|
+
@overload
|
|
118
|
+
def __get__(self, obj: None, owner: type | None = None) -> IndexColumn[_V]: ...
|
|
119
|
+
|
|
120
|
+
@overload
|
|
121
|
+
def __get__(self, obj: NacImage, owner: type | None = None) -> _V | None: ...
|
|
122
|
+
|
|
123
|
+
def __get__(self, obj: NacImage | None, owner: type | None = None) -> IndexColumn[_V] | _V | None:
|
|
124
|
+
if obj is None:
|
|
125
|
+
return self
|
|
126
|
+
value = obj._get(self.column)
|
|
127
|
+
return None if value is None else self.cast(value)
|
|
128
|
+
|
|
129
|
+
def __repr__(self) -> str:
|
|
130
|
+
return f"IndexColumn({self.column!r}, {getattr(self.cast, '__name__', self.cast)})"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class NacImage:
|
|
134
|
+
"""A single LROC NAC (or WAC) image identified by its PDS product ID.
|
|
135
|
+
|
|
136
|
+
Product ID anatomy::
|
|
137
|
+
|
|
138
|
+
M 1096757719 L E
|
|
139
|
+
│ └── type: E = EDR, C = CDR
|
|
140
|
+
└──── side: L = left, R = right
|
|
141
|
+
|
|
142
|
+
Short base IDs (``M1096757719L``) are automatically normalized to EDR
|
|
143
|
+
(``M1096757719LE``). The correct CumIndex dataset is auto-detected from
|
|
144
|
+
the type suffix, so no manual dataset selection is needed.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
product_id : str
|
|
149
|
+
Full or short NAC/WAC product ID, e.g. ``"M1524360231LE"``,
|
|
150
|
+
``"M1524360231L"``, or ``"M1524360231LC"``.
|
|
151
|
+
cumindex : CumIndex, optional
|
|
152
|
+
Pre-built CumIndex instance to reuse. If None, one is constructed
|
|
153
|
+
lazily on first metadata access (dataset auto-detected from suffix).
|
|
154
|
+
_row : pd.Series, optional
|
|
155
|
+
Pre-populated metadata row from a bulk ``CumIndex.get_images()`` call.
|
|
156
|
+
Avoids a per-image database round-trip.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
product_id: str,
|
|
162
|
+
cumindex: CumIndex | None = None,
|
|
163
|
+
_row: pd.Series | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
self._product_id = _normalize_nac_id(product_id)
|
|
166
|
+
self._cumindex = cumindex
|
|
167
|
+
self.__row = _row # pre-populated; None triggers lazy DB load
|
|
168
|
+
|
|
169
|
+
# Identity
|
|
170
|
+
@property
|
|
171
|
+
def product_id(self) -> str:
|
|
172
|
+
return self._product_id
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def side(self) -> str | None:
|
|
176
|
+
"""``"L"`` for left NAC, ``"R"`` for right NAC, ``None`` for WAC."""
|
|
177
|
+
parts = parse_nac_id(self._product_id)
|
|
178
|
+
return parts[1] if parts else None
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def product_type(self) -> str | None:
|
|
182
|
+
"""``"edr"`` for LE/RE, ``"cdr"`` for LC/RC, ``None`` for WAC."""
|
|
183
|
+
parts = parse_nac_id(self._product_id)
|
|
184
|
+
return parts[2] if parts else None
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def edr_id(self) -> str | None:
|
|
188
|
+
"""Product ID for the EDR version of this observation (e.g. ``M...LE``)."""
|
|
189
|
+
return nac_id(self._product_id, product_type="edr")
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def cdr_id(self) -> str | None:
|
|
193
|
+
"""Product ID for the CDR version of this observation (e.g. ``M...LC``)."""
|
|
194
|
+
return nac_id(self._product_id, product_type="cdr")
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def is_left(self) -> bool:
|
|
198
|
+
return self.side == "L"
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def is_right(self) -> bool:
|
|
202
|
+
return self.side == "R"
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def is_nac(self) -> bool:
|
|
206
|
+
return self.side is not None
|
|
207
|
+
|
|
208
|
+
# Stereo pair
|
|
209
|
+
@property
|
|
210
|
+
def pair_id(self) -> str | None:
|
|
211
|
+
"""Product ID of the stereo sibling (L↔R, same type suffix). None for WAC."""
|
|
212
|
+
if self.side is None:
|
|
213
|
+
return None
|
|
214
|
+
return nac_id(self._product_id, side="R" if self.side == "L" else "L")
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def has_pair(self) -> bool:
|
|
218
|
+
"""True if the stereo sibling exists in the EDR index (DB check)."""
|
|
219
|
+
pid = self.pair_id
|
|
220
|
+
if pid is None:
|
|
221
|
+
return False
|
|
222
|
+
result = self._get_cumindex().query(
|
|
223
|
+
f"SELECT count(*) AS n FROM cumindex WHERE upper(trim(PRODUCT_ID)) = '{pid}'"
|
|
224
|
+
)
|
|
225
|
+
return bool(result["n"].iloc[0] > 0)
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def pair(self) -> NacImage | None:
|
|
229
|
+
"""The stereo sibling as a NacImage, or None if not in the index."""
|
|
230
|
+
pid = self.pair_id
|
|
231
|
+
if pid is None:
|
|
232
|
+
return None
|
|
233
|
+
col = self._get_cumindex().get_images([pid])
|
|
234
|
+
return col[0] if len(col) > 0 else None
|
|
235
|
+
|
|
236
|
+
# Metadata (lazy, DB-backed)
|
|
237
|
+
@property
|
|
238
|
+
def _row(self) -> pd.Series:
|
|
239
|
+
if self.__row is None:
|
|
240
|
+
df = self._get_cumindex().lookup([self._product_id])
|
|
241
|
+
if df.empty:
|
|
242
|
+
ds = self.product_type or "edr"
|
|
243
|
+
raise ValueError(f"Product {self._product_id!r} not found in {ds.upper()} index")
|
|
244
|
+
self.__row = df.iloc[0]
|
|
245
|
+
return self.__row
|
|
246
|
+
|
|
247
|
+
def _get(self, col: str):
|
|
248
|
+
val = self._row.get(col)
|
|
249
|
+
if val is None:
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
if pd.isna(val):
|
|
253
|
+
return None
|
|
254
|
+
except (TypeError, ValueError):
|
|
255
|
+
pass
|
|
256
|
+
return val
|
|
257
|
+
|
|
258
|
+
# Product / archive identity
|
|
259
|
+
pds_dataset_name = IndexColumn("DATA_SET_ID", str)
|
|
260
|
+
pds_volume_name = IndexColumn("VOLUME_ID", str)
|
|
261
|
+
original_product_id = IndexColumn("ORIGINAL_PRODUCT_ID", str)
|
|
262
|
+
product_version = IndexColumn("PRODUCT_VERSION_ID", str)
|
|
263
|
+
instrument_host = IndexColumn("INSTRUMENT_HOST_ID", str)
|
|
264
|
+
instrument = IndexColumn("INSTRUMENT_ID", str)
|
|
265
|
+
mission_phase = IndexColumn("MISSION_PHASE_NAME", str)
|
|
266
|
+
rationale_desc = IndexColumn("RATIONALE_DESC", str)
|
|
267
|
+
data_quality = IndexColumn("DATA_QUALITY_ID", int)
|
|
268
|
+
|
|
269
|
+
# Timing
|
|
270
|
+
start_time = IndexColumn("START_TIME", _raw)
|
|
271
|
+
stop_time = IndexColumn("STOP_TIME", _raw)
|
|
272
|
+
nac_preroll_start_time = IndexColumn("NAC_PREROLL_START_TIME", _raw)
|
|
273
|
+
spacecraft_clock_partition = IndexColumn("SPACECRAFT_CLOCK_PARTITION", int)
|
|
274
|
+
spacecraft_clock_start_count = IndexColumn("SPACECRAFT_CLOCK_START_COUNT", str)
|
|
275
|
+
spacecraft_clock_stop_count = IndexColumn("SPACECRAFT_CLOCK_STOP_COUNT", str)
|
|
276
|
+
nac_spacecraft_clock_preroll_count = IndexColumn("NAC_SPACECRAFT_CLOCK_PREROLL_COUNT", str)
|
|
277
|
+
|
|
278
|
+
# Orbit / attitude
|
|
279
|
+
target_name = IndexColumn("TARGET_NAME", str)
|
|
280
|
+
orbit_number = IndexColumn("ORBIT_NUMBER", int)
|
|
281
|
+
slew_angle = IndexColumn("SLEW_ANGLE", float)
|
|
282
|
+
"""Off-nadir slew angle (degrees). Equivalent to emission_angle."""
|
|
283
|
+
lro_node_crossing = IndexColumn("ORBIT_NODE", str)
|
|
284
|
+
lro_flight_direction = IndexColumn("LRO_FLIGHT_DIRECTION", str)
|
|
285
|
+
|
|
286
|
+
# NAC instrument / mode
|
|
287
|
+
nac_line_exposure_duration = IndexColumn("NAC_LINE_EXPOSURE_DURATION", float)
|
|
288
|
+
nac_frame = IndexColumn("NAC_FRAME_ID", str)
|
|
289
|
+
nac_dac_reset = IndexColumn("NAC_DAC_RESET", int)
|
|
290
|
+
nac_channel_a_offset = IndexColumn("NAC_CHANNEL_A_OFFSET", int)
|
|
291
|
+
nac_channel_b_offset = IndexColumn("NAC_CHANNEL_B_OFFSET", int)
|
|
292
|
+
instrument_mode_code = IndexColumn("INSTRUMENT_MODE_CODE", int)
|
|
293
|
+
compand_select_code = IndexColumn("COMPAND_SELECT_CODE", int)
|
|
294
|
+
mode_compression = IndexColumn("MODE_COMPRESSION", bool)
|
|
295
|
+
mode_test = IndexColumn("MODE_TEST", bool)
|
|
296
|
+
|
|
297
|
+
# NAC temperatures
|
|
298
|
+
temperature_scs = IndexColumn("NAC_TEMPERATURE_SCS", float)
|
|
299
|
+
temperature_fpa = IndexColumn("NAC_TEMPERATURE_FPA", float)
|
|
300
|
+
temperature_fpga = IndexColumn("NAC_TEMPERATURE_FPGA", float)
|
|
301
|
+
temperature_telescope = IndexColumn("NAC_TEMPERATURE_TELESCOPE", float)
|
|
302
|
+
|
|
303
|
+
# Image dimensions
|
|
304
|
+
lines = IndexColumn("IMAGE_LINES", int)
|
|
305
|
+
line_samples = IndexColumn("LINE_SAMPLES", int)
|
|
306
|
+
sample_bits = IndexColumn("SAMPLE_BITS", int)
|
|
307
|
+
scaled_pixel_width = IndexColumn("SCALED_PIXEL_WIDTH", float)
|
|
308
|
+
scaled_pixel_height = IndexColumn("SCALED_PIXEL_HEIGHT", float)
|
|
309
|
+
pixel_scale = IndexColumn("RESOLUTION", float)
|
|
310
|
+
"""Ground-sample distance in metres/pixel."""
|
|
311
|
+
|
|
312
|
+
# Illumination / geometry angles
|
|
313
|
+
emission_angle = IndexColumn("EMISSION_ANGLE", float)
|
|
314
|
+
"""Off-nadir emission angle (degrees). Same as slew_angle."""
|
|
315
|
+
incidence_angle = IndexColumn("INCIDENCE_ANGLE", float)
|
|
316
|
+
phase_angle = IndexColumn("PHASE_ANGLE", float)
|
|
317
|
+
north_azimuth = IndexColumn("NORTH_AZIMUTH", float)
|
|
318
|
+
sub_solar_azimuth = IndexColumn("SUB_SOLAR_AZIMUTH", float)
|
|
319
|
+
sub_solar_latitude = IndexColumn("SUB_SOLAR_LATITUDE", float)
|
|
320
|
+
sub_solar_longitude = IndexColumn("SUB_SOLAR_LONGITUDE", float)
|
|
321
|
+
sub_spacecraft_latitude = IndexColumn("SUB_SPACECRAFT_LATITUDE", float)
|
|
322
|
+
sub_spacecraft_longitude = IndexColumn("SUB_SPACECRAFT_LONGITUDE", float)
|
|
323
|
+
solar_distance = IndexColumn("SOLAR_DISTANCE", float)
|
|
324
|
+
"""Distance from the Sun in km."""
|
|
325
|
+
solar_longitude = IndexColumn("SOLAR_LONGITUDE", float)
|
|
326
|
+
|
|
327
|
+
# Footprint coordinates
|
|
328
|
+
center_latitude = IndexColumn("CENTER_LATITUDE", float)
|
|
329
|
+
center_longitude = IndexColumn("CENTER_LONGITUDE", float)
|
|
330
|
+
upper_right_latitude = IndexColumn("UPPER_RIGHT_LATITUDE", float)
|
|
331
|
+
upper_right_longitude = IndexColumn("UPPER_RIGHT_LONGITUDE", float)
|
|
332
|
+
lower_right_latitude = IndexColumn("LOWER_RIGHT_LATITUDE", float)
|
|
333
|
+
lower_right_longitude = IndexColumn("LOWER_RIGHT_LONGITUDE", float)
|
|
334
|
+
lower_left_latitude = IndexColumn("LOWER_LEFT_LATITUDE", float)
|
|
335
|
+
lower_left_longitude = IndexColumn("LOWER_LEFT_LONGITUDE", float)
|
|
336
|
+
upper_left_latitude = IndexColumn("UPPER_LEFT_LATITUDE", float)
|
|
337
|
+
upper_left_longitude = IndexColumn("UPPER_LEFT_LONGITUDE", float)
|
|
338
|
+
|
|
339
|
+
# Spacecraft position
|
|
340
|
+
spacecraft_altitude = IndexColumn("SPACECRAFT_ALTITUDE", float)
|
|
341
|
+
"""Spacecraft altitude above the lunar surface in km."""
|
|
342
|
+
target_center_distance = IndexColumn("TARGET_CENTER_DISTANCE", float)
|
|
343
|
+
"""Distance from spacecraft to lunar centre in km."""
|
|
344
|
+
|
|
345
|
+
# Display
|
|
346
|
+
def metadata_items(self) -> list[tuple[str, str]]:
|
|
347
|
+
"""Return ``(label, value)`` pairs for all available metadata fields.
|
|
348
|
+
|
|
349
|
+
Used by the CLI query command and the GUI — single source of truth
|
|
350
|
+
for both ordering and human-readable labels.
|
|
351
|
+
"""
|
|
352
|
+
result = []
|
|
353
|
+
for col, label in METADATA_LABELS:
|
|
354
|
+
val = self._get(col)
|
|
355
|
+
if val is not None:
|
|
356
|
+
result.append((label, str(val)))
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
# URLs
|
|
360
|
+
@property
|
|
361
|
+
def edr_url(self) -> str | None:
|
|
362
|
+
"""PDS URL for the raw EDR .IMG file."""
|
|
363
|
+
edr = self.edr_id
|
|
364
|
+
if edr is None:
|
|
365
|
+
return None
|
|
366
|
+
if self.product_type == "edr":
|
|
367
|
+
return self._get("PDS_URL")
|
|
368
|
+
# CDR image — cross-lookup the EDR counterpart
|
|
369
|
+
try:
|
|
370
|
+
df = CumIndex(dataset="edr").lookup([edr])
|
|
371
|
+
return df.iloc[0]["PDS_URL"] if not df.empty else None
|
|
372
|
+
except FileNotFoundError:
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def cdr_url(self) -> str | None:
|
|
377
|
+
"""PDS URL for the calibrated CDR .IMG file (requires CDR database)."""
|
|
378
|
+
cdr = self.cdr_id
|
|
379
|
+
if cdr is None:
|
|
380
|
+
return None
|
|
381
|
+
if self.product_type == "cdr":
|
|
382
|
+
return self._get("PDS_URL")
|
|
383
|
+
# EDR image — cross-lookup the CDR counterpart
|
|
384
|
+
try:
|
|
385
|
+
df = CumIndex(dataset="cdr").lookup([cdr])
|
|
386
|
+
return df.iloc[0]["PDS_URL"] if not df.empty else None
|
|
387
|
+
except FileNotFoundError:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
# Single-image downloads
|
|
391
|
+
def download_edr(self, output_dir: Path, force: bool = False) -> Path:
|
|
392
|
+
"""Download the raw EDR to ``<output_dir>/<edr_id>.IMG``."""
|
|
393
|
+
return self._download(self.edr_url, self.edr_id, output_dir, force)
|
|
394
|
+
|
|
395
|
+
def download_cdr(self, output_dir: Path, force: bool = False) -> Path:
|
|
396
|
+
"""Download the calibrated CDR to ``<output_dir>/<cdr_id>.IMG`` (requires the CDR database)."""
|
|
397
|
+
return self._download(self.cdr_url, self.cdr_id, output_dir, force)
|
|
398
|
+
|
|
399
|
+
def _download(self, url: str | None, pid: str | None, output_dir: Path, force: bool) -> Path:
|
|
400
|
+
if not url or not pid:
|
|
401
|
+
raise ValueError(f"No download URL for {pid or self._product_id} (is its index built?)")
|
|
402
|
+
dest = Path(output_dir) / f"{pid}.IMG"
|
|
403
|
+
if reuse_existing(dest, force):
|
|
404
|
+
return dest
|
|
405
|
+
return download_file(url, dest)
|
|
406
|
+
|
|
407
|
+
# Processing
|
|
408
|
+
def process(self, output_dir: Path, **kwargs: Any) -> Path:
|
|
409
|
+
"""Run the ISIS + GDAL pipeline on the EDR, returning the GeoTIFF.
|
|
410
|
+
|
|
411
|
+
*kwargs* are :class:`NacProcessor` options (``projection``,
|
|
412
|
+
``resolution``, ``web``, ``keep``, ``force``).
|
|
413
|
+
"""
|
|
414
|
+
url = self.edr_url
|
|
415
|
+
if not url:
|
|
416
|
+
raise ValueError(f"No EDR URL for {self._product_id}")
|
|
417
|
+
return NacProcessor(output_dir, **kwargs).process(url)
|
|
418
|
+
|
|
419
|
+
# Quick view
|
|
420
|
+
def to_png(self, output_dir: Path, input_path: Path) -> Path:
|
|
421
|
+
"""Convert an image (e.g. a GeoTIFF from :meth:`process`) to ``<output_dir>/<id>.png``."""
|
|
422
|
+
return to_png(input_path, Path(output_dir) / f"{self._product_id}.png")
|
|
423
|
+
|
|
424
|
+
def show(self, input_path: Path) -> None:
|
|
425
|
+
"""Display an image file of this product inline (Jupyter / matplotlib)."""
|
|
426
|
+
img = mpimg.imread(str(input_path))
|
|
427
|
+
fig, ax = plt.subplots(figsize=(10, 8))
|
|
428
|
+
ax.imshow(img, cmap="gray")
|
|
429
|
+
ax.set_title(self._product_id)
|
|
430
|
+
ax.axis("off")
|
|
431
|
+
plt.tight_layout()
|
|
432
|
+
plt.show()
|
|
433
|
+
|
|
434
|
+
# Helpers
|
|
435
|
+
def _get_cumindex(self) -> CumIndex:
|
|
436
|
+
if self._cumindex is None:
|
|
437
|
+
dataset = self.product_type or "edr"
|
|
438
|
+
self._cumindex = CumIndex(dataset=dataset)
|
|
439
|
+
return self._cumindex
|
|
440
|
+
|
|
441
|
+
def __repr__(self) -> str:
|
|
442
|
+
return f"NacImage({self._product_id!r})"
|
|
443
|
+
|
|
444
|
+
def __eq__(self, other: object) -> bool:
|
|
445
|
+
return isinstance(other, NacImage) and self._product_id == other._product_id
|
|
446
|
+
|
|
447
|
+
def __hash__(self) -> int:
|
|
448
|
+
return hash(self._product_id)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# NacImageCollection
|
|
452
|
+
class NacImageCollection:
|
|
453
|
+
"""An ordered collection of NacImages with bulk download and processing.
|
|
454
|
+
|
|
455
|
+
Returned by ``CumIndex.get_images()``. Behaves like a list but also
|
|
456
|
+
exposes bulk operations that parallelise across images.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
def __init__(self, images: list[NacImage]) -> None:
|
|
460
|
+
self._images = images
|
|
461
|
+
|
|
462
|
+
@classmethod
|
|
463
|
+
def from_sources(
|
|
464
|
+
cls,
|
|
465
|
+
sources: list[str],
|
|
466
|
+
data_dir: Path | None = None,
|
|
467
|
+
) -> NacImageCollection:
|
|
468
|
+
"""Build a collection from product IDs or local .IMG paths.
|
|
469
|
+
|
|
470
|
+
Raw HTTP(S) URLs are skipped — use ``PdsDownloader`` for those.
|
|
471
|
+
Automatically routes ``LE``/``RE`` → EDR index, ``LC``/``RC`` → CDR index.
|
|
472
|
+
IDs not found in the index are silently excluded.
|
|
473
|
+
"""
|
|
474
|
+
product_ids: list[str] = []
|
|
475
|
+
for s in sources:
|
|
476
|
+
s = s.strip()
|
|
477
|
+
if s.startswith(("http://", "https://")):
|
|
478
|
+
continue
|
|
479
|
+
p = Path(s)
|
|
480
|
+
pid = p.stem.upper() if p.exists() else _normalize_nac_id(s)
|
|
481
|
+
product_ids.append(pid)
|
|
482
|
+
|
|
483
|
+
if not product_ids:
|
|
484
|
+
return cls([])
|
|
485
|
+
|
|
486
|
+
cdr_ids = [p for p in product_ids if (parse_nac_id(p) or ("", "", ""))[2] == "cdr"]
|
|
487
|
+
edr_ids = [p for p in product_ids if p not in cdr_ids] # EDR, WAC or ambiguous
|
|
488
|
+
|
|
489
|
+
rows: dict[str, pd.Series] = {}
|
|
490
|
+
for dataset, ids in [("edr", edr_ids), ("cdr", cdr_ids)]:
|
|
491
|
+
if not ids:
|
|
492
|
+
continue
|
|
493
|
+
try:
|
|
494
|
+
ci = CumIndex(dataset=dataset, data_dir=data_dir)
|
|
495
|
+
df = ci.lookup(ids)
|
|
496
|
+
for _, row in df.iterrows():
|
|
497
|
+
pid = str(row.get("PRODUCT_ID", "")).strip().upper()
|
|
498
|
+
rows[pid] = row
|
|
499
|
+
except FileNotFoundError:
|
|
500
|
+
pass # dataset not yet built
|
|
501
|
+
|
|
502
|
+
images = [NacImage(pid, _row=rows[pid]) for pid in product_ids if pid in rows]
|
|
503
|
+
return cls(images)
|
|
504
|
+
|
|
505
|
+
# List interface
|
|
506
|
+
def __len__(self) -> int:
|
|
507
|
+
return len(self._images)
|
|
508
|
+
|
|
509
|
+
def __iter__(self):
|
|
510
|
+
return iter(self._images)
|
|
511
|
+
|
|
512
|
+
def __getitem__(self, idx):
|
|
513
|
+
return self._images[idx]
|
|
514
|
+
|
|
515
|
+
def __repr__(self) -> str:
|
|
516
|
+
return f"NacImageCollection({len(self._images)} images)"
|
|
517
|
+
|
|
518
|
+
# Metadata
|
|
519
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
520
|
+
"""Return a DataFrame with one row per image (all metadata columns)."""
|
|
521
|
+
rows = [img._row for img in self._images]
|
|
522
|
+
return pd.DataFrame(rows).reset_index(drop=True)
|
|
523
|
+
|
|
524
|
+
# Bulk downloads
|
|
525
|
+
def download_edrs(
|
|
526
|
+
self,
|
|
527
|
+
output_dir: Path,
|
|
528
|
+
workers: int = 4,
|
|
529
|
+
force: bool = False,
|
|
530
|
+
) -> dict[str, Path | None]:
|
|
531
|
+
"""Download all EDR .IMG files in parallel.
|
|
532
|
+
|
|
533
|
+
Returns a dict mapping product_id → local Path (or None on failure).
|
|
534
|
+
"""
|
|
535
|
+
return self._bulk_download(NacImage.download_edr, output_dir, workers, force)
|
|
536
|
+
|
|
537
|
+
def download_cdrs(
|
|
538
|
+
self,
|
|
539
|
+
output_dir: Path,
|
|
540
|
+
workers: int = 4,
|
|
541
|
+
force: bool = False,
|
|
542
|
+
) -> dict[str, Path | None]:
|
|
543
|
+
"""Download all CDR .IMG files in parallel (requires CDR database)."""
|
|
544
|
+
return self._bulk_download(NacImage.download_cdr, output_dir, workers, force)
|
|
545
|
+
|
|
546
|
+
def _bulk_download(
|
|
547
|
+
self, download: Callable[..., Path], output_dir: Path, workers: int, force: bool
|
|
548
|
+
) -> dict[str, Path | None]:
|
|
549
|
+
return _parallel(
|
|
550
|
+
{img.product_id: img for img in self._images},
|
|
551
|
+
lambda img: download(img, output_dir, force=force),
|
|
552
|
+
workers,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Bulk processing
|
|
556
|
+
def process(self, output_dir: Path, workers: int = 4, **kwargs: Any) -> dict[str, Path | None]:
|
|
557
|
+
"""Run the ISIS + GDAL pipeline on all EDRs in parallel.
|
|
558
|
+
|
|
559
|
+
*kwargs* are :class:`NacProcessor` options. Returns a dict mapping
|
|
560
|
+
product_id → GeoTIFF Path (or None on failure).
|
|
561
|
+
"""
|
|
562
|
+
urls = {img.product_id: img.edr_url for img in self._images}
|
|
563
|
+
sources = [(pid, url) for pid, url in urls.items() if url]
|
|
564
|
+
results: dict[str, Path | None] = dict.fromkeys(urls)
|
|
565
|
+
geotiffs = NacProcessor(output_dir, **kwargs).process_batch([url for _, url in sources], workers)
|
|
566
|
+
results.update((pid, tif) for (pid, _), tif in zip(sources, geotiffs))
|
|
567
|
+
return results
|
|
568
|
+
|
|
569
|
+
# Bulk PNG conversion
|
|
570
|
+
def to_pngs(
|
|
571
|
+
self,
|
|
572
|
+
output_dir: Path,
|
|
573
|
+
input_paths: dict[str, Path],
|
|
574
|
+
workers: int = 4,
|
|
575
|
+
) -> dict[str, Path | None]:
|
|
576
|
+
"""Convert a set of TIFs to PNGs via gdal_translate in parallel.
|
|
577
|
+
|
|
578
|
+
Parameters
|
|
579
|
+
----------
|
|
580
|
+
output_dir : Path
|
|
581
|
+
input_paths : dict[str, Path]
|
|
582
|
+
Mapping of product_id → source TIF path (e.g. from download_cdrs()).
|
|
583
|
+
workers : int
|
|
584
|
+
|
|
585
|
+
Returns
|
|
586
|
+
-------
|
|
587
|
+
dict mapping product_id → PNG Path (or None on failure).
|
|
588
|
+
"""
|
|
589
|
+
output_dir = Path(output_dir)
|
|
590
|
+
return _parallel(
|
|
591
|
+
{pid: (src, output_dir / f"{pid}.png") for pid, src in input_paths.items()},
|
|
592
|
+
lambda paths: to_png(*paths),
|
|
593
|
+
workers,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class NacProcessor:
|
|
598
|
+
"""Download, calibrate and map-project LROC NAC EDRs into GeoTIFFs.
|
|
599
|
+
|
|
600
|
+
Each image ends up in *output_dir* as ``<id>.tif`` (map-projected I/F,
|
|
601
|
+
:data:`~lunar_toolkit.constants.GEOTIFF_OPTIONS`). Intermediates
|
|
602
|
+
(a downloaded ``<id>.IMG``, ``<id>.cal.echo.cub``, ``<id>.map.cub``) are
|
|
603
|
+
written beside it and removed after success unless *keep*; after a
|
|
604
|
+
failure they stay, so a re-run resumes. Steps whose output exists are
|
|
605
|
+
skipped unless *force*.
|
|
606
|
+
|
|
607
|
+
Parameters
|
|
608
|
+
----------
|
|
609
|
+
output_dir : Path
|
|
610
|
+
Where downloads, cubes and GeoTIFFs are written.
|
|
611
|
+
web : bool
|
|
612
|
+
Use the USGS SPICE web service in ``spiceinit``.
|
|
613
|
+
projection : str
|
|
614
|
+
ISIS projection for ``mosrange`` (default: equirectangular).
|
|
615
|
+
resolution : float, optional
|
|
616
|
+
Map resolution in m/pixel (default: ``mosrange``'s estimate).
|
|
617
|
+
keep : bool
|
|
618
|
+
Keep the downloaded EDR and the intermediate cubes.
|
|
619
|
+
force : bool
|
|
620
|
+
Redo every step even when its output exists.
|
|
621
|
+
"""
|
|
622
|
+
|
|
623
|
+
def __init__(
|
|
624
|
+
self,
|
|
625
|
+
output_dir: Path = Path("data"),
|
|
626
|
+
*,
|
|
627
|
+
web: bool = True,
|
|
628
|
+
projection: str = DEFAULT_PROJECTION,
|
|
629
|
+
resolution: float | None = None,
|
|
630
|
+
keep: bool = False,
|
|
631
|
+
force: bool = False,
|
|
632
|
+
) -> None:
|
|
633
|
+
self.output_dir = Path(output_dir)
|
|
634
|
+
self.web = web
|
|
635
|
+
self.projection = projection
|
|
636
|
+
self.resolution = resolution
|
|
637
|
+
self.keep = keep
|
|
638
|
+
self.force = force
|
|
639
|
+
|
|
640
|
+
def process(self, source: str | Path) -> Path:
|
|
641
|
+
"""Process one EDR (local ``.IMG`` path or HTTP(S) URL); returns the GeoTIFF."""
|
|
642
|
+
source = str(source)
|
|
643
|
+
is_url = source.startswith(("http://", "https://"))
|
|
644
|
+
pid = Path(url_filename(source) if is_url else source).name.split(".")[0].upper()
|
|
645
|
+
if not pid:
|
|
646
|
+
raise ValueError(f"Cannot derive image ID from {source!r}")
|
|
647
|
+
geotiff = self.output_dir / f"{pid}.tif"
|
|
648
|
+
if reuse_existing(geotiff, self.force):
|
|
649
|
+
return geotiff
|
|
650
|
+
if not is_dry_run():
|
|
651
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
652
|
+
img = self.output_dir / f"{pid}.IMG" if is_url else Path(source)
|
|
653
|
+
if is_url and not reuse_existing(img, self.force):
|
|
654
|
+
download_file(source, img)
|
|
655
|
+
echo = calibrate_lronac(img, self.output_dir, web=self.web, keep=self.keep, force=self.force)
|
|
656
|
+
map_cub = self.output_dir / f"{pid}.map.cub"
|
|
657
|
+
map_project(echo, map_cub, self.projection, self.resolution, keep=self.keep)
|
|
658
|
+
run("gdal_translate", [*GEOTIFF_OPTIONS, map_cub, geotiff])
|
|
659
|
+
if not self.keep and not is_dry_run():
|
|
660
|
+
for f in (map_cub, echo, img if is_url else None):
|
|
661
|
+
if f is not None:
|
|
662
|
+
f.unlink(missing_ok=True)
|
|
663
|
+
log.info("Pipeline complete → %s", geotiff)
|
|
664
|
+
return geotiff
|
|
665
|
+
|
|
666
|
+
def process_batch(self, sources: Sequence[str | Path], max_workers: int = 4) -> list[Path | None]:
|
|
667
|
+
"""Process several images in parallel (None for failures)."""
|
|
668
|
+
results: list[Path | None] = [None] * len(sources)
|
|
669
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
670
|
+
futures = {pool.submit(self.process, src): i for i, src in enumerate(sources)}
|
|
671
|
+
for future in as_completed(futures):
|
|
672
|
+
idx = futures[future]
|
|
673
|
+
try:
|
|
674
|
+
results[idx] = future.result()
|
|
675
|
+
except Exception as exc:
|
|
676
|
+
log.error("Failed %s: %s", sources[idx], exc)
|
|
677
|
+
return results
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def to_png(src: Path, dst: Path) -> Path:
|
|
681
|
+
"""Convert an image to an 8-bit, min/max-scaled PNG with ``gdal_translate``."""
|
|
682
|
+
Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
|
683
|
+
run("gdal_translate", ["-of", "PNG", "-ot", "Byte", "-scale", src, dst], capture=True)
|
|
684
|
+
return Path(dst)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _parallel(items: dict[str, _T], func: Callable[[_T], Path], workers: int) -> dict[str, Path | None]:
|
|
688
|
+
"""``{key: func(item)}`` run in a thread pool; failures are logged and map to None."""
|
|
689
|
+
|
|
690
|
+
def call(key: str, item: _T) -> tuple[str, Path | None]:
|
|
691
|
+
try:
|
|
692
|
+
return key, func(item)
|
|
693
|
+
except Exception as exc:
|
|
694
|
+
log.error("%s: %s", key, exc)
|
|
695
|
+
return key, None
|
|
696
|
+
|
|
697
|
+
results: dict[str, Path | None] = {}
|
|
698
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
699
|
+
for future in as_completed([pool.submit(call, k, v) for k, v in items.items()]):
|
|
700
|
+
key, path = future.result()
|
|
701
|
+
results[key] = path
|
|
702
|
+
return results
|