Coverage for lunar_toolkit/cli/kaguya.py: 68%
100 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"""kaguya - download Kaguya TC (Level 2B0) or MI (Level 2B2) products and import them into ISIS."""
3from __future__ import annotations
5import argparse
6import sys
7from pathlib import Path
8from typing import Any
10COMMAND = "kaguya"
13def 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="""
19Examples
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
30Per 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
33local kernels, so MI CSM cameras default to --no-web runs only (--csm forces).
34The instrument is inferred from the product IDs (MI: M[VN]A_2B2_...) unless
35--instrument is given.
36Steps 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 )
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 )
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 )
156def 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
162 sources = load_sources(args)
163 if not sources and not (args.bbox or args.point): 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 print("No sources. Pass product IDs, .lbl paths, --from-file, --bbox or --point.", file=sys.stderr)
165 return 1
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]
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: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true
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): 196 ↛ 197line 196 didn't jump to line 197 because the loop on line 196 never started
197 print(f" {missing} - not found on ODE", file=sys.stderr)
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}")
205 if args.download_only: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
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
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: 231 ↛ 242line 231 didn't jump to line 242 because the condition on line 231 was always true
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: 236 ↛ 237line 236 didn't jump to line 237 because the loop on line 236 never started
237 proc.import_label(lbl)
239 print_dry_run(import_all)
240 return 0
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