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,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
|