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,130 @@
1
+ """build-db - download CUMINDEX files and build local Parquet databases."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from ..constants import DATASETS, DEFAULT_CACHE_DIR
8
+
9
+ COMMAND = "build-db"
10
+
11
+
12
+ def add_parser(sub: argparse._SubParsersAction) -> None:
13
+ p = sub.add_parser(
14
+ COMMAND,
15
+ help="Build the local Parquet index database(s).",
16
+ formatter_class=argparse.RawDescriptionHelpFormatter,
17
+ epilog="""
18
+ Examples
19
+ --------
20
+ lunar build-db # EDR only (default)
21
+ lunar build-db edr cdr # EDR and CDR
22
+ lunar build-db --all # EDR, CDR, and RDR
23
+ lunar build-db edr --force # rebuild even if exists
24
+ lunar build-db edr --update # append PDS volumes newer than the built release
25
+ lunar build-db edr --release LROLRC_0066C --volume-only # one volume (~20k rows)
26
+ """,
27
+ )
28
+ p.add_argument(
29
+ "datasets",
30
+ nargs="*",
31
+ metavar="DATASET",
32
+ help="Datasets to build: edr, cdr, rdr (default: edr if none given).",
33
+ )
34
+ p.add_argument(
35
+ "--all",
36
+ action="store_true",
37
+ help="Build all three datasets: edr, cdr, rdr.",
38
+ )
39
+ p.add_argument(
40
+ "--force",
41
+ action="store_true",
42
+ help="Rebuild even if the Parquet already exists.",
43
+ )
44
+ p.add_argument(
45
+ "--update",
46
+ action="store_true",
47
+ help="Append newer per-volume INDEX.TABs to an existing database.",
48
+ )
49
+ p.add_argument(
50
+ "--release",
51
+ default=None,
52
+ metavar="VOLUME",
53
+ help="Build from this PDS volume, e.g. LROLRC_0066C (default: newest on the server).",
54
+ )
55
+ p.add_argument(
56
+ "--volume-only",
57
+ action="store_true",
58
+ help="Use the volume's own INDEX/INDEX.TAB instead of the cumulative CUMINDEX.TAB.",
59
+ )
60
+ p.add_argument(
61
+ "--wkt",
62
+ action="store_true",
63
+ help="Also store an FP_WKT footprint polygon column.",
64
+ )
65
+ p.add_argument(
66
+ "--memory-limit",
67
+ default="4GB",
68
+ metavar="SIZE",
69
+ help="DuckDB memory limit; sorting spills to disk beyond it (default: 4GB).",
70
+ )
71
+ p.add_argument(
72
+ "--threads",
73
+ type=int,
74
+ default=None,
75
+ metavar="N",
76
+ help="DuckDB worker threads (default: all cores).",
77
+ )
78
+ p.add_argument(
79
+ "--data-dir",
80
+ type=Path,
81
+ default=None,
82
+ metavar="DIR",
83
+ help="Where to write Parquet files (default: LUNAR_DATA_DIR or the user data dir).",
84
+ )
85
+ p.add_argument(
86
+ "--cache-dir",
87
+ default=str(DEFAULT_CACHE_DIR),
88
+ metavar="DIR",
89
+ help=f"Where to cache raw TAB/LBL downloads (default: {DEFAULT_CACHE_DIR}).",
90
+ )
91
+
92
+
93
+ def run(args: argparse.Namespace) -> int:
94
+ from ..lroc.cumindex import CumIndex
95
+
96
+ if args.all:
97
+ datasets = list(DATASETS)
98
+ elif args.datasets:
99
+ invalid = [d for d in args.datasets if d not in DATASETS]
100
+ if invalid:
101
+ print(
102
+ f"Unknown dataset(s): {', '.join(invalid)}. Choose from: {', '.join(DATASETS)}",
103
+ file=sys.stderr,
104
+ )
105
+ return 1
106
+ datasets = args.datasets
107
+ else:
108
+ datasets = ["edr"]
109
+
110
+ cache_dir = Path(args.cache_dir)
111
+
112
+ for dataset in datasets:
113
+ ds = DATASETS[dataset]
114
+ print(f"\n{ds['label']}")
115
+ ci = CumIndex(dataset=dataset, data_dir=args.data_dir, cache_dir=cache_dir)
116
+ print(f" Parquet : {ci.parquet_path}")
117
+ print(f" Cache : {cache_dir}")
118
+ if args.update and ci.parquet_path.exists() and not args.force:
119
+ ci.update_db(memory_limit=args.memory_limit, threads=args.threads)
120
+ continue
121
+ ci.build_db(
122
+ force=args.force,
123
+ release=args.release,
124
+ volume_only=args.volume_only,
125
+ memory_limit=args.memory_limit,
126
+ threads=args.threads,
127
+ wkt=args.wkt,
128
+ )
129
+
130
+ return 0
@@ -0,0 +1,111 @@
1
+ """download - download LROC image files by product ID, text file, or URL."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ COMMAND = "download"
8
+
9
+
10
+ def add_parser(sub: argparse._SubParsersAction) -> None:
11
+ p = sub.add_parser(
12
+ COMMAND,
13
+ help="Download LROC image files to a local directory.",
14
+ formatter_class=argparse.RawDescriptionHelpFormatter,
15
+ epilog="""
16
+ Examples
17
+ --------
18
+ lunar download M1096757719LE M1096757719RE
19
+ lunar download M1096757719LE --output-dir ./raw
20
+ lunar download --from-file ids.txt --output-dir ./raw
21
+ lunar download https://pds.lroc.im-ldi.com/data/.../M1096757719LE.IMG
22
+ lunar download M1096757719LC --dataset cdr # CDR image
23
+
24
+ Files already in --output-dir are skipped (use --force to download them again);
25
+ interrupted downloads resume from their .part file.
26
+ """,
27
+ )
28
+ p.add_argument(
29
+ "sources",
30
+ nargs="*",
31
+ metavar="SOURCE",
32
+ help="Product IDs (e.g. M1096757719LE) or PDS URLs.",
33
+ )
34
+ p.add_argument(
35
+ "--from-file",
36
+ metavar="FILE",
37
+ help="Text file of product IDs or URLs, one per line (#comments ignored).",
38
+ )
39
+ p.add_argument(
40
+ "--output-dir",
41
+ default=None,
42
+ metavar="DIR",
43
+ help="Destination directory (default: current working directory).",
44
+ )
45
+ p.add_argument(
46
+ "--dataset",
47
+ choices=["edr", "cdr"],
48
+ default="edr",
49
+ help="Download each image's raw EDR or calibrated CDR file (default: edr); "
50
+ "IDs are looked up in the index matching their LE/RE or LC/RC suffix.",
51
+ )
52
+ p.add_argument("--force", action="store_true", help="Download again even if the file exists.")
53
+ p.add_argument(
54
+ "--workers",
55
+ type=int,
56
+ default=8,
57
+ metavar="N",
58
+ help="Parallel download workers (default: 8).",
59
+ )
60
+ p.add_argument(
61
+ "--data-dir",
62
+ type=Path,
63
+ default=None,
64
+ metavar="DIR",
65
+ help="Override Parquet data directory.",
66
+ )
67
+
68
+
69
+ def run(args: argparse.Namespace) -> int:
70
+ from ..downloader import PdsDownloader
71
+ from ..lroc.nac import NacImageCollection
72
+ from ._utils import load_sources
73
+
74
+ sources = load_sources(args)
75
+ if not sources:
76
+ print("No sources provided. Pass product IDs, URLs, or --from-file.", file=sys.stderr)
77
+ return 1
78
+
79
+ output_dir = Path(args.output_dir) if args.output_dir else Path.cwd()
80
+ output_dir.mkdir(parents=True, exist_ok=True)
81
+
82
+ raw_urls = [s.strip() for s in sources if s.strip().startswith(("http://", "https://"))]
83
+ id_sources = [s for s in sources if not s.strip().startswith(("http://", "https://"))]
84
+
85
+ ok = fail = 0
86
+
87
+ if id_sources:
88
+ collection = NacImageCollection.from_sources(id_sources, data_dir=args.data_dir)
89
+ if not collection:
90
+ print("No product IDs found in the index.", file=sys.stderr)
91
+ else:
92
+ if args.dataset == "cdr":
93
+ results = collection.download_cdrs(output_dir, workers=args.workers, force=args.force)
94
+ else:
95
+ results = collection.download_edrs(output_dir, workers=args.workers, force=args.force)
96
+ ok += sum(1 for p in results.values() if p is not None)
97
+ fail += sum(1 for p in results.values() if p is None)
98
+
99
+ if raw_urls:
100
+ print(f"Downloading {len(raw_urls)} URL(s) to {output_dir} ...")
101
+ dl = PdsDownloader(max_workers=args.workers)
102
+ url_results = dl.download_urls(raw_urls, output_dir, force=args.force)
103
+ ok += sum(1 for p in url_results.values() if p is not None)
104
+ fail += sum(1 for p in url_results.values() if p is None)
105
+
106
+ if ok == 0 and fail == 0:
107
+ print("Nothing to download.", file=sys.stderr)
108
+ return 1
109
+
110
+ print(f"Done: {ok} succeeded{f', {fail} failed' if fail else ''}.")
111
+ return 0 if fail == 0 else 1
@@ -0,0 +1,253 @@
1
+ """kaguya - download Kaguya TC (Level 2B0) or MI (Level 2B2) products and import them into ISIS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ COMMAND = "kaguya"
11
+
12
+
13
+ def add_parser(sub: argparse._SubParsersAction) -> None:
14
+ p = sub.add_parser(
15
+ COMMAND,
16
+ help="Download Kaguya TC / MI images and import them into ISIS (cube + CSM camera).",
17
+ formatter_class=argparse.RawDescriptionHelpFormatter,
18
+ epilog="""
19
+ Examples
20
+ --------
21
+ lunar kaguya TC1W2B0_01_02936N034E0938 TC2W2B0_01_02936N036E0938 -o ./tc
22
+ lunar kaguya --point 34.0 93.8 --radius-km 2 --limit 4 -o ./tc
23
+ lunar kaguya --bbox 93.5 33.9 94.0 34.1 --product-type mono --inc 40 70 -o ./tc
24
+ lunar kaguya ./tc/TC1W2B0_01_02936N034E0938.lbl --mapproject --resolution 10
25
+ lunar kaguya TC1W2B0_01_02936N034E0938 --download-only
26
+ lunar kaguya TC1W2B0_01_02936N034E0938 --dry-run
27
+ lunar kaguya MVA_2B2_01_01935N340E0939 MNA_2B2_01_01935N339E0939 -o ./mi
28
+ lunar kaguya --instrument mi --point 34.0 93.9 --product-type nir --limit 2 -o ./mi
29
+
30
+ Per product, <output-dir> gets <id>.lbl, <id>.cub (kaguyatc2isis / kaguyami2isis
31
+ + spiceinit), <id>.json (CSM camera from isd_generate) and, with --mapproject,
32
+ <id>.tif. MI cubes hold all VIS (5) or NIR (4) bands; ALE's MI drivers need
33
+ local kernels, so MI CSM cameras default to --no-web runs only (--csm forces).
34
+ The instrument is inferred from the product IDs (MI: M[VN]A_2B2_...) unless
35
+ --instrument is given.
36
+ Steps whose output exists are skipped (use --force to redo them).
37
+ --bbox is WEST SOUTH EAST NORTH, longitudes positive east.
38
+ """,
39
+ )
40
+ p.add_argument(
41
+ "sources",
42
+ nargs="*",
43
+ metavar="SOURCE",
44
+ help="TC / MI product IDs (e.g. TC1W2B0_01_02936N034E0938) or local .lbl files.",
45
+ )
46
+ p.add_argument(
47
+ "--instrument",
48
+ "-i",
49
+ choices=("tc", "mi"),
50
+ default=None,
51
+ help="Terrain Camera or Multiband Imager (default: from SOURCE, else tc).",
52
+ )
53
+ p.add_argument(
54
+ "--from-file", metavar="FILE", help="Text file of sources, one per line (#comments ignored)."
55
+ )
56
+ p.add_argument(
57
+ "--output-dir",
58
+ "-o",
59
+ default=None,
60
+ metavar="DIR",
61
+ help="Output directory (default: current working directory).",
62
+ )
63
+
64
+ search = p.add_argument_group("ODE search (instead of / in addition to SOURCE)")
65
+ region = search.add_mutually_exclusive_group()
66
+ region.add_argument(
67
+ "--bbox",
68
+ nargs=4,
69
+ type=float,
70
+ metavar=("WEST", "SOUTH", "EAST", "NORTH"),
71
+ help="Bounding box in degrees.",
72
+ )
73
+ region.add_argument(
74
+ "--point",
75
+ nargs=2,
76
+ type=float,
77
+ metavar=("LAT", "LON"),
78
+ help="Search around a point (use with --radius-km).",
79
+ )
80
+ search.add_argument(
81
+ "--radius-km", type=float, default=1.0, metavar="KM", help="Radius for --point (default: 1 km)."
82
+ )
83
+ search.add_argument(
84
+ "--product-type",
85
+ "-t",
86
+ default=None,
87
+ metavar="PT",
88
+ help="TC: stereo (TCWL2B, default) or mono (TCSL2B); MI: vis (VIS2B, default) or nir (NIR2B).",
89
+ )
90
+ search.add_argument("--inc", nargs=2, type=float, metavar=("MIN", "MAX"), help="Incidence angle range.")
91
+ search.add_argument("--emi", nargs=2, type=float, metavar=("MIN", "MAX"), help="Emission angle range.")
92
+ search.add_argument("--phase", nargs=2, type=float, metavar=("MIN", "MAX"), help="Phase angle range.")
93
+ search.add_argument("--time", nargs=2, metavar=("START", "END"), help="Observation time range (UTC).")
94
+ search.add_argument(
95
+ "--limit",
96
+ type=int,
97
+ default=10,
98
+ metavar="N",
99
+ help="Maximum number of products from the search (default: 10).",
100
+ )
101
+
102
+ isis = p.add_argument_group("ISIS import")
103
+ isis.add_argument(
104
+ "--no-web",
105
+ dest="web",
106
+ action="store_false",
107
+ help="spiceinit with local kernels ($ISISDATA/kaguya) instead of the web service.",
108
+ )
109
+ isis.add_argument(
110
+ "--shape",
111
+ default="system",
112
+ metavar="SHAPE",
113
+ help="spiceinit shape: system (LOLA DEM in $ISISDATA/base/dems, default) or ellipsoid.",
114
+ )
115
+ isis.add_argument(
116
+ "--csm",
117
+ action=argparse.BooleanOptionalAction,
118
+ default=None,
119
+ help="Write a CSM camera (.json) with isd_generate (default: TC always, MI with --no-web).",
120
+ )
121
+ isis.add_argument(
122
+ "--only-naif-spice",
123
+ action=argparse.BooleanOptionalAction,
124
+ default=None,
125
+ help="TC: pass --only_naif_spice to isd_generate (default: with --no-web; MI always).",
126
+ )
127
+ isis.add_argument("--mapproject", action="store_true", help="Also cam2map and write <id>.tif.")
128
+ isis.add_argument(
129
+ "--projection",
130
+ default="equirectangular",
131
+ metavar="PROJ",
132
+ help="ISIS projection for --mapproject (default: equirectangular).",
133
+ )
134
+ isis.add_argument(
135
+ "--resolution",
136
+ type=float,
137
+ default=None,
138
+ metavar="M",
139
+ help="Map resolution in m/pixel for --mapproject.",
140
+ )
141
+ isis.add_argument("--keep", action="store_true", help="Keep the PDS .img and the map-projected cube.")
142
+ isis.add_argument("--force", action="store_true", help="Redo steps whose output exists.")
143
+ isis.add_argument(
144
+ "--download-only", action="store_true", help="Download (and gunzip) only; no ISIS processing."
145
+ )
146
+ isis.add_argument(
147
+ "--dry-run",
148
+ action="store_true",
149
+ help="Print the ISIS commands without downloading or running anything.",
150
+ )
151
+ isis.add_argument(
152
+ "--workers", type=int, default=1, metavar="N", help="Products processed in parallel (default: 1)."
153
+ )
154
+
155
+
156
+ def run(args: argparse.Namespace) -> int:
157
+ from ..downloader import url_filename
158
+ from ..kaguya import mi, tc
159
+ from ..ode import OdeClient, OdeError
160
+ from ._utils import load_sources, ode_filters, print_dry_run, region_args
161
+
162
+ sources = load_sources(args)
163
+ if not sources and not (args.bbox or args.point):
164
+ print("No sources. Pass product IDs, .lbl paths, --from-file, --bbox or --point.", file=sys.stderr)
165
+ return 1
166
+
167
+ instrument = args.instrument or (
168
+ "mi" if any(mi.is_mi_product_id(Path(s).name.split(".")[0]) for s in sources) else "tc"
169
+ )
170
+ steps: dict[str, tuple[Any, Any, Any]] = {
171
+ "mi": (mi.lookup_mi_products, mi.mi_products, mi.download_mi_product),
172
+ "tc": (tc.lookup_products, tc.tc_products, tc.download_product),
173
+ }
174
+ lookup, select, download = steps[instrument]
175
+
176
+ labels = [Path(s) for s in sources if s.lower().endswith(".lbl")]
177
+ ids = [s for s in sources if not s.lower().endswith(".lbl")]
178
+ client = OdeClient()
179
+ try:
180
+ products: list[Any] = select(lookup(ids, client)) if ids else []
181
+ if args.bbox or args.point:
182
+ products += select(
183
+ client.search(
184
+ f"kaguya_{instrument}",
185
+ args.product_type,
186
+ max_results=args.limit,
187
+ **region_args(args),
188
+ **ode_filters(args),
189
+ )
190
+ )
191
+ except (OdeError, ValueError) as exc:
192
+ print(f"Error: {exc}", file=sys.stderr)
193
+ return 1
194
+ products = list({p.product_id: p for p in products}.values())
195
+ seen = {p.product_id for p in products}
196
+ for missing in sorted({i.upper() for i in ids} - seen):
197
+ print(f" {missing} - not found on ODE", file=sys.stderr)
198
+
199
+ output_dir = Path(args.output_dir) if args.output_dir else Path.cwd()
200
+ print(f"{len(products) + len(labels)} Kaguya {instrument.upper()} product(s) -> {output_dir}")
201
+ for p in products:
202
+ size = f" ({p.image_kbytes / 1024:.0f} MB)" if p.image_kbytes else ""
203
+ print(f" {p.product_id}{size}")
204
+
205
+ if args.download_only:
206
+ failed = 0
207
+ for p in products:
208
+ try:
209
+ print(f" {p.product_id} -> {download(p, output_dir, force=args.force)}")
210
+ except Exception as exc:
211
+ print(f" {p.product_id} - FAILED: {exc}", file=sys.stderr)
212
+ failed += 1
213
+ return 1 if failed else 0
214
+
215
+ common: dict[str, Any] = {
216
+ "web": args.web,
217
+ "mapproject": args.mapproject,
218
+ "projection": args.projection,
219
+ "resolution": args.resolution,
220
+ "keep": args.keep,
221
+ "force": args.force,
222
+ "spiceinit_params": {"shape": args.shape},
223
+ }
224
+ proc: tc.TcProcessor | mi.MiProcessor
225
+ if instrument == "mi":
226
+ proc = mi.MiProcessor(output_dir, csm=args.csm, **common)
227
+ else:
228
+ proc = tc.TcProcessor(
229
+ output_dir, csm=args.csm is not False, only_naif_spice=args.only_naif_spice, **common
230
+ )
231
+ if args.dry_run:
232
+
233
+ def import_all() -> None:
234
+ for p in products:
235
+ proc.import_label(output_dir / url_filename(p.label_url))
236
+ for lbl in labels:
237
+ proc.import_label(lbl)
238
+
239
+ print_dry_run(import_all)
240
+ return 0
241
+
242
+ sources_all: list = [*products, *labels]
243
+ results = proc.process_batch(sources_all, max_workers=args.workers)
244
+ ok = 0
245
+ for src, out in zip(sources_all, results):
246
+ name = src.name if isinstance(src, Path) else src.product_id
247
+ if out is None:
248
+ print(f" {name} - FAILED", file=sys.stderr)
249
+ continue
250
+ ok += 1
251
+ files = [f for f in (out.cube, out.camera, out.geotiff) if f]
252
+ print(f" {name} -> {', '.join(str(f) for f in files)}")
253
+ return 0 if ok == len(sources_all) else 1
@@ -0,0 +1,144 @@
1
+ """ode-search - search ShadowCam / LROC / Kaguya TC products via ODE REST."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ COMMAND = "ode-search"
8
+
9
+
10
+ def add_parser(sub: argparse._SubParsersAction) -> None:
11
+ from ..ode import INSTRUMENTS
12
+
13
+ types = "; ".join(f"{k}: {', '.join(v['product_types'])}" for k, v in INSTRUMENTS.items())
14
+ p = sub.add_parser(
15
+ COMMAND,
16
+ help="Search lunar products (ShadowCam, LROC NAC, Kaguya TC) via the ODE REST API.",
17
+ formatter_class=argparse.RawDescriptionHelpFormatter,
18
+ epilog=f"""
19
+ Examples
20
+ --------
21
+ lunar ode-search --instrument shadowcam --bbox 0 -90 360 -89 --inc 80 90
22
+ lunar ode-search --instrument shadowcam --point -89.67 129.8 --radius-km 15
23
+ lunar ode-search --instrument lroc --product-type cdr --bbox 355 -1 5 1 --csv hits.csv
24
+ lunar ode-search --instrument shadowcam --point -89.67 129.8 --radius-km 5 \\
25
+ --download ./shadowcam --kind product
26
+ lunar ode-search --instrument shadowcam --aoi aoi.geojson # QuickMap polygon
27
+
28
+ Product types - {types}
29
+ (raw ODE pt codes such as EDRNAC4 are accepted too).
30
+ --bbox is WEST SOUTH EAST NORTH, longitudes positive east; WEST > EAST crosses 0/360.
31
+ --aoi takes a GeoJSON / WKT file or a WKT string; ODE is queried with its bounding
32
+ box and footprints are then intersected with the polygon.
33
+ """,
34
+ )
35
+ p.add_argument(
36
+ "--instrument",
37
+ "-i",
38
+ default="shadowcam",
39
+ help=f"Instrument: {', '.join(INSTRUMENTS)} (default: shadowcam).",
40
+ )
41
+ p.add_argument(
42
+ "--product-type",
43
+ "-t",
44
+ default=None,
45
+ metavar="PT",
46
+ help="Product type alias or ODE pt code (default: instrument's default).",
47
+ )
48
+ region = p.add_mutually_exclusive_group()
49
+ region.add_argument(
50
+ "--bbox",
51
+ nargs=4,
52
+ type=float,
53
+ metavar=("WEST", "SOUTH", "EAST", "NORTH"),
54
+ help="Bounding box in degrees.",
55
+ )
56
+ region.add_argument(
57
+ "--point",
58
+ nargs=2,
59
+ type=float,
60
+ metavar=("LAT", "LON"),
61
+ help="Search around a point (use with --radius-km).",
62
+ )
63
+ region.add_argument(
64
+ "--aoi", metavar="FILE_OR_WKT", help="Area of interest: GeoJSON / WKT file or WKT polygon string."
65
+ )
66
+ p.add_argument(
67
+ "--radius-km", type=float, default=1.0, metavar="KM", help="Radius for --point (default: 1 km)."
68
+ )
69
+ p.add_argument("--inc", nargs=2, type=float, metavar=("MIN", "MAX"), help="Incidence angle range.")
70
+ p.add_argument("--emi", nargs=2, type=float, metavar=("MIN", "MAX"), help="Emission angle range.")
71
+ p.add_argument("--phase", nargs=2, type=float, metavar=("MIN", "MAX"), help="Phase angle range.")
72
+ p.add_argument(
73
+ "--time",
74
+ nargs=2,
75
+ metavar=("START", "END"),
76
+ help="Observation time range (UTC, e.g. 2023-01-01 2023-12-31).",
77
+ )
78
+ p.add_argument(
79
+ "--limit",
80
+ type=int,
81
+ default=200,
82
+ metavar="N",
83
+ help="Maximum number of products to return (default: 200).",
84
+ )
85
+ p.add_argument("--count", action="store_true", help="Only print the number of matches.")
86
+ p.add_argument("--csv", metavar="FILE", help="Write results (common schema) to CSV.")
87
+ p.add_argument("--download", metavar="DIR", help="Download the selected files to DIR.")
88
+ p.add_argument(
89
+ "--kind",
90
+ default="product",
91
+ help="Files to download: product, label, browse, referenced, derived, all.",
92
+ )
93
+ p.add_argument(
94
+ "--match",
95
+ default=None,
96
+ metavar="TEXT",
97
+ help="Only download files whose name contains TEXT (e.g. _cog.tif).",
98
+ )
99
+ p.add_argument(
100
+ "--workers", type=int, default=4, metavar="N", help="Parallel download workers (default: 4)."
101
+ )
102
+
103
+
104
+ def run(args: argparse.Namespace) -> int:
105
+ from ..ode import OdeClient, OdeError
106
+ from ._utils import ode_filters, region_args
107
+
108
+ region, filters = region_args(args), ode_filters(args)
109
+
110
+ client = OdeClient()
111
+ try:
112
+ if args.count:
113
+ print(client.count(args.instrument, args.product_type, **region, **filters))
114
+ return 0
115
+ df = client.search(args.instrument, args.product_type, max_results=args.limit, **region, **filters)
116
+ except (OdeError, ValueError) as exc:
117
+ print(f"Error: {exc}", file=sys.stderr)
118
+ return 1
119
+
120
+ print(f"{len(df)} product(s)")
121
+ if not df.empty:
122
+ cols = [
123
+ "product_id",
124
+ "product_type",
125
+ "start_time",
126
+ "center_lat",
127
+ "center_lon",
128
+ "incidence",
129
+ "emission",
130
+ "phase",
131
+ "pixel_scale",
132
+ ]
133
+ print(df[cols].to_string(index=False, max_rows=50))
134
+ if args.csv:
135
+ df.drop(columns=["files"]).to_csv(args.csv, index=False)
136
+ print(f"Wrote {args.csv}")
137
+ if args.download and not df.empty:
138
+ results = client.download(
139
+ df, Path(args.download), kind=args.kind, match=args.match, workers=args.workers
140
+ )
141
+ failed = sum(1 for p in results.values() if p is None)
142
+ print(f"Downloaded {len(results) - failed}/{len(results)} file(s).")
143
+ return 1 if failed else 0
144
+ return 0