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