Coverage for lunar_toolkit/ode.py: 77%

198 statements  

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

1"""PDS ODE REST client (ShadowCam, LROC NAC, Kaguya TC) returning the observation schema.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import logging 

7import time 

8import urllib.error 

9import urllib.parse 

10import urllib.request 

11from collections.abc import Iterable 

12from pathlib import Path 

13from typing import TYPE_CHECKING, Any 

14 

15from .constants import modernize_pds_url 

16from .downloader import _USER_AGENT, PdsDownloader 

17from .geometry import aoi_mask, aoi_region, load_aoi, query_region, region_bbox 

18from .observation import OBSERVATION_COLUMNS, normalize_observations 

19 

20if TYPE_CHECKING: # pragma: no cover 

21 import pandas as pd 

22 

23log = logging.getLogger(__name__) 

24 

25ODE_REST_URL = "https://oderest.rsl.wustl.edu/live2/" 

26 

27# Instrument registry: CLI/API key → ODE instrument host id, instrument id and 

28# friendly product-type aliases (raw ODE ``pt`` values are accepted too). 

29INSTRUMENTS: dict[str, dict[str, Any]] = { 

30 "shadowcam": { 

31 "label": "ShadowCam", 

32 "ihid": "KPLO", 

33 "iid": "ShadowCam", 

34 "product_types": {"raw": "RAW", "cal": "CAL", "cmos": "CMOS", "umos": "UMOS", "dtm": "DTM"}, 

35 "default": "cal", 

36 }, 

37 "lroc": { 

38 "label": "LROC NAC", 

39 "ihid": "LRO", 

40 "iid": "LROC", 

41 "product_types": {"edr": "EDRNAC4", "cdr": "CDRNAC4"}, 

42 "default": "edr", 

43 }, 

44 "kaguya_tc": { 

45 "label": "Kaguya TC", 

46 "ihid": "SLN", 

47 "iid": "TC", 

48 "product_types": { 

49 "stereo": "TCWL2B", # Stereo Level 2B0 

50 "mono": "TCSL2B", # Mono Level 2B0 

51 "dtm": "TCDTMO", # DTM ortho 

52 "ortho": "TCORT", # Ortho map 

53 "morning": "TCMORM", 

54 "evening": "TCEVEM", 

55 }, 

56 "default": "stereo", 

57 }, 

58 "kaguya_mi": { 

59 "label": "Kaguya MI", 

60 "ihid": "SLN", 

61 "iid": "MI", 

62 "product_types": { 

63 "vis": "VIS2B", # VIS Level 2B2 (kaguyami2isis) 

64 "nir": "NIR2B", # NIR Level 2B2 (kaguyami2isis) 

65 "vis2c": "VIS2C", # VIS Level 2C2 

66 "nir2c": "NIR2C", # NIR Level 2C2 

67 "l3c5": "MI3C", # Level 3C5 reflectance, map-projected 

68 "map": "MIMAP", # Map tiles 

69 }, 

70 "default": "vis", 

71 }, 

72} 

73 

74_PRODUCT_QUERY = {"query": "product", "output": "JSON", "target": "moon"} 

75 

76_ALIASES = { 

77 "sc": "shadowcam", 

78 "kplo": "shadowcam", 

79 "nac": "lroc", 

80 "lroc_nac": "lroc", 

81 "kaguya": "kaguya_tc", 

82 "tc": "kaguya_tc", 

83 "selene": "kaguya_tc", 

84 "mi": "kaguya_mi", 

85} 

86 

87 

88def resolve_instrument(instrument: str, product_type: str | None = None) -> tuple[str, dict, str]: 

89 """Return ``(key, registry_entry, ode_pt)`` for user-facing names.""" 

90 key = instrument.strip().lower().replace("-", "_") 

91 key = _ALIASES.get(key, key) 

92 if key not in INSTRUMENTS: 

93 raise ValueError(f"Unknown instrument {instrument!r}. Valid: {sorted(INSTRUMENTS)}") 

94 entry = INSTRUMENTS[key] 

95 pt_key = (product_type or entry["default"]).strip() 

96 pts = entry["product_types"] 

97 if pt_key.lower() in pts: 97 ↛ 99line 97 didn't jump to line 99 because the condition on line 97 was always true

98 return key, entry, pts[pt_key.lower()] 

99 if pt_key.upper() in {v.upper() for v in pts.values()} or product_type: 

100 return key, entry, pt_key.upper() 

101 raise ValueError(f"Unknown product type {product_type!r} for {key}: {sorted(pts)}") 

102 

103 

104class OdeError(RuntimeError): 

105 """ODE returned an error status.""" 

106 

107 

108class OdeClient: 

109 """Query ODE REST and return `OBSERVATION_COLUMNS` DataFrames. 

110 

111 Parameters 

112 ---------- 

113 base_url : str 

114 ODE REST endpoint (default: ``live2``). 

115 timeout : float 

116 Per-request timeout in seconds — ODE footprint queries can take 

117 30 s or more. 

118 page_size : int 

119 Products per request (ODE ``limit``); results are paged with ``offset``. 

120 """ 

121 

122 def __init__( 

123 self, 

124 base_url: str = ODE_REST_URL, 

125 timeout: float = 180.0, 

126 page_size: int = 100, 

127 retries: int = 2, 

128 ) -> None: 

129 self.base_url = base_url 

130 self.timeout = timeout 

131 self.page_size = page_size 

132 self.retries = retries 

133 

134 # Low-level request 

135 

136 def build_params( 

137 self, 

138 instrument: str, 

139 product_type: str | None = None, 

140 lat: tuple[float, float] | None = None, 

141 lon: tuple[float, float] | None = None, 

142 point: tuple[float, float] | None = None, 

143 radius_km: float = 0.0, 

144 incidence: tuple[float, float] | None = None, 

145 emission: tuple[float, float] | None = None, 

146 phase: tuple[float, float] | None = None, 

147 time_range: tuple[str, str] | None = None, 

148 loc: str | None = None, 

149 results: str = "fmp", 

150 aoi=None, 

151 ) -> dict[str, str]: 

152 """Translate search arguments into ODE query parameters. 

153 

154 An ``aoi`` (see [`lunar_toolkit.geometry.load_aoi`][lunar_toolkit.geometry.load_aoi]) is sent as 

155 its bounding box; [`search`][lunar_toolkit.ode.OdeClient.search] refines the results exactly. 

156 """ 

157 _, entry, pt = resolve_instrument(instrument, product_type) 

158 params: dict[str, str] = { 

159 **_PRODUCT_QUERY, 

160 "results": results, 

161 "ihid": entry["ihid"], 

162 "iid": entry["iid"], 

163 "pt": pt, 

164 } 

165 if aoi is not None or point is not None or lat is not None or lon is not None: 165 ↛ 182line 165 didn't jump to line 182 because the condition on line 165 was always true

166 if aoi is not None: 

167 if point is not None or lat is not None or lon is not None: 

168 raise ValueError("Pass either aoi or lat/lon/point, not both") 

169 region = aoi_region(aoi) 

170 else: 

171 region = query_region(lat=lat, lon=lon, point=point, radius_km=radius_km) 

172 west, south, east, north = region_bbox(region) 

173 params.update( 

174 { 

175 "minlat": _fmt(south), 

176 "maxlat": _fmt(north), 

177 # ODE: positive east 0–360, boxes may cross the meridian 

178 "westernlon": _fmt(west), 

179 "easternlon": _fmt(east), 

180 } 

181 ) 

182 for (lo, hi), rng in ( 

183 (("mininangle", "maxinangle"), incidence), 

184 (("minemangle", "maxemangle"), emission), 

185 (("minphangle", "maxphangle"), phase), 

186 ): 

187 if rng is not None: 

188 params[lo], params[hi] = _fmt(rng[0]), _fmt(rng[1]) 

189 if time_range is not None: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true

190 params["minobtime"], params["maxobtime"] = str(time_range[0]), str(time_range[1]) 

191 if loc: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 params["loc"] = loc 

193 return params 

194 

195 def request(self, params: dict[str, str]) -> dict: 

196 """GET the ODE endpoint and return the decoded ``ODEResults`` dict.""" 

197 url = self.base_url + "?" + urllib.parse.urlencode(params) 

198 req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) 

199 last: Exception | None = None 

200 for attempt in range(self.retries + 1): 

201 try: 

202 with urllib.request.urlopen(req, timeout=self.timeout) as resp: 

203 data = json.loads(resp.read().decode("utf-8", "replace")) 

204 break 

205 except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: 

206 last = exc 

207 time.sleep(2 * (attempt + 1)) 

208 else: 

209 raise OdeError(f"ODE request failed: {last}") from last 

210 res = data.get("ODEResults", data) 

211 if str(res.get("Status", "")).lower() == "error": 

212 raise OdeError(res.get("Error") or json.dumps(res)[:500]) 

213 return res 

214 

215 # Search 

216 

217 def search( 

218 self, 

219 instrument: str, 

220 product_type: str | None = None, 

221 lat: tuple[float, float] | None = None, 

222 lon: tuple[float, float] | None = None, 

223 point: tuple[float, float] | None = None, 

224 radius_km: float = 0.0, 

225 incidence: tuple[float, float] | None = None, 

226 emission: tuple[float, float] | None = None, 

227 phase: tuple[float, float] | None = None, 

228 time_range: tuple[str, str] | None = None, 

229 max_results: int | None = 1000, 

230 loc: str | None = None, 

231 aoi=None, 

232 ) -> pd.DataFrame: 

233 """Search ODE and return the common observation schema. 

234 

235 Region arguments follow [`CumIndex.search`][lunar_toolkit.lroc.cumindex.CumIndex.search]: 

236 ``lat=(min, max)`` and ``lon=(west, east)`` (``west > east`` crosses 0/360), or 

237 ``point=(lat, lon)`` with ``radius_km`` (converted to a bounding box; 

238 circles reaching a pole become a full-longitude cap). ODE matches the 

239 box against product footprints (``loc=f``, its default). With 

240 ``aoi`` (GeoJSON / WKT path, WKT string or 

241 [`Aoi`][lunar_toolkit.geometry.Aoi]) the AOI's 

242 bounding box is queried and each ``footprint_wkt`` is then 

243 intersected with the polygon; paging continues until 

244 ``max_results`` products survive that refinement. 

245 

246 Extra columns: ``files`` (list of ODE product-file dicts) and 

247 ``ode_url`` (ODE product page). 

248 """ 

249 key, entry, _ = resolve_instrument(instrument, product_type) 

250 if aoi is not None: 

251 aoi = load_aoi(aoi) 

252 params = self.build_params( 

253 instrument, 

254 product_type, 

255 lat=lat, 

256 lon=lon, 

257 point=point, 

258 radius_km=radius_km, 

259 incidence=incidence, 

260 emission=emission, 

261 phase=phase, 

262 time_range=time_range, 

263 loc=loc, 

264 aoi=aoi, 

265 ) 

266 records: list[dict] = [] 

267 offset = 0 

268 while True: 

269 limit = self.page_size 

270 if max_results is not None: 270 ↛ 276line 270 didn't jump to line 276 because the condition on line 270 was always true

271 remaining = max_results - len(records) 

272 if remaining <= 0: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true

273 break 

274 if aoi is None: 

275 limit = min(limit, remaining) 

276 res = self.request({**params, "limit": str(limit), "offset": str(offset)}) 

277 products = extract_products(res) 

278 page = [parse_product(p, instrument_label=entry["label"]) for p in products] 

279 if aoi is not None and page: 

280 page = [r for r, keep in zip(page, aoi_mask(to_dataframe(page), aoi)) if keep] 

281 records += page 

282 if len(products) < limit: 

283 break 

284 offset += len(products) 

285 if max_results is not None: 285 ↛ 287line 285 didn't jump to line 287 because the condition on line 285 was always true

286 records = records[:max_results] 

287 return to_dataframe(records) 

288 

289 def lookup(self, product_ids: Iterable[str], instrument_label: str | None = None) -> pd.DataFrame: 

290 """Observations for exact product IDs, one ODE ``productid`` request each. 

291 

292 Unknown IDs are skipped with a warning. 

293 """ 

294 records: list[dict] = [] 

295 for pid in product_ids: 

296 pid = pid.strip().upper() 

297 products = extract_products(self.request({**_PRODUCT_QUERY, "results": "fmp", "productid": pid})) 

298 if not products: 

299 log.warning("ODE has no product %s", pid) 

300 records += [parse_product(p, instrument_label=instrument_label) for p in products] 

301 return to_dataframe(records) 

302 

303 def count(self, instrument: str, product_type: str | None = None, **kwargs) -> int: 

304 """Number of matching products (``results=c``).""" 

305 params = self.build_params(instrument, product_type, results="c", **kwargs) 

306 res = self.request(params) 

307 return int(res.get("Count", 0) or 0) 

308 

309 # Download 

310 

311 def download( 

312 self, 

313 df: pd.DataFrame, 

314 output_dir: Path, 

315 kind: str = "product", 

316 match: str | None = None, 

317 workers: int = 4, 

318 force: bool = False, 

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

320 """Download files listed in the ``files`` column of a search result. 

321 

322 Parameters 

323 ---------- 

324 kind : str 

325 ODE file ``Type`` to select: ``"product"`` (default; data files 

326 only, labels skipped), ``"label"``, ``"browse"``, ``"referenced"``, 

327 ``"derived"`` or ``"all"``. 

328 match : str, optional 

329 Case-insensitive substring the file name must contain 

330 (e.g. ``"_cog.tif"``). 

331 force : bool 

332 Download again even when the file already exists in *output_dir*. 

333 """ 

334 urls = select_file_urls(df, kind=kind, match=match) 

335 if not urls: 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true

336 log.warning("No files selected for download.") 

337 return {} 

338 log.info("Downloading %d file(s) to %s", len(urls), output_dir) 

339 return PdsDownloader(max_workers=workers).download_urls(urls, Path(output_dir), force=force) 

340 

341 

342# Response parsing (pure functions — unit-tested against saved JSON) 

343 

344 

345def extract_products(res: dict) -> list[dict]: 

346 """Return the product list from an ``ODEResults`` dict (handles 0/1/N).""" 

347 prods = res.get("Products") 

348 if not isinstance(prods, dict): # "No Products Found" string or missing 

349 return [] 

350 p = prods.get("Product") 

351 if p is None: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 return [] 

353 return p if isinstance(p, list) else [p] 

354 

355 

356def _files(prod: dict) -> list[dict]: 

357 pf = (prod.get("Product_files") or {}).get("Product_file") or [] 

358 return pf if isinstance(pf, list) else [pf] 

359 

360 

361def _num(v) -> float | None: 

362 try: 

363 return float(v) 

364 except (TypeError, ValueError): 

365 return None 

366 

367 

368def parse_product(prod: dict, instrument_label: str | None = None) -> dict: 

369 """Map one ODE product dict to the observation schema (+ extras).""" 

370 pdsid = str(prod.get("pdsid", "")).strip() 

371 product_id = pdsid.rsplit(".", 1)[-1].upper() if pdsid else None 

372 files = _files(prod) 

373 for f in files: 

374 if f.get("URL"): 374 ↛ 373line 374 didn't jump to line 373 because the condition on line 374 was always true

375 f["URL"] = modernize_pds_url(f["URL"]) 

376 

377 data_files = [f for f in files if str(f.get("Type", "")).lower() == "product"] 

378 url = next( 

379 (f["URL"] for f in data_files if not str(f.get("FileName", "")).upper().endswith((".XML", ".LBL"))), 

380 None, 

381 ) 

382 label_url = prod.get("LabelURL") or next( 

383 (f["URL"] for f in data_files if str(f.get("FileName", "")).upper().endswith((".XML", ".LBL"))), 

384 None, 

385 ) 

386 if label_url: 386 ↛ 389line 386 didn't jump to line 389 because the condition on line 386 was always true

387 label_url = modernize_pds_url(label_url) 

388 

389 sub_solar_az = None 

390 for k in ("Sub_solar_azimuth", "Subsolar_azimuth", "Solar_azimuth"): 

391 if k in prod: 391 ↛ 392line 391 didn't jump to line 392 because the condition on line 391 was never true

392 sub_solar_az = _num(prod[k]) 

393 break 

394 

395 # ODE echoes the LROC CUMINDEX row in ODE_notes ("NOTE: Index Record:" 

396 # followed by the CSV record). Its 83 columns follow CUMINDEX.LBL, so 

397 # 0-based index = COLUMN_NUMBER - 1; 999.99-style N/A constants → None. 

398 geom: dict[str, float | None] = {} 

399 notes = (prod.get("ODE_notes") or {}).get("ODE_note") or [] 

400 notes = notes if isinstance(notes, list) else [notes] 

401 for i, note in enumerate(notes[:-1]): 

402 if str(note).startswith("NOTE: Index Record"): 

403 import csv 

404 import io 

405 

406 row = next(csv.reader(io.StringIO(str(notes[i + 1]))), []) 

407 if len(row) == 83: 407 ↛ 418line 407 didn't jump to line 418 because the condition on line 407 was always true

408 for key, col in ( 

409 ("slew_angle", 9), 

410 ("north_azimuth", 61), 

411 ("sub_solar_azimuth", 62), 

412 ("sub_spacecraft_lat", 65), 

413 ("sub_spacecraft_lon", 66), 

414 ("spacecraft_altitude", 79), 

415 ): 

416 v = _num(row[col].strip()) 

417 geom[key] = None if v is None or abs(v) >= 999.0 else v 

418 break 

419 if sub_solar_az is None: 419 ↛ 422line 419 didn't jump to line 422 because the condition on line 419 was always true

420 sub_solar_az = geom.get("sub_solar_azimuth") 

421 

422 return { 

423 "product_id": product_id, 

424 "instrument": instrument_label or f"{prod.get('ihid', '')} {prod.get('iid', '')}".strip(), 

425 "product_type": prod.get("pt"), 

426 "start_time": prod.get("UTC_start_time") or prod.get("Observation_time"), 

427 "center_lat": _num(prod.get("Center_latitude")), 

428 "center_lon": _num(prod.get("Center_longitude")), 

429 "min_lat": _num(prod.get("Minimum_latitude")), 

430 "max_lat": _num(prod.get("Maximum_latitude")), 

431 "lon_west": _num(prod.get("Westernmost_longitude")), 

432 "lon_east": _num(prod.get("Easternmost_longitude")), 

433 "footprint_wkt": prod.get("Footprint_geometry") or prod.get("Footprint_C0_geometry"), 

434 "incidence": _num(prod.get("Incidence_angle")), 

435 "emission": _num(prod.get("Emission_angle")), 

436 "phase": _num(prod.get("Phase_angle")), 

437 "slew_angle": geom.get("slew_angle"), 

438 "sub_solar_azimuth": sub_solar_az, 

439 "north_azimuth": geom.get("north_azimuth"), 

440 "sub_spacecraft_lat": geom.get("sub_spacecraft_lat"), 

441 "sub_spacecraft_lon": geom.get("sub_spacecraft_lon"), 

442 "spacecraft_altitude": geom.get("spacecraft_altitude"), 

443 "pixel_scale": _num(prod.get("Map_resolution") or prod.get("Map_scale")), 

444 "url": url, 

445 "label_url": label_url, 

446 "source": "ode", 

447 "files": files, 

448 "ode_url": prod.get("ProductURL"), 

449 } 

450 

451 

452def to_dataframe(records: list[dict]) -> pd.DataFrame: 

453 """Observation DataFrame with the ``files`` / ``ode_url`` extras appended.""" 

454 df = normalize_observations(records) 

455 df["files"] = [r.get("files", []) for r in records] if records else [] 

456 df["ode_url"] = [r.get("ode_url") for r in records] if records else [] 

457 return df[OBSERVATION_COLUMNS + ["files", "ode_url"]] 

458 

459 

460def select_file_urls(df: pd.DataFrame, kind: str = "product", match: str | None = None) -> list[str]: 

461 """Pick file URLs from the ``files`` column (see 

462 [`OdeClient.download`][lunar_toolkit.ode.OdeClient.download]).""" 

463 kind = kind.lower() 

464 urls: list[str] = [] 

465 for files in df.get("files", []): 

466 for f in files or []: 

467 name = str(f.get("FileName", "")) 

468 ftype = str(f.get("Type", "")).lower() 

469 is_label = name.upper().endswith((".XML", ".LBL")) 

470 if kind == "label": 

471 ok = ftype == "product" and is_label 

472 elif kind == "product": 

473 ok = ftype == "product" and not is_label 

474 elif kind == "all": 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true

475 ok = True 

476 else: 

477 ok = ftype == kind 

478 if ok and match and match.lower() not in name.lower(): 

479 ok = False 

480 if ok and f.get("URL") and f["URL"] not in urls: 

481 urls.append(f["URL"]) 

482 return urls 

483 

484 

485def _fmt(x: float) -> str: 

486 return f"{float(x):.6g}"