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.
- lunar_toolkit/__init__.py +6 -0
- lunar_toolkit/asp.py +432 -0
- lunar_toolkit/cli/__init__.py +88 -0
- lunar_toolkit/cli/_jobs.py +437 -0
- lunar_toolkit/cli/_status.py +368 -0
- lunar_toolkit/cli/_utils.py +128 -0
- lunar_toolkit/cli/app.py +205 -0
- lunar_toolkit/cli/build_db.py +130 -0
- lunar_toolkit/cli/download.py +111 -0
- lunar_toolkit/cli/kaguya.py +253 -0
- lunar_toolkit/cli/ode_search.py +144 -0
- lunar_toolkit/cli/pairs.py +236 -0
- lunar_toolkit/cli/process.py +138 -0
- lunar_toolkit/cli/query.py +70 -0
- lunar_toolkit/cli/reference.py +233 -0
- lunar_toolkit/cli/search.py +90 -0
- lunar_toolkit/cli/serve.py +706 -0
- lunar_toolkit/cli/sfs.py +344 -0
- lunar_toolkit/cli/sfs_report.py +148 -0
- lunar_toolkit/cli/shadowcam.py +246 -0
- lunar_toolkit/cli/stereo.py +85 -0
- lunar_toolkit/cli/workspace.py +84 -0
- lunar_toolkit/config.py +345 -0
- lunar_toolkit/constants.py +217 -0
- lunar_toolkit/csm.py +340 -0
- lunar_toolkit/downloader.py +447 -0
- lunar_toolkit/geometry.py +556 -0
- lunar_toolkit/isis.py +222 -0
- lunar_toolkit/kaguya/__init__.py +45 -0
- lunar_toolkit/kaguya/mi.py +274 -0
- lunar_toolkit/kaguya/tc.py +370 -0
- lunar_toolkit/lroc/__init__.py +6 -0
- lunar_toolkit/lroc/cumindex.py +1197 -0
- lunar_toolkit/lroc/nac.py +702 -0
- lunar_toolkit/observation.py +127 -0
- lunar_toolkit/ode.py +485 -0
- lunar_toolkit/provenance.py +312 -0
- lunar_toolkit/reference.py +1111 -0
- lunar_toolkit/selection.py +714 -0
- lunar_toolkit/sfs.py +1534 -0
- lunar_toolkit/sfs_products.py +676 -0
- lunar_toolkit/sfs_report.py +1328 -0
- lunar_toolkit/shadowcam/__init__.py +29 -0
- lunar_toolkit/shadowcam/sc.py +392 -0
- lunar_toolkit/static/index.html +3259 -0
- lunar_toolkit/stereo.py +519 -0
- lunar_toolkit/stereo_dem.py +145 -0
- lunar_toolkit/tools.py +264 -0
- lunar_toolkit/workspace.py +1173 -0
- lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
- lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
- lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
- lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
- lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
- 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()
|