Coverage for lunar_toolkit/isis.py: 92%

96 statements  

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

1"""ISIS command wrappers (plus ALE ``isd_generate``), returning output paths.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from pathlib import Path 

7from typing import Any 

8 

9from .constants import DEFAULT_PROJECTION 

10from .tools import ToolError, find_tool, is_dry_run, isis_params, reuse_existing, run, tool_env 

11 

12log = logging.getLogger(__name__) 

13 

14 

15def isis(program: str, cwd: Path | None = None, **params: Any) -> None: 

16 """Run any ISIS *program* with ``key=value`` *params*.""" 

17 run(program, isis_params(params), cwd=cwd) 

18 

19 

20def lronac2isis(from_img: Path, to_cub: Path, **params: Any) -> Path: 

21 isis("lronac2isis", from_=from_img, to=to_cub, **params) 

22 return Path(to_cub) 

23 

24 

25def kaguyatc2isis(from_img: Path, to_cub: Path, **params: Any) -> Path: 

26 isis("kaguyatc2isis", from_=from_img, to=to_cub, **params) 

27 return Path(to_cub) 

28 

29 

30MOON_SHAPE_DEM = "base/dems/ldem_128ppd_Mar2011_clon180_radius_pad.cub" 

31 

32 

33def spiceinit(from_cub: Path, spksmithed: bool = True, web: bool = True, **params: Any) -> Path: 

34 """Attach SPICE; ``web=True`` uses the USGS SPICE web service. 

35 

36 ISIS's default lunar shape model is the 2 GB LOLA DEM in ``$ISISDATA`` (not in a 

37 minimal ISISDATA). When it is missing and no other shape is asked for, the 

38 ellipsoid is used instead: ASP projects onto its own prior DEM and CSM cameras do 

39 not use the ISIS shape model. 

40 """ 

41 if params.get("shape") in (None, "system") and not system_shape_available(): 

42 log.warning("No ISIS lunar shape model ($ISISDATA/%s); spiceinit shape=ellipsoid", MOON_SHAPE_DEM) 

43 params["shape"] = "ellipsoid" 

44 isis("spiceinit", from_=from_cub, spksmithed=spksmithed, web=web, **params) 

45 return Path(from_cub) 

46 

47 

48def system_shape_available() -> bool: 

49 """Whether ``$ISISDATA`` (as the ISIS tools see it) has the default lunar shape model. 

50 

51 True when ``spiceinit`` cannot be found or under [`dry_run`][lunar_toolkit.tools.dry_run] 

52 (nothing to decide then). 

53 """ 

54 tool = find_tool("spiceinit") 

55 if tool is None or is_dry_run(): 55 ↛ 57line 55 didn't jump to line 57 because the condition on line 55 was always true

56 return True 

57 data = tool_env(tool).get("ISISDATA") 

58 return data is not None and (Path(data) / MOON_SHAPE_DEM).exists() 

59 

60 

61def lronaccal(from_cub: Path, to_cub: Path, **params: Any) -> Path: 

62 isis("lronaccal", from_=from_cub, to=to_cub, **params) 

63 return Path(to_cub) 

64 

65 

66def lronacecho(from_cub: Path, to_cub: Path, **params: Any) -> Path: 

67 isis("lronacecho", from_=from_cub, to=to_cub, **params) 

68 return Path(to_cub) 

69 

70 

71def reduce(from_cub: Path, to_cub: Path, scale: float, algorithm: str = "average", **params: Any) -> Path: 

72 """Downsample by *scale* in both directions (``sscale`` = ``lscale``).""" 

73 isis( 

74 "reduce", 

75 from_=from_cub, 

76 to=to_cub, 

77 algorithm=algorithm, 

78 mode="scale", 

79 sscale=scale, 

80 lscale=scale, 

81 **params, 

82 ) 

83 return Path(to_cub) 

84 

85 

86def mosrange(fromlist: Path, projection: str, to_map: Path, **params: Any) -> Path: 

87 isis("mosrange", fromlist=fromlist, projection=projection, to=to_map, **params) 

88 return Path(to_map) 

89 

90 

91def cam2map( 

92 from_cub: Path, 

93 to_cub: Path, 

94 map: Path, 

95 warpalgorithm: str = "forwardpatch", 

96 patchsize: int = 50, 

97 **params: Any, 

98) -> Path: 

99 isis( 

100 "cam2map", 

101 from_=from_cub, 

102 to=to_cub, 

103 map=map, 

104 warpalgorithm=warpalgorithm, 

105 patchsize=patchsize, 

106 **params, 

107 ) 

108 return Path(to_cub) 

109 

110 

111def map_project( 

112 from_cub: Path, 

113 to_cub: Path, 

114 projection: str = DEFAULT_PROJECTION, 

115 resolution: float | None = None, 

116 keep: bool = False, 

117) -> Path: 

118 """``mosrange`` → ``cam2map``: project a cube onto a map fitted to its footprint. 

119 

120 Parameters 

121 ---------- 

122 from_cub, to_cub : Path 

123 spiceinit'ed input cube and map-projected output cube. 

124 projection : str 

125 ISIS projection for ``mosrange``. 

126 resolution : float, optional 

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

128 keep : bool 

129 Keep the ``<id>.lis`` / ``<id>.map`` files written next to *to_cub*. 

130 """ 

131 to_cub = Path(to_cub) 

132 pid = Path(from_cub).name.split(".")[0] 

133 fromlist, map_file = to_cub.with_name(f"{pid}.lis"), to_cub.with_name(f"{pid}.map") 

134 if not is_dry_run(): 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true

135 fromlist.write_text(f"{from_cub}\n") 

136 mosrange(fromlist, projection, map_file) 

137 params: dict[str, Any] = {"pixres": "mpp", "resolution": resolution} if resolution else {} 

138 cam2map(from_cub, to_cub, map_file, **params) 

139 if not keep and not is_dry_run(): 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true

140 fromlist.unlink(missing_ok=True) 

141 map_file.unlink(missing_ok=True) 

142 return to_cub 

143 

144 

145def isd_generate( 

146 cub: Path, out: Path | None = None, kernel: Path | None = None, only_naif_spice: bool = False 

147) -> Path: 

148 """Write a CSM camera (ISD ``.json``) for a spiceinit'ed cube. 

149 

150 *kernel* defaults to the cube itself, so its attached SPICE is used. 

151 Returns the JSON path (``<cub stem>.json`` next to the cube by default). 

152 """ 

153 cub = Path(cub) 

154 out = Path(out) if out else cub.with_suffix(".json") 

155 args = ["-k", kernel or cub, "-o", out] 

156 if only_naif_spice: 

157 args.append("--only_naif_spice") 

158 run("isd_generate", [*args, cub]) 

159 return out 

160 

161 

162def calibrate_lronac( 

163 img: Path, 

164 out_dir: Path | None = None, 

165 *, 

166 web: bool = True, 

167 keep: bool = False, 

168 force: bool = False, 

169) -> Path: 

170 """lronac2isis → spiceinit → lronaccal → lronacecho for one NAC EDR. 

171 

172 Writes ``<id>.cal.echo.cub`` in *out_dir* (default: next to *img*) and 

173 returns it; an existing output is reused unless *force*. The raw and 

174 calibrated cubes are removed unless *keep*. 

175 """ 

176 img = Path(img) 

177 out_dir = Path(out_dir) if out_dir else img.parent 

178 pid = img.name.split(".")[0] 

179 raw, cal, echo = (out_dir / f"{pid}{ext}" for ext in (".cub", ".cal.cub", ".cal.echo.cub")) 

180 if reuse_existing(echo, force): 

181 return echo 

182 if not is_dry_run(): 

183 out_dir.mkdir(parents=True, exist_ok=True) 

184 lronac2isis(img, raw) 

185 spiceinit(raw, web=web) 

186 try: 

187 lronaccal(raw, cal) 

188 except ToolError: 

189 # Some dates have no pair of darks within lronaccal's 45-day window. 

190 log.warning("lronaccal with paired darks failed for %s; retrying with the nearest dark", pid) 

191 lronaccal(raw, cal, darkfiletype="NEAREST") 

192 lronacecho(cal, echo) 

193 if not keep and not is_dry_run(): 

194 raw.unlink(missing_ok=True) 

195 cal.unlink(missing_ok=True) 

196 return echo 

197 

198 

199def prepare_lronac( 

200 src: Path, out_dir: Path, *, csm: bool = True, web: bool = True, force: bool = False 

201) -> tuple[Path, Path | None]: 

202 """Calibrated cube and optional CSM camera for one NAC image. 

203 

204 A ``.cub`` *src* is used as is; anything else (an EDR ``.IMG``) goes through 

205 [`calibrate_lronac`][lunar_toolkit.isis.calibrate_lronac] into *out_dir*. With *csm*, ``<id>.json`` is 

206 written in *out_dir* by [`isd_generate`][lunar_toolkit.isis.isd_generate]. Existing outputs are reused 

207 unless *force*. 

208 

209 Returns 

210 ------- 

211 (cube, camera) 

212 *camera* is None without *csm*. 

213 """ 

214 src, out_dir = Path(src), Path(out_dir) 

215 cube = src if src.suffix.lower() == ".cub" else calibrate_lronac(src, out_dir, web=web, force=force) 

216 if not csm: 

217 return cube, None 

218 camera = out_dir / f"{src.name.split('.')[0]}.json" 

219 if not reuse_existing(camera, force): 

220 if not is_dry_run(): 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 out_dir.mkdir(parents=True, exist_ok=True) 

222 isd_generate(cube, camera) 

223 return cube, camera