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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Common observation schema shared by all search backends.
|
|
2
|
+
|
|
3
|
+
Longitudes are 0–360 east (``lon_west > lon_east`` crosses the seam); angles in degrees,
|
|
4
|
+
``pixel_scale`` in m/px, ``spacecraft_altitude`` in km; missing values are None."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .constants import is_nac_id
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
OBSERVATION_COLUMNS: list[str] = [
|
|
16
|
+
"product_id",
|
|
17
|
+
"instrument", # e.g. "LROC NAC", "ShadowCam", "Kaguya TC"
|
|
18
|
+
"product_type", # e.g. "EDR", "CDR", "CAL", "RAW", "DTM"
|
|
19
|
+
"start_time", # pandas Timestamp (UTC, tz-naive)
|
|
20
|
+
"center_lat",
|
|
21
|
+
"center_lon",
|
|
22
|
+
"min_lat",
|
|
23
|
+
"max_lat",
|
|
24
|
+
"lon_west",
|
|
25
|
+
"lon_east",
|
|
26
|
+
"footprint_wkt", # lat/lon WKT if known (ODE: 0–360 cylindrical)
|
|
27
|
+
"incidence",
|
|
28
|
+
"emission",
|
|
29
|
+
"phase",
|
|
30
|
+
"slew_angle",
|
|
31
|
+
"sub_solar_azimuth", # None when the source does not provide it
|
|
32
|
+
"north_azimuth", # PDS NORTH_AZIMUTH (image-frame), None if unknown
|
|
33
|
+
"sub_spacecraft_lat", # sub-spacecraft point at image centre time
|
|
34
|
+
"sub_spacecraft_lon",
|
|
35
|
+
"spacecraft_altitude", # km above the surface
|
|
36
|
+
"pixel_scale",
|
|
37
|
+
"url", # primary product file
|
|
38
|
+
"label_url", # PDS label (.xml / .lbl)
|
|
39
|
+
"source", # "cumindex" or "ode"
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def empty_observations() -> pd.DataFrame:
|
|
44
|
+
import pandas as pd
|
|
45
|
+
|
|
46
|
+
return pd.DataFrame({c: pd.Series(dtype="object") for c in OBSERVATION_COLUMNS})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize_observations(records: list[dict]) -> pd.DataFrame:
|
|
50
|
+
"""Build an observation DataFrame from dicts (missing keys → None)."""
|
|
51
|
+
import pandas as pd
|
|
52
|
+
|
|
53
|
+
if not records:
|
|
54
|
+
return empty_observations()
|
|
55
|
+
df = pd.DataFrame.from_records(
|
|
56
|
+
[{c: r.get(c) for c in OBSERVATION_COLUMNS} for r in records],
|
|
57
|
+
columns=OBSERVATION_COLUMNS,
|
|
58
|
+
)
|
|
59
|
+
for c in (
|
|
60
|
+
"center_lat",
|
|
61
|
+
"center_lon",
|
|
62
|
+
"min_lat",
|
|
63
|
+
"max_lat",
|
|
64
|
+
"lon_west",
|
|
65
|
+
"lon_east",
|
|
66
|
+
"incidence",
|
|
67
|
+
"emission",
|
|
68
|
+
"phase",
|
|
69
|
+
"slew_angle",
|
|
70
|
+
"sub_solar_azimuth",
|
|
71
|
+
"pixel_scale",
|
|
72
|
+
"north_azimuth",
|
|
73
|
+
"sub_spacecraft_lat",
|
|
74
|
+
"sub_spacecraft_lon",
|
|
75
|
+
"spacecraft_altitude",
|
|
76
|
+
):
|
|
77
|
+
df[c] = pd.to_numeric(df[c], errors="coerce")
|
|
78
|
+
df["start_time"] = pd.to_datetime(df["start_time"], errors="coerce", utc=True).dt.tz_localize(None)
|
|
79
|
+
return df
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def observations_from_cumindex(df: pd.DataFrame, dataset: str | None = "edr") -> pd.DataFrame:
|
|
83
|
+
"""Map enriched ``CumIndex`` query results to :data:`OBSERVATION_COLUMNS`."""
|
|
84
|
+
import pandas as pd
|
|
85
|
+
|
|
86
|
+
if df is None or df.empty:
|
|
87
|
+
return empty_observations()
|
|
88
|
+
|
|
89
|
+
def g(row, col):
|
|
90
|
+
v = row.get(col)
|
|
91
|
+
try:
|
|
92
|
+
return None if pd.isna(v) else v
|
|
93
|
+
except (TypeError, ValueError): # array-like value
|
|
94
|
+
return v
|
|
95
|
+
|
|
96
|
+
recs = []
|
|
97
|
+
for _, row in df.iterrows():
|
|
98
|
+
pid = str(row.get("PRODUCT_ID", ""))
|
|
99
|
+
recs.append(
|
|
100
|
+
{
|
|
101
|
+
"product_id": pid,
|
|
102
|
+
"instrument": "LROC NAC" if is_nac_id(pid) else "LROC WAC",
|
|
103
|
+
"product_type": (dataset or "").upper() or None,
|
|
104
|
+
"start_time": g(row, "START_TIME"),
|
|
105
|
+
"center_lat": g(row, "CENTER_LATITUDE"),
|
|
106
|
+
"center_lon": g(row, "CENTER_LONGITUDE"),
|
|
107
|
+
"min_lat": g(row, "FP_MIN_LAT"),
|
|
108
|
+
"max_lat": g(row, "FP_MAX_LAT"),
|
|
109
|
+
"lon_west": g(row, "FP_LON_WEST"),
|
|
110
|
+
"lon_east": g(row, "FP_LON_EAST"),
|
|
111
|
+
"footprint_wkt": g(row, "FP_WKT"),
|
|
112
|
+
"incidence": g(row, "INCIDENCE_ANGLE"),
|
|
113
|
+
"emission": g(row, "EMISSION_ANGLE"),
|
|
114
|
+
"phase": g(row, "PHASE_ANGLE"),
|
|
115
|
+
"slew_angle": g(row, "SLEW_ANGLE"),
|
|
116
|
+
"sub_solar_azimuth": g(row, "SUB_SOLAR_AZIMUTH"),
|
|
117
|
+
"north_azimuth": g(row, "NORTH_AZIMUTH"),
|
|
118
|
+
"sub_spacecraft_lat": g(row, "SUB_SPACECRAFT_LATITUDE"),
|
|
119
|
+
"sub_spacecraft_lon": g(row, "SUB_SPACECRAFT_LONGITUDE"),
|
|
120
|
+
"spacecraft_altitude": g(row, "SPACECRAFT_ALTITUDE"),
|
|
121
|
+
"pixel_scale": g(row, "RESOLUTION") if "RESOLUTION" in row else g(row, "PIXEL_SCALE"),
|
|
122
|
+
"url": g(row, "PDS_URL"),
|
|
123
|
+
"label_url": g(row, "LABEL_URL"),
|
|
124
|
+
"source": "cumindex",
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
return normalize_observations(recs)
|
lunar_toolkit/ode.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""PDS ODE REST client (ShadowCam, LROC NAC, Kaguya TC) returning the observation schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from .constants import modernize_pds_url
|
|
16
|
+
from .downloader import _USER_AGENT, PdsDownloader
|
|
17
|
+
from .geometry import aoi_mask, aoi_region, load_aoi, query_region, region_bbox
|
|
18
|
+
from .observation import OBSERVATION_COLUMNS, normalize_observations
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
ODE_REST_URL = "https://oderest.rsl.wustl.edu/live2/"
|
|
26
|
+
|
|
27
|
+
# Instrument registry: CLI/API key → ODE instrument host id, instrument id and
|
|
28
|
+
# friendly product-type aliases (raw ODE ``pt`` values are accepted too).
|
|
29
|
+
INSTRUMENTS: dict[str, dict[str, Any]] = {
|
|
30
|
+
"shadowcam": {
|
|
31
|
+
"label": "ShadowCam",
|
|
32
|
+
"ihid": "KPLO",
|
|
33
|
+
"iid": "ShadowCam",
|
|
34
|
+
"product_types": {"raw": "RAW", "cal": "CAL", "cmos": "CMOS", "umos": "UMOS", "dtm": "DTM"},
|
|
35
|
+
"default": "cal",
|
|
36
|
+
},
|
|
37
|
+
"lroc": {
|
|
38
|
+
"label": "LROC NAC",
|
|
39
|
+
"ihid": "LRO",
|
|
40
|
+
"iid": "LROC",
|
|
41
|
+
"product_types": {"edr": "EDRNAC4", "cdr": "CDRNAC4"},
|
|
42
|
+
"default": "edr",
|
|
43
|
+
},
|
|
44
|
+
"kaguya_tc": {
|
|
45
|
+
"label": "Kaguya TC",
|
|
46
|
+
"ihid": "SLN",
|
|
47
|
+
"iid": "TC",
|
|
48
|
+
"product_types": {
|
|
49
|
+
"stereo": "TCWL2B", # Stereo Level 2B0
|
|
50
|
+
"mono": "TCSL2B", # Mono Level 2B0
|
|
51
|
+
"dtm": "TCDTMO", # DTM ortho
|
|
52
|
+
"ortho": "TCORT", # Ortho map
|
|
53
|
+
"morning": "TCMORM",
|
|
54
|
+
"evening": "TCEVEM",
|
|
55
|
+
},
|
|
56
|
+
"default": "stereo",
|
|
57
|
+
},
|
|
58
|
+
"kaguya_mi": {
|
|
59
|
+
"label": "Kaguya MI",
|
|
60
|
+
"ihid": "SLN",
|
|
61
|
+
"iid": "MI",
|
|
62
|
+
"product_types": {
|
|
63
|
+
"vis": "VIS2B", # VIS Level 2B2 (kaguyami2isis)
|
|
64
|
+
"nir": "NIR2B", # NIR Level 2B2 (kaguyami2isis)
|
|
65
|
+
"vis2c": "VIS2C", # VIS Level 2C2
|
|
66
|
+
"nir2c": "NIR2C", # NIR Level 2C2
|
|
67
|
+
"l3c5": "MI3C", # Level 3C5 reflectance, map-projected
|
|
68
|
+
"map": "MIMAP", # Map tiles
|
|
69
|
+
},
|
|
70
|
+
"default": "vis",
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_PRODUCT_QUERY = {"query": "product", "output": "JSON", "target": "moon"}
|
|
75
|
+
|
|
76
|
+
_ALIASES = {
|
|
77
|
+
"sc": "shadowcam",
|
|
78
|
+
"kplo": "shadowcam",
|
|
79
|
+
"nac": "lroc",
|
|
80
|
+
"lroc_nac": "lroc",
|
|
81
|
+
"kaguya": "kaguya_tc",
|
|
82
|
+
"tc": "kaguya_tc",
|
|
83
|
+
"selene": "kaguya_tc",
|
|
84
|
+
"mi": "kaguya_mi",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_instrument(instrument: str, product_type: str | None = None) -> tuple[str, dict, str]:
|
|
89
|
+
"""Return ``(key, registry_entry, ode_pt)`` for user-facing names."""
|
|
90
|
+
key = instrument.strip().lower().replace("-", "_")
|
|
91
|
+
key = _ALIASES.get(key, key)
|
|
92
|
+
if key not in INSTRUMENTS:
|
|
93
|
+
raise ValueError(f"Unknown instrument {instrument!r}. Valid: {sorted(INSTRUMENTS)}")
|
|
94
|
+
entry = INSTRUMENTS[key]
|
|
95
|
+
pt_key = (product_type or entry["default"]).strip()
|
|
96
|
+
pts = entry["product_types"]
|
|
97
|
+
if pt_key.lower() in pts:
|
|
98
|
+
return key, entry, pts[pt_key.lower()]
|
|
99
|
+
if pt_key.upper() in {v.upper() for v in pts.values()} or product_type:
|
|
100
|
+
return key, entry, pt_key.upper()
|
|
101
|
+
raise ValueError(f"Unknown product type {product_type!r} for {key}: {sorted(pts)}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OdeError(RuntimeError):
|
|
105
|
+
"""ODE returned an error status."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class OdeClient:
|
|
109
|
+
"""Query ODE REST and return :data:`OBSERVATION_COLUMNS` DataFrames.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
base_url : str
|
|
114
|
+
ODE REST endpoint (default: ``live2``).
|
|
115
|
+
timeout : float
|
|
116
|
+
Per-request timeout in seconds — ODE footprint queries can take
|
|
117
|
+
30 s or more.
|
|
118
|
+
page_size : int
|
|
119
|
+
Products per request (ODE ``limit``); results are paged with ``offset``.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
base_url: str = ODE_REST_URL,
|
|
125
|
+
timeout: float = 180.0,
|
|
126
|
+
page_size: int = 100,
|
|
127
|
+
retries: int = 2,
|
|
128
|
+
) -> None:
|
|
129
|
+
self.base_url = base_url
|
|
130
|
+
self.timeout = timeout
|
|
131
|
+
self.page_size = page_size
|
|
132
|
+
self.retries = retries
|
|
133
|
+
|
|
134
|
+
# Low-level request
|
|
135
|
+
|
|
136
|
+
def build_params(
|
|
137
|
+
self,
|
|
138
|
+
instrument: str,
|
|
139
|
+
product_type: str | None = None,
|
|
140
|
+
lat: tuple[float, float] | None = None,
|
|
141
|
+
lon: tuple[float, float] | None = None,
|
|
142
|
+
point: tuple[float, float] | None = None,
|
|
143
|
+
radius_km: float = 0.0,
|
|
144
|
+
incidence: tuple[float, float] | None = None,
|
|
145
|
+
emission: tuple[float, float] | None = None,
|
|
146
|
+
phase: tuple[float, float] | None = None,
|
|
147
|
+
time_range: tuple[str, str] | None = None,
|
|
148
|
+
loc: str | None = None,
|
|
149
|
+
results: str = "fmp",
|
|
150
|
+
aoi=None,
|
|
151
|
+
) -> dict[str, str]:
|
|
152
|
+
"""Translate search arguments into ODE query parameters.
|
|
153
|
+
|
|
154
|
+
An ``aoi`` (see :func:`lunar_toolkit.geometry.load_aoi`) is sent as
|
|
155
|
+
its bounding box; :meth:`search` refines the results exactly.
|
|
156
|
+
"""
|
|
157
|
+
_, entry, pt = resolve_instrument(instrument, product_type)
|
|
158
|
+
params: dict[str, str] = {
|
|
159
|
+
**_PRODUCT_QUERY,
|
|
160
|
+
"results": results,
|
|
161
|
+
"ihid": entry["ihid"],
|
|
162
|
+
"iid": entry["iid"],
|
|
163
|
+
"pt": pt,
|
|
164
|
+
}
|
|
165
|
+
if aoi is not None or point is not None or lat is not None or lon is not None:
|
|
166
|
+
if aoi is not None:
|
|
167
|
+
if point is not None or lat is not None or lon is not None:
|
|
168
|
+
raise ValueError("Pass either aoi or lat/lon/point, not both")
|
|
169
|
+
region = aoi_region(aoi)
|
|
170
|
+
else:
|
|
171
|
+
region = query_region(lat=lat, lon=lon, point=point, radius_km=radius_km)
|
|
172
|
+
west, south, east, north = region_bbox(region)
|
|
173
|
+
params.update(
|
|
174
|
+
{
|
|
175
|
+
"minlat": _fmt(south),
|
|
176
|
+
"maxlat": _fmt(north),
|
|
177
|
+
# ODE: positive east 0–360, boxes may cross the meridian
|
|
178
|
+
"westernlon": _fmt(west),
|
|
179
|
+
"easternlon": _fmt(east),
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
for (lo, hi), rng in (
|
|
183
|
+
(("mininangle", "maxinangle"), incidence),
|
|
184
|
+
(("minemangle", "maxemangle"), emission),
|
|
185
|
+
(("minphangle", "maxphangle"), phase),
|
|
186
|
+
):
|
|
187
|
+
if rng is not None:
|
|
188
|
+
params[lo], params[hi] = _fmt(rng[0]), _fmt(rng[1])
|
|
189
|
+
if time_range is not None:
|
|
190
|
+
params["minobtime"], params["maxobtime"] = str(time_range[0]), str(time_range[1])
|
|
191
|
+
if loc:
|
|
192
|
+
params["loc"] = loc
|
|
193
|
+
return params
|
|
194
|
+
|
|
195
|
+
def request(self, params: dict[str, str]) -> dict:
|
|
196
|
+
"""GET the ODE endpoint and return the decoded ``ODEResults`` dict."""
|
|
197
|
+
url = self.base_url + "?" + urllib.parse.urlencode(params)
|
|
198
|
+
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
|
199
|
+
last: Exception | None = None
|
|
200
|
+
for attempt in range(self.retries + 1):
|
|
201
|
+
try:
|
|
202
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
203
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
204
|
+
break
|
|
205
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
|
206
|
+
last = exc
|
|
207
|
+
time.sleep(2 * (attempt + 1))
|
|
208
|
+
else:
|
|
209
|
+
raise OdeError(f"ODE request failed: {last}") from last
|
|
210
|
+
res = data.get("ODEResults", data)
|
|
211
|
+
if str(res.get("Status", "")).lower() == "error":
|
|
212
|
+
raise OdeError(res.get("Error") or json.dumps(res)[:500])
|
|
213
|
+
return res
|
|
214
|
+
|
|
215
|
+
# Search
|
|
216
|
+
|
|
217
|
+
def search(
|
|
218
|
+
self,
|
|
219
|
+
instrument: str,
|
|
220
|
+
product_type: str | None = None,
|
|
221
|
+
lat: tuple[float, float] | None = None,
|
|
222
|
+
lon: tuple[float, float] | None = None,
|
|
223
|
+
point: tuple[float, float] | None = None,
|
|
224
|
+
radius_km: float = 0.0,
|
|
225
|
+
incidence: tuple[float, float] | None = None,
|
|
226
|
+
emission: tuple[float, float] | None = None,
|
|
227
|
+
phase: tuple[float, float] | None = None,
|
|
228
|
+
time_range: tuple[str, str] | None = None,
|
|
229
|
+
max_results: int | None = 1000,
|
|
230
|
+
loc: str | None = None,
|
|
231
|
+
aoi=None,
|
|
232
|
+
) -> pd.DataFrame:
|
|
233
|
+
"""Search ODE and return the common observation schema.
|
|
234
|
+
|
|
235
|
+
Region arguments follow :meth:`CumIndex.search`: ``lat=(min, max)``
|
|
236
|
+
and ``lon=(west, east)`` (``west > east`` crosses 0/360), or
|
|
237
|
+
``point=(lat, lon)`` with ``radius_km`` (converted to a bounding box;
|
|
238
|
+
circles reaching a pole become a full-longitude cap). ODE matches the
|
|
239
|
+
box against product footprints (``loc=f``, its default). With
|
|
240
|
+
``aoi`` (GeoJSON / WKT path, WKT string or
|
|
241
|
+
:class:`~lunar_toolkit.geometry.Aoi`) the AOI's
|
|
242
|
+
bounding box is queried and each ``footprint_wkt`` is then
|
|
243
|
+
intersected with the polygon; paging continues until
|
|
244
|
+
``max_results`` products survive that refinement.
|
|
245
|
+
|
|
246
|
+
Extra columns: ``files`` (list of ODE product-file dicts) and
|
|
247
|
+
``ode_url`` (ODE product page).
|
|
248
|
+
"""
|
|
249
|
+
key, entry, _ = resolve_instrument(instrument, product_type)
|
|
250
|
+
if aoi is not None:
|
|
251
|
+
aoi = load_aoi(aoi)
|
|
252
|
+
params = self.build_params(
|
|
253
|
+
instrument,
|
|
254
|
+
product_type,
|
|
255
|
+
lat=lat,
|
|
256
|
+
lon=lon,
|
|
257
|
+
point=point,
|
|
258
|
+
radius_km=radius_km,
|
|
259
|
+
incidence=incidence,
|
|
260
|
+
emission=emission,
|
|
261
|
+
phase=phase,
|
|
262
|
+
time_range=time_range,
|
|
263
|
+
loc=loc,
|
|
264
|
+
aoi=aoi,
|
|
265
|
+
)
|
|
266
|
+
records: list[dict] = []
|
|
267
|
+
offset = 0
|
|
268
|
+
while True:
|
|
269
|
+
limit = self.page_size
|
|
270
|
+
if max_results is not None:
|
|
271
|
+
remaining = max_results - len(records)
|
|
272
|
+
if remaining <= 0:
|
|
273
|
+
break
|
|
274
|
+
if aoi is None:
|
|
275
|
+
limit = min(limit, remaining)
|
|
276
|
+
res = self.request({**params, "limit": str(limit), "offset": str(offset)})
|
|
277
|
+
products = extract_products(res)
|
|
278
|
+
page = [parse_product(p, instrument_label=entry["label"]) for p in products]
|
|
279
|
+
if aoi is not None and page:
|
|
280
|
+
page = [r for r, keep in zip(page, aoi_mask(to_dataframe(page), aoi)) if keep]
|
|
281
|
+
records += page
|
|
282
|
+
if len(products) < limit:
|
|
283
|
+
break
|
|
284
|
+
offset += len(products)
|
|
285
|
+
if max_results is not None:
|
|
286
|
+
records = records[:max_results]
|
|
287
|
+
return to_dataframe(records)
|
|
288
|
+
|
|
289
|
+
def lookup(self, product_ids: Iterable[str], instrument_label: str | None = None) -> pd.DataFrame:
|
|
290
|
+
"""Observations for exact product IDs, one ODE ``productid`` request each.
|
|
291
|
+
|
|
292
|
+
Unknown IDs are skipped with a warning.
|
|
293
|
+
"""
|
|
294
|
+
records: list[dict] = []
|
|
295
|
+
for pid in product_ids:
|
|
296
|
+
pid = pid.strip().upper()
|
|
297
|
+
products = extract_products(self.request({**_PRODUCT_QUERY, "results": "fmp", "productid": pid}))
|
|
298
|
+
if not products:
|
|
299
|
+
log.warning("ODE has no product %s", pid)
|
|
300
|
+
records += [parse_product(p, instrument_label=instrument_label) for p in products]
|
|
301
|
+
return to_dataframe(records)
|
|
302
|
+
|
|
303
|
+
def count(self, instrument: str, product_type: str | None = None, **kwargs) -> int:
|
|
304
|
+
"""Number of matching products (``results=c``)."""
|
|
305
|
+
params = self.build_params(instrument, product_type, results="c", **kwargs)
|
|
306
|
+
res = self.request(params)
|
|
307
|
+
return int(res.get("Count", 0) or 0)
|
|
308
|
+
|
|
309
|
+
# Download
|
|
310
|
+
|
|
311
|
+
def download(
|
|
312
|
+
self,
|
|
313
|
+
df: pd.DataFrame,
|
|
314
|
+
output_dir: Path,
|
|
315
|
+
kind: str = "product",
|
|
316
|
+
match: str | None = None,
|
|
317
|
+
workers: int = 4,
|
|
318
|
+
force: bool = False,
|
|
319
|
+
) -> dict[str, Path | None]:
|
|
320
|
+
"""Download files listed in the ``files`` column of a search result.
|
|
321
|
+
|
|
322
|
+
Parameters
|
|
323
|
+
----------
|
|
324
|
+
kind : str
|
|
325
|
+
ODE file ``Type`` to select: ``"product"`` (default; data files
|
|
326
|
+
only, labels skipped), ``"label"``, ``"browse"``, ``"referenced"``,
|
|
327
|
+
``"derived"`` or ``"all"``.
|
|
328
|
+
match : str, optional
|
|
329
|
+
Case-insensitive substring the file name must contain
|
|
330
|
+
(e.g. ``"_cog.tif"``).
|
|
331
|
+
force : bool
|
|
332
|
+
Download again even when the file already exists in *output_dir*.
|
|
333
|
+
"""
|
|
334
|
+
urls = select_file_urls(df, kind=kind, match=match)
|
|
335
|
+
if not urls:
|
|
336
|
+
log.warning("No files selected for download.")
|
|
337
|
+
return {}
|
|
338
|
+
log.info("Downloading %d file(s) to %s", len(urls), output_dir)
|
|
339
|
+
return PdsDownloader(max_workers=workers).download_urls(urls, Path(output_dir), force=force)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# Response parsing (pure functions — unit-tested against saved JSON)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def extract_products(res: dict) -> list[dict]:
|
|
346
|
+
"""Return the product list from an ``ODEResults`` dict (handles 0/1/N)."""
|
|
347
|
+
prods = res.get("Products")
|
|
348
|
+
if not isinstance(prods, dict): # "No Products Found" string or missing
|
|
349
|
+
return []
|
|
350
|
+
p = prods.get("Product")
|
|
351
|
+
if p is None:
|
|
352
|
+
return []
|
|
353
|
+
return p if isinstance(p, list) else [p]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _files(prod: dict) -> list[dict]:
|
|
357
|
+
pf = (prod.get("Product_files") or {}).get("Product_file") or []
|
|
358
|
+
return pf if isinstance(pf, list) else [pf]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _num(v) -> float | None:
|
|
362
|
+
try:
|
|
363
|
+
return float(v)
|
|
364
|
+
except (TypeError, ValueError):
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def parse_product(prod: dict, instrument_label: str | None = None) -> dict:
|
|
369
|
+
"""Map one ODE product dict to the observation schema (+ extras)."""
|
|
370
|
+
pdsid = str(prod.get("pdsid", "")).strip()
|
|
371
|
+
product_id = pdsid.rsplit(".", 1)[-1].upper() if pdsid else None
|
|
372
|
+
files = _files(prod)
|
|
373
|
+
for f in files:
|
|
374
|
+
if f.get("URL"):
|
|
375
|
+
f["URL"] = modernize_pds_url(f["URL"])
|
|
376
|
+
|
|
377
|
+
data_files = [f for f in files if str(f.get("Type", "")).lower() == "product"]
|
|
378
|
+
url = next(
|
|
379
|
+
(f["URL"] for f in data_files if not str(f.get("FileName", "")).upper().endswith((".XML", ".LBL"))),
|
|
380
|
+
None,
|
|
381
|
+
)
|
|
382
|
+
label_url = prod.get("LabelURL") or next(
|
|
383
|
+
(f["URL"] for f in data_files if str(f.get("FileName", "")).upper().endswith((".XML", ".LBL"))),
|
|
384
|
+
None,
|
|
385
|
+
)
|
|
386
|
+
if label_url:
|
|
387
|
+
label_url = modernize_pds_url(label_url)
|
|
388
|
+
|
|
389
|
+
sub_solar_az = None
|
|
390
|
+
for k in ("Sub_solar_azimuth", "Subsolar_azimuth", "Solar_azimuth"):
|
|
391
|
+
if k in prod:
|
|
392
|
+
sub_solar_az = _num(prod[k])
|
|
393
|
+
break
|
|
394
|
+
|
|
395
|
+
# ODE echoes the LROC CUMINDEX row in ODE_notes ("NOTE: Index Record:"
|
|
396
|
+
# followed by the CSV record). Its 83 columns follow CUMINDEX.LBL, so
|
|
397
|
+
# 0-based index = COLUMN_NUMBER - 1; 999.99-style N/A constants → None.
|
|
398
|
+
geom: dict[str, float | None] = {}
|
|
399
|
+
notes = (prod.get("ODE_notes") or {}).get("ODE_note") or []
|
|
400
|
+
notes = notes if isinstance(notes, list) else [notes]
|
|
401
|
+
for i, note in enumerate(notes[:-1]):
|
|
402
|
+
if str(note).startswith("NOTE: Index Record"):
|
|
403
|
+
import csv
|
|
404
|
+
import io
|
|
405
|
+
|
|
406
|
+
row = next(csv.reader(io.StringIO(str(notes[i + 1]))), [])
|
|
407
|
+
if len(row) == 83:
|
|
408
|
+
for key, col in (
|
|
409
|
+
("slew_angle", 9),
|
|
410
|
+
("north_azimuth", 61),
|
|
411
|
+
("sub_solar_azimuth", 62),
|
|
412
|
+
("sub_spacecraft_lat", 65),
|
|
413
|
+
("sub_spacecraft_lon", 66),
|
|
414
|
+
("spacecraft_altitude", 79),
|
|
415
|
+
):
|
|
416
|
+
v = _num(row[col].strip())
|
|
417
|
+
geom[key] = None if v is None or abs(v) >= 999.0 else v
|
|
418
|
+
break
|
|
419
|
+
if sub_solar_az is None:
|
|
420
|
+
sub_solar_az = geom.get("sub_solar_azimuth")
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
"product_id": product_id,
|
|
424
|
+
"instrument": instrument_label or f"{prod.get('ihid', '')} {prod.get('iid', '')}".strip(),
|
|
425
|
+
"product_type": prod.get("pt"),
|
|
426
|
+
"start_time": prod.get("UTC_start_time") or prod.get("Observation_time"),
|
|
427
|
+
"center_lat": _num(prod.get("Center_latitude")),
|
|
428
|
+
"center_lon": _num(prod.get("Center_longitude")),
|
|
429
|
+
"min_lat": _num(prod.get("Minimum_latitude")),
|
|
430
|
+
"max_lat": _num(prod.get("Maximum_latitude")),
|
|
431
|
+
"lon_west": _num(prod.get("Westernmost_longitude")),
|
|
432
|
+
"lon_east": _num(prod.get("Easternmost_longitude")),
|
|
433
|
+
"footprint_wkt": prod.get("Footprint_geometry") or prod.get("Footprint_C0_geometry"),
|
|
434
|
+
"incidence": _num(prod.get("Incidence_angle")),
|
|
435
|
+
"emission": _num(prod.get("Emission_angle")),
|
|
436
|
+
"phase": _num(prod.get("Phase_angle")),
|
|
437
|
+
"slew_angle": geom.get("slew_angle"),
|
|
438
|
+
"sub_solar_azimuth": sub_solar_az,
|
|
439
|
+
"north_azimuth": geom.get("north_azimuth"),
|
|
440
|
+
"sub_spacecraft_lat": geom.get("sub_spacecraft_lat"),
|
|
441
|
+
"sub_spacecraft_lon": geom.get("sub_spacecraft_lon"),
|
|
442
|
+
"spacecraft_altitude": geom.get("spacecraft_altitude"),
|
|
443
|
+
"pixel_scale": _num(prod.get("Map_resolution") or prod.get("Map_scale")),
|
|
444
|
+
"url": url,
|
|
445
|
+
"label_url": label_url,
|
|
446
|
+
"source": "ode",
|
|
447
|
+
"files": files,
|
|
448
|
+
"ode_url": prod.get("ProductURL"),
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def to_dataframe(records: list[dict]) -> pd.DataFrame:
|
|
453
|
+
"""Observation DataFrame with the ``files`` / ``ode_url`` extras appended."""
|
|
454
|
+
df = normalize_observations(records)
|
|
455
|
+
df["files"] = [r.get("files", []) for r in records] if records else []
|
|
456
|
+
df["ode_url"] = [r.get("ode_url") for r in records] if records else []
|
|
457
|
+
return df[OBSERVATION_COLUMNS + ["files", "ode_url"]]
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def select_file_urls(df: pd.DataFrame, kind: str = "product", match: str | None = None) -> list[str]:
|
|
461
|
+
"""Pick file URLs from the ``files`` column (see :meth:`OdeClient.download`)."""
|
|
462
|
+
kind = kind.lower()
|
|
463
|
+
urls: list[str] = []
|
|
464
|
+
for files in df.get("files", []):
|
|
465
|
+
for f in files or []:
|
|
466
|
+
name = str(f.get("FileName", ""))
|
|
467
|
+
ftype = str(f.get("Type", "")).lower()
|
|
468
|
+
is_label = name.upper().endswith((".XML", ".LBL"))
|
|
469
|
+
if kind == "label":
|
|
470
|
+
ok = ftype == "product" and is_label
|
|
471
|
+
elif kind == "product":
|
|
472
|
+
ok = ftype == "product" and not is_label
|
|
473
|
+
elif kind == "all":
|
|
474
|
+
ok = True
|
|
475
|
+
else:
|
|
476
|
+
ok = ftype == kind
|
|
477
|
+
if ok and match and match.lower() not in name.lower():
|
|
478
|
+
ok = False
|
|
479
|
+
if ok and f.get("URL") and f["URL"] not in urls:
|
|
480
|
+
urls.append(f["URL"])
|
|
481
|
+
return urls
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _fmt(x: float) -> str:
|
|
485
|
+
return f"{float(x):.6g}"
|