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

1"""kaguya - download Kaguya TC (Level 2B0) or MI (Level 2B2) products and import them into ISIS.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import sys 

7from pathlib import Path 

8from typing import Any 

9 

10COMMAND = "kaguya" 

11 

12 

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 

29 

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 ) 

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 

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 

161 

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 

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: 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) 

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

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: 231 ↛ 242line 231 didn't jump to line 242 because the condition on line 231 was always true

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: 236 ↛ 237line 236 didn't jump to line 237 because the loop on line 236 never started

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