Coverage for lunar_toolkit/cli/shadowcam.py: 66%
95 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 02:58 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 02:58 +0000
1"""shadowcam - download KPLO ShadowCam cubes and prepare them for ASP (SPICE + CSM camera)."""
3from __future__ import annotations
5import argparse
6import sys
7from pathlib import Path
9COMMAND = "shadowcam"
12def add_parser(sub: argparse._SubParsersAction) -> None:
13 p = sub.add_parser(
14 COMMAND,
15 help="Download ShadowCam cubes, spiceinit them and write CSM cameras for ASP.",
16 formatter_class=argparse.RawDescriptionHelpFormatter,
17 epilog="""
18Examples
19--------
20 lunar shadowcam M074289249S M074296291S -o ./sc
21 lunar shadowcam M013178466SC --validate -o ./sc
22 lunar shadowcam --point -89.67 129.8 --radius-km 5 --inc 80 90 --limit 4 -o ./sc
23 lunar shadowcam --aoi malapert.geojson --product cal --download-only -o ./sc
24 lunar shadowcam ./sc/M013178466SE.cub --mapproject --dem ref.tif --resolution 1.7
25 lunar shadowcam M013178466S --dry-run
27Follows the ASP 3.7 ShadowCam example, which needs ASP's custom ISIS / ALE
28build and the KPLO kernels in $ISISDATA/kplo/kernels (rsync.im-ldi.com::kplo).
29Observation IDs (M...S) map to the raw cube M...SE (used in ASP's example) or,
30with --product cal, the calibrated M...SC. Per product, <output-dir> gets
31<id>.cub (spiceinit'ed in place), <id>.json (isd_generate), with --validate
32<id>.cam_test.txt and with --mapproject <id>.tif (ASP mapproject with --dem,
33else ISIS cam2map). Steps whose output exists are skipped (--force redoes them).
34--bbox is WEST SOUTH EAST NORTH, longitudes positive east.
35""",
36 )
37 p.add_argument(
38 "sources",
39 nargs="*",
40 metavar="SOURCE",
41 help="Observation / product IDs (M013178466S, M013178466SE, M013178466SC) or local .cub files.",
42 )
43 p.add_argument(
44 "--from-file", metavar="FILE", help="Text file of sources, one per line (#comments ignored)."
45 )
46 p.add_argument(
47 "--output-dir",
48 "-o",
49 default=None,
50 metavar="DIR",
51 help="Output directory (default: current working directory).",
52 )
53 p.add_argument(
54 "--product",
55 "-t",
56 choices=("raw", "cal"),
57 default="raw",
58 help="raw (M...SE, ASP's example, default) or cal (M...SC) for IDs and searches.",
59 )
61 search = p.add_argument_group("ODE search (instead of / in addition to SOURCE)")
62 region = search.add_mutually_exclusive_group()
63 region.add_argument(
64 "--bbox",
65 nargs=4,
66 type=float,
67 metavar=("WEST", "SOUTH", "EAST", "NORTH"),
68 help="Bounding box in degrees.",
69 )
70 region.add_argument(
71 "--point",
72 nargs=2,
73 type=float,
74 metavar=("LAT", "LON"),
75 help="Search around a point (use with --radius-km).",
76 )
77 region.add_argument(
78 "--aoi", metavar="FILE_OR_WKT", help="Area of interest: GeoJSON / WKT file or WKT polygon string."
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("--inc", nargs=2, type=float, metavar=("MIN", "MAX"), help="Incidence angle range.")
84 search.add_argument("--emi", nargs=2, type=float, metavar=("MIN", "MAX"), help="Emission angle range.")
85 search.add_argument("--phase", nargs=2, type=float, metavar=("MIN", "MAX"), help="Phase angle range.")
86 search.add_argument("--time", nargs=2, metavar=("START", "END"), help="Observation time range (UTC).")
87 search.add_argument(
88 "--limit",
89 type=int,
90 default=10,
91 metavar="N",
92 help="Maximum number of products from the search (default: 10).",
93 )
95 proc = p.add_argument_group("Processing")
96 proc.add_argument(
97 "--web",
98 action="store_true",
99 help="spiceinit with the USGS SPICE web service (isd_generate still needs local kernels).",
100 )
101 proc.add_argument(
102 "--shape",
103 default="system",
104 metavar="SHAPE",
105 help="spiceinit shape: system (LOLA DEM in $ISISDATA/base/dems, default) or ellipsoid.",
106 )
107 proc.add_argument(
108 "--no-csm",
109 dest="csm",
110 action="store_false",
111 help="Do not write a CSM camera (.json) with isd_generate.",
112 )
113 proc.add_argument(
114 "--validate",
115 action="store_true",
116 help="Compare ISIS and CSM cameras with cam_test (<id>.cam_test.txt).",
117 )
118 proc.add_argument("--mapproject", action="store_true", help="Also write a map-projected <id>.tif.")
119 proc.add_argument("--dem", metavar="DEM", help="DEM for ASP mapproject (default: ISIS cam2map).")
120 proc.add_argument(
121 "--resolution",
122 type=float,
123 default=None,
124 metavar="M",
125 help="Map resolution in m/pixel for --mapproject.",
126 )
127 proc.add_argument(
128 "--t-srs", default=None, metavar="PROJ", help="PROJ string for ASP mapproject with --dem."
129 )
130 proc.add_argument(
131 "--projection",
132 default="equirectangular",
133 metavar="PROJ",
134 help="ISIS projection for --mapproject without --dem (default: equirectangular).",
135 )
136 proc.add_argument("--keep", action="store_true", help="Keep the ISIS map-projected cube.")
137 proc.add_argument("--force", action="store_true", help="Redo processing steps whose output exists.")
138 proc.add_argument("--download-only", action="store_true", help="Download only; no processing.")
139 proc.add_argument(
140 "--dry-run",
141 action="store_true",
142 help="Print the ISIS / ASP commands without downloading or running anything.",
143 )
144 proc.add_argument(
145 "--workers", type=int, default=1, metavar="N", help="Products processed in parallel (default: 1)."
146 )
149def run(args: argparse.Namespace) -> int:
150 from ..ode import OdeClient, OdeError
151 from ..shadowcam.sc import (
152 ScProcessor,
153 ScProduct,
154 download_product,
155 lookup_products,
156 product_id_for,
157 sc_products,
158 )
159 from ._utils import load_sources, ode_filters, print_dry_run, region_args
161 sources = load_sources(args)
162 region = region_args(args)
163 if not sources and not region: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 print(
165 "No sources. Pass IDs, .cub paths, --from-file, --bbox, --point or --aoi.",
166 file=sys.stderr,
167 )
168 return 1
170 cubes = [Path(s) for s in sources if s.lower().endswith(".cub")]
171 ids = [s for s in sources if not s.lower().endswith(".cub")]
172 client = OdeClient()
173 try:
174 wanted = {product_id_for(i, args.product) for i in ids}
175 products: list[ScProduct] = sc_products(lookup_products(ids, client, args.product)) if ids else []
176 if region: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 products += sc_products(
178 client.search(
179 "shadowcam",
180 args.product,
181 max_results=args.limit,
182 **region,
183 **ode_filters(args),
184 )
185 )
186 except (OdeError, ValueError) as exc:
187 print(f"Error: {exc}", file=sys.stderr)
188 return 1
189 products = list({p.product_id: p for p in products}.values())
190 for missing in sorted(wanted - {p.product_id for p in products}): 190 ↛ 191line 190 didn't jump to line 191 because the loop on line 190 never started
191 print(f" {missing} - not found on ODE", file=sys.stderr)
193 output_dir = Path(args.output_dir) if args.output_dir else Path.cwd()
194 print(f"{len(products) + len(cubes)} product(s) -> {output_dir}")
195 for p in products:
196 size = f" ({p.cube_kbytes / 1024:.0f} MB)" if p.cube_kbytes else ""
197 print(f" {p.product_id}{size}")
199 if args.download_only: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 failed = 0
201 for p in products:
202 try:
203 print(f" {p.product_id} -> {download_product(p, output_dir)}")
204 except Exception as exc:
205 print(f" {p.product_id} - FAILED: {exc}", file=sys.stderr)
206 failed += 1
207 return 1 if failed else 0
209 proc = ScProcessor(
210 output_dir,
211 product=args.product,
212 web=args.web,
213 csm=args.csm,
214 validate=args.validate,
215 mapproject=args.mapproject,
216 dem=Path(args.dem) if args.dem else None,
217 resolution=args.resolution,
218 t_srs=args.t_srs,
219 projection=args.projection,
220 keep=args.keep,
221 force=args.force,
222 spiceinit_params={"shape": args.shape},
223 )
224 if args.dry_run: 224 ↛ 235line 224 didn't jump to line 235 because the condition on line 224 was always true
226 def process_all() -> None:
227 for p in products:
228 proc.process_cube(proc.cube_path(p))
229 for cub in cubes: 229 ↛ 230line 229 didn't jump to line 230 because the loop on line 229 never started
230 proc.process_cube(cub)
232 print_dry_run(process_all)
233 return 0
235 sources_all: list = [*products, *cubes]
236 results = proc.process_batch(sources_all, max_workers=args.workers)
237 ok = 0
238 for src, out in zip(sources_all, results):
239 name = src.product_id if isinstance(src, ScProduct) else src.name
240 if out is None:
241 print(f" {name} - FAILED", file=sys.stderr)
242 continue
243 ok += 1
244 files = [f for f in (out.cube, out.camera, out.cam_test, out.geotiff) if f]
245 print(f" {name} -> {', '.join(str(f) for f in files)}")
246 return 0 if ok == len(sources_all) else 1