lunar-toolkit 0.1.0__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 (65) hide show
  1. lunar_toolkit/__init__.py +6 -0
  2. lunar_toolkit/asp.py +451 -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 +229 -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 +719 -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 +1559 -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/basemaps/lola_dem_global.tif +0 -0
  46. lunar_toolkit/static/basemaps/lola_dem_north.tif +0 -0
  47. lunar_toolkit/static/basemaps/lola_dem_south.tif +0 -0
  48. lunar_toolkit/static/basemaps/lola_shade_global.jpg +0 -0
  49. lunar_toolkit/static/basemaps/lola_shade_north.jpg +0 -0
  50. lunar_toolkit/static/basemaps/lola_shade_south.jpg +0 -0
  51. lunar_toolkit/static/basemaps/manifest.json +11 -0
  52. lunar_toolkit/static/basemaps/wac_global.jpg +0 -0
  53. lunar_toolkit/static/basemaps/wac_north.jpg +0 -0
  54. lunar_toolkit/static/basemaps/wac_south.jpg +0 -0
  55. lunar_toolkit/static/index.html +4197 -0
  56. lunar_toolkit/stereo.py +519 -0
  57. lunar_toolkit/stereo_dem.py +145 -0
  58. lunar_toolkit/tools.py +264 -0
  59. lunar_toolkit/workspace.py +1173 -0
  60. lunar_toolkit-0.1.0.dist-info/METADATA +180 -0
  61. lunar_toolkit-0.1.0.dist-info/RECORD +65 -0
  62. lunar_toolkit-0.1.0.dist-info/WHEEL +5 -0
  63. lunar_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  64. lunar_toolkit-0.1.0.dist-info/licenses/LICENSE +28 -0
  65. lunar_toolkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,6 @@
1
+ """Find, download and process lunar orbital imagery and build DEMs with ISIS + ASP."""
2
+
3
+ from .downloader import PdsDownloader
4
+ from .lroc import CumIndex, NacImage, NacImageCollection, NacProcessor
5
+
6
+ __all__ = ["CumIndex", "NacImage", "NacImageCollection", "NacProcessor", "PdsDownloader"]
lunar_toolkit/asp.py ADDED
@@ -0,0 +1,451 @@
1
+ """Ames Stereo Pipeline wrappers: positional inputs as arguments, other options as keywords."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import time
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+ from .tools import ToolError, asp_options, gdal_options, is_dry_run, run
16
+
17
+
18
+ def _paths(items: Sequence[Any]) -> list[str]:
19
+ return [str(p) for p in items]
20
+
21
+
22
+ def _parent(prefix: Path) -> None:
23
+ if not is_dry_run():
24
+ Path(prefix).parent.mkdir(parents=True, exist_ok=True)
25
+
26
+
27
+ def mapproject(
28
+ dem: Path, image: Path, out: Path, camera: Path | None = None, *, cwd: Path | None = None, **options: Any
29
+ ) -> Path:
30
+ """Orthorectify *image* onto *dem* (``tr=``, ``t_projwin=``, ``bundle_adjust_prefix=``)."""
31
+ _parent(out)
32
+ cams = [camera] if camera else []
33
+ run("mapproject", [*asp_options(options), dem, image, *cams, out], cwd=cwd)
34
+ return Path(out)
35
+
36
+
37
+ def bundle_adjust(
38
+ images: Sequence[Path],
39
+ out_prefix: Path,
40
+ cameras: Sequence[Path] = (),
41
+ *,
42
+ mapprojected: Sequence[Path] | None = None,
43
+ dem: Path | None = None,
44
+ parallel: bool = False,
45
+ cwd: Path | None = None,
46
+ **options: Any,
47
+ ) -> Path:
48
+ """Run ``bundle_adjust`` (or ``parallel_bundle_adjust`` with *parallel*).
49
+
50
+ *mapprojected* (same order as *images*) and the *dem* they were projected
51
+ onto become ``--mapprojected-data``, which finds matches between images of
52
+ very different illumination. Returns *out_prefix*.
53
+
54
+ Raises :class:`~lunar_toolkit.tools.ToolError` when the run writes no fresh
55
+ ``.adjust`` file for some image: ASP exits 0 even when it finds nothing to
56
+ optimize ("No points to optimize ... Cannot continue").
57
+ """
58
+ _parent(out_prefix)
59
+ if mapprojected is not None:
60
+ options["mapprojected_data"] = " ".join(_paths([*mapprojected, *([dem] if dem else [])]))
61
+ tool = "parallel_bundle_adjust" if parallel else "bundle_adjust"
62
+ start = time.time()
63
+ run(tool, [*_paths(images), *_paths(cameras), *asp_options(options), "-o", out_prefix], cwd=cwd)
64
+ if not is_dry_run():
65
+ prefix = Path(cwd or ".") / out_prefix
66
+ cams: Sequence[Path | None] = cameras if len(cameras) == len(images) else [None] * len(images)
67
+ missing = [
68
+ p
69
+ for p in (bundle_adjust_file_name(prefix, im, cam) for im, cam in zip(images, cams))
70
+ if not p.is_file() or p.stat().st_mtime < start - 2
71
+ ]
72
+ if missing:
73
+ raise ToolError(
74
+ f"{tool} wrote no adjustment for {len(missing)} of {len(images)} images "
75
+ f"(e.g. {missing[0].name}); see {out_prefix}-log-bundle_adjust-*.txt"
76
+ )
77
+ return Path(out_prefix)
78
+
79
+
80
+ def apply_transform(
81
+ images: Sequence[Path],
82
+ transform: Path,
83
+ out_prefix: Path,
84
+ cameras: Sequence[Path] = (),
85
+ *,
86
+ input_adjustments_prefix: Path | None = None,
87
+ cwd: Path | None = None,
88
+ **options: Any,
89
+ ) -> Path:
90
+ """Apply a ``pc_align`` 4×4 transform to (adjusted) cameras, without optimizing."""
91
+ return bundle_adjust(
92
+ images,
93
+ out_prefix,
94
+ cameras,
95
+ initial_transform=transform,
96
+ input_adjustments_prefix=input_adjustments_prefix,
97
+ apply_initial_transform_only=True,
98
+ cwd=cwd,
99
+ **options,
100
+ )
101
+
102
+
103
+ def sfs(
104
+ dem: Path,
105
+ images: Sequence[Path],
106
+ out_prefix: Path,
107
+ cameras: Sequence[Path] = (),
108
+ *,
109
+ parallel: bool = False,
110
+ cwd: Path | None = None,
111
+ **options: Any,
112
+ ) -> Path:
113
+ """Run ``sfs`` (or ``parallel_sfs``); returns ``<out_prefix>-DEM-final.tif``."""
114
+ _parent(out_prefix)
115
+ tool = "parallel_sfs" if parallel else "sfs"
116
+ run(
117
+ tool, ["-i", dem, *_paths(images), *_paths(cameras), *asp_options(options), "-o", out_prefix], cwd=cwd
118
+ )
119
+ return Path(f"{out_prefix}-DEM-final.tif")
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class SunAngles:
124
+ image: str
125
+ azimuth: float
126
+ elevation: float
127
+
128
+
129
+ _SUN_RE = re.compile(r"Sun azimuth and elevation for: (?P<image>\S+) are (?P<az>\S+) and (?P<el>\S+) degrees")
130
+
131
+
132
+ def parse_sun_angles(text: str) -> list[SunAngles]:
133
+ """Parse ``sfs --query`` output into per-image Sun azimuth / elevation."""
134
+ return [SunAngles(m["image"], float(m["az"]), float(m["el"])) for m in _SUN_RE.finditer(text)]
135
+
136
+
137
+ def sun_angles(
138
+ dem: Path,
139
+ images: Sequence[Path],
140
+ cameras: Sequence[Path] = (),
141
+ *,
142
+ out_prefix: Path = Path("sfs_query/run"),
143
+ cwd: Path | None = None,
144
+ **options: Any,
145
+ ) -> list[SunAngles]:
146
+ """Sun azimuth (from north) and elevation per image at the DEM centre.
147
+
148
+ Runs ``sfs --query``; results come back in input order. Sort by azimuth
149
+ before bundle adjustment and SfS.
150
+ """
151
+ _parent(out_prefix)
152
+ res = run(
153
+ "sfs",
154
+ ["-i", dem, *_paths(images), *_paths(cameras), "--query", *asp_options(options), "-o", out_prefix],
155
+ cwd=cwd,
156
+ capture=True,
157
+ )
158
+ return parse_sun_angles(res.stdout or "")
159
+
160
+
161
+ def pc_align(
162
+ reference: Path,
163
+ source: Path,
164
+ out_prefix: Path,
165
+ *,
166
+ max_displacement: float,
167
+ cwd: Path | None = None,
168
+ **options: Any,
169
+ ) -> Path:
170
+ """Align *source* to *reference*; returns ``<out_prefix>-transform.txt``."""
171
+ _parent(out_prefix)
172
+ run(
173
+ "pc_align",
174
+ ["--max-displacement", max_displacement, *asp_options(options), reference, source, "-o", out_prefix],
175
+ cwd=cwd,
176
+ )
177
+ return Path(f"{out_prefix}-transform.txt")
178
+
179
+
180
+ def point2dem(cloud: Path, *, cwd: Path | None = None, **options: Any) -> Path:
181
+ """Grid a point cloud; returns ``<prefix>-DEM.tif`` (prefix from ``o=`` or the cloud)."""
182
+ run("point2dem", [*asp_options(options), cloud], cwd=cwd)
183
+ prefix = options.get("o") or options.get("output_prefix")
184
+ if prefix is None:
185
+ stem = str(cloud)
186
+ prefix = stem[: -len("-PC.tif")] if stem.endswith("-PC.tif") else str(Path(stem).with_suffix(""))
187
+ return Path(f"{prefix}-DEM.tif")
188
+
189
+
190
+ def stereo(
191
+ left: Path,
192
+ right: Path,
193
+ out_prefix: Path,
194
+ left_camera: Path | None = None,
195
+ right_camera: Path | None = None,
196
+ *,
197
+ dem: Path | None = None,
198
+ parallel: bool = True,
199
+ cwd: Path | None = None,
200
+ **options: Any,
201
+ ) -> Path:
202
+ """Run ``parallel_stereo`` (or ``stereo``); returns ``<out_prefix>-PC.tif``.
203
+
204
+ Pass *dem* when the inputs are mapprojected onto it.
205
+ """
206
+ _parent(out_prefix)
207
+ cams = [c for c in (left_camera, right_camera) if c]
208
+ tail = [dem] if dem else []
209
+ tool = "parallel_stereo" if parallel else "stereo"
210
+ run(tool, [*asp_options(options), left, right, *cams, out_prefix, *tail], cwd=cwd)
211
+ return Path(f"{out_prefix}-PC.tif")
212
+
213
+
214
+ def dem_mosaic(inputs: Sequence[Path], out: Path, *, cwd: Path | None = None, **options: Any) -> Path:
215
+ """Mosaic DEMs or images (``max=True`` for a maximally-lit mosaic)."""
216
+ _parent(out)
217
+ run("dem_mosaic", [*_paths(inputs), *asp_options(options), "-o", out], cwd=cwd)
218
+ return Path(out)
219
+
220
+
221
+ def hillshade(
222
+ dem: Path,
223
+ out: Path | None = None,
224
+ *,
225
+ azimuth: float = 300,
226
+ elevation: float = 20,
227
+ cwd: Path | None = None,
228
+ **options: Any,
229
+ ) -> Path:
230
+ out = Path(out) if out else Path(dem).with_name(Path(dem).stem + "_hillshade.tif")
231
+ run(
232
+ "hillshade",
233
+ [dem, "--azimuth", azimuth, "--elevation", elevation, *asp_options(options), "-o", out],
234
+ cwd=cwd,
235
+ )
236
+ return out
237
+
238
+
239
+ def geodiff(dem1: Path, dem2: Path, out_prefix: Path, *, cwd: Path | None = None, **options: Any) -> Path:
240
+ """``dem1 - dem2`` (``absolute=True`` for |diff|); returns ``<out_prefix>-diff.tif``."""
241
+ _parent(out_prefix)
242
+ run("geodiff", [*asp_options(options), dem1, dem2, "-o", out_prefix], cwd=cwd)
243
+ return Path(f"{out_prefix}-diff.tif")
244
+
245
+
246
+ def gdal_translate(
247
+ src: Path,
248
+ dst: Path,
249
+ *,
250
+ projwin: Sequence[float] | None = None,
251
+ tr: float | None = None,
252
+ r: str | None = None,
253
+ cwd: Path | None = None,
254
+ **options: Any,
255
+ ) -> Path:
256
+ """Crop (``projwin`` = ulx uly lrx lry) and / or resample (``tr`` metres, ``r`` method)."""
257
+ _parent(dst)
258
+ opts = {
259
+ "projwin": projwin,
260
+ "tr": (tr, tr) if tr is not None else None,
261
+ "r": r,
262
+ "of": "GTiff",
263
+ "co": "COMPRESS=LZW",
264
+ **options,
265
+ }
266
+ run("gdal_translate", [*gdal_options(opts), src, dst], cwd=cwd)
267
+ return Path(dst)
268
+
269
+
270
+ def bundle_adjust_file_name(prefix: Path, image: Path, camera: Path | None = None) -> Path:
271
+ """``<prefix>-<stem>.adjust`` as ASP names it (stem of *camera*, else *image*, up to the last dot)."""
272
+ name = Path(camera or image).name
273
+ stem = name[: name.rfind(".")] if "." in name else name
274
+ return Path(f"{prefix}-{stem}.adjust")
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class MatchOffsetStats:
279
+ """One row of ``<prefix>-mapproj_match_offset_stats.txt`` (metres; -1 when no matches)."""
280
+
281
+ image: str
282
+ p25: float
283
+ p50: float
284
+ p75: float
285
+ p85: float
286
+ p95: float
287
+ count: int
288
+
289
+
290
+ def read_mapproj_offset_stats(path: Path) -> dict[str, MatchOffsetStats]:
291
+ """Parse ``bundle_adjust --mapproj-dem`` per-image offsets, keyed by image file name.
292
+
293
+ Format (ASP ``saveMapprojOffsets``): ``# image_name 25% 50% 75% 85% 95% count``.
294
+ """
295
+ out = {}
296
+ for line in Path(path).read_text().splitlines():
297
+ parts = line.split()
298
+ if not parts or parts[0].startswith("#") or len(parts) < 7:
299
+ continue
300
+ p25, p50, p75, p85, p95 = map(float, parts[1:6])
301
+ out[Path(parts[0]).name] = MatchOffsetStats(parts[0], p25, p50, p75, p85, p95, int(float(parts[6])))
302
+ return out
303
+
304
+
305
+ @dataclass(frozen=True)
306
+ class ResidualStats:
307
+ """One row of ``<prefix>-final_residuals_stats.txt`` (pixels)."""
308
+
309
+ image: str
310
+ mean: float
311
+ median: float
312
+ count: int
313
+
314
+
315
+ def read_residual_stats(path: Path) -> dict[str, ResidualStats]:
316
+ """Parse per-camera reprojection errors (``# Image, mean, median, count``), keyed by file name."""
317
+ out = {}
318
+ for line in Path(path).read_text().splitlines():
319
+ parts = [p.strip() for p in line.split(",")]
320
+ if not parts[0] or parts[0].startswith("#") or len(parts) < 4:
321
+ continue
322
+ out[Path(parts[0]).name] = ResidualStats(parts[0], float(parts[1]), float(parts[2]), int(parts[3]))
323
+ return out
324
+
325
+
326
+ def read_residual_norms(path: Path) -> dict[str, np.ndarray]:
327
+ """Per-image reprojection error norms (pixels) from ``<prefix>-final_residuals_raw_pixels.txt``.
328
+
329
+ The file has, per image, a ``name, count`` line followed by *count* ``ex, ey`` lines.
330
+ """
331
+ out: dict[str, np.ndarray] = {}
332
+ lines = Path(path).read_text().splitlines()
333
+ i = 0
334
+ while i < len(lines):
335
+ head = [p.strip() for p in lines[i].split(",")]
336
+ i += 1
337
+ if len(head) != 2 or not head[0] or head[0].startswith("#"):
338
+ continue
339
+ n = int(head[1])
340
+ xy = np.array([[float(v) for v in ln.split(",")] for ln in lines[i : i + n]]).reshape(-1, 2)
341
+ out[Path(head[0]).name] = np.hypot(xy[:, 0], xy[:, 1])
342
+ i += n
343
+ return out
344
+
345
+
346
+ def image_subset(
347
+ images: Sequence[Path], out: Path, *, threshold: float, cwd: Path | None = None, **options: Any
348
+ ) -> list[tuple[str, int]]:
349
+ """Run ``image_subset``; returns ``(image, contributing pixels)`` best first.
350
+
351
+ Writes the image list to ``<out>.in.txt``; ``t_projwin=(xmin, ymin, xmax, ymax)`` limits
352
+ the evaluated extent. Empty in a dry run.
353
+ """
354
+ _parent(out)
355
+ image_list = Path(f"{out}.in.txt")
356
+ if not is_dry_run():
357
+ image_list.write_text("".join(f"{p}\n" for p in images))
358
+ run(
359
+ "image_subset",
360
+ ["--image-list", image_list, "--threshold", threshold, *asp_options(options), "-o", out],
361
+ cwd=cwd,
362
+ )
363
+ if is_dry_run() or not Path(out).exists():
364
+ return []
365
+ return [
366
+ (parts[0], int(float(parts[1])))
367
+ for parts in (ln.split() for ln in Path(out).read_text().splitlines())
368
+ if len(parts) >= 2 and not parts[0].startswith("#")
369
+ ]
370
+
371
+
372
+ def raster_info(path: Path) -> dict:
373
+ """``gdalinfo -json`` of *path* (empty in a dry run)."""
374
+ res = run("gdalinfo", ["-json", path], capture=True)
375
+ return json.loads(res.stdout) if res.stdout else {}
376
+
377
+
378
+ def raster_bounds(path: Path) -> tuple[float, float, float, float] | None:
379
+ """``(ulx, uly, lrx, lry)`` of a north-up raster, or None in a dry run."""
380
+ info = raster_info(path)
381
+ if not info:
382
+ return None
383
+ x0, dx, _, y0, _, dy = info["geoTransform"]
384
+ nx, ny = info["size"]
385
+ return x0, y0, x0 + nx * dx, y0 + ny * dy
386
+
387
+
388
+ def srs_proj4(path: Path) -> str:
389
+ """PROJ string of a raster's spatial reference (``gdalsrsinfo -o proj4``; empty in a dry run)."""
390
+ res = run("gdalsrsinfo", ["-o", "proj4", path], capture=True)
391
+ return (res.stdout or "").strip().strip("'")
392
+
393
+
394
+ @dataclass(frozen=True)
395
+ class Raster:
396
+ """Band 1 of a north-up raster: ``data[row, col]`` (NaN = nodata), top-left corner and pixel size."""
397
+
398
+ data: np.ndarray
399
+ x0: float
400
+ y0: float
401
+ dx: float
402
+ dy: float
403
+
404
+
405
+ def read_raster(
406
+ path: Path,
407
+ *,
408
+ projwin: Sequence[float] | None = None,
409
+ outsize: tuple[int, int] | None = None,
410
+ r: str = "average",
411
+ ) -> Raster | None:
412
+ """Read (a resampled window of) a raster through ``gdal_translate -of AAIGrid /vsistdout/``.
413
+
414
+ *projwin* (``ulx uly lrx lry``) may extend past the raster (filled with nodata) and
415
+ *outsize* ``(columns, rows)`` resamples with *r*, so several images read with the
416
+ same window share one grid. Meant for small grids; returns None in a dry run.
417
+ """
418
+ opts = {
419
+ "q": True,
420
+ "of": "AAIGrid",
421
+ "ot": "Float32",
422
+ "b": 1,
423
+ "projwin": projwin,
424
+ "outsize": outsize,
425
+ "r": r if outsize else None,
426
+ }
427
+ res = run("gdal_translate", [*gdal_options(opts), path, "/vsistdout/"], capture=True)
428
+ return parse_aaigrid(res.stdout) if res.stdout else None
429
+
430
+
431
+ def parse_aaigrid(text: str) -> Raster:
432
+ """Parse ESRI ASCII grid text (as GDAL's AAIGrid driver writes it)."""
433
+ lines = text.splitlines()
434
+ head: dict[str, float] = {}
435
+ k = 0
436
+ while k < len(lines) and lines[k][:1].isalpha():
437
+ key, value = lines[k].split()[:2]
438
+ head[key.lower()] = float(value)
439
+ k += 1
440
+ nx, ny = int(head["ncols"]), int(head["nrows"])
441
+ dx = head.get("dx", head.get("cellsize"))
442
+ dy = head.get("dy", head.get("cellsize"))
443
+ if dx is None or dy is None:
444
+ raise ValueError("AAIGrid header lacks cellsize / dx / dy")
445
+ x0 = head["xllcorner"] if "xllcorner" in head else head["xllcenter"] - dx / 2
446
+ yll = head["yllcorner"] if "yllcorner" in head else head["yllcenter"] - dy / 2
447
+ # GDAL also writes the .prj (WKT) to /vsistdout/ after the cells.
448
+ data = np.array(" ".join(lines[k:]).split()[: nx * ny], dtype=float).reshape(ny, nx)
449
+ if "nodata_value" in head:
450
+ data[data == head["nodata_value"]] = np.nan
451
+ return Raster(data, x0, yll + ny * dy, dx, -dy)
@@ -0,0 +1,88 @@
1
+ """``lunar`` CLI; each subcommand module exports COMMAND, add_parser(sub) and run(args)."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+
7
+ from . import (
8
+ app,
9
+ build_db,
10
+ download,
11
+ kaguya,
12
+ ode_search,
13
+ pairs,
14
+ process,
15
+ query,
16
+ reference,
17
+ search,
18
+ serve,
19
+ sfs,
20
+ sfs_report,
21
+ shadowcam,
22
+ stereo,
23
+ workspace,
24
+ )
25
+
26
+ _COMMANDS = [
27
+ build_db,
28
+ download,
29
+ query,
30
+ search,
31
+ ode_search,
32
+ pairs,
33
+ reference,
34
+ process,
35
+ kaguya,
36
+ shadowcam,
37
+ stereo,
38
+ sfs,
39
+ sfs_report,
40
+ workspace,
41
+ serve,
42
+ app,
43
+ ]
44
+
45
+
46
+ def _build_parser() -> argparse.ArgumentParser:
47
+ parser = argparse.ArgumentParser(
48
+ prog="lunar",
49
+ description=(
50
+ "Lunar imagery toolkit - build LROC index databases, search footprints, "
51
+ "query ODE (ShadowCam, LROC, Kaguya TC and MI), download and import images into ISIS, "
52
+ "build stereo and shape-from-shading DEMs with ISIS + ASP, and fetch Artemis III "
53
+ "reference data."
54
+ ),
55
+ formatter_class=argparse.RawDescriptionHelpFormatter,
56
+ )
57
+ parser.add_argument(
58
+ "-v",
59
+ "--verbose",
60
+ action="store_true",
61
+ help="Enable DEBUG-level logging.",
62
+ )
63
+ sub = parser.add_subparsers(dest="cmd", required=True)
64
+ for cmd in _COMMANDS:
65
+ cmd.add_parser(sub)
66
+ return parser
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ parser = _build_parser()
71
+ args = parser.parse_args(argv)
72
+
73
+ logging.basicConfig(
74
+ level=logging.DEBUG if args.verbose else logging.INFO,
75
+ format="%(asctime)s %(levelname)-8s %(message)s",
76
+ datefmt="%H:%M:%S",
77
+ )
78
+
79
+ for cmd in _COMMANDS:
80
+ if args.cmd == cmd.COMMAND:
81
+ return cmd.run(args)
82
+
83
+ parser.print_help()
84
+ return 1
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main())