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.
- lunar_toolkit/__init__.py +6 -0
- lunar_toolkit/asp.py +432 -0
- lunar_toolkit/cli/__init__.py +88 -0
- lunar_toolkit/cli/_jobs.py +437 -0
- lunar_toolkit/cli/_status.py +368 -0
- lunar_toolkit/cli/_utils.py +128 -0
- lunar_toolkit/cli/app.py +205 -0
- lunar_toolkit/cli/build_db.py +130 -0
- lunar_toolkit/cli/download.py +111 -0
- lunar_toolkit/cli/kaguya.py +253 -0
- lunar_toolkit/cli/ode_search.py +144 -0
- lunar_toolkit/cli/pairs.py +236 -0
- lunar_toolkit/cli/process.py +138 -0
- lunar_toolkit/cli/query.py +70 -0
- lunar_toolkit/cli/reference.py +233 -0
- lunar_toolkit/cli/search.py +90 -0
- lunar_toolkit/cli/serve.py +706 -0
- lunar_toolkit/cli/sfs.py +344 -0
- lunar_toolkit/cli/sfs_report.py +148 -0
- lunar_toolkit/cli/shadowcam.py +246 -0
- lunar_toolkit/cli/stereo.py +85 -0
- lunar_toolkit/cli/workspace.py +84 -0
- lunar_toolkit/config.py +345 -0
- lunar_toolkit/constants.py +217 -0
- lunar_toolkit/csm.py +340 -0
- lunar_toolkit/downloader.py +447 -0
- lunar_toolkit/geometry.py +556 -0
- lunar_toolkit/isis.py +222 -0
- lunar_toolkit/kaguya/__init__.py +45 -0
- lunar_toolkit/kaguya/mi.py +274 -0
- lunar_toolkit/kaguya/tc.py +370 -0
- lunar_toolkit/lroc/__init__.py +6 -0
- lunar_toolkit/lroc/cumindex.py +1197 -0
- lunar_toolkit/lroc/nac.py +702 -0
- lunar_toolkit/observation.py +127 -0
- lunar_toolkit/ode.py +485 -0
- lunar_toolkit/provenance.py +312 -0
- lunar_toolkit/reference.py +1111 -0
- lunar_toolkit/selection.py +714 -0
- lunar_toolkit/sfs.py +1534 -0
- lunar_toolkit/sfs_products.py +676 -0
- lunar_toolkit/sfs_report.py +1328 -0
- lunar_toolkit/shadowcam/__init__.py +29 -0
- lunar_toolkit/shadowcam/sc.py +392 -0
- lunar_toolkit/static/index.html +3259 -0
- lunar_toolkit/stereo.py +519 -0
- lunar_toolkit/stereo_dem.py +145 -0
- lunar_toolkit/tools.py +264 -0
- lunar_toolkit/workspace.py +1173 -0
- lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
- lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
- lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
- lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
- lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
- 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)
|