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

1"""shadowcam - download KPLO ShadowCam cubes and prepare them for ASP (SPICE + CSM camera).""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import sys 

7from pathlib import Path 

8 

9COMMAND = "shadowcam" 

10 

11 

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 

26 

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 ) 

60 

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 ) 

94 

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 ) 

147 

148 

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 

160 

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 

169 

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) 

192 

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}") 

198 

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 

208 

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

225 

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) 

231 

232 print_dry_run(process_all) 

233 return 0 

234 

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