Coverage for lunar_toolkit/kaguya/mi.py: 71%

126 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 02:58 +0000

1"""Kaguya Multiband Imager Level 2B2: ODE file selection, download and ISIS import.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import re 

7from collections.abc import Iterable, Sequence 

8from concurrent.futures import ThreadPoolExecutor, as_completed 

9from dataclasses import dataclass 

10from pathlib import Path 

11from typing import TYPE_CHECKING, Any 

12 

13from ..constants import DEFAULT_PROJECTION 

14from ..isis import isd_generate, isis, map_project, spiceinit 

15from ..tools import is_dry_run, reuse_existing, run 

16from .tc import TcProduct, download_product, image_pointer, select_tc_files 

17 

18if TYPE_CHECKING: # pragma: no cover 

19 import pandas as pd 

20 

21 from ..ode import OdeClient 

22 

23log = logging.getLogger(__name__) 

24 

25# Level 2B2 MI product IDs, e.g. MVA_2B2_01_01935N340E0939 (V = VIS, N = NIR). 

26MI_ID_RE = re.compile(r"^M[VN][A-Z]_2B2_\d{2}_\d{5}[NS]\d{3}E\d{4}$", re.IGNORECASE) 

27 

28# kaguyami2isis: keep every DN, as for TC. 

29KAGUYAMI2ISIS_PARAMS: dict[str, Any] = { 

30 "setnullrange": False, 

31 "sethrsrange": False, 

32 "sethisrange": False, 

33 "setlrsrange": False, 

34 "setlisrange": False, 

35} 

36 

37# spiceinit: reconstructed CK / SPK, LOLA shape. 

38SPICEINIT_PARAMS: dict[str, Any] = { 

39 "ckrecon": True, 

40 "ckpredicted": False, 

41 "spkrecon": True, 

42 "spkpredicted": False, 

43 "shape": "system", 

44} 

45 

46 

47@dataclass(frozen=True) 

48class MiProduct: 

49 """Data and label URLs of one Level 2B2 MI product.""" 

50 

51 product_id: str 

52 image_url: str 

53 label_url: str 

54 image_kbytes: float | None = None 

55 

56 @property 

57 def sensor(self) -> str: 

58 """``"VIS"`` (5 bands) or ``"NIR"`` (4 bands).""" 

59 return "VIS" if self.product_id[1] == "V" else "NIR" 

60 

61 

62@dataclass 

63class MiOutputs: 

64 """Files written for one product (None for skipped steps).""" 

65 

66 product_id: str 

67 cube: Path 

68 camera: Path | None = None 

69 map_cube: Path | None = None 

70 geotiff: Path | None = None 

71 

72 

73def is_mi_product_id(product_id: str) -> bool: 

74 """True for a Level 2B2 MI product ID that ``kaguyami2isis`` can import.""" 

75 return bool(MI_ID_RE.match(product_id.strip())) 

76 

77 

78def select_mi_files(files: Iterable[dict], product_id: str | None = None) -> MiProduct | None: 

79 """Pick the data and label files of an MI product from its ODE ``files`` list. 

80 

81 MI products have the TC layout (detached ``.lbl`` + ``.img.gz``), so 

82 this follows [`select_tc_files`][lunar_toolkit.kaguya.tc.select_tc_files]. 

83 

84 Returns 

85 ------- 

86 MiProduct or None 

87 None when the list lacks a ``.img`` / ``.img.gz`` data file or a 

88 ``.lbl`` label. 

89 """ 

90 p = select_tc_files(files, product_id) 

91 return None if p is None else MiProduct(p.product_id, p.image_url, p.label_url, p.image_kbytes) 

92 

93 

94def mi_products(df: pd.DataFrame) -> list[MiProduct]: 

95 """MI Level 2B2 products with downloadable data + label files from an ODE search. 

96 

97 Rows that are not Level 2B2 products (2C2, 3C5, maps) or lack either 

98 file are skipped with a warning. 

99 """ 

100 out: list[MiProduct] = [] 

101 pids = df["product_id"] if "product_id" in df else [None] * len(df) 

102 for pid, files in zip(pids, df.get("files", [])): 

103 if pid and not is_mi_product_id(pid): 

104 log.warning("Skipping %s: not a Level 2B2 MI product", pid) 

105 continue 

106 product = select_mi_files(files, pid) 

107 if product is None: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 log.warning("Skipping %s: no .img/.lbl product files", pid) 

109 continue 

110 out.append(product) 

111 return out 

112 

113 

114def lookup_mi_products(product_ids: Iterable[str], client: OdeClient | None = None) -> pd.DataFrame: 

115 """Query ODE by product ID and return the observation DataFrame (see 

116 [`OdeClient.lookup`][lunar_toolkit.ode.OdeClient.lookup]).""" 

117 from ..ode import OdeClient 

118 

119 return (client or OdeClient()).lookup(product_ids, instrument_label="Kaguya MI") 

120 

121 

122def download_mi_product( 

123 product: MiProduct, 

124 output_dir: Path, 

125 force: bool = False, 

126 keep_gz: bool = False, 

127 show_progress: bool = True, 

128) -> Path: 

129 """Download (and gunzip) one product's label and image; returns the label path. 

130 

131 See [`download_product`][lunar_toolkit.kaguya.tc.download_product]. 

132 """ 

133 tc = TcProduct(product.product_id, product.image_url, product.label_url, product.image_kbytes) 

134 return download_product(tc, output_dir, force=force, keep_gz=keep_gz, show_progress=show_progress) 

135 

136 

137class MiProcessor: 

138 """Download and import Kaguya MI Level 2B2 products into ISIS. 

139 

140 Each product ends up in *output_dir* as ``<id>.cub`` (multi-band, 

141 spiceinit'ed), ``<id>.json`` (CSM camera, when *csm*) and, when 

142 *mapproject*, ``<id>.tif``. Steps whose output exists are skipped 

143 unless *force*. 

144 

145 Parameters 

146 ---------- 

147 output_dir : Path 

148 Where labels, images, cubes and cameras are written. 

149 web : bool 

150 Use the USGS SPICE web service in ``spiceinit`` (no local Kaguya 

151 kernels needed). Set False when ``$ISISDATA/kaguya`` is installed. 

152 csm : bool, optional 

153 Write a CSM camera with ``isd_generate``. ALE only has NAIF-SPICE 

154 MI drivers, which need local kernels, so the default is ``not web``. 

155 mapproject : bool 

156 Also ``cam2map`` the cube and write a GeoTIFF. 

157 projection : str 

158 ISIS projection for ``mosrange`` (default: equirectangular). 

159 resolution : float, optional 

160 Map resolution in m/pixel (default: ``mosrange``'s estimate). 

161 keep : bool 

162 Keep the PDS ``.img`` and the map-projected cube after use. 

163 force : bool 

164 Redo every step even when its output exists. 

165 kaguyami2isis_params, spiceinit_params : dict, optional 

166 Overrides merged into `KAGUYAMI2ISIS_PARAMS` / 

167 `SPICEINIT_PARAMS`. 

168 """ 

169 

170 def __init__( 

171 self, 

172 output_dir: Path = Path("kaguya"), 

173 *, 

174 web: bool = True, 

175 csm: bool | None = None, 

176 mapproject: bool = False, 

177 projection: str = DEFAULT_PROJECTION, 

178 resolution: float | None = None, 

179 keep: bool = False, 

180 force: bool = False, 

181 kaguyami2isis_params: dict[str, Any] | None = None, 

182 spiceinit_params: dict[str, Any] | None = None, 

183 ) -> None: 

184 self.output_dir = Path(output_dir) 

185 self.web = web 

186 self.csm = (not web) if csm is None else csm 

187 self.mapproject = mapproject 

188 self.projection = projection 

189 self.resolution = resolution 

190 self.keep = keep 

191 self.force = force 

192 self.kaguyami2isis_params = {**KAGUYAMI2ISIS_PARAMS, **(kaguyami2isis_params or {})} 

193 self.spiceinit_params = {**SPICEINIT_PARAMS, **(spiceinit_params or {})} 

194 

195 def process(self, source: MiProduct | str | Path) -> MiOutputs: 

196 """Download (if needed) and import one product. 

197 

198 *source* is a [`MiProduct`][lunar_toolkit.kaguya.mi.MiProduct], a local ``.lbl`` path or a product 

199 ID (looked up on ODE). 

200 """ 

201 if isinstance(source, MiProduct): 

202 label = download_mi_product(source, self.output_dir, force=self.force) 

203 elif Path(source).suffix.lower() == ".lbl": 

204 label = Path(source) 

205 else: 

206 products = mi_products(lookup_mi_products([str(source)])) 

207 if not products: 

208 raise ValueError(f"No Kaguya MI Level 2B2 product {source!r} on ODE") 

209 label = download_mi_product(products[0], self.output_dir, force=self.force) 

210 return self.import_label(label) 

211 

212 def process_batch( 

213 self, 

214 sources: Sequence[MiProduct | str | Path], 

215 max_workers: int = 2, 

216 ) -> list[MiOutputs | None]: 

217 """Process several products in parallel (None for failures).""" 

218 results: list[MiOutputs | None] = [None] * len(sources) 

219 with ThreadPoolExecutor(max_workers=max_workers) as pool: 

220 futures = {pool.submit(self.process, s): i for i, s in enumerate(sources)} 

221 for future in as_completed(futures): 

222 i = futures[future] 

223 try: 

224 results[i] = future.result() 

225 except Exception as exc: 

226 src = sources[i] 

227 log.error("Failed %s: %s", src.product_id if isinstance(src, MiProduct) else src, exc) 

228 return results 

229 

230 def import_label(self, label: Path) -> MiOutputs: 

231 """Run the ISIS chain on a downloaded PDS3 label (image beside it).""" 

232 label = Path(label) 

233 pid = label.name.split(".")[0].upper() 

234 if not is_dry_run(): 

235 self.output_dir.mkdir(parents=True, exist_ok=True) 

236 cube = self.output_dir / f"{pid}.cub" 

237 out = MiOutputs(pid, cube) 

238 

239 if not reuse_existing(cube, self.force): 

240 try: 

241 isis("kaguyami2isis", from_=label, to=cube, **self.kaguyami2isis_params) 

242 spiceinit(cube, web=self.web, **self.spiceinit_params) 

243 except Exception: 

244 cube.unlink(missing_ok=True) # never reuse a cube without SPICE 

245 raise 

246 if not self.keep and not is_dry_run(): 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true

247 name = image_pointer(label) if label.exists() else None 

248 if name: 

249 (label.parent / name).unlink(missing_ok=True) 

250 

251 if self.csm: 

252 out.camera = self.output_dir / f"{pid}.json" 

253 if not reuse_existing(out.camera, self.force): 

254 isd_generate(cube, out.camera, only_naif_spice=True) 

255 

256 if self.mapproject: 

257 out.geotiff = self.output_dir / f"{pid}.tif" 

258 map_cube = self.output_dir / f"{pid}.map.cub" 

259 if not reuse_existing(out.geotiff, self.force): 259 ↛ 267line 259 didn't jump to line 267 because the condition on line 259 was always true

260 map_project(cube, map_cube, self.projection, self.resolution, keep=self.keep) 

261 run( 

262 "gdal_translate", 

263 ["-of", "GTiff", "-co", "COMPRESS=DEFLATE", "-co", "TILED=YES", map_cube, out.geotiff], 

264 ) 

265 if not self.keep and not is_dry_run(): 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true

266 map_cube.unlink(missing_ok=True) 

267 if self.keep: 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true

268 out.map_cube = map_cube 

269 return out 

270 

271 

272def import_mi(label: Path, output_dir: Path | None = None, **kwargs: Any) -> MiOutputs: 

273 """Import one downloaded MI product; see [`MiProcessor`][lunar_toolkit.kaguya.mi.MiProcessor] for 

274 *kwargs*.""" 

275 label = Path(label) 

276 return MiProcessor(output_dir or label.parent, **kwargs).import_label(label)