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,1197 @@
1
+ """LROC PDS CUMINDEX as a local DuckDB / Parquet database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ import json
7
+ import logging
8
+ import re
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ import xml.etree.ElementTree as ET
13
+ from collections.abc import Callable, Iterable
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from ..constants import (
18
+ DATASETS,
19
+ DEFAULT_CACHE_DIR,
20
+ FOOTPRINT_CORNERS,
21
+ INDEX_DIR,
22
+ MOON_RADIUS_KM,
23
+ NAC_PRODUCT_TYPES,
24
+ NAC_SUFFIXES,
25
+ PDS_STORE_URL,
26
+ POLAR_LAT_THRESHOLD,
27
+ VOLINDEX_LBL_FILENAME,
28
+ VOLINDEX_TAB_FILENAME,
29
+ find_parquet,
30
+ is_nac_id,
31
+ parse_nac_id,
32
+ pds_url_from_file_spec,
33
+ )
34
+ from ..downloader import _USER_AGENT, PdsDownloader
35
+ from ..geometry import aoi_region, filter_aoi, load_aoi, query_region
36
+
37
+ if TYPE_CHECKING: # pragma: no cover
38
+ import pandas as pd
39
+
40
+ from .nac import NacImageCollection
41
+
42
+ log = logging.getLogger(__name__)
43
+
44
+ # Bump when the Parquet layout changes in a way readers should know about.
45
+ SCHEMA_VERSION = 2
46
+
47
+ # Footprint columns derived at build time (all prefixed FP_).
48
+ FOOTPRINT_COLUMNS = [
49
+ "FP_MIN_LAT",
50
+ "FP_MAX_LAT",
51
+ "FP_LON_WEST",
52
+ "FP_LON_EAST",
53
+ "FP_LON_SPAN",
54
+ "FP_CONTAINS_POLE",
55
+ "FP_PS_HEMI",
56
+ "FP_PS_XMIN",
57
+ "FP_PS_XMAX",
58
+ "FP_PS_YMIN",
59
+ "FP_PS_YMAX",
60
+ ]
61
+
62
+
63
+ class CumIndex:
64
+ """Query the LROC PDS CUMINDEX via DuckDB on a local Parquet database.
65
+
66
+ Parameters
67
+ ----------
68
+ dataset : str
69
+ Which PDS dataset to use: ``"edr"`` (default), ``"cdr"``, or ``"rdr"``.
70
+ data_dir : Path, optional
71
+ Directory containing ``cumindex_<dataset>.parquet``. Defaults to
72
+ ``LUNAR_DATA_DIR``, else the user data dir.
73
+ cache_dir : Path
74
+ Where raw CUMINDEX/INDEX .TAB / .LBL downloads are cached.
75
+ Defaults to ``~/.cache/lunar_toolkit/``.
76
+ release : str, optional
77
+ PDS volume to read labels / index tables from (e.g. ``"LROLRC_0066C"``).
78
+ Defaults to the release recorded in the build sidecar, else the
79
+ fallback constant. ``build_db()`` auto-discovers the newest volume.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ dataset: str = "edr",
85
+ data_dir: Path | None = None,
86
+ cache_dir: Path = DEFAULT_CACHE_DIR,
87
+ release: str | None = None,
88
+ ) -> None:
89
+ if dataset not in DATASETS:
90
+ raise ValueError(f"Unknown dataset {dataset!r}. Valid options: {list(DATASETS)}")
91
+ self._dataset = dataset
92
+ self._ds = DATASETS[dataset]
93
+ self._explicit_data_dir = Path(data_dir) if data_dir is not None else None
94
+ self._cache_dir = Path(cache_dir)
95
+ self._cache = PdsDownloader(self._cache_dir)
96
+ self._release = _norm_volume(release) if release else None
97
+ self._schema: pd.DataFrame | None = None
98
+ self._meta: dict | None = None
99
+
100
+ # Dataset URLs
101
+ @property
102
+ def dataset(self) -> str:
103
+ return self._dataset
104
+
105
+ @property
106
+ def release(self) -> str:
107
+ """Volume used for label / index URLs (no trailing slash)."""
108
+ if self._release:
109
+ return self._release
110
+ side = self.build_info()
111
+ if side and side.get("release"):
112
+ return side["release"]
113
+ return _norm_volume(self._ds["release"])
114
+
115
+ def _index_base_url(self, volume: str | None = None) -> str:
116
+ vol = _norm_volume(volume) if volume else self.release
117
+ return self._ds["base_url"] + vol + "/" + self._ds["index_dir"]
118
+
119
+ @property
120
+ def lbl_url(self) -> str:
121
+ return self._index_base_url() + self._ds["lbl_filename"]
122
+
123
+ @property
124
+ def tab_url(self) -> str:
125
+ return self._index_base_url() + self._ds["tab_filename"]
126
+
127
+ def volume_index_urls(self, volume: str) -> tuple[str, str]:
128
+ """``(INDEX.LBL, INDEX.TAB)`` URLs for a single PDS volume."""
129
+ base = self._index_base_url(volume)
130
+ return base + VOLINDEX_LBL_FILENAME, base + VOLINDEX_TAB_FILENAME
131
+
132
+ # Parquet path / sidecar
133
+ @property
134
+ def parquet_path(self) -> Path:
135
+ """Path to the Parquet database for this dataset."""
136
+ return find_parquet(self._ds["parquet"], self._explicit_data_dir)
137
+
138
+ @property
139
+ def meta_path(self) -> Path:
140
+ """Build-metadata sidecar next to the Parquet (``*.meta.json``)."""
141
+ p = self.parquet_path
142
+ return p.with_name(p.stem + ".meta.json")
143
+
144
+ def build_info(self) -> dict | None:
145
+ """Return the build sidecar as a dict, or None if absent."""
146
+ mp = self.meta_path
147
+ if not mp.exists():
148
+ return None
149
+ try:
150
+ return json.loads(mp.read_text())
151
+ except (OSError, json.JSONDecodeError):
152
+ return None
153
+
154
+ def _ensure_parquet(self) -> Path:
155
+ p = self.parquet_path
156
+ if not p.exists():
157
+ raise FileNotFoundError(
158
+ f"Database not found: {p}\n"
159
+ f"Build it with: lunar build-db {self._dataset}\n"
160
+ f"Or set LUNAR_DATA_DIR to a directory that contains {self._ds['parquet']}"
161
+ )
162
+ return p
163
+
164
+ # Schema / metadata (from .LBL)
165
+ @property
166
+ def schema(self) -> pd.DataFrame:
167
+ """Column definitions from CUMINDEX.LBL (lazy, downloads to cache)."""
168
+ if self._schema is None:
169
+ lbl_text = self._read_lbl(self.lbl_url)
170
+ self._schema = _parse_pds_lbl_columns(lbl_text)
171
+ self._meta = _parse_lbl_meta(lbl_text)
172
+ return self._schema
173
+
174
+ @property
175
+ def meta(self) -> dict:
176
+ """Top-level scalar fields from CUMINDEX.LBL (lazy)."""
177
+ if self._meta is None:
178
+ self._meta = _parse_lbl_meta(self._read_lbl(self.lbl_url))
179
+ return self._meta
180
+
181
+ def _read_lbl(self, url: str) -> str:
182
+ return self._cache.ensure_cached(url).read_text(encoding="latin-1")
183
+
184
+ # Volume discovery
185
+ def discover_volumes(self) -> list[str]:
186
+ """List this dataset's PDS volumes (sorted oldest → newest).
187
+
188
+ Uses the S3 ``ListObjectsV2`` listing exposed by the PDS store; falls
189
+ back to probing ``INDEX/INDEX.LBL`` of successive volume numbers.
190
+ """
191
+ try:
192
+ vols = list_store_volumes(self._ds["base_url"], self._ds["volume_re"])
193
+ if vols:
194
+ return vols
195
+ except (OSError, ET.ParseError, ValueError) as exc:
196
+ log.warning("Volume listing failed (%s); probing instead", exc)
197
+ start = self.build_info() or {}
198
+ return probe_volumes(
199
+ self._ds["base_url"],
200
+ start.get("release") or _norm_volume(self._ds["release"]),
201
+ )
202
+
203
+ def latest_release(self) -> str:
204
+ """Newest volume on the server (fallback: the constant release)."""
205
+ try:
206
+ vols = self.discover_volumes()
207
+ except Exception as exc: # network down etc.
208
+ log.warning("Could not discover volumes (%s)", exc)
209
+ vols = []
210
+ return vols[-1] if vols else _norm_volume(self._ds["release"])
211
+
212
+ # Build database
213
+ def build_db(
214
+ self,
215
+ force: bool = False,
216
+ release: str | None = None,
217
+ volume_only: bool = False,
218
+ memory_limit: str = "4GB",
219
+ threads: int | None = None,
220
+ wkt: bool = False,
221
+ on_stage: Callable[[str], None] | None = None,
222
+ ) -> Path:
223
+ """Download the PDS index table and build the Parquet database.
224
+
225
+ Parameters
226
+ ----------
227
+ force : bool
228
+ Rebuild even if the Parquet already exists.
229
+ release : str, optional
230
+ Volume to build from (e.g. ``"LROLRC_0066C"``). Default: newest
231
+ volume discovered on the server, else the fallback constant.
232
+ volume_only : bool
233
+ Build from that volume's own ``INDEX/INDEX.TAB`` (one release,
234
+ ~20 k rows) instead of the cumulative ``CUMINDEX.TAB`` (~3.8 GB).
235
+ memory_limit : str
236
+ DuckDB ``memory_limit``; sorts spill to a temp dir under the cache.
237
+ threads : int, optional
238
+ DuckDB worker threads (default: all cores).
239
+ wkt : bool
240
+ Also store a ``FP_WKT`` footprint polygon column.
241
+ on_stage : callable, optional
242
+ Called with ``"download"`` and ``"build"`` as those stages start.
243
+
244
+ Returns
245
+ -------
246
+ Path to the written Parquet file.
247
+ """
248
+ p = self.parquet_path
249
+ if p.exists() and not force:
250
+ log.info("Database already exists: %s (force=True rebuilds, update_db() appends new volumes)", p)
251
+ return p
252
+
253
+ p.parent.mkdir(parents=True, exist_ok=True)
254
+ clean_stale_build_files(p.parent)
255
+
256
+ if on_stage:
257
+ on_stage("download")
258
+ vol = _norm_volume(release) if release else self.latest_release()
259
+ self._release = vol
260
+ self._schema = self._meta = None
261
+ if volume_only:
262
+ lbl_url, tab_url = self.volume_index_urls(vol)
263
+ else:
264
+ lbl_url, tab_url = self.lbl_url, self.tab_url
265
+
266
+ lbl_text = self._read_lbl(lbl_url)
267
+ meta = _parse_lbl_meta(lbl_text)
268
+ columns = parse_lbl_columns(lbl_text)
269
+ tab_path = self._cache.ensure_cached(tab_url, expected_md5=meta.get("md5_checksum"))
270
+
271
+ if on_stage:
272
+ on_stage("build")
273
+ n = build_parquet(
274
+ [(tab_path, columns)],
275
+ p,
276
+ temp_dir=self._cache_dir / "duckdb_tmp",
277
+ memory_limit=memory_limit,
278
+ threads=threads,
279
+ wkt=wkt,
280
+ expected_rows=meta.get("file_records") or meta.get("rows"),
281
+ )
282
+ now = _utcnow()
283
+ self._write_sidecar(
284
+ {
285
+ "schema_version": SCHEMA_VERSION,
286
+ "dataset": self._dataset,
287
+ "data_set_id": self._ds["data_set_id"],
288
+ "source": "volume_index" if volume_only else "cumindex",
289
+ "release": vol,
290
+ "lbl_url": lbl_url,
291
+ "tab_url": tab_url,
292
+ "md5": meta.get("md5_checksum"),
293
+ "rows": n,
294
+ "wkt": wkt,
295
+ "volumes": {
296
+ vol: {
297
+ "kind": "volume_index" if volume_only else "cumindex",
298
+ "rows": n,
299
+ "md5": meta.get("md5_checksum"),
300
+ "added_at": now,
301
+ }
302
+ },
303
+ "built_at": now,
304
+ "updated_at": now,
305
+ }
306
+ )
307
+ return p
308
+
309
+ def update_db(
310
+ self,
311
+ volumes: Iterable[str] | None = None,
312
+ memory_limit: str = "4GB",
313
+ threads: int | None = None,
314
+ on_stage: Callable[[str], None] | None = None,
315
+ ) -> list[str]:
316
+ """Append per-volume ``INDEX.TAB`` files newer than the built release.
317
+
318
+ Parameters
319
+ ----------
320
+ volumes : iterable of str, optional
321
+ Explicit volumes to add. Default: every discovered volume newer
322
+ than the sidecar's ``release`` (or ``max(VOLUME_ID)`` in the
323
+ Parquet when no sidecar exists).
324
+ on_stage : callable, optional
325
+ As in :meth:`build_db`.
326
+
327
+ Returns
328
+ -------
329
+ The list of volumes that were appended (empty if already up to date).
330
+ Rows are de-duplicated on ``PRODUCT_ID`` (newest volume wins).
331
+ """
332
+ p = self._ensure_parquet()
333
+ if on_stage:
334
+ on_stage("download")
335
+ side = self.build_info() or {}
336
+ current = side.get("release") or self._max_volume_in_parquet()
337
+ if volumes is None:
338
+ if current is None:
339
+ raise RuntimeError("Cannot determine the built release; rebuild with --force")
340
+ found = self.discover_volumes()
341
+ ck = _volume_key(current)
342
+ new_vols = [v for v in found if _volume_key(v) > ck]
343
+ else:
344
+ new_vols = sorted({_norm_volume(v) for v in volumes}, key=_volume_key)
345
+
346
+ if not new_vols:
347
+ log.info("%s is up to date (release %s)", p.name, current)
348
+ return []
349
+
350
+ log.info("Appending %d volume(s) to %s: %s", len(new_vols), p.name, ", ".join(new_vols))
351
+ clean_stale_build_files(p.parent)
352
+ sources: list[tuple[Path, list[dict]]] = []
353
+ added: dict[str, dict] = {}
354
+ for vol in new_vols:
355
+ lbl_url, tab_url = self.volume_index_urls(vol)
356
+ lbl_text = self._read_lbl(lbl_url)
357
+ meta = _parse_lbl_meta(lbl_text)
358
+ tab = self._cache.ensure_cached(tab_url, expected_md5=meta.get("md5_checksum"))
359
+ sources.append((tab, parse_lbl_columns(lbl_text)))
360
+ added[vol] = {
361
+ "kind": "volume_index",
362
+ "rows": meta.get("file_records") or meta.get("rows"),
363
+ "md5": meta.get("md5_checksum"),
364
+ "added_at": _utcnow(),
365
+ }
366
+
367
+ if on_stage:
368
+ on_stage("build")
369
+ tmp = p.with_name(p.stem + ".update.parquet")
370
+ n = build_parquet(
371
+ sources,
372
+ tmp,
373
+ base_parquet=p,
374
+ temp_dir=self._cache_dir / "duckdb_tmp",
375
+ memory_limit=memory_limit,
376
+ threads=threads,
377
+ wkt=bool(side.get("wkt")),
378
+ )
379
+ tmp.replace(p)
380
+
381
+ vols = dict(side.get("volumes") or {})
382
+ vols.update(added)
383
+ side.update(
384
+ {
385
+ "schema_version": SCHEMA_VERSION,
386
+ "dataset": self._dataset,
387
+ "data_set_id": self._ds["data_set_id"],
388
+ "release": new_vols[-1],
389
+ "rows": n,
390
+ "volumes": vols,
391
+ "updated_at": _utcnow(),
392
+ }
393
+ )
394
+ side.setdefault("built_at", side["updated_at"])
395
+ self._write_sidecar(side)
396
+ self._release = new_vols[-1]
397
+ return new_vols
398
+
399
+ def _write_sidecar(self, info: dict) -> None:
400
+ self.meta_path.write_text(json.dumps(info, indent=2, sort_keys=True))
401
+
402
+ def _max_volume_in_parquet(self) -> str | None:
403
+ import duckdb
404
+
405
+ row = duckdb.sql(
406
+ f"SELECT max(VOLUME_ID) FROM read_parquet({_sql_str(self._ensure_parquet())})"
407
+ ).fetchone()
408
+ return row[0] if row and row[0] else None
409
+
410
+ # DuckDB query interface
411
+ def _connect(self):
412
+ import duckdb
413
+
414
+ con = duckdb.connect()
415
+ _register_macros(con)
416
+ con.execute(f"CREATE VIEW cumindex AS SELECT * FROM read_parquet({_sql_str(self._ensure_parquet())})")
417
+ return con
418
+
419
+ def query(self, sql: str) -> pd.DataFrame:
420
+ """Run arbitrary DuckDB SQL against the Parquet database.
421
+
422
+ Use the alias ``cumindex`` as the table name:
423
+
424
+ ci.query("SELECT PRODUCT_ID, EMISSION_ANGLE FROM cumindex LIMIT 10")
425
+ """
426
+ con = self._connect()
427
+ try:
428
+ return con.execute(sql).df()
429
+ finally:
430
+ con.close()
431
+
432
+ def lookup(
433
+ self,
434
+ product_ids: list[str],
435
+ columns: list[str] | None = None,
436
+ ) -> pd.DataFrame:
437
+ """Return metadata rows for the given product IDs.
438
+
439
+ Queries the Parquet via DuckDB — no full dataset loaded into RAM.
440
+
441
+ Parameters
442
+ ----------
443
+ product_ids : list[str]
444
+ e.g. ``["M1524360231LE", "M1512659523RE"]``
445
+ columns : list[str], optional
446
+ Subset of columns to return. ``PRODUCT_ID`` and
447
+ ``FILE_SPECIFICATION_NAME`` are always included.
448
+ """
449
+ ids_clean = [p.strip().upper() for p in product_ids]
450
+ if not ids_clean:
451
+ return self._enrich(self.query("SELECT * FROM cumindex LIMIT 0"))
452
+ ids_sql = ", ".join(_sql_str(i) for i in ids_clean)
453
+ col_sql = _col_select(columns)
454
+ sql = f"SELECT {col_sql} FROM cumindex WHERE PRODUCT_ID IN ({ids_sql})"
455
+ return self._enrich(self.query(sql))
456
+
457
+ def search(
458
+ self,
459
+ lat: tuple[float, float] | None = None,
460
+ lon: tuple[float, float] | None = None,
461
+ point: tuple[float, float] | None = None,
462
+ radius_km: float = 0.0,
463
+ emission_angle: tuple[float, float] | None = None,
464
+ incidence_angle: tuple[float, float] | None = None,
465
+ phase_angle: tuple[float, float] | None = None,
466
+ nac_only: bool = False,
467
+ columns: list[str] | None = None,
468
+ wkt: bool = False,
469
+ limit: int | None = None,
470
+ aoi=None,
471
+ ) -> pd.DataFrame:
472
+ """Return images whose *footprint* intersects a region.
473
+
474
+ Region: either a lat/lon box (``lat=(min, max)``, ``lon=(west, east)``;
475
+ ``west > east`` crosses the 0/360 seam, longitudes may be given in
476
+ −180..180 or 0..360, omitted ``lon`` = all longitudes), a circle
477
+ (``point=(lat, lon)``, ``radius_km``) or an area of interest
478
+ (``aoi=``: GeoJSON / WKT path, WKT string or
479
+ :class:`~lunar_toolkit.geometry.Aoi`).
480
+
481
+ Matching is bounding-box based and conservative (may include a few
482
+ false positives, never drops a true hit for convex-ish footprints):
483
+ the image's lat range and seam-aware longitude arc must overlap the
484
+ query's, and for images poleward of ±60° their polar stereographic
485
+ x/y box must overlap the query's projected box. Footprints that
486
+ enclose a pole are treated as spanning all longitudes. With ``aoi``
487
+ the bbox matches are then refined exactly: each corner footprint
488
+ polygon must intersect the AOI polygon (in polar stereographic when
489
+ the AOI lies poleward of ±60°), and ``limit`` applies after that.
490
+
491
+ Example
492
+ -------
493
+ ::
494
+
495
+ df = ci.search(lat=(-90, -85), nac_only=True, incidence_angle=(80, 95))
496
+ df = ci.search(lat=(-10, 10), lon=(355, 5)) # across 0°
497
+ df = ci.search(point=(-89.67, 129.8), radius_km=15) # Shackleton
498
+ df = ci.search(aoi="aoi.geojson") # QuickMap polygon
499
+ """
500
+ if aoi is not None:
501
+ if lat is not None or lon is not None or point is not None:
502
+ raise ValueError("Pass either aoi or lat/lon/point, not both")
503
+ aoi = load_aoi(aoi)
504
+ region = aoi_region(aoi)
505
+ else:
506
+ region = query_region(lat=lat, lon=lon, point=point, radius_km=radius_km)
507
+ conditions = [region_sql(region)]
508
+ conditions += _angle_conditions(emission_angle, incidence_angle, phase_angle)
509
+ if nac_only:
510
+ conditions.append(_NAC_ONLY_SQL)
511
+
512
+ cols = _col_select(columns, extra=FOOTPRINT_COLUMNS)
513
+ if wkt or aoi is not None:
514
+ cols += f", {footprint_wkt_sql(self._parquet_columns())} AS FP_WKT_QUERY"
515
+ sql = f"SELECT {cols} FROM cumindex WHERE " + " AND ".join(conditions)
516
+ if limit and aoi is None:
517
+ sql += f" LIMIT {int(limit)}"
518
+ df = self._enrich(self.query(sql))
519
+ if aoi is not None:
520
+ df = filter_aoi(df, aoi, wkt_column="FP_WKT_QUERY")
521
+ if limit:
522
+ df = df.head(int(limit))
523
+ if "FP_WKT_QUERY" in df.columns:
524
+ query_wkt = df.pop("FP_WKT_QUERY")
525
+ if wkt and "FP_WKT" not in df.columns:
526
+ df["FP_WKT"] = query_wkt
527
+ return df
528
+
529
+ def _parquet_columns(self) -> list[str]:
530
+ import duckdb
531
+
532
+ rel = duckdb.sql(f"SELECT * FROM read_parquet({_sql_str(self._ensure_parquet())}) LIMIT 0")
533
+ return list(rel.columns)
534
+
535
+ def get_images(self, product_ids: list[str]) -> NacImageCollection:
536
+ """Look up product IDs and return a NacImageCollection.
537
+
538
+ Single DuckDB query; each NacImage is pre-populated with its metadata
539
+ row so subsequent property accesses require zero additional DB calls.
540
+
541
+ Parameters
542
+ ----------
543
+ product_ids : list[str]
544
+ e.g. ``["M1524360231LE", "M1512659523RE"]``
545
+
546
+ Returns
547
+ -------
548
+ NacImageCollection
549
+ Contains only the IDs that were found in the index, in input order.
550
+ """
551
+ from .nac import NacImage, NacImageCollection # deferred — avoids circular import
552
+
553
+ df = self.lookup(product_ids)
554
+ row_map = {str(row["PRODUCT_ID"]): row for _, row in df.iterrows()}
555
+ ids = [pid.strip().upper() for pid in product_ids]
556
+ return NacImageCollection(
557
+ [NacImage(pid, cumindex=self, _row=row_map[pid]) for pid in ids if pid in row_map]
558
+ )
559
+
560
+ def load(self, nrows: int | None = None) -> pd.DataFrame:
561
+ """Load the full index into memory as a DataFrame.
562
+
563
+ For most workflows prefer ``lookup()`` or ``search()``.
564
+ Pass ``nrows`` for a quick preview that needs no Parquet: only the
565
+ label and the first ``nrows`` records of the TAB are fetched (HTTP
566
+ Range request), then parsed with the same DuckDB extraction as
567
+ ``build_db()``.
568
+ """
569
+ if nrows is not None:
570
+ import duckdb
571
+
572
+ lbl_text = self._read_lbl(self.lbl_url)
573
+ meta = _parse_lbl_meta(lbl_text)
574
+ columns = parse_lbl_columns(lbl_text)
575
+ row_bytes = meta.get("record_bytes") or meta.get("row_bytes") or 903
576
+ full = self._cache.cache_path(self.tab_url)
577
+ if full.exists():
578
+ tab = full
579
+ else:
580
+ tab = full.with_name(full.stem + f".head{int(nrows)}" + full.suffix)
581
+ if not tab.exists():
582
+ _fetch_head(self.tab_url, tab, int(nrows) * int(row_bytes))
583
+ con = duckdb.connect()
584
+ try:
585
+ _register_macros(con)
586
+ sql = _select_sql([(tab, columns)], wkt=False)
587
+ return con.execute(f"{sql} LIMIT {int(nrows)}").df()
588
+ finally:
589
+ con.close()
590
+
591
+ import pyarrow.parquet as pq
592
+
593
+ return pq.read_table(self._ensure_parquet()).to_pandas()
594
+
595
+ # Enrichment
596
+ def _enrich(self, df: pd.DataFrame) -> pd.DataFrame:
597
+ """Add PDS_URL and derived convenience columns to a query result."""
598
+ import pandas as pd
599
+
600
+ if df.empty:
601
+ return df
602
+
603
+ df = df.copy()
604
+
605
+ if "FILE_SPECIFICATION_NAME" in df.columns:
606
+ df["PDS_URL"] = df["FILE_SPECIFICATION_NAME"].map(
607
+ lambda p: pds_url_from_file_spec(p) if isinstance(p, str) and p else None
608
+ )
609
+ df["LABEL_URL"] = df["PDS_URL"].map(
610
+ lambda u: re.sub(r"\.[A-Za-z0-9]+$", ".xml", u) if u else None
611
+ )
612
+ if "CENTER_LATITUDE" in df.columns:
613
+ df["LAT"] = pd.to_numeric(df["CENTER_LATITUDE"], errors="coerce")
614
+ if "CENTER_LONGITUDE" in df.columns:
615
+ df["LON"] = pd.to_numeric(df["CENTER_LONGITUDE"], errors="coerce")
616
+ if "START_TIME" in df.columns:
617
+ df["DATE"] = pd.to_datetime(df["START_TIME"], errors="coerce").dt.date
618
+ if "PRODUCT_ID" in df.columns:
619
+ pid = df["PRODUCT_ID"].astype(str)
620
+ nac = [parse_nac_id(p) if is_nac_id(p) else None for p in pid]
621
+ df["IS_NAC"] = [t is not None for t in nac]
622
+ df["IS_WAC"] = pid.str.contains(r"[CMUV][EC]$") & ~df["IS_NAC"]
623
+ df["NAC_SIDE"] = [t[1] if t else None for t in nac]
624
+ df["PRODUCT_TYPE"] = pid.str[-1:].map(NAC_PRODUCT_TYPES).where(df["IS_NAC"] | df["IS_WAC"])
625
+
626
+ return df.reset_index(drop=True)
627
+
628
+ def to_observations(self, df: pd.DataFrame) -> pd.DataFrame:
629
+ """Convert query results to the common multi-instrument schema."""
630
+ from ..observation import observations_from_cumindex
631
+
632
+ return observations_from_cumindex(df, dataset=self._dataset)
633
+
634
+
635
+ # Parquet builder (DuckDB-native)
636
+ def build_parquet(
637
+ sources: list[tuple[Path, list[dict]]],
638
+ parquet_path: Path,
639
+ base_parquet: Path | None = None,
640
+ temp_dir: Path | None = None,
641
+ memory_limit: str = "4GB",
642
+ threads: int | None = None,
643
+ wkt: bool = False,
644
+ expected_rows: int | None = None,
645
+ ) -> int:
646
+ """Build a sorted, ZSTD-compressed Parquet from fixed-width PDS TAB file(s).
647
+
648
+ Parameters
649
+ ----------
650
+ sources : list of ``(tab_path, lbl_columns)``
651
+ Each TAB is parsed with its own label layout (record lengths differ
652
+ between volumes) and the results are combined ``UNION ALL BY NAME``.
653
+ base_parquet : Path, optional
654
+ Existing database to merge with (incremental update). Rows are then
655
+ de-duplicated on ``PRODUCT_ID``, keeping the newest ``VOLUME_ID``.
656
+
657
+ Returns
658
+ -------
659
+ int
660
+ Number of rows written.
661
+
662
+ Notes
663
+ -----
664
+ Everything happens in one ``COPY (...) TO`` statement; DuckDB spills
665
+ the sort to *temp_dir*.
666
+ """
667
+ import duckdb
668
+
669
+ parquet_path = Path(parquet_path)
670
+ parquet_path.parent.mkdir(parents=True, exist_ok=True)
671
+ tmp_out = parquet_path.with_name(parquet_path.name + ".tmp")
672
+
673
+ con = duckdb.connect()
674
+ try:
675
+ if temp_dir is not None:
676
+ Path(temp_dir).mkdir(parents=True, exist_ok=True)
677
+ con.execute(f"SET temp_directory={_sql_str(temp_dir)}")
678
+ con.execute(f"SET memory_limit={_sql_str(memory_limit)}")
679
+ con.execute("SET preserve_insertion_order=false")
680
+ if threads:
681
+ con.execute(f"SET threads={int(threads)}")
682
+ _register_macros(con)
683
+
684
+ select = _select_sql(sources, wkt=wkt, base_parquet=base_parquet)
685
+ names = ", ".join(Path(t).name for t, _ in sources)
686
+ base = f" + {Path(base_parquet).name}" if base_parquet else ""
687
+ log.info("Building %s from %s%s", parquet_path.name, names, base)
688
+ if expected_rows:
689
+ log.info("Expected rows: %s", f"{expected_rows:,}")
690
+
691
+ con.execute(
692
+ f"COPY ({select} ORDER BY CENTER_LATITUDE NULLS LAST, PRODUCT_ID) "
693
+ f"TO {_sql_str(tmp_out)} (FORMAT PARQUET, COMPRESSION ZSTD)"
694
+ )
695
+ row = con.execute(f"SELECT count(*) FROM read_parquet({_sql_str(tmp_out)})").fetchone()
696
+ n = row[0] if row else 0
697
+ except BaseException:
698
+ if tmp_out.exists():
699
+ tmp_out.unlink()
700
+ raise
701
+ finally:
702
+ con.close()
703
+
704
+ tmp_out.replace(parquet_path)
705
+ size_mb = parquet_path.stat().st_size / 1e6
706
+ log.info("Done — %s rows → %s (%.1f MB)", f"{n:,}", parquet_path.name, size_mb)
707
+ if expected_rows and n != expected_rows and base_parquet is None:
708
+ log.warning("Expected %s rows, got %s", f"{expected_rows:,}", f"{n:,}")
709
+ return int(n)
710
+
711
+
712
+ def _tab_source_sql(tab_path: Path) -> str:
713
+ """DuckDB table function reading a fixed-width TAB as one VARCHAR column."""
714
+ return (
715
+ f"read_csv({_sql_str(tab_path)}, columns={{'line': 'VARCHAR'}}, "
716
+ f"delim=chr(31), quote='', escape='', header=false, auto_detect=false, "
717
+ f"encoding='latin-1')"
718
+ )
719
+
720
+
721
+ def _select_sql(
722
+ sources: list[tuple[Path, list[dict]]],
723
+ wkt: bool = False,
724
+ base_parquet: Path | None = None,
725
+ ) -> str:
726
+ """Full SELECT (extraction → union → dedup → footprint columns)."""
727
+ parts = []
728
+ all_names: list[str] = []
729
+ for tab, cols in sources:
730
+ exprs = ",\n ".join(f"{column_sql(c)} AS {_ident(c['name'])}" for c in cols)
731
+ parts.append(f"SELECT {exprs}\nFROM {_tab_source_sql(tab)}\nWHERE length(trim(line)) > 0")
732
+ all_names += [c["name"] for c in cols if c["name"] not in all_names]
733
+ raw = "\nUNION ALL BY NAME\n".join(f"({p})" for p in parts)
734
+ if base_parquet is not None:
735
+ # Incremental update: new rows win. An anti-join against the (small)
736
+ # set of new product IDs is far cheaper than a window over all rows.
737
+ raw = (
738
+ f"WITH new_rows AS (SELECT * FROM ({raw}) QUALIFY row_number() OVER "
739
+ f"(PARTITION BY PRODUCT_ID ORDER BY VOLUME_ID DESC NULLS LAST) = 1)\n"
740
+ f"SELECT * FROM (SELECT COLUMNS(c -> NOT starts_with(c, 'FP_')) "
741
+ f"FROM read_parquet({_sql_str(base_parquet)}) "
742
+ f"WHERE PRODUCT_ID NOT IN (SELECT PRODUCT_ID FROM new_rows)) "
743
+ f"UNION ALL BY NAME SELECT * FROM new_rows"
744
+ )
745
+ fp = footprint_sql(all_names, wkt=wkt)
746
+ return f"SELECT *{', ' + fp if fp else ''} FROM ({raw}) AS t"
747
+
748
+
749
+ def column_sql(col: dict) -> str:
750
+ """SQL expression extracting one LBL column from the ``line`` VARCHAR.
751
+
752
+ Bytes → ``substr(line, START_BYTE, BYTES)``, trimmed of blanks and
753
+ quotes, typed by ``DATA_TYPE`` (falling back to name hints) with
754
+ ``TRY_CAST``; ``NOT_APPLICABLE_CONSTANT`` / ``MISSING_CONSTANT`` → NULL.
755
+ """
756
+ start, nbytes = int(col["start_byte"]), int(col["bytes"])
757
+ raw = f"nullif(trim(substr(line, {start}, {nbytes}), ' \"'), '')"
758
+ kind = _column_kind(col)
759
+ sentinels = [
760
+ str(col[k]).strip().strip('"')
761
+ for k in ("not_applicable_constant", "missing_constant", "unknown_constant")
762
+ if col.get(k) not in (None, "")
763
+ ]
764
+
765
+ if kind in ("BIGINT", "DOUBLE"):
766
+ expr = f"TRY_CAST({raw} AS {kind})"
767
+ for s in sentinels:
768
+ try:
769
+ v = float(s)
770
+ except ValueError:
771
+ continue
772
+ lit = str(int(v)) if kind == "BIGINT" and v.is_integer() else repr(v)
773
+ expr = f"nullif({expr}, {lit})"
774
+ return expr
775
+ if kind == "TIMESTAMP":
776
+ return f"TRY_CAST({raw} AS TIMESTAMP)"
777
+
778
+ expr = raw
779
+ for s in sentinels:
780
+ expr = f"nullif({expr}, {_sql_str(s)})"
781
+ if col["name"].upper() == "PRODUCT_ID":
782
+ expr = f"upper({expr})"
783
+ return expr
784
+
785
+
786
+ _NUMERIC_HINTS = [
787
+ # Schema columns
788
+ "COLUMN_NUMBER",
789
+ "START_BYTE",
790
+ "BYTES",
791
+ # Image dimensions
792
+ "LINES",
793
+ "LINE_SAMPLES",
794
+ "SAMPLE_BITS",
795
+ # Angles / azimuths
796
+ "_ANGLE",
797
+ "_AZIMUTH",
798
+ "SOLAR_LONGITUDE",
799
+ # Spatial coordinates (all corners + center + subsolar + subspacecraft)
800
+ "_LATITUDE",
801
+ "_LONGITUDE",
802
+ # Distance / altitude
803
+ "ALTITUDE",
804
+ "DISTANCE",
805
+ # Resolution
806
+ "PIXEL_SCALE",
807
+ "SCALED_PIXEL",
808
+ "RESOLUTION",
809
+ # Timing
810
+ "EXPOSURE_DURATION",
811
+ # NAC instrument / temperatures
812
+ "TEMPERATURE",
813
+ "DAC_RESET",
814
+ "CHANNEL_A_OFFSET",
815
+ "CHANNEL_B_OFFSET",
816
+ "MODE_CODE",
817
+ "SELECT_CODE",
818
+ # Orbit
819
+ "ORBIT_NUMBER",
820
+ ]
821
+
822
+ _TIME_COLUMNS = {"START_TIME", "STOP_TIME", "NAC_PREROLL_START_TIME", "PRODUCT_CREATION_TIME"}
823
+
824
+
825
+ def _column_kind(col: dict) -> str:
826
+ """DuckDB type for an LBL column: LBL DATA_TYPE first, then name hints."""
827
+ name = col["name"].upper()
828
+ dtype = str(col.get("data_type") or "").upper()
829
+ if dtype in (
830
+ "ASCII_INTEGER",
831
+ "INTEGER",
832
+ "MSB_INTEGER",
833
+ "LSB_INTEGER",
834
+ "UNSIGNED_INTEGER",
835
+ "ASCII_UNSIGNED_INTEGER",
836
+ ):
837
+ return "BIGINT"
838
+ if dtype in ("ASCII_REAL", "REAL", "FLOAT", "IEEE_REAL", "PC_REAL"):
839
+ return "DOUBLE"
840
+ if dtype in ("TIME", "DATE", "ASCII_TIME", "ASCII_DATE_TIME") or name in _TIME_COLUMNS:
841
+ return "TIMESTAMP"
842
+ if dtype in ("", "UNKNOWN") and any(h in name for h in _NUMERIC_HINTS):
843
+ return "DOUBLE"
844
+ return "VARCHAR"
845
+
846
+
847
+ # Footprints
848
+ _MACROS = [
849
+ # longitude → [0, 360)
850
+ "CREATE OR REPLACE MACRO _lon360(x) AS ((x % 360.0) + 360.0) % 360.0",
851
+ # signed shortest longitude step a → b in [-180, 180)
852
+ "CREATE OR REPLACE MACRO _dlon(a, b) AS ((_lon360(b) - _lon360(a) + 540.0) % 360.0) - 180.0",
853
+ # polar stereographic radius (m) for hemisphere 'S' / 'N', true scale at the pole
854
+ f"CREATE OR REPLACE MACRO _ps_rho(lat, hemi) AS 2.0 * {MOON_RADIUS_KM * 1000.0!r} * "
855
+ "tan(pi() / 4.0 + (CASE WHEN hemi = 'S' THEN 1.0 ELSE -1.0 END) * radians(lat) / 2.0)",
856
+ "CREATE OR REPLACE MACRO _ps_x(lat, lon, hemi) AS _ps_rho(lat, hemi) * sin(radians(lon))",
857
+ "CREATE OR REPLACE MACRO _ps_y(lat, lon, hemi) AS "
858
+ "(CASE WHEN hemi = 'S' THEN 1.0 ELSE -1.0 END) * _ps_rho(lat, hemi) * cos(radians(lon))",
859
+ # does arc [w1, w1+s1] overlap arc [w2, w2+s2]? (degrees, s in [0, 360])
860
+ "CREATE OR REPLACE MACRO _arc_overlap(w1, s1, w2, s2) AS "
861
+ "(s1 >= 360 OR s2 >= 360 OR _lon360(w2 - w1) <= s1 OR _lon360(w1 - w2) <= s2)",
862
+ ]
863
+
864
+
865
+ def _register_macros(con) -> None:
866
+ for m in _MACROS:
867
+ con.execute(m)
868
+
869
+
870
+ def _corner_pairs(names: Iterable[str]) -> list[tuple[str, str]] | None:
871
+ have = set(names)
872
+ pairs = [(f"{c}_LATITUDE", f"{c}_LONGITUDE") for c in FOOTPRINT_CORNERS]
873
+ if all(a in have and b in have for a, b in pairs):
874
+ return pairs
875
+ return None
876
+
877
+
878
+ def footprint_sql(names: Iterable[str], wkt: bool = False) -> str:
879
+ """Derived ``FP_*`` column expressions over an extracted-row relation.
880
+
881
+ * ``FP_MIN_LAT`` / ``FP_MAX_LAT`` — corner (and centre) latitude range,
882
+ extended to ±90 when the footprint encloses a pole.
883
+ * ``FP_LON_WEST`` / ``FP_LON_EAST`` / ``FP_LON_SPAN`` — the shortest
884
+ longitude arc covering all corners, in [0, 360); ``WEST > EAST`` means
885
+ the arc crosses the 0/360 seam; ``SPAN = 360`` means all longitudes.
886
+ * ``FP_CONTAINS_POLE`` — winding number of the corner ring ≠ 0.
887
+ * ``FP_PS_*`` — polar stereographic bbox (m, R = 1737.4 km) for images
888
+ whose centre is poleward of ±60° (``FP_PS_HEMI`` = ``'S'`` / ``'N'``).
889
+ """
890
+ names = list(names)
891
+ if "CENTER_LATITUDE" not in names or "CENTER_LONGITUDE" not in names:
892
+ return ""
893
+ clat, clon = "CENTER_LATITUDE", "CENTER_LONGITUDE"
894
+ pairs = _corner_pairs(names)
895
+ if pairs:
896
+ lats = [f"coalesce({a}, {clat})" for a, _ in pairs]
897
+ lons = [f"coalesce({b}, {clon})" for _, b in pairs]
898
+ else:
899
+ lats, lons = [clat], [clon]
900
+
901
+ l360 = "[" + ", ".join(f"_lon360({x})" for x in lons) + "]"
902
+ l180 = "[" + ", ".join(f"(_lon360({x}) + 180.0) % 360.0 - 180.0" for x in lons) + "]"
903
+ span0 = f"(list_max({l360}) - list_min({l360}))"
904
+ span180 = f"(list_max({l180}) - list_min({l180}))"
905
+ if pairs:
906
+ ring = lons + [lons[0]]
907
+ winding = " + ".join(f"_dlon({ring[i]}, {ring[i + 1]})" for i in range(len(lons)))
908
+ pole = f"(abs({winding}) > 180.0)"
909
+ else:
910
+ pole = "false"
911
+ west = (
912
+ f"CASE WHEN {pole} THEN 0.0 WHEN {span0} <= {span180} THEN list_min({l360}) "
913
+ f"ELSE _lon360(list_min({l180})) END"
914
+ )
915
+ span = f"CASE WHEN {pole} THEN 360.0 WHEN {span0} <= {span180} THEN {span0} ELSE {span180} END"
916
+ lat_list = "[" + ", ".join(lats + [clat]) + "]"
917
+
918
+ hemi = (
919
+ f"CASE WHEN {clat} < -{POLAR_LAT_THRESHOLD} THEN 'S' WHEN {clat} > {POLAR_LAT_THRESHOLD} THEN 'N' END"
920
+ )
921
+ xs = "[" + ", ".join(f"_ps_x({a}, {b}, FP_PS_HEMI)" for a, b in zip(lats, lons)) + "]"
922
+ ys = "[" + ", ".join(f"_ps_y({a}, {b}, FP_PS_HEMI)" for a, b in zip(lats, lons)) + "]"
923
+
924
+ # Two-level: the inner block defines pole/hemi aliases, reused below via
925
+ # DuckDB's lateral column aliases.
926
+ exprs = [
927
+ f"{pole} AS FP_CONTAINS_POLE",
928
+ f"CASE WHEN FP_CONTAINS_POLE AND {clat} < 0 THEN -90.0 ELSE list_min({lat_list}) END AS FP_MIN_LAT",
929
+ f"CASE WHEN FP_CONTAINS_POLE AND {clat} >= 0 THEN 90.0 ELSE list_max({lat_list}) END AS FP_MAX_LAT",
930
+ f"{west} AS FP_LON_WEST",
931
+ f"{span} AS FP_LON_SPAN",
932
+ "CASE WHEN FP_LON_SPAN >= 360 THEN 360.0 ELSE _lon360(FP_LON_WEST + FP_LON_SPAN) END AS FP_LON_EAST",
933
+ f"{hemi} AS FP_PS_HEMI",
934
+ f"CASE WHEN FP_PS_HEMI IS NOT NULL THEN list_min({xs}) END AS FP_PS_XMIN",
935
+ f"CASE WHEN FP_PS_HEMI IS NOT NULL THEN list_max({xs}) END AS FP_PS_XMAX",
936
+ f"CASE WHEN FP_PS_HEMI IS NOT NULL THEN list_min({ys}) END AS FP_PS_YMIN",
937
+ f"CASE WHEN FP_PS_HEMI IS NOT NULL THEN list_max({ys}) END AS FP_PS_YMAX",
938
+ ]
939
+ if wkt:
940
+ exprs.append(f"{footprint_wkt_sql(names, west_expr='FP_LON_WEST')} AS FP_WKT")
941
+ return ",\n ".join(exprs)
942
+
943
+
944
+ def footprint_wkt_sql(names: Iterable[str], west_expr: str = "FP_LON_WEST") -> str:
945
+ """SQL for a ``POLYGON((lon lat, ...))`` WKT of the corner ring.
946
+
947
+ Longitudes are unwrapped relative to the footprint's western edge so the
948
+ ring never jumps at the seam (values may exceed 360). For pole-enclosing
949
+ footprints the lat/lon ring is only indicative — use the ``FP_PS_*`` box.
950
+ """
951
+ pairs = _corner_pairs(names)
952
+ if not pairs:
953
+ return "NULL"
954
+ pts = []
955
+ for a, b in pairs + [pairs[0]]:
956
+ lon = f"round({west_expr} + _lon360(round({b} - {west_expr}, 6)), 4)"
957
+ pts.append(f"CAST({lon} AS VARCHAR) || ' ' || CAST(round({a}, 4) AS VARCHAR)")
958
+ return "'POLYGON((' || " + " || ', ' || ".join(pts) + " || '))'"
959
+
960
+
961
+ # Search regions
962
+ def region_sql(region: dict) -> str:
963
+ """WHERE clause matching ``FP_*`` columns against a :func:`query_region`."""
964
+ s, n = region["lat"]
965
+ conds = [f"FP_MAX_LAT >= {s!r}", f"FP_MIN_LAT <= {n!r}"]
966
+ if region["span"] < 360.0:
967
+ conds.append(f"_arc_overlap(FP_LON_WEST, FP_LON_SPAN, {region['west']!r}, {region['span']!r})")
968
+ for hemi, (x0, x1, y0, y1) in region["ps"].items():
969
+ conds.append(
970
+ f"(FP_PS_HEMI IS DISTINCT FROM '{hemi}' OR (FP_PS_XMAX >= {x0!r} AND FP_PS_XMIN <= {x1!r} "
971
+ f"AND FP_PS_YMAX >= {y0!r} AND FP_PS_YMIN <= {y1!r}))"
972
+ )
973
+ return "(" + " AND ".join(conds) + ")"
974
+
975
+
976
+ # Volume discovery / housekeeping
977
+ _VOLUME_RE = re.compile(r"^LROLRC_(\d{4})([A-Z]?)$")
978
+
979
+
980
+ def _norm_volume(v: str) -> str:
981
+ return str(v).strip().strip("/").upper()
982
+
983
+
984
+ def _volume_key(v: str) -> tuple[int, str]:
985
+ m = _VOLUME_RE.match(_norm_volume(v))
986
+ if not m:
987
+ return (-1, _norm_volume(v))
988
+ return (int(m.group(1)), m.group(2))
989
+
990
+
991
+ def parse_s3_listing(xml_text: str) -> tuple[list[str], str | None]:
992
+ """Return ``(common_prefixes, next_continuation_token)`` from ListObjectsV2 XML."""
993
+ root = ET.fromstring(xml_text)
994
+ ns = ""
995
+ if root.tag.startswith("{"):
996
+ ns = root.tag.split("}")[0] + "}"
997
+ prefixes = [el.text for el in root.iter(f"{ns}Prefix") if el.text and el is not root.find(f"{ns}Prefix")]
998
+ token = None
999
+ if (root.findtext(f"{ns}IsTruncated") or "").lower() == "true":
1000
+ token = root.findtext(f"{ns}NextContinuationToken")
1001
+ return prefixes, token
1002
+
1003
+
1004
+ def list_store_volumes(base_url: str, volume_re: str, timeout: float = 30.0) -> list[str]:
1005
+ """List volume directories under a dataset on the PDS S3 store."""
1006
+ if not base_url.startswith(PDS_STORE_URL):
1007
+ raise ValueError(f"Not a PDS store URL: {base_url}")
1008
+ prefix = base_url[len(PDS_STORE_URL) :]
1009
+ pat = re.compile(rf"^{re.escape(prefix)}({volume_re})/$")
1010
+ vols: list[str] = []
1011
+ token: str | None = None
1012
+ while True:
1013
+ q = {"list-type": "2", "prefix": prefix, "delimiter": "/"}
1014
+ if token:
1015
+ q["continuation-token"] = token
1016
+ url = PDS_STORE_URL + "?" + urllib.parse.urlencode(q)
1017
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
1018
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
1019
+ prefixes, token = parse_s3_listing(resp.read().decode("utf-8", "replace"))
1020
+ vols += [m.group(1) for p in prefixes if (m := pat.match(p))]
1021
+ if not token:
1022
+ break
1023
+ return sorted(set(vols), key=_volume_key)
1024
+
1025
+
1026
+ def probe_volumes(
1027
+ base_url: str,
1028
+ start: str,
1029
+ max_numbers: int = 12,
1030
+ timeout: float = 15.0,
1031
+ ) -> list[str]:
1032
+ """Probe ``<vol>/INDEX/INDEX.LBL`` for volumes at/after *start*.
1033
+
1034
+ Volume names look like ``LROLRC_0066C`` (number + optional A/B/C… part
1035
+ letter). Stops after two consecutive numbers without any hit.
1036
+ """
1037
+ num, _ = _volume_key(start)
1038
+ m = re.match(r"^(LROLRC_)(\d{4})", _norm_volume(start))
1039
+ if not m or num < 0:
1040
+ return [_norm_volume(start)]
1041
+ prefix = m.group(1)
1042
+ found = [_norm_volume(start)]
1043
+ misses = 0
1044
+ for n in range(num, num + max_numbers):
1045
+ hit = False
1046
+ for suffix in ("", "A", "B", "C", "D", "E"):
1047
+ vol = f"{prefix}{n:04d}{suffix}"
1048
+ if _volume_key(vol) <= _volume_key(start):
1049
+ continue
1050
+ if _url_exists(base_url + vol + "/" + INDEX_DIR + VOLINDEX_LBL_FILENAME, timeout):
1051
+ found.append(vol)
1052
+ hit = True
1053
+ if n > num:
1054
+ misses = 0 if hit else misses + 1
1055
+ if misses >= 2:
1056
+ break
1057
+ return sorted(set(found), key=_volume_key)
1058
+
1059
+
1060
+ def _url_exists(url: str, timeout: float = 15.0) -> bool:
1061
+ req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": _USER_AGENT})
1062
+ try:
1063
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
1064
+ return 200 <= getattr(resp, "status", 200) < 300
1065
+ except urllib.error.HTTPError:
1066
+ return False
1067
+
1068
+
1069
+ def _fetch_head(url: str, dest: Path, nbytes: int, timeout: float = 60.0) -> Path:
1070
+ """Fetch the first *nbytes* of *url* (HTTP Range) into *dest*."""
1071
+ dest.parent.mkdir(parents=True, exist_ok=True)
1072
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT, "Range": f"bytes=0-{nbytes - 1}"})
1073
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
1074
+ data = resp.read(nbytes)
1075
+ tmp = dest.with_suffix(dest.suffix + ".part")
1076
+ tmp.write_bytes(data)
1077
+ tmp.replace(dest)
1078
+ return dest
1079
+
1080
+
1081
+ def clean_stale_build_files(directory: Path) -> list[Path]:
1082
+ """Delete leftovers of interrupted builds (``*.build.duckdb[.wal]``, ``*.tmp``)."""
1083
+ removed: list[Path] = []
1084
+ directory = Path(directory)
1085
+ if not directory.is_dir():
1086
+ return removed
1087
+ for pattern in ("*.build.duckdb", "*.build.duckdb.wal", "*.parquet.tmp", "*.update.parquet"):
1088
+ for f in directory.glob(pattern):
1089
+ try:
1090
+ f.unlink()
1091
+ removed.append(f)
1092
+ log.info("Removed stale build file %s", f)
1093
+ except OSError:
1094
+ pass
1095
+ return removed
1096
+
1097
+
1098
+ # LBL parsing
1099
+ def parse_lbl_columns(lbl_content: str) -> list[dict]:
1100
+ """Parse ``OBJECT = COLUMN`` blocks of a PDS3 label (pure Python).
1101
+
1102
+ Keys are lower-cased LBL keywords; ``column_number``, ``start_byte`` and
1103
+ ``bytes`` are ints. Sorted by ``column_number``.
1104
+ """
1105
+ blocks = re.findall(
1106
+ r"OBJECT\s*=\s*COLUMN(.*?)END_OBJECT\s*=\s*COLUMN",
1107
+ lbl_content,
1108
+ re.DOTALL,
1109
+ )
1110
+
1111
+ def _parse_value(val: str) -> str:
1112
+ val = val.strip().strip('"')
1113
+ return re.sub(r"\s*\n\s*", " ", val).strip()
1114
+
1115
+ pattern = re.compile(r"([A-Z_]+)\s*=\s*(.*?)(?=\n\s*[A-Z_]+\s*=|\Z)", re.DOTALL)
1116
+ records = []
1117
+ for block in blocks:
1118
+ rec: dict[str, Any] = {
1119
+ m.group(1).strip().lower(): _parse_value(m.group(2)) for m in pattern.finditer(block)
1120
+ }
1121
+ for k in ("column_number", "start_byte", "bytes"):
1122
+ if k in rec:
1123
+ try:
1124
+ rec[k] = int(rec[k])
1125
+ except ValueError:
1126
+ rec[k] = None
1127
+ if rec.get("name") and rec.get("start_byte") and rec.get("bytes"):
1128
+ records.append(rec)
1129
+ return sorted(records, key=lambda r: r.get("column_number") or 0)
1130
+
1131
+
1132
+ def _parse_pds_lbl_columns(lbl_content: str) -> pd.DataFrame:
1133
+ """``parse_lbl_columns`` as a DataFrame (the ``CumIndex.schema`` format)."""
1134
+ import pandas as pd
1135
+
1136
+ df = pd.DataFrame(parse_lbl_columns(lbl_content))
1137
+ for col in ("column_number", "start_byte", "bytes"):
1138
+ if col in df.columns:
1139
+ df[col] = pd.to_numeric(df[col], errors="coerce").astype("Int64")
1140
+ preferred = ["name", "column_number", "data_type", "start_byte", "bytes", "format", "description"]
1141
+ ordered = [c for c in preferred if c in df.columns]
1142
+ extras = [c for c in df.columns if c not in preferred]
1143
+ return df[ordered + extras].reset_index(drop=True)
1144
+
1145
+
1146
+ def _parse_lbl_meta(lbl_content: str) -> dict:
1147
+ """Top-level label fields (ints for sizes, strings for ids / checksum)."""
1148
+ meta: dict = {}
1149
+ for key in ("RECORD_BYTES", "FILE_RECORDS", "ROWS", "COLUMNS", "ROW_BYTES"):
1150
+ m = re.search(rf"^\s*{key}\s*=\s*(\d+)", lbl_content, re.MULTILINE)
1151
+ if m:
1152
+ meta[key.lower()] = int(m.group(1))
1153
+ for key in ("MD5_CHECKSUM", "VOLUME_ID", "DATA_SET_ID", "PRODUCT_CREATION_TIME"):
1154
+ m = re.search(rf'^\s*{key}\s*=\s*"?([^"\r\n]+?)"?\s*$', lbl_content, re.MULTILINE)
1155
+ if m:
1156
+ meta[key.lower()] = m.group(1).strip()
1157
+ return meta
1158
+
1159
+
1160
+ # Private helpers
1161
+ _NAC_ONLY_SQL = "(" + " OR ".join(f"PRODUCT_ID LIKE '%{s}'" for s in NAC_SUFFIXES) + ")"
1162
+
1163
+
1164
+ def _angle_conditions(
1165
+ emission_angle: tuple[float, float] | None,
1166
+ incidence_angle: tuple[float, float] | None,
1167
+ phase_angle: tuple[float, float] | None,
1168
+ ) -> list[str]:
1169
+ out = []
1170
+ for col, rng in (
1171
+ ("EMISSION_ANGLE", emission_angle),
1172
+ ("INCIDENCE_ANGLE", incidence_angle),
1173
+ ("PHASE_ANGLE", phase_angle),
1174
+ ):
1175
+ if rng is not None:
1176
+ out.append(f"{col} BETWEEN {float(rng[0])} AND {float(rng[1])}")
1177
+ return out
1178
+
1179
+
1180
+ def _col_select(columns: list[str] | None, extra: list[str] | None = None) -> str:
1181
+ """Build a SELECT column list, always including the key identity columns."""
1182
+ if not columns:
1183
+ return "*"
1184
+ always = {"PRODUCT_ID", "FILE_SPECIFICATION_NAME", "CENTER_LATITUDE", "CENTER_LONGITUDE", "START_TIME"}
1185
+ return ", ".join(_ident(c) for c in sorted(always | set(columns) | set(extra or [])))
1186
+
1187
+
1188
+ def _ident(name: str) -> str:
1189
+ return '"' + str(name).replace('"', '""') + '"'
1190
+
1191
+
1192
+ def _sql_str(value) -> str:
1193
+ return "'" + str(value).replace("'", "''") + "'"
1194
+
1195
+
1196
+ def _utcnow() -> str:
1197
+ return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat()