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,370 @@
1
+ """Kaguya Terrain Camera Level 2B0: ODE file selection, download and ISIS import."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import logging
7
+ import re
8
+ import shutil
9
+ from collections.abc import Iterable, Sequence
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from ..constants import DEFAULT_PROJECTION, GEOTIFF_OPTIONS
16
+ from ..downloader import download_file, url_filename
17
+ from ..isis import isd_generate, kaguyatc2isis, map_project, spiceinit
18
+ from ..tools import is_dry_run, reuse_existing, run
19
+
20
+ if TYPE_CHECKING: # pragma: no cover
21
+ import pandas as pd
22
+
23
+ from ..ode import OdeClient
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ # Level 2B0 TC product IDs, e.g. TC1W2B0_01_02936N034E0938 (W = stereo, S = mono).
28
+ TC_ID_RE = re.compile(r"^TC[12][WS]2B0_\d{2}_\d{5}[NS]\d{3}E\d{4}$", re.IGNORECASE)
29
+
30
+ # kaguyatc2isis: keep every DN (ASP turns the special-pixel ranges off).
31
+ KAGUYATC2ISIS_PARAMS: dict[str, Any] = {
32
+ "setnullrange": False,
33
+ "sethrsrange": False,
34
+ "sethisrange": False,
35
+ "setlrsrange": False,
36
+ "setlisrange": False,
37
+ }
38
+
39
+ # spiceinit: reconstructed CK, smithed / reconstructed SPK (ASP example).
40
+ # ``attach`` is dropped with ``web=true``, which always attaches (ISIS rejects both).
41
+ SPICEINIT_PARAMS: dict[str, Any] = {
42
+ "attach": True,
43
+ "cksmithed": False,
44
+ "ckrecon": True,
45
+ "ckpredicted": False,
46
+ "cknadir": False,
47
+ "spkrecon": True,
48
+ "spkpredicted": False,
49
+ "shape": "system",
50
+ "startpad": 0.0,
51
+ "endpad": 0.0,
52
+ }
53
+
54
+ _IMAGE_POINTER_RE = re.compile(r'^\s*\^IMAGE\s*=\s*\(?\s*"([^"]+)"', re.MULTILINE)
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class TcProduct:
59
+ """Data and label URLs of one Level 2B0 TC product."""
60
+
61
+ product_id: str
62
+ image_url: str
63
+ label_url: str
64
+ image_kbytes: float | None = None
65
+
66
+ @property
67
+ def is_stereo(self) -> bool:
68
+ return self.product_id[3] == "W"
69
+
70
+ @property
71
+ def sensor(self) -> str:
72
+ """``"TC1"`` or ``"TC2"``."""
73
+ return self.product_id[:3]
74
+
75
+
76
+ @dataclass
77
+ class TcOutputs:
78
+ """Files written for one product (None for skipped steps)."""
79
+
80
+ product_id: str
81
+ cube: Path
82
+ camera: Path | None = None
83
+ map_cube: Path | None = None
84
+ geotiff: Path | None = None
85
+
86
+
87
+ def is_tc_product_id(product_id: str) -> bool:
88
+ """True for a Level 2B0 TC product ID that ``kaguyatc2isis`` can import."""
89
+ return bool(TC_ID_RE.match(product_id.strip()))
90
+
91
+
92
+ def select_tc_files(files: Iterable[dict], product_id: str | None = None) -> TcProduct | None:
93
+ """Pick the data and label files of a TC product from its ODE ``files`` list.
94
+
95
+ Parameters
96
+ ----------
97
+ files : iterable of dict
98
+ ODE product-file dicts (``FileName``, ``Type``, ``URL``, ``KBytes``).
99
+ product_id : str, optional
100
+ Product ID; derived from the data file name when omitted.
101
+
102
+ Returns
103
+ -------
104
+ TcProduct or None
105
+ None when the list lacks a ``.img`` / ``.img.gz`` data file or a
106
+ ``.lbl`` label. An uncompressed ``.img`` is preferred over ``.img.gz``.
107
+ """
108
+ image: dict | None = None
109
+ label: dict | None = None
110
+ for f in files or []:
111
+ if str(f.get("Type", "")).lower() != "product" or not f.get("URL"):
112
+ continue
113
+ name = str(f.get("FileName") or url_filename(f["URL"])).upper()
114
+ if name.endswith(".LBL") and label is None:
115
+ label = f
116
+ elif name.endswith(".IMG") or (name.endswith(".IMG.GZ") and image is None):
117
+ image = f
118
+ if image is None or label is None:
119
+ return None
120
+ pid = product_id or url_filename(image["URL"]).split(".")[0]
121
+ kb = image.get("KBytes")
122
+ try:
123
+ kbytes = float(kb) if kb is not None else None
124
+ except (TypeError, ValueError):
125
+ kbytes = None
126
+ return TcProduct(pid.upper(), image["URL"], label["URL"], kbytes)
127
+
128
+
129
+ def tc_products(df: pd.DataFrame) -> list[TcProduct]:
130
+ """TC products with downloadable data + label files from an ODE search.
131
+
132
+ Rows that are not Level 2B0 products (DTMs, ortho maps) or lack either
133
+ file are skipped with a warning.
134
+ """
135
+ out: list[TcProduct] = []
136
+ pids = df["product_id"] if "product_id" in df else [None] * len(df)
137
+ for pid, files in zip(pids, df.get("files", [])):
138
+ if pid and not is_tc_product_id(pid):
139
+ log.warning("Skipping %s: not a Level 2B0 TC product", pid)
140
+ continue
141
+ product = select_tc_files(files, pid)
142
+ if product is None:
143
+ log.warning("Skipping %s: no .img/.lbl product files", pid)
144
+ continue
145
+ out.append(product)
146
+ return out
147
+
148
+
149
+ def lookup_products(
150
+ product_ids: Iterable[str],
151
+ client: OdeClient | None = None,
152
+ ) -> pd.DataFrame:
153
+ """Query ODE by product ID and return the observation DataFrame (see :meth:`OdeClient.lookup`)."""
154
+ from ..ode import OdeClient
155
+
156
+ return (client or OdeClient()).lookup(product_ids, instrument_label="Kaguya TC")
157
+
158
+
159
+ def image_pointer(label: Path) -> str | None:
160
+ """File name in the label's ``^IMAGE`` pointer (None if attached / absent)."""
161
+ m = _IMAGE_POINTER_RE.search(Path(label).read_text(encoding="latin-1", errors="replace"))
162
+ return m.group(1) if m else None
163
+
164
+
165
+ def decompress(gz: Path, out: Path | None = None, remove: bool = False) -> Path:
166
+ """Gunzip *gz* to *out* (default: name without ``.gz``) via a ``.part`` file."""
167
+ gz = Path(gz)
168
+ out = Path(out) if out else gz.with_suffix("")
169
+ tmp = out.with_name(out.name + ".part")
170
+ log.info("Decompressing %s", gz.name)
171
+ with gzip.open(gz, "rb") as src, open(tmp, "wb") as dst:
172
+ shutil.copyfileobj(src, dst, 8 << 20)
173
+ tmp.replace(out)
174
+ if remove:
175
+ gz.unlink()
176
+ return out
177
+
178
+
179
+ def download_product(
180
+ product: TcProduct,
181
+ output_dir: Path,
182
+ force: bool = False,
183
+ keep_gz: bool = False,
184
+ show_progress: bool = True,
185
+ ) -> Path:
186
+ """Download (and gunzip) one product's label and image into *output_dir*.
187
+
188
+ The image is written under the name in the label's ``^IMAGE`` pointer so
189
+ ``kaguyatc2isis`` finds it next to the label. Existing files are reused
190
+ unless *force*. Returns the label path.
191
+ """
192
+ output_dir = Path(output_dir)
193
+ output_dir.mkdir(parents=True, exist_ok=True)
194
+ label = output_dir / url_filename(product.label_url)
195
+ if not reuse_existing(label, force):
196
+ download_file(product.label_url, label, show_progress=show_progress)
197
+ img = output_dir / (image_pointer(label) or f"{product.product_id}.img")
198
+ if reuse_existing(img, force):
199
+ return label
200
+ remote = output_dir / url_filename(product.image_url)
201
+ if not reuse_existing(remote, force):
202
+ download_file(product.image_url, remote, show_progress=show_progress)
203
+ if remote.name.lower().endswith(".gz"):
204
+ decompress(remote, img, remove=not keep_gz)
205
+ elif remote != img:
206
+ remote.replace(img)
207
+ return label
208
+
209
+
210
+ class TcProcessor:
211
+ """Download and import Kaguya TC Level 2B0 products into ISIS.
212
+
213
+ Each product ends up in *output_dir* as ``<id>.cub`` (spiceinit'ed),
214
+ ``<id>.json`` (CSM camera, when *csm*) and, when *mapproject*,
215
+ ``<id>.tif`` (:data:`~lunar_toolkit.constants.GEOTIFF_OPTIONS`), the
216
+ same flat layout and resume / *keep* / *force* behaviour as
217
+ :class:`~lunar_toolkit.lroc.nac.NacProcessor`. Steps whose output
218
+ exists are skipped unless *force*.
219
+
220
+ Parameters
221
+ ----------
222
+ output_dir : Path
223
+ Where labels, images, cubes and cameras are written.
224
+ web : bool
225
+ Use the USGS SPICE web service in ``spiceinit`` (no local Kaguya
226
+ kernels needed). Set False when ``$ISISDATA/kaguya`` is installed.
227
+ csm : bool
228
+ Write a CSM camera with ``isd_generate``.
229
+ only_naif_spice : bool, optional
230
+ Pass ``--only_naif_spice`` to ``isd_generate`` (ASP's recipe; needs
231
+ local kernels). Default: ``not web``.
232
+ mapproject : bool
233
+ Also ``cam2map`` the cube and write a GeoTIFF.
234
+ projection : str
235
+ ISIS projection for ``mosrange`` (default: equirectangular).
236
+ resolution : float, optional
237
+ Map resolution in m/pixel (default: ``mosrange``'s estimate).
238
+ keep : bool
239
+ Keep the PDS ``.img`` and the map-projected cube after use.
240
+ force : bool
241
+ Redo every step even when its output exists.
242
+ kaguyatc2isis_params, spiceinit_params : dict, optional
243
+ Overrides merged into :data:`KAGUYATC2ISIS_PARAMS` /
244
+ :data:`SPICEINIT_PARAMS`.
245
+ """
246
+
247
+ def __init__(
248
+ self,
249
+ output_dir: Path = Path("kaguya"),
250
+ *,
251
+ web: bool = True,
252
+ csm: bool = True,
253
+ only_naif_spice: bool | None = None,
254
+ mapproject: bool = False,
255
+ projection: str = DEFAULT_PROJECTION,
256
+ resolution: float | None = None,
257
+ keep: bool = False,
258
+ force: bool = False,
259
+ kaguyatc2isis_params: dict[str, Any] | None = None,
260
+ spiceinit_params: dict[str, Any] | None = None,
261
+ ) -> None:
262
+ self.output_dir = Path(output_dir)
263
+ self.web = web
264
+ self.csm = csm
265
+ self.only_naif_spice = (not web) if only_naif_spice is None else only_naif_spice
266
+ self.mapproject = mapproject
267
+ self.projection = projection
268
+ self.resolution = resolution
269
+ self.keep = keep
270
+ self.force = force
271
+ self.kaguyatc2isis_params = {**KAGUYATC2ISIS_PARAMS, **(kaguyatc2isis_params or {})}
272
+ self.spiceinit_params = {**SPICEINIT_PARAMS, **(spiceinit_params or {})}
273
+
274
+ def process(self, source: TcProduct | str | Path) -> TcOutputs:
275
+ """Download (if needed) and import one product.
276
+
277
+ *source* is a :class:`TcProduct`, a local ``.lbl`` path or a product
278
+ ID (looked up on ODE).
279
+ """
280
+ if isinstance(source, TcProduct):
281
+ label = download_product(source, self.output_dir, force=self.force)
282
+ elif Path(source).suffix.lower() == ".lbl":
283
+ label = Path(source)
284
+ else:
285
+ products = tc_products(lookup_products([str(source)]))
286
+ if not products:
287
+ raise ValueError(f"No Kaguya TC Level 2B0 product {source!r} on ODE")
288
+ label = download_product(products[0], self.output_dir, force=self.force)
289
+ return self.import_label(label)
290
+
291
+ def process_batch(
292
+ self,
293
+ sources: Sequence[TcProduct | str | Path],
294
+ max_workers: int = 2,
295
+ ) -> list[TcOutputs | None]:
296
+ """Process several products in parallel (None for failures)."""
297
+ results: list[TcOutputs | None] = [None] * len(sources)
298
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
299
+ futures = {pool.submit(self.process, s): i for i, s in enumerate(sources)}
300
+ for future in as_completed(futures):
301
+ i = futures[future]
302
+ try:
303
+ results[i] = future.result()
304
+ except Exception as exc:
305
+ log.error("Failed %s: %s", _source_name(sources[i]), exc)
306
+ return results
307
+
308
+ def import_label(self, label: Path) -> TcOutputs:
309
+ """Run the ISIS chain on a downloaded PDS3 label (image beside it)."""
310
+ label = Path(label)
311
+ pid = label.name.split(".")[0].upper()
312
+ if not is_dry_run():
313
+ self.output_dir.mkdir(parents=True, exist_ok=True)
314
+ cube = self.output_dir / f"{pid}.cub"
315
+ out = TcOutputs(pid, cube)
316
+
317
+ if not reuse_existing(cube, self.force):
318
+ try:
319
+ kaguyatc2isis(label, cube, **self.kaguyatc2isis_params)
320
+ spiceinit(cube, web=self.web, **self._spiceinit_params())
321
+ except Exception:
322
+ cube.unlink(missing_ok=True) # never reuse a cube without SPICE
323
+ raise
324
+ if not self.keep and not is_dry_run():
325
+ self._remove_image(label)
326
+
327
+ if self.csm:
328
+ out.camera = self.output_dir / f"{pid}.json"
329
+ if not reuse_existing(out.camera, self.force):
330
+ isd_generate(cube, out.camera, only_naif_spice=self.only_naif_spice)
331
+
332
+ if self.mapproject:
333
+ out.geotiff = self.output_dir / f"{pid}.tif"
334
+ map_cube = self.output_dir / f"{pid}.map.cub"
335
+ if not reuse_existing(out.geotiff, self.force):
336
+ self._mapproject(cube, map_cube, out.geotiff)
337
+ if self.keep:
338
+ out.map_cube = map_cube
339
+ return out
340
+
341
+ def _mapproject(self, cube: Path, map_cube: Path, tif: Path) -> None:
342
+ map_project(cube, map_cube, self.projection, self.resolution, keep=self.keep)
343
+ run("gdal_translate", [*GEOTIFF_OPTIONS, map_cube, tif])
344
+ if not self.keep and not is_dry_run():
345
+ map_cube.unlink(missing_ok=True)
346
+
347
+ def _spiceinit_params(self) -> dict[str, Any]:
348
+ params = dict(self.spiceinit_params)
349
+ if self.web:
350
+ params.pop("attach", None)
351
+ return params
352
+
353
+ @staticmethod
354
+ def _remove_image(label: Path) -> None:
355
+ try:
356
+ name = image_pointer(label)
357
+ except OSError:
358
+ return
359
+ if name:
360
+ (label.parent / name).unlink(missing_ok=True)
361
+
362
+
363
+ def import_tc(label: Path, output_dir: Path | None = None, **kwargs: Any) -> TcOutputs:
364
+ """Import one downloaded TC product; see :class:`TcProcessor` for *kwargs*."""
365
+ label = Path(label)
366
+ return TcProcessor(output_dir or label.parent, **kwargs).import_label(label)
367
+
368
+
369
+ def _source_name(source: Any) -> str:
370
+ return source.product_id if isinstance(source, TcProduct) else str(source)
@@ -0,0 +1,6 @@
1
+ """LROC: PDS cumulative-index database and NAC images."""
2
+
3
+ from .cumindex import CumIndex
4
+ from .nac import NacImage, NacImageCollection, NacProcessor
5
+
6
+ __all__ = ["CumIndex", "NacImage", "NacImageCollection", "NacProcessor"]