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,127 @@
1
+ """Common observation schema shared by all search backends.
2
+
3
+ Longitudes are 0–360 east (``lon_west > lon_east`` crosses the seam); angles in degrees,
4
+ ``pixel_scale`` in m/px, ``spacecraft_altitude`` in km; missing values are None."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ from .constants import is_nac_id
11
+
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ import pandas as pd
14
+
15
+ OBSERVATION_COLUMNS: list[str] = [
16
+ "product_id",
17
+ "instrument", # e.g. "LROC NAC", "ShadowCam", "Kaguya TC"
18
+ "product_type", # e.g. "EDR", "CDR", "CAL", "RAW", "DTM"
19
+ "start_time", # pandas Timestamp (UTC, tz-naive)
20
+ "center_lat",
21
+ "center_lon",
22
+ "min_lat",
23
+ "max_lat",
24
+ "lon_west",
25
+ "lon_east",
26
+ "footprint_wkt", # lat/lon WKT if known (ODE: 0–360 cylindrical)
27
+ "incidence",
28
+ "emission",
29
+ "phase",
30
+ "slew_angle",
31
+ "sub_solar_azimuth", # None when the source does not provide it
32
+ "north_azimuth", # PDS NORTH_AZIMUTH (image-frame), None if unknown
33
+ "sub_spacecraft_lat", # sub-spacecraft point at image centre time
34
+ "sub_spacecraft_lon",
35
+ "spacecraft_altitude", # km above the surface
36
+ "pixel_scale",
37
+ "url", # primary product file
38
+ "label_url", # PDS label (.xml / .lbl)
39
+ "source", # "cumindex" or "ode"
40
+ ]
41
+
42
+
43
+ def empty_observations() -> pd.DataFrame:
44
+ import pandas as pd
45
+
46
+ return pd.DataFrame({c: pd.Series(dtype="object") for c in OBSERVATION_COLUMNS})
47
+
48
+
49
+ def normalize_observations(records: list[dict]) -> pd.DataFrame:
50
+ """Build an observation DataFrame from dicts (missing keys → None)."""
51
+ import pandas as pd
52
+
53
+ if not records:
54
+ return empty_observations()
55
+ df = pd.DataFrame.from_records(
56
+ [{c: r.get(c) for c in OBSERVATION_COLUMNS} for r in records],
57
+ columns=OBSERVATION_COLUMNS,
58
+ )
59
+ for c in (
60
+ "center_lat",
61
+ "center_lon",
62
+ "min_lat",
63
+ "max_lat",
64
+ "lon_west",
65
+ "lon_east",
66
+ "incidence",
67
+ "emission",
68
+ "phase",
69
+ "slew_angle",
70
+ "sub_solar_azimuth",
71
+ "pixel_scale",
72
+ "north_azimuth",
73
+ "sub_spacecraft_lat",
74
+ "sub_spacecraft_lon",
75
+ "spacecraft_altitude",
76
+ ):
77
+ df[c] = pd.to_numeric(df[c], errors="coerce")
78
+ df["start_time"] = pd.to_datetime(df["start_time"], errors="coerce", utc=True).dt.tz_localize(None)
79
+ return df
80
+
81
+
82
+ def observations_from_cumindex(df: pd.DataFrame, dataset: str | None = "edr") -> pd.DataFrame:
83
+ """Map enriched ``CumIndex`` query results to :data:`OBSERVATION_COLUMNS`."""
84
+ import pandas as pd
85
+
86
+ if df is None or df.empty:
87
+ return empty_observations()
88
+
89
+ def g(row, col):
90
+ v = row.get(col)
91
+ try:
92
+ return None if pd.isna(v) else v
93
+ except (TypeError, ValueError): # array-like value
94
+ return v
95
+
96
+ recs = []
97
+ for _, row in df.iterrows():
98
+ pid = str(row.get("PRODUCT_ID", ""))
99
+ recs.append(
100
+ {
101
+ "product_id": pid,
102
+ "instrument": "LROC NAC" if is_nac_id(pid) else "LROC WAC",
103
+ "product_type": (dataset or "").upper() or None,
104
+ "start_time": g(row, "START_TIME"),
105
+ "center_lat": g(row, "CENTER_LATITUDE"),
106
+ "center_lon": g(row, "CENTER_LONGITUDE"),
107
+ "min_lat": g(row, "FP_MIN_LAT"),
108
+ "max_lat": g(row, "FP_MAX_LAT"),
109
+ "lon_west": g(row, "FP_LON_WEST"),
110
+ "lon_east": g(row, "FP_LON_EAST"),
111
+ "footprint_wkt": g(row, "FP_WKT"),
112
+ "incidence": g(row, "INCIDENCE_ANGLE"),
113
+ "emission": g(row, "EMISSION_ANGLE"),
114
+ "phase": g(row, "PHASE_ANGLE"),
115
+ "slew_angle": g(row, "SLEW_ANGLE"),
116
+ "sub_solar_azimuth": g(row, "SUB_SOLAR_AZIMUTH"),
117
+ "north_azimuth": g(row, "NORTH_AZIMUTH"),
118
+ "sub_spacecraft_lat": g(row, "SUB_SPACECRAFT_LATITUDE"),
119
+ "sub_spacecraft_lon": g(row, "SUB_SPACECRAFT_LONGITUDE"),
120
+ "spacecraft_altitude": g(row, "SPACECRAFT_ALTITUDE"),
121
+ "pixel_scale": g(row, "RESOLUTION") if "RESOLUTION" in row else g(row, "PIXEL_SCALE"),
122
+ "url": g(row, "PDS_URL"),
123
+ "label_url": g(row, "LABEL_URL"),
124
+ "source": "cumindex",
125
+ }
126
+ )
127
+ return normalize_observations(recs)
lunar_toolkit/ode.py ADDED
@@ -0,0 +1,485 @@
1
+ """PDS ODE REST client (ShadowCam, LROC NAC, Kaguya TC) returning the observation schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from collections.abc import Iterable
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from .constants import modernize_pds_url
16
+ from .downloader import _USER_AGENT, PdsDownloader
17
+ from .geometry import aoi_mask, aoi_region, load_aoi, query_region, region_bbox
18
+ from .observation import OBSERVATION_COLUMNS, normalize_observations
19
+
20
+ if TYPE_CHECKING: # pragma: no cover
21
+ import pandas as pd
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ ODE_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).
29
+ INSTRUMENTS: 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
+
88
+ def 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:
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
+
104
+ class OdeError(RuntimeError):
105
+ """ODE returned an error status."""
106
+
107
+
108
+ class OdeClient:
109
+ """Query ODE REST and return :data:`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 :func:`lunar_toolkit.geometry.load_aoi`) is sent as
155
+ its bounding box; :meth:`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:
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:
190
+ params["minobtime"], params["maxobtime"] = str(time_range[0]), str(time_range[1])
191
+ if loc:
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 :meth:`CumIndex.search`: ``lat=(min, max)``
236
+ 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
+ :class:`~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:
271
+ remaining = max_results - len(records)
272
+ if remaining <= 0:
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:
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:
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
+
345
+ def 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:
352
+ return []
353
+ return p if isinstance(p, list) else [p]
354
+
355
+
356
+ def _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
+
361
+ def _num(v) -> float | None:
362
+ try:
363
+ return float(v)
364
+ except (TypeError, ValueError):
365
+ return None
366
+
367
+
368
+ def 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"):
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:
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:
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:
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:
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
+
452
+ def 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
+
460
+ def select_file_urls(df: pd.DataFrame, kind: str = "product", match: str | None = None) -> list[str]:
461
+ """Pick file URLs from the ``files`` column (see :meth:`OdeClient.download`)."""
462
+ kind = kind.lower()
463
+ urls: list[str] = []
464
+ for files in df.get("files", []):
465
+ for f in files or []:
466
+ name = str(f.get("FileName", ""))
467
+ ftype = str(f.get("Type", "")).lower()
468
+ is_label = name.upper().endswith((".XML", ".LBL"))
469
+ if kind == "label":
470
+ ok = ftype == "product" and is_label
471
+ elif kind == "product":
472
+ ok = ftype == "product" and not is_label
473
+ elif kind == "all":
474
+ ok = True
475
+ else:
476
+ ok = ftype == kind
477
+ if ok and match and match.lower() not in name.lower():
478
+ ok = False
479
+ if ok and f.get("URL") and f["URL"] not in urls:
480
+ urls.append(f["URL"])
481
+ return urls
482
+
483
+
484
+ def _fmt(x: float) -> str:
485
+ return f"{float(x):.6g}"