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,29 @@
1
+ """KPLO ShadowCam download, SPICE and CSM cameras for ASP."""
2
+
3
+ from .sc import (
4
+ ScOutputs,
5
+ ScProcessor,
6
+ ScProduct,
7
+ download_product,
8
+ has_spice,
9
+ import_sc,
10
+ is_sc_product_id,
11
+ lookup_products,
12
+ product_id_for,
13
+ sc_products,
14
+ select_sc_files,
15
+ )
16
+
17
+ __all__ = [
18
+ "ScOutputs",
19
+ "ScProcessor",
20
+ "ScProduct",
21
+ "download_product",
22
+ "has_spice",
23
+ "import_sc",
24
+ "is_sc_product_id",
25
+ "lookup_products",
26
+ "product_id_for",
27
+ "sc_products",
28
+ "select_sc_files",
29
+ ]
@@ -0,0 +1,392 @@
1
+ """KPLO ShadowCam: ODE file selection, download, SPICE and CSM cameras for ASP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from collections.abc import Iterable, Sequence
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from .. import asp
14
+ from ..constants import DEFAULT_PROJECTION
15
+ from ..downloader import download_file, url_filename
16
+ from ..isis import isd_generate, map_project, spiceinit
17
+ from ..tools import is_dry_run, reuse_existing, run
18
+
19
+ if TYPE_CHECKING: # pragma: no cover
20
+ import pandas as pd
21
+
22
+ from ..ode import OdeClient
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ # Observation M013178466S ships M013178466SE (raw, 8-bit, 3144 samples incl.
27
+ # bias columns; ODE "RAW") and M013178466SC (calibrated, float, 3072 samples;
28
+ # ODE "CAL"). Both are ISIS cubes with the Instrument group attached.
29
+ SC_ID_RE = re.compile(r"^M\d{9}S([CE]?)$", re.IGNORECASE)
30
+
31
+ PRODUCT_SUFFIX = {"raw": "E", "cal": "C"}
32
+
33
+ # ASP 3.7 example (docs/examples/shadowcam.rst): plain ``spiceinit from=<cube>``.
34
+ SPICEINIT_PARAMS: dict[str, Any] = {"shape": "system"}
35
+
36
+ # ``cam_test`` ISIS-vs-CSM comparison from the ASP example.
37
+ CAM_TEST_SAMPLE_RATE = 50
38
+
39
+ _POINTING_RE = re.compile(rb"^\s*InstrumentPointing\s*=", re.MULTILINE)
40
+ _HISTORY_RE = re.compile(
41
+ rb"^Object = History\s+Name\s*=\s*IsisCube\s+StartByte\s*=\s*(\d+)\s+Bytes\s*=\s*(\d+)", re.MULTILINE
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ScProduct:
47
+ """Cube and PDS4 label URLs of one ShadowCam product."""
48
+
49
+ product_id: str
50
+ cube_url: str
51
+ label_url: str | None = None
52
+ cube_kbytes: float | None = None
53
+
54
+ @property
55
+ def observation_id(self) -> str:
56
+ """``M013178466S`` for ``M013178466SE`` / ``M013178466SC``."""
57
+ return self.product_id[:-1]
58
+
59
+ @property
60
+ def is_calibrated(self) -> bool:
61
+ return self.product_id.endswith("C")
62
+
63
+ @property
64
+ def cube_name(self) -> str:
65
+ return f"{self.product_id}.cub"
66
+
67
+
68
+ @dataclass
69
+ class ScOutputs:
70
+ """Files written for one product (None for skipped steps)."""
71
+
72
+ product_id: str
73
+ cube: Path
74
+ camera: Path | None = None
75
+ cam_test: Path | None = None
76
+ geotiff: Path | None = None
77
+
78
+
79
+ def is_sc_product_id(product_id: str) -> bool:
80
+ """True for a ShadowCam observation (``M…S``) or product (``M…SE`` / ``M…SC``) ID."""
81
+ return bool(SC_ID_RE.match(product_id.strip()))
82
+
83
+
84
+ def product_id_for(source: str, product: str = "raw") -> str:
85
+ """Product ID for an observation or product ID.
86
+
87
+ Parameters
88
+ ----------
89
+ source : str
90
+ ``M013178466S`` (observation), ``M013178466SE`` or ``M013178466SC``.
91
+ product : str
92
+ ``"raw"`` (``…SE``, the cube in ASP's example) or ``"cal"`` (``…SC``);
93
+ only used for observation IDs.
94
+ """
95
+ m = SC_ID_RE.match(source.strip())
96
+ if not m:
97
+ raise ValueError(f"Not a ShadowCam ID: {source!r}")
98
+ pid = source.strip().upper()
99
+ if m.group(1):
100
+ return pid
101
+ try:
102
+ return pid + PRODUCT_SUFFIX[product.lower()]
103
+ except KeyError:
104
+ raise ValueError(f"Unknown ShadowCam product {product!r}: {sorted(PRODUCT_SUFFIX)}") from None
105
+
106
+
107
+ def select_sc_files(files: Iterable[dict], product_id: str | None = None) -> ScProduct | None:
108
+ """Pick the cube and label of a ShadowCam product from its ODE ``files`` list.
109
+
110
+ Parameters
111
+ ----------
112
+ files : iterable of dict
113
+ ODE product-file dicts (``FileName``, ``Type``, ``URL``, ``KBytes``).
114
+ product_id : str, optional
115
+ Product ID; derived from the cube file name when omitted.
116
+
117
+ Returns
118
+ -------
119
+ ScProduct or None
120
+ None when the list has no ``Product`` ``.cub`` (mosaics, DTMs). The
121
+ map-projected COGs and smear maps (``Referenced``) are never picked.
122
+ """
123
+ cube: dict | None = None
124
+ label: dict | None = None
125
+ for f in files or []:
126
+ if str(f.get("Type", "")).lower() != "product" or not f.get("URL"):
127
+ continue
128
+ name = str(f.get("FileName") or url_filename(f["URL"])).upper()
129
+ if name.endswith(".CUB") and cube is None:
130
+ cube = f
131
+ elif name.endswith(".XML") and label is None:
132
+ label = f
133
+ if cube is None:
134
+ return None
135
+ pid = (product_id or url_filename(cube["URL"]).split(".")[0]).upper()
136
+ try:
137
+ kbytes = float(cube["KBytes"]) if cube.get("KBytes") is not None else None
138
+ except (TypeError, ValueError):
139
+ kbytes = None
140
+ return ScProduct(pid, cube["URL"], label["URL"] if label else None, kbytes)
141
+
142
+
143
+ def sc_products(df: pd.DataFrame) -> list[ScProduct]:
144
+ """ShadowCam products with a downloadable cube from an ODE search.
145
+
146
+ Rows that are not ``…SE`` / ``…SC`` products (mosaics, DTMs) or lack a
147
+ cube are skipped with a warning.
148
+ """
149
+ out: list[ScProduct] = []
150
+ pids = df["product_id"] if "product_id" in df else [None] * len(df)
151
+ for pid, files in zip(pids, df.get("files", [])):
152
+ if pid and not is_sc_product_id(pid):
153
+ log.warning("Skipping %s: not a ShadowCam RAW / CAL product", pid)
154
+ continue
155
+ product = select_sc_files(files, pid)
156
+ if product is None:
157
+ log.warning("Skipping %s: no .cub product file", pid)
158
+ continue
159
+ out.append(product)
160
+ return out
161
+
162
+
163
+ def lookup_products(
164
+ product_ids: Iterable[str],
165
+ client: OdeClient | None = None,
166
+ product: str = "raw",
167
+ ) -> pd.DataFrame:
168
+ """Query ODE by observation / product ID (see :func:`product_id_for`, :meth:`OdeClient.lookup`)."""
169
+ from ..ode import OdeClient
170
+
171
+ ids = [product_id_for(p, product) for p in product_ids]
172
+ return (client or OdeClient()).lookup(ids, instrument_label="ShadowCam")
173
+
174
+
175
+ def download_product(
176
+ product: ScProduct,
177
+ output_dir: Path,
178
+ force: bool = False,
179
+ show_progress: bool = True,
180
+ ) -> Path:
181
+ """Download one product's cube (and PDS4 label) into *output_dir*.
182
+
183
+ Existing files are reused unless *force*; interrupted downloads resume.
184
+ Returns the cube path.
185
+ """
186
+ output_dir = Path(output_dir)
187
+ output_dir.mkdir(parents=True, exist_ok=True)
188
+ if product.label_url:
189
+ label = output_dir / url_filename(product.label_url)
190
+ if not reuse_existing(label, force):
191
+ download_file(product.label_url, label, show_progress=show_progress)
192
+ cube = output_dir / product.cube_name
193
+ if not reuse_existing(cube, force):
194
+ download_file(product.cube_url, cube, show_progress=show_progress)
195
+ return cube
196
+
197
+
198
+ def has_spice(cube: Path) -> bool:
199
+ """True when ``spiceinit`` completed on *cube*.
200
+
201
+ A failed ``spiceinit`` can leave SPICE tables in the label, so the cube's
202
+ History must also record a ``spiceinit`` run.
203
+ """
204
+ try:
205
+ with open(cube, "rb") as f:
206
+ head = f.read(1 << 20)
207
+ end = head.find(b"\nEnd\n")
208
+ label = head if end < 0 else head[:end]
209
+ m = _HISTORY_RE.search(label)
210
+ if not (m and _POINTING_RE.search(label)):
211
+ return False
212
+ f.seek(int(m.group(1)) - 1)
213
+ return b"Object = spiceinit" in f.read(int(m.group(2)))
214
+ except OSError:
215
+ return False
216
+
217
+
218
+ class ScProcessor:
219
+ """Download ShadowCam cubes and prepare them for ASP (SPICE + CSM camera).
220
+
221
+ Follows the ASP 3.7 ShadowCam example: ``spiceinit`` on the delivered cube
222
+ (in place), ``isd_generate -k <cube> <cube>`` for the CSM camera and,
223
+ optionally, ``cam_test`` and ``mapproject``. Needs ASP 3.7's custom ISIS /
224
+ ALE build and the KPLO kernels under ``$ISISDATA/kplo/kernels`` (rsync
225
+ from ``rsync.im-ldi.com::kplo``) plus ``$ISISDATA/base/kernels``.
226
+
227
+ Per product *output_dir* gets ``<id>.cub``, ``<id>.json`` (when *csm*),
228
+ ``<id>.cam_test.txt`` (when *validate*) and ``<id>.tif`` (when
229
+ *mapproject*). Steps whose output exists (for ``spiceinit``: a cube
230
+ whose History records it, see :func:`has_spice`) are skipped unless
231
+ *force*.
232
+
233
+ Parameters
234
+ ----------
235
+ output_dir : Path
236
+ Where cubes, labels and cameras are written.
237
+ product : str
238
+ ``"raw"`` (``…SE``, the cube in ASP's example) or ``"cal"`` (``…SC``,
239
+ radiometrically calibrated, bias columns removed) for observation IDs.
240
+ web : bool
241
+ Use the USGS SPICE web service (it serves the KPLO kernels) in
242
+ ``spiceinit``. ALE's ShadowCam driver only reads NAIF kernel files,
243
+ so ``isd_generate`` needs the local kernels either way.
244
+ csm : bool
245
+ Write a CSM camera with ``isd_generate``.
246
+ validate : bool
247
+ Run ``cam_test`` (ISIS vs CSM camera) and save its report.
248
+ mapproject : bool
249
+ Write a GeoTIFF: ASP ``mapproject`` onto *dem* when given, else
250
+ ISIS ``cam2map`` onto the ``spiceinit`` shape.
251
+ dem : Path, optional
252
+ DEM for ASP ``mapproject``.
253
+ resolution : float, optional
254
+ Map resolution in m/pixel (ASP ``--tr`` / ``cam2map`` resolution).
255
+ t_srs : str, optional
256
+ PROJ string for ASP ``mapproject`` (default: the DEM's projection).
257
+ projection : str
258
+ ISIS projection for ``mosrange`` without *dem*.
259
+ keep : bool
260
+ Keep the ISIS map-projected cube.
261
+ force : bool
262
+ Redo every processing step (downloads are always reused).
263
+ spiceinit_params : dict, optional
264
+ Overrides merged into :data:`SPICEINIT_PARAMS`.
265
+ """
266
+
267
+ def __init__(
268
+ self,
269
+ output_dir: Path = Path("shadowcam"),
270
+ *,
271
+ product: str = "raw",
272
+ web: bool = False,
273
+ csm: bool = True,
274
+ validate: bool = False,
275
+ mapproject: bool = False,
276
+ dem: Path | None = None,
277
+ resolution: float | None = None,
278
+ t_srs: str | None = None,
279
+ projection: str = DEFAULT_PROJECTION,
280
+ keep: bool = False,
281
+ force: bool = False,
282
+ spiceinit_params: dict[str, Any] | None = None,
283
+ ) -> None:
284
+ if product.lower() not in PRODUCT_SUFFIX:
285
+ raise ValueError(f"Unknown ShadowCam product {product!r}: {sorted(PRODUCT_SUFFIX)}")
286
+ self.output_dir = Path(output_dir)
287
+ self.product = product.lower()
288
+ self.web = web
289
+ self.csm = csm
290
+ self.validate = validate
291
+ self.mapproject = mapproject
292
+ self.dem = Path(dem) if dem else None
293
+ self.resolution = resolution
294
+ self.t_srs = t_srs
295
+ self.projection = projection
296
+ self.keep = keep
297
+ self.force = force
298
+ self.spiceinit_params = {**SPICEINIT_PARAMS, **(spiceinit_params or {})}
299
+
300
+ def process(self, source: ScProduct | str | Path) -> ScOutputs:
301
+ """Download (if needed) and process one product.
302
+
303
+ *source* is a :class:`ScProduct`, a local ``.cub`` path or an
304
+ observation / product ID (looked up on ODE).
305
+ """
306
+ if isinstance(source, ScProduct):
307
+ cube = download_product(source, self.output_dir)
308
+ elif Path(source).suffix.lower() == ".cub":
309
+ cube = Path(source)
310
+ else:
311
+ products = sc_products(lookup_products([str(source)], product=self.product))
312
+ if not products:
313
+ raise ValueError(f"No ShadowCam product {source!r} on ODE")
314
+ cube = download_product(products[0], self.output_dir)
315
+ return self.process_cube(cube)
316
+
317
+ def process_batch(
318
+ self,
319
+ sources: Sequence[ScProduct | str | Path],
320
+ max_workers: int = 2,
321
+ ) -> list[ScOutputs | None]:
322
+ """Process several products in parallel (None for failures)."""
323
+ results: list[ScOutputs | None] = [None] * len(sources)
324
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
325
+ futures = {pool.submit(self.process, s): i for i, s in enumerate(sources)}
326
+ for future in as_completed(futures):
327
+ i = futures[future]
328
+ try:
329
+ results[i] = future.result()
330
+ except Exception as exc:
331
+ log.error("Failed %s: %s", _source_name(sources[i]), exc)
332
+ return results
333
+
334
+ def cube_path(self, product: ScProduct) -> Path:
335
+ """Where :meth:`process` downloads *product*'s cube."""
336
+ return self.output_dir / product.cube_name
337
+
338
+ def process_cube(self, cube: Path) -> ScOutputs:
339
+ """Run the SPICE / CSM chain on a downloaded ShadowCam cube."""
340
+ cube = Path(cube)
341
+ pid = cube.name.split(".")[0].upper()
342
+ if not is_dry_run():
343
+ self.output_dir.mkdir(parents=True, exist_ok=True)
344
+ out = ScOutputs(pid, cube)
345
+
346
+ if self.force or not has_spice(cube):
347
+ spiceinit(cube, web=self.web, **self.spiceinit_params)
348
+ else:
349
+ log.info("Reusing SPICE in %s", cube)
350
+
351
+ if self.csm:
352
+ out.camera = self.output_dir / f"{pid}.json"
353
+ if not reuse_existing(out.camera, self.force):
354
+ isd_generate(cube, out.camera)
355
+
356
+ if self.validate and out.camera:
357
+ out.cam_test = self.output_dir / f"{pid}.cam_test.txt"
358
+ if not reuse_existing(out.cam_test, self.force):
359
+ self._cam_test(cube, out.camera, out.cam_test)
360
+
361
+ if self.mapproject:
362
+ out.geotiff = self.output_dir / f"{pid}.tif"
363
+ if not reuse_existing(out.geotiff, self.force):
364
+ self._mapproject(cube, out.camera, out.geotiff)
365
+ return out
366
+
367
+ def _cam_test(self, cube: Path, camera: Path, report: Path) -> None:
368
+ args = ["--image", cube, "--cam1", cube, "--cam2", camera]
369
+ args += ["--session1", "isis", "--session2", "csm", "--sample-rate", CAM_TEST_SAMPLE_RATE]
370
+ res = run("cam_test", args, capture=True)
371
+ if not is_dry_run():
372
+ report.write_text(res.stdout or "")
373
+
374
+ def _mapproject(self, cube: Path, camera: Path | None, tif: Path) -> None:
375
+ if self.dem:
376
+ asp.mapproject(self.dem, cube, tif, camera, tr=self.resolution, t_srs=self.t_srs)
377
+ return
378
+ map_cube = tif.with_suffix(".map.cub")
379
+ map_project(cube, map_cube, self.projection, self.resolution, keep=self.keep)
380
+ run("gdal_translate", ["-of", "GTiff", "-co", "COMPRESS=DEFLATE", "-co", "TILED=YES", map_cube, tif])
381
+ if not self.keep and not is_dry_run():
382
+ map_cube.unlink(missing_ok=True)
383
+
384
+
385
+ def import_sc(cube: Path, output_dir: Path | None = None, **kwargs: Any) -> ScOutputs:
386
+ """Process one downloaded ShadowCam cube; see :class:`ScProcessor` for *kwargs*."""
387
+ cube = Path(cube)
388
+ return ScProcessor(output_dir or cube.parent, **kwargs).process_cube(cube)
389
+
390
+
391
+ def _source_name(source: Any) -> str:
392
+ return source.product_id if isinstance(source, ScProduct) else str(source)