Coverage for lunar_toolkit/lroc/nac.py: 51%

357 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 02:58 +0000

1"""LROC NAC images: metadata, download and the ISIS + GDAL processing pipeline.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from collections.abc import Callable, Sequence 

7from concurrent.futures import ThreadPoolExecutor, as_completed 

8from pathlib import Path 

9from typing import Any, Generic, TypeVar, overload 

10 

11import matplotlib.image as mpimg 

12import matplotlib.pyplot as plt 

13import pandas as pd 

14 

15from ..constants import DEFAULT_PROJECTION, GEOTIFF_OPTIONS, nac_id, parse_nac_id 

16from ..downloader import download_file, url_filename 

17from ..isis import calibrate_lronac, map_project 

18from ..tools import is_dry_run, reuse_existing, run 

19from .cumindex import CumIndex 

20 

21log = 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. 

28METADATA_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 

97def _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 

102def _raw(value: Any) -> Any: 

103 return value 

104 

105 

106class IndexColumn(Generic[_V]): 

107 """Read-only [`NacImage`][lunar_toolkit.lroc.nac.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: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

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 

133class 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: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true

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: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true

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: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true

250 return None 

251 try: 

252 if pd.isna(val): 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true

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 [`NacProcessor`][lunar_toolkit.lroc.nac.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 [`process`][lunar_toolkit.lroc.nac.NacImage.process]) to 

422 ``<output_dir>/<id>.png``.""" 

423 return to_png(input_path, Path(output_dir) / f"{self._product_id}.png") 

424 

425 def show(self, input_path: Path) -> None: 

426 """Display an image file of this product inline (Jupyter / matplotlib).""" 

427 img = mpimg.imread(str(input_path)) 

428 fig, ax = plt.subplots(figsize=(10, 8)) 

429 ax.imshow(img, cmap="gray") 

430 ax.set_title(self._product_id) 

431 ax.axis("off") 

432 plt.tight_layout() 

433 plt.show() 

434 

435 # Helpers 

436 def _get_cumindex(self) -> CumIndex: 

437 if self._cumindex is None: 

438 dataset = self.product_type or "edr" 

439 self._cumindex = CumIndex(dataset=dataset) 

440 return self._cumindex 

441 

442 def __repr__(self) -> str: 

443 return f"NacImage({self._product_id!r})" 

444 

445 def __eq__(self, other: object) -> bool: 

446 return isinstance(other, NacImage) and self._product_id == other._product_id 

447 

448 def __hash__(self) -> int: 

449 return hash(self._product_id) 

450 

451 

452# NacImageCollection 

453class NacImageCollection: 

454 """An ordered collection of NacImages with bulk download and processing. 

455 

456 Returned by ``CumIndex.get_images()``. Behaves like a list but also 

457 exposes bulk operations that parallelise across images. 

458 """ 

459 

460 def __init__(self, images: list[NacImage]) -> None: 

461 self._images = images 

462 

463 @classmethod 

464 def from_sources( 

465 cls, 

466 sources: list[str], 

467 data_dir: Path | None = None, 

468 ) -> NacImageCollection: 

469 """Build a collection from product IDs or local .IMG paths. 

470 

471 Raw HTTP(S) URLs are skipped — use ``PdsDownloader`` for those. 

472 Automatically routes ``LE``/``RE`` → EDR index, ``LC``/``RC`` → CDR index. 

473 IDs not found in the index are silently excluded. 

474 """ 

475 product_ids: list[str] = [] 

476 for s in sources: 

477 s = s.strip() 

478 if s.startswith(("http://", "https://")): 

479 continue 

480 p = Path(s) 

481 pid = p.stem.upper() if p.exists() else _normalize_nac_id(s) 

482 product_ids.append(pid) 

483 

484 if not product_ids: 

485 return cls([]) 

486 

487 cdr_ids = [p for p in product_ids if (parse_nac_id(p) or ("", "", ""))[2] == "cdr"] 

488 edr_ids = [p for p in product_ids if p not in cdr_ids] # EDR, WAC or ambiguous 

489 

490 rows: dict[str, pd.Series] = {} 

491 for dataset, ids in [("edr", edr_ids), ("cdr", cdr_ids)]: 

492 if not ids: 

493 continue 

494 try: 

495 ci = CumIndex(dataset=dataset, data_dir=data_dir) 

496 df = ci.lookup(ids) 

497 for _, row in df.iterrows(): 

498 pid = str(row.get("PRODUCT_ID", "")).strip().upper() 

499 rows[pid] = row 

500 except FileNotFoundError: 

501 pass # dataset not yet built 

502 

503 images = [NacImage(pid, _row=rows[pid]) for pid in product_ids if pid in rows] 

504 return cls(images) 

505 

506 # List interface 

507 def __len__(self) -> int: 

508 return len(self._images) 

509 

510 def __iter__(self): 

511 return iter(self._images) 

512 

513 def __getitem__(self, idx): 

514 return self._images[idx] 

515 

516 def __repr__(self) -> str: 

517 return f"NacImageCollection({len(self._images)} images)" 

518 

519 # Metadata 

520 def to_dataframe(self) -> pd.DataFrame: 

521 """Return a DataFrame with one row per image (all metadata columns).""" 

522 rows = [img._row for img in self._images] 

523 return pd.DataFrame(rows).reset_index(drop=True) 

524 

525 # Bulk downloads 

526 def download_edrs( 

527 self, 

528 output_dir: Path, 

529 workers: int = 4, 

530 force: bool = False, 

531 ) -> dict[str, Path | None]: 

532 """Download all EDR .IMG files in parallel. 

533 

534 Returns a dict mapping product_id → local Path (or None on failure). 

535 """ 

536 return self._bulk_download(NacImage.download_edr, output_dir, workers, force) 

537 

538 def download_cdrs( 

539 self, 

540 output_dir: Path, 

541 workers: int = 4, 

542 force: bool = False, 

543 ) -> dict[str, Path | None]: 

544 """Download all CDR .IMG files in parallel (requires CDR database).""" 

545 return self._bulk_download(NacImage.download_cdr, output_dir, workers, force) 

546 

547 def _bulk_download( 

548 self, download: Callable[..., Path], output_dir: Path, workers: int, force: bool 

549 ) -> dict[str, Path | None]: 

550 return _parallel( 

551 {img.product_id: img for img in self._images}, 

552 lambda img: download(img, output_dir, force=force), 

553 workers, 

554 ) 

555 

556 # Bulk processing 

557 def process(self, output_dir: Path, workers: int = 4, **kwargs: Any) -> dict[str, Path | None]: 

558 """Run the ISIS + GDAL pipeline on all EDRs in parallel. 

559 

560 *kwargs* are [`NacProcessor`][lunar_toolkit.lroc.nac.NacProcessor] options. Returns a dict mapping 

561 product_id → GeoTIFF Path (or None on failure). 

562 """ 

563 urls = {img.product_id: img.edr_url for img in self._images} 

564 sources = [(pid, url) for pid, url in urls.items() if url] 

565 results: dict[str, Path | None] = dict.fromkeys(urls) 

566 geotiffs = NacProcessor(output_dir, **kwargs).process_batch([url for _, url in sources], workers) 

567 results.update((pid, tif) for (pid, _), tif in zip(sources, geotiffs)) 

568 return results 

569 

570 # Bulk PNG conversion 

571 def to_pngs( 

572 self, 

573 output_dir: Path, 

574 input_paths: dict[str, Path], 

575 workers: int = 4, 

576 ) -> dict[str, Path | None]: 

577 """Convert a set of TIFs to PNGs via gdal_translate in parallel. 

578 

579 Parameters 

580 ---------- 

581 output_dir : Path 

582 input_paths : dict[str, Path] 

583 Mapping of product_id → source TIF path (e.g. from download_cdrs()). 

584 workers : int 

585 

586 Returns 

587 ------- 

588 dict mapping product_id → PNG Path (or None on failure). 

589 """ 

590 output_dir = Path(output_dir) 

591 return _parallel( 

592 {pid: (src, output_dir / f"{pid}.png") for pid, src in input_paths.items()}, 

593 lambda paths: to_png(*paths), 

594 workers, 

595 ) 

596 

597 

598class NacProcessor: 

599 """Download, calibrate and map-project LROC NAC EDRs into GeoTIFFs. 

600 

601 Each image ends up in *output_dir* as ``<id>.tif`` (map-projected I/F, 

602 `GEOTIFF_OPTIONS`). Intermediates 

603 (a downloaded ``<id>.IMG``, ``<id>.cal.echo.cub``, ``<id>.map.cub``) are 

604 written beside it and removed after success unless *keep*; after a 

605 failure they stay, so a re-run resumes. Steps whose output exists are 

606 skipped unless *force*. 

607 

608 Parameters 

609 ---------- 

610 output_dir : Path 

611 Where downloads, cubes and GeoTIFFs are written. 

612 web : bool 

613 Use the USGS SPICE web service in ``spiceinit``. 

614 projection : str 

615 ISIS projection for ``mosrange`` (default: equirectangular). 

616 resolution : float, optional 

617 Map resolution in m/pixel (default: ``mosrange``'s estimate). 

618 keep : bool 

619 Keep the downloaded EDR and the intermediate cubes. 

620 force : bool 

621 Redo every step even when its output exists. 

622 """ 

623 

624 def __init__( 

625 self, 

626 output_dir: Path = Path("data"), 

627 *, 

628 web: bool = True, 

629 projection: str = DEFAULT_PROJECTION, 

630 resolution: float | None = None, 

631 keep: bool = False, 

632 force: bool = False, 

633 ) -> None: 

634 self.output_dir = Path(output_dir) 

635 self.web = web 

636 self.projection = projection 

637 self.resolution = resolution 

638 self.keep = keep 

639 self.force = force 

640 

641 def process(self, source: str | Path) -> Path: 

642 """Process one EDR (local ``.IMG`` path or HTTP(S) URL); returns the GeoTIFF.""" 

643 source = str(source) 

644 is_url = source.startswith(("http://", "https://")) 

645 pid = Path(url_filename(source) if is_url else source).name.split(".")[0].upper() 

646 if not pid: 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true

647 raise ValueError(f"Cannot derive image ID from {source!r}") 

648 geotiff = self.output_dir / f"{pid}.tif" 

649 if reuse_existing(geotiff, self.force): 

650 return geotiff 

651 if not is_dry_run(): 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true

652 self.output_dir.mkdir(parents=True, exist_ok=True) 

653 img = self.output_dir / f"{pid}.IMG" if is_url else Path(source) 

654 if is_url and not reuse_existing(img, self.force): 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true

655 download_file(source, img) 

656 echo = calibrate_lronac(img, self.output_dir, web=self.web, keep=self.keep, force=self.force) 

657 map_cub = self.output_dir / f"{pid}.map.cub" 

658 map_project(echo, map_cub, self.projection, self.resolution, keep=self.keep) 

659 run("gdal_translate", [*GEOTIFF_OPTIONS, map_cub, geotiff]) 

660 if not self.keep and not is_dry_run(): 660 ↛ 661line 660 didn't jump to line 661 because the condition on line 660 was never true

661 for f in (map_cub, echo, img if is_url else None): 

662 if f is not None: 

663 f.unlink(missing_ok=True) 

664 log.info("Pipeline complete → %s", geotiff) 

665 return geotiff 

666 

667 def process_batch(self, sources: Sequence[str | Path], max_workers: int = 4) -> list[Path | None]: 

668 """Process several images in parallel (None for failures).""" 

669 results: list[Path | None] = [None] * len(sources) 

670 with ThreadPoolExecutor(max_workers=max_workers) as pool: 

671 futures = {pool.submit(self.process, src): i for i, src in enumerate(sources)} 

672 for future in as_completed(futures): 

673 idx = futures[future] 

674 try: 

675 results[idx] = future.result() 

676 except Exception as exc: 

677 log.error("Failed %s: %s", sources[idx], exc) 

678 return results 

679 

680 

681def to_png(src: Path, dst: Path) -> Path: 

682 """Convert an image to an 8-bit, min/max-scaled PNG with ``gdal_translate``.""" 

683 Path(dst).parent.mkdir(parents=True, exist_ok=True) 

684 run("gdal_translate", ["-of", "PNG", "-ot", "Byte", "-scale", src, dst], capture=True) 

685 return Path(dst) 

686 

687 

688def _parallel(items: dict[str, _T], func: Callable[[_T], Path], workers: int) -> dict[str, Path | None]: 

689 """``{key: func(item)}`` run in a thread pool; failures are logged and map to None.""" 

690 

691 def call(key: str, item: _T) -> tuple[str, Path | None]: 

692 try: 

693 return key, func(item) 

694 except Exception as exc: 

695 log.error("%s: %s", key, exc) 

696 return key, None 

697 

698 results: dict[str, Path | None] = {} 

699 with ThreadPoolExecutor(max_workers=workers) as pool: 

700 for future in as_completed([pool.submit(call, k, v) for k, v in items.items()]): 

701 key, path = future.result() 

702 results[key] = path 

703 return results