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,236 @@
|
|
|
1
|
+
"""pairs - rank NAC stereo pairs, or pick illumination-diverse images for SfS."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
COMMAND = "pairs"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_parser(sub: argparse._SubParsersAction) -> None:
|
|
11
|
+
p = sub.add_parser(
|
|
12
|
+
COMMAND,
|
|
13
|
+
help="Rank NAC stereo pairs (or select SfS images with --sfs) over a box, point+radius or AOI polygon.",
|
|
14
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
15
|
+
epilog="""
|
|
16
|
+
Examples
|
|
17
|
+
--------
|
|
18
|
+
lunar pairs --point -89.67 129.8 --radius-km 3 --top 20
|
|
19
|
+
lunar pairs --bbox 355 -5 5 5 --conv 15 45 --csv pairs.csv
|
|
20
|
+
lunar pairs --point -89.67 129.8 --radius-km 3 --sfs --inc 80 95 --list sfs_images.txt
|
|
21
|
+
lunar pairs --point -89.67 129.8 --radius-km 3 --sfs --coverage --views 3
|
|
22
|
+
lunar pairs --aoi aoi.geojson --sfs --coverage
|
|
23
|
+
lunar pairs --point -89.5 0 --radius-km 30 --engine duckdb --workspace work/pole
|
|
24
|
+
|
|
25
|
+
--bbox is WEST SOUTH EAST NORTH (degrees, positive east; WEST > EAST crosses 0/360).
|
|
26
|
+
--aoi takes a GeoJSON / WKT file or a WKT string (lon lat, degrees, positive east).
|
|
27
|
+
The search region is also the AOI that overlaps / coverage are measured in.
|
|
28
|
+
--sfs output (and --list) is sorted by sub-solar azimuth, as ASP SfS expects.
|
|
29
|
+
--engine duckdb ranks pairs with a DuckDB spatial self-join (same pairs and metrics as
|
|
30
|
+
the Python engine); auto uses it from 300 images when the spatial extension loads.
|
|
31
|
+
--workspace keeps the images and pairs tables in DIR/lunar.duckdb (see lunar workspace).
|
|
32
|
+
""",
|
|
33
|
+
)
|
|
34
|
+
p.add_argument("--dataset", choices=["edr", "cdr"], default="edr", help="Index to search (default: edr).")
|
|
35
|
+
region = p.add_mutually_exclusive_group(required=True)
|
|
36
|
+
region.add_argument("--bbox", nargs=4, type=float, metavar=("WEST", "SOUTH", "EAST", "NORTH"))
|
|
37
|
+
region.add_argument("--point", nargs=2, type=float, metavar=("LAT", "LON"))
|
|
38
|
+
region.add_argument(
|
|
39
|
+
"--aoi", metavar="FILE_OR_WKT", help="Area of interest: GeoJSON / WKT file or WKT polygon string."
|
|
40
|
+
)
|
|
41
|
+
p.add_argument("--radius-km", type=float, default=1.0, metavar="KM")
|
|
42
|
+
p.add_argument("--inc", nargs=2, type=float, metavar=("MIN", "MAX"), help="Incidence angle range.")
|
|
43
|
+
p.add_argument("--emi-max", type=float, default=None, metavar="DEG", help="Maximum emission angle.")
|
|
44
|
+
p.add_argument(
|
|
45
|
+
"--conv",
|
|
46
|
+
nargs=2,
|
|
47
|
+
type=float,
|
|
48
|
+
default=(10.0, 60.0),
|
|
49
|
+
metavar=("MIN", "MAX"),
|
|
50
|
+
help="Convergence angle limits (default: 10 60).",
|
|
51
|
+
)
|
|
52
|
+
p.add_argument("--max-inc-diff", type=float, default=20.0, metavar="DEG")
|
|
53
|
+
p.add_argument(
|
|
54
|
+
"--max-az-diff",
|
|
55
|
+
type=float,
|
|
56
|
+
default=30.0,
|
|
57
|
+
metavar="DEG",
|
|
58
|
+
help="Maximum sub-solar azimuth difference within a pair.",
|
|
59
|
+
)
|
|
60
|
+
p.add_argument("--min-overlap", type=float, default=0.1, metavar="FRAC")
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"--sfs", action="store_true", help="Select illumination-diverse images instead of stereo pairs."
|
|
63
|
+
)
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"--az-bin",
|
|
66
|
+
type=float,
|
|
67
|
+
default=30.0,
|
|
68
|
+
metavar="DEG",
|
|
69
|
+
help="--sfs: azimuth bin width / minimum separation (default: 30).",
|
|
70
|
+
)
|
|
71
|
+
p.add_argument("--per-bin", type=int, default=1, metavar="N")
|
|
72
|
+
p.add_argument("--max-images", type=int, default=None, metavar="N")
|
|
73
|
+
p.add_argument(
|
|
74
|
+
"--greedy",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="--sfs: enforce --az-bin separation between all picks instead of fixed bins.",
|
|
77
|
+
)
|
|
78
|
+
p.add_argument(
|
|
79
|
+
"--coverage", action="store_true", help="--sfs: per-location greedy selection over an AOI grid."
|
|
80
|
+
)
|
|
81
|
+
p.add_argument(
|
|
82
|
+
"--views",
|
|
83
|
+
type=int,
|
|
84
|
+
default=3,
|
|
85
|
+
metavar="N",
|
|
86
|
+
help="--sfs --coverage: target distinct Sun azimuths per location (default: 3).",
|
|
87
|
+
)
|
|
88
|
+
p.add_argument(
|
|
89
|
+
"--engine",
|
|
90
|
+
choices=["auto", "python", "duckdb"],
|
|
91
|
+
default="auto",
|
|
92
|
+
help="Stereo pair engine (default: auto = duckdb from 300 images).",
|
|
93
|
+
)
|
|
94
|
+
p.add_argument(
|
|
95
|
+
"--workspace",
|
|
96
|
+
type=Path,
|
|
97
|
+
metavar="DIR",
|
|
98
|
+
help="Write the images and pairs tables to DIR/lunar.duckdb (implies --engine duckdb).",
|
|
99
|
+
)
|
|
100
|
+
p.add_argument("--top", type=int, default=30, metavar="N", help="Rows to print (default: 30).")
|
|
101
|
+
p.add_argument("--csv", metavar="FILE", help="Write the full result to CSV.")
|
|
102
|
+
p.add_argument("--list", metavar="FILE", help="--sfs: write selected product IDs, one per line.")
|
|
103
|
+
p.add_argument(
|
|
104
|
+
"--data-dir", type=Path, default=None, metavar="DIR", help="Override Parquet data directory."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run(args: argparse.Namespace) -> int:
|
|
109
|
+
from ..lroc.cumindex import CumIndex
|
|
110
|
+
from ._utils import region_args
|
|
111
|
+
|
|
112
|
+
ci = CumIndex(dataset=args.dataset, data_dir=args.data_dir)
|
|
113
|
+
region = region_args(args)
|
|
114
|
+
aoi = region.get("aoi", region)
|
|
115
|
+
try:
|
|
116
|
+
df = ci.search(
|
|
117
|
+
**region,
|
|
118
|
+
incidence_angle=tuple(args.inc) if args.inc else None,
|
|
119
|
+
emission_angle=(0.0, args.emi_max) if args.emi_max is not None else None,
|
|
120
|
+
nac_only=True,
|
|
121
|
+
wkt=True,
|
|
122
|
+
)
|
|
123
|
+
except FileNotFoundError as exc:
|
|
124
|
+
print(exc, file=sys.stderr)
|
|
125
|
+
return 1
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
obs = ci.to_observations(df)
|
|
130
|
+
print(f"{len(obs)} image(s) in region")
|
|
131
|
+
|
|
132
|
+
if args.sfs:
|
|
133
|
+
from ..selection import select_for_coverage, select_illumination_diverse
|
|
134
|
+
|
|
135
|
+
if args.coverage:
|
|
136
|
+
res = select_for_coverage(
|
|
137
|
+
obs,
|
|
138
|
+
aoi,
|
|
139
|
+
azimuth_sep_deg=args.az_bin,
|
|
140
|
+
views_per_cell=args.views,
|
|
141
|
+
max_images=args.max_images,
|
|
142
|
+
)
|
|
143
|
+
vf = res.attrs.get("view_fractions", {})
|
|
144
|
+
if vf:
|
|
145
|
+
print(
|
|
146
|
+
"AOI fraction by number of distinct Sun azimuths: "
|
|
147
|
+
+ ", ".join(f"{k}: {v:.0%}" for k, v in vf.items())
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
res = select_illumination_diverse(
|
|
151
|
+
obs,
|
|
152
|
+
azimuth_bin_deg=args.az_bin,
|
|
153
|
+
per_bin=args.per_bin,
|
|
154
|
+
max_images=args.max_images,
|
|
155
|
+
aoi=aoi,
|
|
156
|
+
method="greedy" if args.greedy else "bins",
|
|
157
|
+
)
|
|
158
|
+
print(f"{len(res)} image(s) selected (sorted by Sun azimuth)")
|
|
159
|
+
cols = [
|
|
160
|
+
c
|
|
161
|
+
for c in (
|
|
162
|
+
"product_id",
|
|
163
|
+
"start_time",
|
|
164
|
+
"sub_solar_azimuth",
|
|
165
|
+
"incidence",
|
|
166
|
+
"emission",
|
|
167
|
+
"pixel_scale",
|
|
168
|
+
"aoi_coverage",
|
|
169
|
+
"selection_score",
|
|
170
|
+
)
|
|
171
|
+
if c in res.columns
|
|
172
|
+
]
|
|
173
|
+
if args.list:
|
|
174
|
+
Path(args.list).write_text("".join(f"{p}\n" for p in res["product_id"]))
|
|
175
|
+
print(f"Wrote {args.list}")
|
|
176
|
+
else:
|
|
177
|
+
res = _stereo_pairs(
|
|
178
|
+
obs,
|
|
179
|
+
args,
|
|
180
|
+
aoi=aoi,
|
|
181
|
+
convergence=tuple(args.conv),
|
|
182
|
+
max_incidence_diff=args.max_inc_diff,
|
|
183
|
+
max_azimuth_diff=args.max_az_diff,
|
|
184
|
+
min_overlap=args.min_overlap,
|
|
185
|
+
)
|
|
186
|
+
if res is None:
|
|
187
|
+
return 1
|
|
188
|
+
print(f"{len(res)} pair(s)")
|
|
189
|
+
cols = [
|
|
190
|
+
"product_id_1",
|
|
191
|
+
"product_id_2",
|
|
192
|
+
"convergence",
|
|
193
|
+
"b_h",
|
|
194
|
+
"incidence_diff",
|
|
195
|
+
"azimuth_diff",
|
|
196
|
+
"resolution_ratio",
|
|
197
|
+
"aoi_coverage",
|
|
198
|
+
"dt_days",
|
|
199
|
+
"score",
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
if not res.empty:
|
|
203
|
+
print(res[cols].head(args.top).to_string(index=False, float_format=lambda v: f"{v:.3g}"))
|
|
204
|
+
if args.csv:
|
|
205
|
+
res.drop(columns=[c for c in ("footprint_wkt",) if c in res.columns]).to_csv(args.csv, index=False)
|
|
206
|
+
print(f"Wrote {args.csv}")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _stereo_pairs(obs, args: argparse.Namespace, **kwargs):
|
|
211
|
+
"""find_stereo_pairs, or its DuckDB engine (--engine / --workspace); None on error."""
|
|
212
|
+
from ..stereo import find_stereo_pairs
|
|
213
|
+
from ..workspace import (
|
|
214
|
+
PAIRS_ENGINE_THRESHOLD,
|
|
215
|
+
Workspace,
|
|
216
|
+
WorkspaceError,
|
|
217
|
+
find_stereo_pairs_duckdb,
|
|
218
|
+
spatial_available,
|
|
219
|
+
workspace_path,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
engine = "duckdb" if args.workspace else args.engine
|
|
223
|
+
if engine == "auto":
|
|
224
|
+
engine = "duckdb" if len(obs) >= PAIRS_ENGINE_THRESHOLD and spatial_available() else "python"
|
|
225
|
+
if engine == "python":
|
|
226
|
+
return find_stereo_pairs(obs, **kwargs)
|
|
227
|
+
try:
|
|
228
|
+
if args.workspace:
|
|
229
|
+
with Workspace(args.workspace) as ws:
|
|
230
|
+
res = find_stereo_pairs_duckdb(obs, workspace=ws, **kwargs)
|
|
231
|
+
print(f"Wrote images and pairs to {workspace_path(args.workspace)}")
|
|
232
|
+
return res
|
|
233
|
+
return find_stereo_pairs_duckdb(obs, **kwargs)
|
|
234
|
+
except WorkspaceError as exc:
|
|
235
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
236
|
+
return None
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""process - download and run the ISIS + GDAL pipeline on LROC NAC images."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..constants import DEFAULT_PROJECTION
|
|
8
|
+
|
|
9
|
+
COMMAND = "process"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_parser(sub: argparse._SubParsersAction) -> None:
|
|
13
|
+
p = sub.add_parser(
|
|
14
|
+
COMMAND,
|
|
15
|
+
help="Run the ISIS + GDAL pipeline on LROC NAC images.",
|
|
16
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
17
|
+
epilog="""
|
|
18
|
+
Examples
|
|
19
|
+
--------
|
|
20
|
+
lunar process M1096757719LE
|
|
21
|
+
lunar process M1096757719LE M1096757719RE --workers 2
|
|
22
|
+
lunar process --from-file ids.txt --output-dir ./output
|
|
23
|
+
lunar process /data/M1096757719LE.IMG --keep --resolution 1
|
|
24
|
+
lunar process https://pds.lroc.im-ldi.com/data/.../M1096757719LE.IMG
|
|
25
|
+
|
|
26
|
+
Everything is written to --output-dir: the GeoTIFF <id>.tif (map-projected I/F,
|
|
27
|
+
DEFLATE-compressed) plus intermediates (<id>.IMG, <id>.cal.echo.cub, <id>.map.cub),
|
|
28
|
+
which are removed after success unless --keep. Steps whose output exists are
|
|
29
|
+
skipped, so re-running resumes (use --force to redo them).
|
|
30
|
+
""",
|
|
31
|
+
)
|
|
32
|
+
p.add_argument(
|
|
33
|
+
"sources",
|
|
34
|
+
nargs="*",
|
|
35
|
+
metavar="SOURCE",
|
|
36
|
+
help="Product IDs (e.g. M1096757719LE), local .IMG paths, or PDS URLs.",
|
|
37
|
+
)
|
|
38
|
+
p.add_argument(
|
|
39
|
+
"--from-file",
|
|
40
|
+
metavar="FILE",
|
|
41
|
+
help="Text file of sources, one per line (#comments ignored).",
|
|
42
|
+
)
|
|
43
|
+
p.add_argument(
|
|
44
|
+
"--output-dir",
|
|
45
|
+
default=None,
|
|
46
|
+
metavar="DIR",
|
|
47
|
+
help="Output directory (default: current working directory).",
|
|
48
|
+
)
|
|
49
|
+
p.add_argument(
|
|
50
|
+
"--keep",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Keep the downloaded EDR and the intermediate ISIS cubes.",
|
|
53
|
+
)
|
|
54
|
+
p.add_argument("--force", action="store_true", help="Redo steps whose output exists.")
|
|
55
|
+
p.add_argument(
|
|
56
|
+
"--workers",
|
|
57
|
+
type=int,
|
|
58
|
+
default=1,
|
|
59
|
+
metavar="N",
|
|
60
|
+
help="Parallel workers for batch processing (default: 1).",
|
|
61
|
+
)
|
|
62
|
+
p.add_argument(
|
|
63
|
+
"--projection",
|
|
64
|
+
default=DEFAULT_PROJECTION,
|
|
65
|
+
metavar="PROJ",
|
|
66
|
+
help=f"ISIS map projection for mosrange (default: {DEFAULT_PROJECTION}).",
|
|
67
|
+
)
|
|
68
|
+
p.add_argument("--resolution", type=float, default=None, metavar="M", help="Map resolution in m/pixel.")
|
|
69
|
+
p.add_argument(
|
|
70
|
+
"--no-web",
|
|
71
|
+
dest="web",
|
|
72
|
+
action="store_false",
|
|
73
|
+
help="Use local SPICE kernels in spiceinit instead of the USGS web service.",
|
|
74
|
+
)
|
|
75
|
+
p.add_argument(
|
|
76
|
+
"--data-dir",
|
|
77
|
+
type=Path,
|
|
78
|
+
default=None,
|
|
79
|
+
metavar="DIR",
|
|
80
|
+
help="Override Parquet data directory.",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run(args: argparse.Namespace) -> int:
|
|
85
|
+
from ..lroc.nac import NacImageCollection, NacProcessor
|
|
86
|
+
from ._utils import load_sources
|
|
87
|
+
|
|
88
|
+
sources = load_sources(args)
|
|
89
|
+
if not sources:
|
|
90
|
+
print("No sources provided. Pass product IDs, paths, URLs, or --from-file.", file=sys.stderr)
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
output_dir = Path(args.output_dir) if args.output_dir else Path.cwd()
|
|
94
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
# Route: product IDs -> NacImageCollection; existing paths / URLs -> processor directly
|
|
97
|
+
direct_sources: list[str] = []
|
|
98
|
+
id_sources: list[str] = []
|
|
99
|
+
for s in sources:
|
|
100
|
+
s = s.strip()
|
|
101
|
+
if s.startswith(("http://", "https://")) or Path(s).exists():
|
|
102
|
+
direct_sources.append(s)
|
|
103
|
+
else:
|
|
104
|
+
id_sources.append(s)
|
|
105
|
+
|
|
106
|
+
opts = {
|
|
107
|
+
"projection": args.projection,
|
|
108
|
+
"resolution": args.resolution,
|
|
109
|
+
"web": args.web,
|
|
110
|
+
"keep": args.keep,
|
|
111
|
+
"force": args.force,
|
|
112
|
+
}
|
|
113
|
+
ok = total = 0
|
|
114
|
+
|
|
115
|
+
if id_sources:
|
|
116
|
+
collection = NacImageCollection.from_sources(id_sources, data_dir=args.data_dir)
|
|
117
|
+
results = collection.process(output_dir, workers=args.workers, **opts)
|
|
118
|
+
for pid, tif in results.items():
|
|
119
|
+
total += 1
|
|
120
|
+
if tif:
|
|
121
|
+
print(f" {pid} -> {tif}")
|
|
122
|
+
ok += 1
|
|
123
|
+
else:
|
|
124
|
+
print(f" {pid} - FAILED", file=sys.stderr)
|
|
125
|
+
|
|
126
|
+
if direct_sources:
|
|
127
|
+
proc = NacProcessor(output_dir, **opts)
|
|
128
|
+
tifs = proc.process_batch(direct_sources, max_workers=args.workers)
|
|
129
|
+
for src, tif in zip(direct_sources, tifs):
|
|
130
|
+
total += 1
|
|
131
|
+
label = Path(src).name if not src.startswith("http") else src
|
|
132
|
+
if tif:
|
|
133
|
+
print(f" {label} -> {tif}")
|
|
134
|
+
ok += 1
|
|
135
|
+
else:
|
|
136
|
+
print(f" {label} - FAILED", file=sys.stderr)
|
|
137
|
+
|
|
138
|
+
return 0 if ok == total else 1
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""query - print metadata for LROC NAC images from the Parquet index."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
COMMAND = "query"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_parser(sub: argparse._SubParsersAction) -> None:
|
|
11
|
+
p = sub.add_parser(
|
|
12
|
+
COMMAND,
|
|
13
|
+
help="Print metadata for LROC NAC images from the Parquet index.",
|
|
14
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
15
|
+
epilog="""
|
|
16
|
+
Examples
|
|
17
|
+
--------
|
|
18
|
+
lunar query M1096757719LE
|
|
19
|
+
lunar query M1096757719LE M1096757719RE
|
|
20
|
+
lunar query --from-file ids.txt
|
|
21
|
+
lunar query /path/to/M1096757719LE.IMG
|
|
22
|
+
""",
|
|
23
|
+
)
|
|
24
|
+
p.add_argument(
|
|
25
|
+
"sources",
|
|
26
|
+
nargs="*",
|
|
27
|
+
metavar="SOURCE",
|
|
28
|
+
help="Product IDs, local .IMG paths, or a text file of IDs.",
|
|
29
|
+
)
|
|
30
|
+
p.add_argument(
|
|
31
|
+
"--from-file",
|
|
32
|
+
metavar="FILE",
|
|
33
|
+
help="Text file of product IDs or paths, one per line.",
|
|
34
|
+
)
|
|
35
|
+
p.add_argument(
|
|
36
|
+
"--data-dir",
|
|
37
|
+
type=Path,
|
|
38
|
+
default=None,
|
|
39
|
+
metavar="DIR",
|
|
40
|
+
help="Override Parquet data directory.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run(args: argparse.Namespace) -> int:
|
|
45
|
+
from ..lroc.nac import NacImageCollection
|
|
46
|
+
from ._utils import extract_product_id, load_sources
|
|
47
|
+
|
|
48
|
+
sources = load_sources(args)
|
|
49
|
+
if not sources:
|
|
50
|
+
print("No sources provided. Pass product IDs or --from-file.", file=sys.stderr)
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
product_ids = [extract_product_id(s) for s in sources]
|
|
54
|
+
|
|
55
|
+
collection = NacImageCollection.from_sources(product_ids, data_dir=args.data_dir)
|
|
56
|
+
found = {img.product_id: img for img in collection}
|
|
57
|
+
|
|
58
|
+
for pid in product_ids:
|
|
59
|
+
img = found.get(pid)
|
|
60
|
+
print(pid)
|
|
61
|
+
if img is None:
|
|
62
|
+
print(" (not found in index)")
|
|
63
|
+
else:
|
|
64
|
+
items = img.metadata_items()
|
|
65
|
+
width = max((len(label) for label, _ in items), default=30) + 2
|
|
66
|
+
for label, value in items:
|
|
67
|
+
print(f" {label:<{width}} {value}")
|
|
68
|
+
print()
|
|
69
|
+
|
|
70
|
+
return 0
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""reference - Artemis III candidate regions, PGDA LOLA 5 m site DEMs and Bertone et al. (2026) SfS DEMs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
COMMAND = "reference"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def add_parser(sub: argparse._SubParsersAction) -> None:
|
|
14
|
+
p = sub.add_parser(
|
|
15
|
+
COMMAND,
|
|
16
|
+
help="Reference data for the Artemis III regions: crop windows, PGDA LOLA 5 m DEMs, Bertone SfS DEMs.",
|
|
17
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
18
|
+
epilog="""
|
|
19
|
+
Examples
|
|
20
|
+
--------
|
|
21
|
+
lunar reference list
|
|
22
|
+
lunar reference show malapert # bbox, ROI projwin, PGDA site, Zenodo zip
|
|
23
|
+
lunar reference crop 11 --crop-km 2.5 # 2.5 km window at the ROI centre
|
|
24
|
+
lunar reference crop malapert --center -85.98 -0.26 --format wkt
|
|
25
|
+
lunar reference pgda malapert --kind surf toterr -o ref
|
|
26
|
+
lunar reference pgda malapert --kind clones --clones 10 -o ref
|
|
27
|
+
lunar reference pgda malapert --kind xyzi --csv --crop-km 2.5 -o ref
|
|
28
|
+
lunar reference sdem malapert --list # zip members (remote central directory)
|
|
29
|
+
lunar reference sdem malapert --remote --member GLDELEV GLDSIGM -o ref
|
|
30
|
+
lunar reference sdem malapert --download -o ref --member GLDELEV
|
|
31
|
+
|
|
32
|
+
REGION is an id (0-12), name, slug, unique part of a name or PGDA site
|
|
33
|
+
(11, "Malapert Massif", malapert-massif, malapert, Site23). The ROI is the
|
|
34
|
+
polar stereographic square the SfS DEM covers (= the PGDA site extent);
|
|
35
|
+
projwin is ULX ULY LRX LRY in metres (+proj=stere +lat_0=-90 +R=1737400).
|
|
36
|
+
Existing files are skipped (--force to redo); downloads resume.
|
|
37
|
+
""",
|
|
38
|
+
)
|
|
39
|
+
sp = p.add_subparsers(dest="ref_cmd", required=True)
|
|
40
|
+
|
|
41
|
+
sp.add_parser("list", help="List the 13 regions.").add_argument(
|
|
42
|
+
"--json", action="store_true", help="Print JSON instead of a table."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
show = sp.add_parser("show", help="Show one region.")
|
|
46
|
+
show.add_argument("region", metavar="REGION")
|
|
47
|
+
show.add_argument("--crop-km", type=float, default=None, metavar="KM", help="Also print a crop window.")
|
|
48
|
+
show.add_argument("--json", action="store_true", help="Print JSON.")
|
|
49
|
+
|
|
50
|
+
crop = sp.add_parser("crop", help="Print a square crop window centred on a region.")
|
|
51
|
+
crop.add_argument("region", metavar="REGION")
|
|
52
|
+
crop.add_argument("--crop-km", type=float, default=2.5, metavar="KM", help="Side length (default: 2.5).")
|
|
53
|
+
crop.add_argument(
|
|
54
|
+
"--center", nargs=2, type=float, metavar=("LAT", "LON"), help="Centre (default: ROI centre)."
|
|
55
|
+
)
|
|
56
|
+
crop.add_argument(
|
|
57
|
+
"--format",
|
|
58
|
+
choices=("projwin", "bounds", "wkt", "geojson"),
|
|
59
|
+
default="projwin",
|
|
60
|
+
help="projwin (ULX ULY LRX LRY), bounds (XMIN YMIN XMAX YMAX), lon/lat WKT or GeoJSON.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
pg = sp.add_parser("pgda", help="Download a region's PGDA LOLA 5 m site files.")
|
|
64
|
+
pg.add_argument("region", metavar="REGION")
|
|
65
|
+
pg.add_argument(
|
|
66
|
+
"--kind",
|
|
67
|
+
nargs="+",
|
|
68
|
+
default=["surf"],
|
|
69
|
+
metavar="KIND",
|
|
70
|
+
help="surf ldec toterr slp slperr xyzi clones rois (default: surf).",
|
|
71
|
+
)
|
|
72
|
+
pg.add_argument("--clones", type=int, default=None, metavar="N", help="Only the first N clones.")
|
|
73
|
+
pg.add_argument("--csv", action="store_true", help="Also write each .xyzi as an ASP CSV.")
|
|
74
|
+
pg.add_argument("--crop-km", type=float, default=None, metavar="KM", help="Crop the --csv points.")
|
|
75
|
+
pg.add_argument("--list", action="store_true", help="Print the URLs only.")
|
|
76
|
+
pg.add_argument("--refresh", action="store_true", help="Re-read the PGDA product page.")
|
|
77
|
+
_common(pg)
|
|
78
|
+
|
|
79
|
+
sd = sp.add_parser("sdem", help="List / download / extract a region's Bertone et al. Zenodo zip.")
|
|
80
|
+
sd.add_argument("region", metavar="REGION")
|
|
81
|
+
sd.add_argument(
|
|
82
|
+
"--member",
|
|
83
|
+
nargs="+",
|
|
84
|
+
default=["GLDELEV"],
|
|
85
|
+
metavar="PAT",
|
|
86
|
+
help="Members to extract: product code / substring or glob (default: GLDELEV).",
|
|
87
|
+
)
|
|
88
|
+
mode = sd.add_mutually_exclusive_group()
|
|
89
|
+
mode.add_argument("--list", action="store_true", help="List zip members (reads only the directory).")
|
|
90
|
+
mode.add_argument("--remote", action="store_true", help="Extract members remotely (no full zip).")
|
|
91
|
+
mode.add_argument(
|
|
92
|
+
"--download", action="store_true", help="Download the whole zip (1-4 GB), then extract."
|
|
93
|
+
)
|
|
94
|
+
sd.add_argument("--zip", type=Path, default=None, metavar="ZIP", help="Extract from this local zip.")
|
|
95
|
+
_common(sd)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _common(p: argparse.ArgumentParser) -> None:
|
|
99
|
+
p.add_argument(
|
|
100
|
+
"--output-dir", "-o", type=Path, default=Path("."), metavar="DIR", help="Destination (default: .)."
|
|
101
|
+
)
|
|
102
|
+
p.add_argument("--force", action="store_true", help="Download / extract again even if present.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _region_info(reg, crop_km: float | None = None) -> dict:
|
|
106
|
+
from .. import reference as ref
|
|
107
|
+
|
|
108
|
+
roi = reg.roi
|
|
109
|
+
info = {
|
|
110
|
+
"id": reg.id,
|
|
111
|
+
"name": reg.name,
|
|
112
|
+
"slug": reg.slug,
|
|
113
|
+
"center_lonlat": reg.center,
|
|
114
|
+
"bbox": reg.bbox,
|
|
115
|
+
"roi_projwin": roi.projwin,
|
|
116
|
+
"roi_size_km": (roi.width / 1000.0, roi.height / 1000.0),
|
|
117
|
+
"bbox_projwin": tuple(round(v, 1) for v in reg.bbox_projwin),
|
|
118
|
+
"pgda_site": reg.pgda_site,
|
|
119
|
+
"pgda_url": ref.PGDA_SITES[reg.pgda_site].url if reg.pgda_site else None,
|
|
120
|
+
"zenodo_file": reg.zenodo_file,
|
|
121
|
+
"zenodo_size_gb": round(reg.zenodo_size / 1e9, 2),
|
|
122
|
+
"zenodo_md5": reg.zenodo_md5,
|
|
123
|
+
"zenodo_url": reg.zenodo_url,
|
|
124
|
+
}
|
|
125
|
+
if crop_km:
|
|
126
|
+
info["crop_projwin"] = ref.crop_window(reg, crop_km).projwin
|
|
127
|
+
return info
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run(args: argparse.Namespace) -> int:
|
|
131
|
+
from .. import reference as ref
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
if args.ref_cmd == "list":
|
|
135
|
+
return _list(args)
|
|
136
|
+
reg = ref.get_region(args.region)
|
|
137
|
+
if args.ref_cmd == "show":
|
|
138
|
+
info = _region_info(reg, args.crop_km)
|
|
139
|
+
if args.json:
|
|
140
|
+
print(json.dumps(info, indent=2))
|
|
141
|
+
else:
|
|
142
|
+
for k, v in info.items():
|
|
143
|
+
print(f"{k:15s} {' '.join(f'{x:g}' for x in v) if isinstance(v, tuple) else v}")
|
|
144
|
+
return 0
|
|
145
|
+
if args.ref_cmd == "crop":
|
|
146
|
+
return _crop(args, reg)
|
|
147
|
+
if args.ref_cmd == "pgda":
|
|
148
|
+
return _pgda(args, reg)
|
|
149
|
+
return _sdem(args, reg)
|
|
150
|
+
except (KeyError, ValueError) as exc:
|
|
151
|
+
print(f"Error: {exc.args[0] if exc.args else exc}", file=sys.stderr)
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _list(args: argparse.Namespace) -> int:
|
|
156
|
+
from .. import reference as ref
|
|
157
|
+
|
|
158
|
+
if args.json:
|
|
159
|
+
print(json.dumps([_region_info(r) for r in ref.REGIONS], indent=2))
|
|
160
|
+
return 0
|
|
161
|
+
print(f"{'id':>2} {'name':27s} {'pgda':9s} {'zip GB':>6} roi projwin (ULX ULY LRX LRY, m)")
|
|
162
|
+
for r in ref.REGIONS:
|
|
163
|
+
pw = " ".join(f"{v:.0f}" for v in r.projwin)
|
|
164
|
+
print(f"{r.id:>2} {r.name:27s} {r.pgda_site or '-':9s} {r.zenodo_size / 1e9:6.2f} {pw}")
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _crop(args: argparse.Namespace, reg) -> int:
|
|
169
|
+
from .. import reference as ref
|
|
170
|
+
|
|
171
|
+
box = ref.crop_window(reg, args.crop_km, tuple(args.center) if args.center else None)
|
|
172
|
+
if args.format == "projwin":
|
|
173
|
+
print(" ".join(f"{v:.3f}".rstrip("0").rstrip(".") for v in box.projwin))
|
|
174
|
+
elif args.format == "bounds":
|
|
175
|
+
print(" ".join(f"{v:.3f}".rstrip("0").rstrip(".") for v in box.bounds))
|
|
176
|
+
elif args.format == "wkt":
|
|
177
|
+
print(box.wkt())
|
|
178
|
+
else:
|
|
179
|
+
print(json.dumps({"type": "Polygon", "coordinates": [box.ring_lonlat()]}))
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _pgda(args: argparse.Namespace, reg) -> int:
|
|
184
|
+
from .. import reference as ref
|
|
185
|
+
|
|
186
|
+
index = ref.pgda_index(refresh=args.refresh)
|
|
187
|
+
if args.list:
|
|
188
|
+
for kind, urls in ref.pgda_urls(reg, args.kind, args.clones, index).items():
|
|
189
|
+
for u in urls:
|
|
190
|
+
print(f"{kind:7s} {u}")
|
|
191
|
+
return 0
|
|
192
|
+
paths = ref.download_pgda(reg, args.kind, args.output_dir, args.clones, args.force, index=index)
|
|
193
|
+
for kind, files in paths.items():
|
|
194
|
+
for f in files:
|
|
195
|
+
print(f"{kind:7s} {f}")
|
|
196
|
+
if args.csv:
|
|
197
|
+
box = ref.crop_window(reg, args.crop_km) if args.crop_km else None
|
|
198
|
+
for f in paths.get("xyzi", []):
|
|
199
|
+
out = f.with_suffix(f"{'_crop' if box else ''}.csv")
|
|
200
|
+
ref.write_asp_csv(f, out, box)
|
|
201
|
+
print(f"csv {out} (--csv-format '{ref.ASP_CSV_FORMAT}' --csv-srs '{ref.PS_SRS}')")
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _sdem(args: argparse.Namespace, reg) -> int:
|
|
206
|
+
from .. import reference as ref
|
|
207
|
+
|
|
208
|
+
if args.list:
|
|
209
|
+
members = ref.sdem_members(args.zip or reg)
|
|
210
|
+
for row in members.itertuples():
|
|
211
|
+
print(f"{row.size:>12,} {row.name}")
|
|
212
|
+
print(f"{len(members)} members, {members['size'].sum() / 1e9:.2f} GB uncompressed")
|
|
213
|
+
return 0
|
|
214
|
+
if args.remote:
|
|
215
|
+
source = reg
|
|
216
|
+
elif args.zip:
|
|
217
|
+
source = args.zip
|
|
218
|
+
elif args.download:
|
|
219
|
+
source = ref.download_sdem(reg, args.output_dir, force=args.force)
|
|
220
|
+
else:
|
|
221
|
+
local = args.output_dir / reg.zenodo_file
|
|
222
|
+
if not local.exists():
|
|
223
|
+
print(
|
|
224
|
+
f"{local} not found: pass --download (whole zip, {reg.zenodo_size / 1e9:.1f} GB), "
|
|
225
|
+
"--remote (selected members only) or --zip",
|
|
226
|
+
file=sys.stderr,
|
|
227
|
+
)
|
|
228
|
+
return 1
|
|
229
|
+
source = local
|
|
230
|
+
out_dir = args.output_dir / reg.zenodo_file.removesuffix(".zip")
|
|
231
|
+
for path in ref.extract_sdem(source, args.member, out_dir, force=args.force):
|
|
232
|
+
print(path)
|
|
233
|
+
return 0
|