Coverage for lunar_toolkit/shadowcam/sc.py: 81%

197 statements  

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

1"""KPLO ShadowCam: ODE file selection, download, SPICE and CSM cameras for ASP.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import re 

7from collections.abc import Iterable, Sequence 

8from concurrent.futures import ThreadPoolExecutor, as_completed 

9from dataclasses import dataclass 

10from pathlib import Path 

11from typing import TYPE_CHECKING, Any 

12 

13from .. import asp 

14from ..constants import DEFAULT_PROJECTION 

15from ..downloader import download_file, url_filename 

16from ..isis import isd_generate, map_project, spiceinit 

17from ..tools import is_dry_run, reuse_existing, run 

18 

19if TYPE_CHECKING: # pragma: no cover 

20 import pandas as pd 

21 

22 from ..ode import OdeClient 

23 

24log = logging.getLogger(__name__) 

25 

26# Observation M013178466S ships M013178466SE (raw, 8-bit, 3144 samples incl. 

27# bias columns; ODE "RAW") and M013178466SC (calibrated, float, 3072 samples; 

28# ODE "CAL"). Both are ISIS cubes with the Instrument group attached. 

29SC_ID_RE = re.compile(r"^M\d{9}S([CE]?)$", re.IGNORECASE) 

30 

31PRODUCT_SUFFIX = {"raw": "E", "cal": "C"} 

32 

33# ASP 3.7 example (docs/examples/shadowcam.rst): plain ``spiceinit from=<cube>``. 

34SPICEINIT_PARAMS: dict[str, Any] = {"shape": "system"} 

35 

36# ``cam_test`` ISIS-vs-CSM comparison from the ASP example. 

37CAM_TEST_SAMPLE_RATE = 50 

38 

39_POINTING_RE = re.compile(rb"^\s*InstrumentPointing\s*=", re.MULTILINE) 

40_HISTORY_RE = re.compile( 

41 rb"^Object = History\s+Name\s*=\s*IsisCube\s+StartByte\s*=\s*(\d+)\s+Bytes\s*=\s*(\d+)", re.MULTILINE 

42) 

43 

44 

45@dataclass(frozen=True) 

46class ScProduct: 

47 """Cube and PDS4 label URLs of one ShadowCam product.""" 

48 

49 product_id: str 

50 cube_url: str 

51 label_url: str | None = None 

52 cube_kbytes: float | None = None 

53 

54 @property 

55 def observation_id(self) -> str: 

56 """``M013178466S`` for ``M013178466SE`` / ``M013178466SC``.""" 

57 return self.product_id[:-1] 

58 

59 @property 

60 def is_calibrated(self) -> bool: 

61 return self.product_id.endswith("C") 

62 

63 @property 

64 def cube_name(self) -> str: 

65 return f"{self.product_id}.cub" 

66 

67 

68@dataclass 

69class ScOutputs: 

70 """Files written for one product (None for skipped steps).""" 

71 

72 product_id: str 

73 cube: Path 

74 camera: Path | None = None 

75 cam_test: Path | None = None 

76 geotiff: Path | None = None 

77 

78 

79def is_sc_product_id(product_id: str) -> bool: 

80 """True for a ShadowCam observation (``M…S``) or product (``M…SE`` / ``M…SC``) ID.""" 

81 return bool(SC_ID_RE.match(product_id.strip())) 

82 

83 

84def product_id_for(source: str, product: str = "raw") -> str: 

85 """Product ID for an observation or product ID. 

86 

87 Parameters 

88 ---------- 

89 source : str 

90 ``M013178466S`` (observation), ``M013178466SE`` or ``M013178466SC``. 

91 product : str 

92 ``"raw"`` (``…SE``, the cube in ASP's example) or ``"cal"`` (``…SC``); 

93 only used for observation IDs. 

94 """ 

95 m = SC_ID_RE.match(source.strip()) 

96 if not m: 

97 raise ValueError(f"Not a ShadowCam ID: {source!r}") 

98 pid = source.strip().upper() 

99 if m.group(1): 

100 return pid 

101 try: 

102 return pid + PRODUCT_SUFFIX[product.lower()] 

103 except KeyError: 

104 raise ValueError(f"Unknown ShadowCam product {product!r}: {sorted(PRODUCT_SUFFIX)}") from None 

105 

106 

107def select_sc_files(files: Iterable[dict], product_id: str | None = None) -> ScProduct | None: 

108 """Pick the cube and label of a ShadowCam product from its ODE ``files`` list. 

109 

110 Parameters 

111 ---------- 

112 files : iterable of dict 

113 ODE product-file dicts (``FileName``, ``Type``, ``URL``, ``KBytes``). 

114 product_id : str, optional 

115 Product ID; derived from the cube file name when omitted. 

116 

117 Returns 

118 ------- 

119 ScProduct or None 

120 None when the list has no ``Product`` ``.cub`` (mosaics, DTMs). The 

121 map-projected COGs and smear maps (``Referenced``) are never picked. 

122 """ 

123 cube: dict | None = None 

124 label: dict | None = None 

125 for f in files or []: 

126 if str(f.get("Type", "")).lower() != "product" or not f.get("URL"): 

127 continue 

128 name = str(f.get("FileName") or url_filename(f["URL"])).upper() 

129 if name.endswith(".CUB") and cube is None: 

130 cube = f 

131 elif name.endswith(".XML") and label is None: 131 ↛ 125line 131 didn't jump to line 125 because the condition on line 131 was always true

132 label = f 

133 if cube is None: 

134 return None 

135 pid = (product_id or url_filename(cube["URL"]).split(".")[0]).upper() 

136 try: 

137 kbytes = float(cube["KBytes"]) if cube.get("KBytes") is not None else None 

138 except (TypeError, ValueError): 

139 kbytes = None 

140 return ScProduct(pid, cube["URL"], label["URL"] if label else None, kbytes) 

141 

142 

143def sc_products(df: pd.DataFrame) -> list[ScProduct]: 

144 """ShadowCam products with a downloadable cube from an ODE search. 

145 

146 Rows that are not ``…SE`` / ``…SC`` products (mosaics, DTMs) or lack a 

147 cube are skipped with a warning. 

148 """ 

149 out: list[ScProduct] = [] 

150 pids = df["product_id"] if "product_id" in df else [None] * len(df) 

151 for pid, files in zip(pids, df.get("files", [])): 

152 if pid and not is_sc_product_id(pid): 

153 log.warning("Skipping %s: not a ShadowCam RAW / CAL product", pid) 

154 continue 

155 product = select_sc_files(files, pid) 

156 if product is None: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true

157 log.warning("Skipping %s: no .cub product file", pid) 

158 continue 

159 out.append(product) 

160 return out 

161 

162 

163def lookup_products( 

164 product_ids: Iterable[str], 

165 client: OdeClient | None = None, 

166 product: str = "raw", 

167) -> pd.DataFrame: 

168 """Query ODE by observation / product ID (see 

169 [`product_id_for`][lunar_toolkit.shadowcam.sc.product_id_for], 

170 [`OdeClient.lookup`][lunar_toolkit.ode.OdeClient.lookup]).""" 

171 from ..ode import OdeClient 

172 

173 ids = [product_id_for(p, product) for p in product_ids] 

174 return (client or OdeClient()).lookup(ids, instrument_label="ShadowCam") 

175 

176 

177def download_product( 

178 product: ScProduct, 

179 output_dir: Path, 

180 force: bool = False, 

181 show_progress: bool = True, 

182) -> Path: 

183 """Download one product's cube (and PDS4 label) into *output_dir*. 

184 

185 Existing files are reused unless *force*; interrupted downloads resume. 

186 Returns the cube path. 

187 """ 

188 output_dir = Path(output_dir) 

189 output_dir.mkdir(parents=True, exist_ok=True) 

190 if product.label_url: 190 ↛ 194line 190 didn't jump to line 194 because the condition on line 190 was always true

191 label = output_dir / url_filename(product.label_url) 

192 if not reuse_existing(label, force): 

193 download_file(product.label_url, label, show_progress=show_progress) 

194 cube = output_dir / product.cube_name 

195 if not reuse_existing(cube, force): 

196 download_file(product.cube_url, cube, show_progress=show_progress) 

197 return cube 

198 

199 

200def has_spice(cube: Path) -> bool: 

201 """True when ``spiceinit`` completed on *cube*. 

202 

203 A failed ``spiceinit`` can leave SPICE tables in the label, so the cube's 

204 History must also record a ``spiceinit`` run. 

205 """ 

206 try: 

207 with open(cube, "rb") as f: 

208 head = f.read(1 << 20) 

209 end = head.find(b"\nEnd\n") 

210 label = head if end < 0 else head[:end] 

211 m = _HISTORY_RE.search(label) 

212 if not (m and _POINTING_RE.search(label)): 

213 return False 

214 f.seek(int(m.group(1)) - 1) 

215 return b"Object = spiceinit" in f.read(int(m.group(2))) 

216 except OSError: 

217 return False 

218 

219 

220class ScProcessor: 

221 """Download ShadowCam cubes and prepare them for ASP (SPICE + CSM camera). 

222 

223 Follows the ASP 3.7 ShadowCam example: ``spiceinit`` on the delivered cube 

224 (in place), ``isd_generate -k <cube> <cube>`` for the CSM camera and, 

225 optionally, ``cam_test`` and ``mapproject``. Needs ASP 3.7's custom ISIS / 

226 ALE build and the KPLO kernels under ``$ISISDATA/kplo/kernels`` (rsync 

227 from ``rsync.im-ldi.com::kplo``) plus ``$ISISDATA/base/kernels``. 

228 

229 Per product *output_dir* gets ``<id>.cub``, ``<id>.json`` (when *csm*), 

230 ``<id>.cam_test.txt`` (when *validate*) and ``<id>.tif`` (when 

231 *mapproject*). Steps whose output exists (for ``spiceinit``: a cube 

232 whose History records it, see [`has_spice`][lunar_toolkit.shadowcam.sc.has_spice]) are skipped unless 

233 *force*. 

234 

235 Parameters 

236 ---------- 

237 output_dir : Path 

238 Where cubes, labels and cameras are written. 

239 product : str 

240 ``"raw"`` (``…SE``, the cube in ASP's example) or ``"cal"`` (``…SC``, 

241 radiometrically calibrated, bias columns removed) for observation IDs. 

242 web : bool 

243 Use the USGS SPICE web service (it serves the KPLO kernels) in 

244 ``spiceinit``. ALE's ShadowCam driver only reads NAIF kernel files, 

245 so ``isd_generate`` needs the local kernels either way. 

246 csm : bool 

247 Write a CSM camera with ``isd_generate``. 

248 validate : bool 

249 Run ``cam_test`` (ISIS vs CSM camera) and save its report. 

250 mapproject : bool 

251 Write a GeoTIFF: ASP ``mapproject`` onto *dem* when given, else 

252 ISIS ``cam2map`` onto the ``spiceinit`` shape. 

253 dem : Path, optional 

254 DEM for ASP ``mapproject``. 

255 resolution : float, optional 

256 Map resolution in m/pixel (ASP ``--tr`` / ``cam2map`` resolution). 

257 t_srs : str, optional 

258 PROJ string for ASP ``mapproject`` (default: the DEM's projection). 

259 projection : str 

260 ISIS projection for ``mosrange`` without *dem*. 

261 keep : bool 

262 Keep the ISIS map-projected cube. 

263 force : bool 

264 Redo every processing step (downloads are always reused). 

265 spiceinit_params : dict, optional 

266 Overrides merged into `SPICEINIT_PARAMS`. 

267 """ 

268 

269 def __init__( 

270 self, 

271 output_dir: Path = Path("shadowcam"), 

272 *, 

273 product: str = "raw", 

274 web: bool = False, 

275 csm: bool = True, 

276 validate: bool = False, 

277 mapproject: bool = False, 

278 dem: Path | None = None, 

279 resolution: float | None = None, 

280 t_srs: str | None = None, 

281 projection: str = DEFAULT_PROJECTION, 

282 keep: bool = False, 

283 force: bool = False, 

284 spiceinit_params: dict[str, Any] | None = None, 

285 ) -> None: 

286 if product.lower() not in PRODUCT_SUFFIX: 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true

287 raise ValueError(f"Unknown ShadowCam product {product!r}: {sorted(PRODUCT_SUFFIX)}") 

288 self.output_dir = Path(output_dir) 

289 self.product = product.lower() 

290 self.web = web 

291 self.csm = csm 

292 self.validate = validate 

293 self.mapproject = mapproject 

294 self.dem = Path(dem) if dem else None 

295 self.resolution = resolution 

296 self.t_srs = t_srs 

297 self.projection = projection 

298 self.keep = keep 

299 self.force = force 

300 self.spiceinit_params = {**SPICEINIT_PARAMS, **(spiceinit_params or {})} 

301 

302 def process(self, source: ScProduct | str | Path) -> ScOutputs: 

303 """Download (if needed) and process one product. 

304 

305 *source* is a [`ScProduct`][lunar_toolkit.shadowcam.sc.ScProduct], a local ``.cub`` path or an 

306 observation / product ID (looked up on ODE). 

307 """ 

308 if isinstance(source, ScProduct): 

309 cube = download_product(source, self.output_dir) 

310 elif Path(source).suffix.lower() == ".cub": 

311 cube = Path(source) 

312 else: 

313 products = sc_products(lookup_products([str(source)], product=self.product)) 

314 if not products: 

315 raise ValueError(f"No ShadowCam product {source!r} on ODE") 

316 cube = download_product(products[0], self.output_dir) 

317 return self.process_cube(cube) 

318 

319 def process_batch( 

320 self, 

321 sources: Sequence[ScProduct | str | Path], 

322 max_workers: int = 2, 

323 ) -> list[ScOutputs | None]: 

324 """Process several products in parallel (None for failures).""" 

325 results: list[ScOutputs | None] = [None] * len(sources) 

326 with ThreadPoolExecutor(max_workers=max_workers) as pool: 

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

328 for future in as_completed(futures): 

329 i = futures[future] 

330 try: 

331 results[i] = future.result() 

332 except Exception as exc: 

333 log.error("Failed %s: %s", _source_name(sources[i]), exc) 

334 return results 

335 

336 def cube_path(self, product: ScProduct) -> Path: 

337 """Where [`process`][lunar_toolkit.shadowcam.sc.ScProcessor.process] downloads *product*'s cube.""" 

338 return self.output_dir / product.cube_name 

339 

340 def process_cube(self, cube: Path) -> ScOutputs: 

341 """Run the SPICE / CSM chain on a downloaded ShadowCam cube.""" 

342 cube = Path(cube) 

343 pid = cube.name.split(".")[0].upper() 

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

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

346 out = ScOutputs(pid, cube) 

347 

348 if self.force or not has_spice(cube): 

349 spiceinit(cube, web=self.web, **self.spiceinit_params) 

350 else: 

351 log.info("Reusing SPICE in %s", cube) 

352 

353 if self.csm: 353 ↛ 358line 353 didn't jump to line 358 because the condition on line 353 was always true

354 out.camera = self.output_dir / f"{pid}.json" 

355 if not reuse_existing(out.camera, self.force): 

356 isd_generate(cube, out.camera) 

357 

358 if self.validate and out.camera: 

359 out.cam_test = self.output_dir / f"{pid}.cam_test.txt" 

360 if not reuse_existing(out.cam_test, self.force): 360 ↛ 363line 360 didn't jump to line 363 because the condition on line 360 was always true

361 self._cam_test(cube, out.camera, out.cam_test) 

362 

363 if self.mapproject: 

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

365 if not reuse_existing(out.geotiff, self.force): 365 ↛ 367line 365 didn't jump to line 367 because the condition on line 365 was always true

366 self._mapproject(cube, out.camera, out.geotiff) 

367 return out 

368 

369 def _cam_test(self, cube: Path, camera: Path, report: Path) -> None: 

370 args = ["--image", cube, "--cam1", cube, "--cam2", camera] 

371 args += ["--session1", "isis", "--session2", "csm", "--sample-rate", CAM_TEST_SAMPLE_RATE] 

372 res = run("cam_test", args, capture=True) 

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

374 report.write_text(res.stdout or "") 

375 

376 def _mapproject(self, cube: Path, camera: Path | None, tif: Path) -> None: 

377 if self.dem: 

378 asp.mapproject(self.dem, cube, tif, camera, tr=self.resolution, t_srs=self.t_srs) 

379 return 

380 map_cube = tif.with_suffix(".map.cub") 

381 map_project(cube, map_cube, self.projection, self.resolution, keep=self.keep) 

382 run("gdal_translate", ["-of", "GTiff", "-co", "COMPRESS=DEFLATE", "-co", "TILED=YES", map_cube, tif]) 

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

384 map_cube.unlink(missing_ok=True) 

385 

386 

387def import_sc(cube: Path, output_dir: Path | None = None, **kwargs: Any) -> ScOutputs: 

388 """Process one downloaded ShadowCam cube; see [`ScProcessor`][lunar_toolkit.shadowcam.sc.ScProcessor] for 

389 *kwargs*.""" 

390 cube = Path(cube) 

391 return ScProcessor(output_dir or cube.parent, **kwargs).process_cube(cube) 

392 

393 

394def _source_name(source: Any) -> str: 

395 return source.product_id if isinstance(source, ScProduct) else str(source)