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
« 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."""
3from __future__ import annotations
5import logging
6from pathlib import Path
7from typing import Any
9from .constants import DEFAULT_PROJECTION
10from .tools import ToolError, find_tool, is_dry_run, isis_params, reuse_existing, run, tool_env
12log = logging.getLogger(__name__)
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)
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)
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)
30MOON_SHAPE_DEM = "base/dems/ldem_128ppd_Mar2011_clon180_radius_pad.cub"
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.
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)
48def system_shape_available() -> bool:
49 """Whether ``$ISISDATA`` (as the ISIS tools see it) has the default lunar shape model.
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()
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)
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)
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)
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)
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)
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.
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
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.
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
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.
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
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.
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*.
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