lunar-toolkit 0.1.0.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. lunar_toolkit/__init__.py +6 -0
  2. lunar_toolkit/asp.py +432 -0
  3. lunar_toolkit/cli/__init__.py +88 -0
  4. lunar_toolkit/cli/_jobs.py +437 -0
  5. lunar_toolkit/cli/_status.py +368 -0
  6. lunar_toolkit/cli/_utils.py +128 -0
  7. lunar_toolkit/cli/app.py +205 -0
  8. lunar_toolkit/cli/build_db.py +130 -0
  9. lunar_toolkit/cli/download.py +111 -0
  10. lunar_toolkit/cli/kaguya.py +253 -0
  11. lunar_toolkit/cli/ode_search.py +144 -0
  12. lunar_toolkit/cli/pairs.py +236 -0
  13. lunar_toolkit/cli/process.py +138 -0
  14. lunar_toolkit/cli/query.py +70 -0
  15. lunar_toolkit/cli/reference.py +233 -0
  16. lunar_toolkit/cli/search.py +90 -0
  17. lunar_toolkit/cli/serve.py +706 -0
  18. lunar_toolkit/cli/sfs.py +344 -0
  19. lunar_toolkit/cli/sfs_report.py +148 -0
  20. lunar_toolkit/cli/shadowcam.py +246 -0
  21. lunar_toolkit/cli/stereo.py +85 -0
  22. lunar_toolkit/cli/workspace.py +84 -0
  23. lunar_toolkit/config.py +345 -0
  24. lunar_toolkit/constants.py +217 -0
  25. lunar_toolkit/csm.py +340 -0
  26. lunar_toolkit/downloader.py +447 -0
  27. lunar_toolkit/geometry.py +556 -0
  28. lunar_toolkit/isis.py +222 -0
  29. lunar_toolkit/kaguya/__init__.py +45 -0
  30. lunar_toolkit/kaguya/mi.py +274 -0
  31. lunar_toolkit/kaguya/tc.py +370 -0
  32. lunar_toolkit/lroc/__init__.py +6 -0
  33. lunar_toolkit/lroc/cumindex.py +1197 -0
  34. lunar_toolkit/lroc/nac.py +702 -0
  35. lunar_toolkit/observation.py +127 -0
  36. lunar_toolkit/ode.py +485 -0
  37. lunar_toolkit/provenance.py +312 -0
  38. lunar_toolkit/reference.py +1111 -0
  39. lunar_toolkit/selection.py +714 -0
  40. lunar_toolkit/sfs.py +1534 -0
  41. lunar_toolkit/sfs_products.py +676 -0
  42. lunar_toolkit/sfs_report.py +1328 -0
  43. lunar_toolkit/shadowcam/__init__.py +29 -0
  44. lunar_toolkit/shadowcam/sc.py +392 -0
  45. lunar_toolkit/static/index.html +3259 -0
  46. lunar_toolkit/stereo.py +519 -0
  47. lunar_toolkit/stereo_dem.py +145 -0
  48. lunar_toolkit/tools.py +264 -0
  49. lunar_toolkit/workspace.py +1173 -0
  50. lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
  51. lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
  52. lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
  53. lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  54. lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
  55. lunar_toolkit-0.1.0.dev2.dist-info/top_level.txt +1 -0
lunar_toolkit/isis.py ADDED
@@ -0,0 +1,222 @@
1
+ """ISIS command wrappers (plus ALE ``isd_generate``), returning output paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .constants import DEFAULT_PROJECTION
10
+ from .tools import ToolError, find_tool, is_dry_run, isis_params, reuse_existing, run, tool_env
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ def 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
+
20
+ def 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
+
25
+ def 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
+
30
+ MOON_SHAPE_DEM = "base/dems/ldem_128ppd_Mar2011_clon180_radius_pad.cub"
31
+
32
+
33
+ def 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
+
48
+ def 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 :func:`~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():
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
+
61
+ def 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
+
66
+ def 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
+
71
+ def 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
+
86
+ def 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
+
91
+ def 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
+
111
+ def 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():
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():
140
+ fromlist.unlink(missing_ok=True)
141
+ map_file.unlink(missing_ok=True)
142
+ return to_cub
143
+
144
+
145
+ def 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
+
162
+ def 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
+
199
+ def 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
+ :func:`calibrate_lronac` into *out_dir*. With *csm*, ``<id>.json`` is written
206
+ in *out_dir* by :func:`isd_generate`. Existing outputs are reused unless *force*.
207
+
208
+ Returns
209
+ -------
210
+ (cube, camera)
211
+ *camera* is None without *csm*.
212
+ """
213
+ src, out_dir = Path(src), Path(out_dir)
214
+ cube = src if src.suffix.lower() == ".cub" else calibrate_lronac(src, out_dir, web=web, force=force)
215
+ if not csm:
216
+ return cube, None
217
+ camera = out_dir / f"{src.name.split('.')[0]}.json"
218
+ if not reuse_existing(camera, force):
219
+ if not is_dry_run():
220
+ out_dir.mkdir(parents=True, exist_ok=True)
221
+ isd_generate(cube, camera)
222
+ return cube, camera
@@ -0,0 +1,45 @@
1
+ """SELENE (Kaguya) Terrain Camera and Multiband Imager download and ISIS import."""
2
+
3
+ from .mi import (
4
+ MiOutputs,
5
+ MiProcessor,
6
+ MiProduct,
7
+ download_mi_product,
8
+ import_mi,
9
+ is_mi_product_id,
10
+ lookup_mi_products,
11
+ mi_products,
12
+ select_mi_files,
13
+ )
14
+ from .tc import (
15
+ TcOutputs,
16
+ TcProcessor,
17
+ TcProduct,
18
+ download_product,
19
+ import_tc,
20
+ is_tc_product_id,
21
+ lookup_products,
22
+ select_tc_files,
23
+ tc_products,
24
+ )
25
+
26
+ __all__ = [
27
+ "MiOutputs",
28
+ "MiProcessor",
29
+ "MiProduct",
30
+ "TcOutputs",
31
+ "TcProcessor",
32
+ "TcProduct",
33
+ "download_mi_product",
34
+ "download_product",
35
+ "import_mi",
36
+ "import_tc",
37
+ "is_mi_product_id",
38
+ "is_tc_product_id",
39
+ "lookup_mi_products",
40
+ "lookup_products",
41
+ "mi_products",
42
+ "select_mi_files",
43
+ "select_tc_files",
44
+ "tc_products",
45
+ ]
@@ -0,0 +1,274 @@
1
+ """Kaguya Multiband Imager Level 2B2: ODE file selection, download and ISIS import."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from collections.abc import Iterable, Sequence
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from ..constants import DEFAULT_PROJECTION
14
+ from ..isis import isd_generate, isis, map_project, spiceinit
15
+ from ..tools import is_dry_run, reuse_existing, run
16
+ from .tc import TcProduct, download_product, image_pointer, select_tc_files
17
+
18
+ if TYPE_CHECKING: # pragma: no cover
19
+ import pandas as pd
20
+
21
+ from ..ode import OdeClient
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ # Level 2B2 MI product IDs, e.g. MVA_2B2_01_01935N340E0939 (V = VIS, N = NIR).
26
+ MI_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.
29
+ KAGUYAMI2ISIS_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.
38
+ SPICEINIT_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)
48
+ class 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
63
+ class 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
+
73
+ def 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
+
78
+ def 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 :func:`~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
+
94
+ def 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:
108
+ log.warning("Skipping %s: no .img/.lbl product files", pid)
109
+ continue
110
+ out.append(product)
111
+ return out
112
+
113
+
114
+ def 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 :meth:`OdeClient.lookup`)."""
116
+ from ..ode import OdeClient
117
+
118
+ return (client or OdeClient()).lookup(product_ids, instrument_label="Kaguya MI")
119
+
120
+
121
+ def download_mi_product(
122
+ product: MiProduct,
123
+ output_dir: Path,
124
+ force: bool = False,
125
+ keep_gz: bool = False,
126
+ show_progress: bool = True,
127
+ ) -> Path:
128
+ """Download (and gunzip) one product's label and image; returns the label path.
129
+
130
+ See :func:`~lunar_toolkit.kaguya.tc.download_product`.
131
+ """
132
+ tc = TcProduct(product.product_id, product.image_url, product.label_url, product.image_kbytes)
133
+ return download_product(tc, output_dir, force=force, keep_gz=keep_gz, show_progress=show_progress)
134
+
135
+
136
+ class MiProcessor:
137
+ """Download and import Kaguya MI Level 2B2 products into ISIS.
138
+
139
+ Each product ends up in *output_dir* as ``<id>.cub`` (multi-band,
140
+ spiceinit'ed), ``<id>.json`` (CSM camera, when *csm*) and, when
141
+ *mapproject*, ``<id>.tif``. Steps whose output exists are skipped
142
+ unless *force*.
143
+
144
+ Parameters
145
+ ----------
146
+ output_dir : Path
147
+ Where labels, images, cubes and cameras are written.
148
+ web : bool
149
+ Use the USGS SPICE web service in ``spiceinit`` (no local Kaguya
150
+ kernels needed). Set False when ``$ISISDATA/kaguya`` is installed.
151
+ csm : bool, optional
152
+ Write a CSM camera with ``isd_generate``. ALE only has NAIF-SPICE
153
+ MI drivers, which need local kernels, so the default is ``not web``.
154
+ mapproject : bool
155
+ Also ``cam2map`` the cube and write a GeoTIFF.
156
+ projection : str
157
+ ISIS projection for ``mosrange`` (default: equirectangular).
158
+ resolution : float, optional
159
+ Map resolution in m/pixel (default: ``mosrange``'s estimate).
160
+ keep : bool
161
+ Keep the PDS ``.img`` and the map-projected cube after use.
162
+ force : bool
163
+ Redo every step even when its output exists.
164
+ kaguyami2isis_params, spiceinit_params : dict, optional
165
+ Overrides merged into :data:`KAGUYAMI2ISIS_PARAMS` /
166
+ :data:`SPICEINIT_PARAMS`.
167
+ """
168
+
169
+ def __init__(
170
+ self,
171
+ output_dir: Path = Path("kaguya"),
172
+ *,
173
+ web: bool = True,
174
+ csm: bool | None = None,
175
+ mapproject: bool = False,
176
+ projection: str = DEFAULT_PROJECTION,
177
+ resolution: float | None = None,
178
+ keep: bool = False,
179
+ force: bool = False,
180
+ kaguyami2isis_params: dict[str, Any] | None = None,
181
+ spiceinit_params: dict[str, Any] | None = None,
182
+ ) -> None:
183
+ self.output_dir = Path(output_dir)
184
+ self.web = web
185
+ self.csm = (not web) if csm is None else csm
186
+ self.mapproject = mapproject
187
+ self.projection = projection
188
+ self.resolution = resolution
189
+ self.keep = keep
190
+ self.force = force
191
+ self.kaguyami2isis_params = {**KAGUYAMI2ISIS_PARAMS, **(kaguyami2isis_params or {})}
192
+ self.spiceinit_params = {**SPICEINIT_PARAMS, **(spiceinit_params or {})}
193
+
194
+ def process(self, source: MiProduct | str | Path) -> MiOutputs:
195
+ """Download (if needed) and import one product.
196
+
197
+ *source* is a :class:`MiProduct`, a local ``.lbl`` path or a product
198
+ ID (looked up on ODE).
199
+ """
200
+ if isinstance(source, MiProduct):
201
+ label = download_mi_product(source, self.output_dir, force=self.force)
202
+ elif Path(source).suffix.lower() == ".lbl":
203
+ label = Path(source)
204
+ else:
205
+ products = mi_products(lookup_mi_products([str(source)]))
206
+ if not products:
207
+ raise ValueError(f"No Kaguya MI Level 2B2 product {source!r} on ODE")
208
+ label = download_mi_product(products[0], self.output_dir, force=self.force)
209
+ return self.import_label(label)
210
+
211
+ def process_batch(
212
+ self,
213
+ sources: Sequence[MiProduct | str | Path],
214
+ max_workers: int = 2,
215
+ ) -> list[MiOutputs | None]:
216
+ """Process several products in parallel (None for failures)."""
217
+ results: list[MiOutputs | None] = [None] * len(sources)
218
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
219
+ futures = {pool.submit(self.process, s): i for i, s in enumerate(sources)}
220
+ for future in as_completed(futures):
221
+ i = futures[future]
222
+ try:
223
+ results[i] = future.result()
224
+ except Exception as exc:
225
+ src = sources[i]
226
+ log.error("Failed %s: %s", src.product_id if isinstance(src, MiProduct) else src, exc)
227
+ return results
228
+
229
+ def import_label(self, label: Path) -> MiOutputs:
230
+ """Run the ISIS chain on a downloaded PDS3 label (image beside it)."""
231
+ label = Path(label)
232
+ pid = label.name.split(".")[0].upper()
233
+ if not is_dry_run():
234
+ self.output_dir.mkdir(parents=True, exist_ok=True)
235
+ cube = self.output_dir / f"{pid}.cub"
236
+ out = MiOutputs(pid, cube)
237
+
238
+ if not reuse_existing(cube, self.force):
239
+ try:
240
+ isis("kaguyami2isis", from_=label, to=cube, **self.kaguyami2isis_params)
241
+ spiceinit(cube, web=self.web, **self.spiceinit_params)
242
+ except Exception:
243
+ cube.unlink(missing_ok=True) # never reuse a cube without SPICE
244
+ raise
245
+ if not self.keep and not is_dry_run():
246
+ name = image_pointer(label) if label.exists() else None
247
+ if name:
248
+ (label.parent / name).unlink(missing_ok=True)
249
+
250
+ if self.csm:
251
+ out.camera = self.output_dir / f"{pid}.json"
252
+ if not reuse_existing(out.camera, self.force):
253
+ isd_generate(cube, out.camera, only_naif_spice=True)
254
+
255
+ if self.mapproject:
256
+ out.geotiff = self.output_dir / f"{pid}.tif"
257
+ map_cube = self.output_dir / f"{pid}.map.cub"
258
+ if not reuse_existing(out.geotiff, self.force):
259
+ map_project(cube, map_cube, self.projection, self.resolution, keep=self.keep)
260
+ run(
261
+ "gdal_translate",
262
+ ["-of", "GTiff", "-co", "COMPRESS=DEFLATE", "-co", "TILED=YES", map_cube, out.geotiff],
263
+ )
264
+ if not self.keep and not is_dry_run():
265
+ map_cube.unlink(missing_ok=True)
266
+ if self.keep:
267
+ out.map_cube = map_cube
268
+ return out
269
+
270
+
271
+ def import_mi(label: Path, output_dir: Path | None = None, **kwargs: Any) -> MiOutputs:
272
+ """Import one downloaded MI product; see :class:`MiProcessor` for *kwargs*."""
273
+ label = Path(label)
274
+ return MiProcessor(output_dir or label.parent, **kwargs).import_label(label)