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/csm.py ADDED
@@ -0,0 +1,340 @@
1
+ """Read, write and resample USGS CSM linescan cameras (ISD and model state) to match ISIS ``reduce`` / ``crop``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import isis
13
+ from .tools import is_dry_run
14
+
15
+ LINESCAN = "USGS_ASTRO_LINE_SCANNER_SENSOR_MODEL"
16
+
17
+
18
+ @dataclass
19
+ class CsmCamera:
20
+ """A CSM camera file: an ALE / ``isd_generate`` ISD or a USGS CSM model state.
21
+
22
+ Attributes
23
+ ----------
24
+ data : dict
25
+ The parsed JSON (ISD keys such as ``image_lines`` or state keys such
26
+ as ``m_nLines``).
27
+ kind : {"isd", "state"}
28
+ header : str or None
29
+ For a state, the model-name line that precedes the JSON in
30
+ ``getModelState()`` output and ASP ``.adjusted_state.json`` files
31
+ (``None`` when the file is bare JSON).
32
+ """
33
+
34
+ data: dict[str, Any]
35
+ kind: str
36
+ header: str | None = None
37
+
38
+ @property
39
+ def model(self) -> str:
40
+ key = "name_model" if self.kind == "isd" else "m_modelName"
41
+ return str(self.data.get(key, self.header or ""))
42
+
43
+ @property
44
+ def size(self) -> tuple[int, int]:
45
+ """``(samples, lines)`` of the image the camera describes."""
46
+ if self.kind == "isd":
47
+ return int(self.data["image_samples"]), int(self.data["image_lines"])
48
+ return int(self.data["m_nSamples"]), int(self.data["m_nLines"])
49
+
50
+
51
+ def parse_camera(text: str) -> CsmCamera:
52
+ """Parse a CSM camera from text (ISD JSON, or a model state with or without its name line)."""
53
+ stripped = text.lstrip()
54
+ header = None
55
+ if not stripped.startswith("{"):
56
+ first, _, rest = stripped.partition("\n")
57
+ header, stripped = first.strip(), rest
58
+ data = json.loads(stripped)
59
+ if not isinstance(data, dict):
60
+ raise ValueError("CSM camera JSON must be an object")
61
+ if "m_nLines" in data:
62
+ return CsmCamera(data, "state", header)
63
+ if "image_lines" in data:
64
+ return CsmCamera(data, "isd", None)
65
+ raise ValueError("Not a CSM ISD (image_lines) or model state (m_nLines)")
66
+
67
+
68
+ def read_camera(path: Path) -> CsmCamera:
69
+ """Read a CSM camera file (``.json`` ISD, model state or ASP ``.adjusted_state.json``)."""
70
+ return parse_camera(Path(path).read_text())
71
+
72
+
73
+ def format_camera(cam: CsmCamera) -> str:
74
+ """Text of *cam* in its original layout (state name line kept)."""
75
+ body = json.dumps(cam.data, indent=2)
76
+ return f"{cam.header}\n{body}\n" if cam.header else body + "\n"
77
+
78
+
79
+ def write_camera(cam: CsmCamera, path: Path) -> Path:
80
+ path = Path(path)
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ path.write_text(format_camera(cam))
83
+ return path
84
+
85
+
86
+ def transform_camera(
87
+ cam: CsmCamera,
88
+ sample_scale: float,
89
+ sample_offset: float,
90
+ line_scale: float,
91
+ line_offset: float,
92
+ size: tuple[int, int],
93
+ ) -> CsmCamera:
94
+ """Camera for an image whose pixels map affinely onto *cam*'s image.
95
+
96
+ CSM image coordinates put the top-left pixel edge at 0 (first pixel
97
+ centre at 0.5). A point at ``(x, y)`` of the new image is
98
+ ``(sample_scale * x + sample_offset, line_scale * y + line_offset)`` of
99
+ the old one. Across track this scales the detector sample summing and
100
+ shifts the starting detector sample; along track it scales the
101
+ integration times (and the line summing, which converts the residual
102
+ detector-line offset into image lines in ``groundToImage``) and moves the
103
+ integration start lines so that line ``y`` is exposed when old line
104
+ ``line_scale * y + line_offset`` was.
105
+
106
+ Parameters
107
+ ----------
108
+ cam : CsmCamera
109
+ A USGS linescan ISD or state.
110
+ sample_scale, sample_offset, line_scale, line_offset : float
111
+ The mapping above (ISIS ``reduce`` by *f*: scales *f*, offsets 0;
112
+ ISIS ``crop`` from sample *s*, line *l*: scales 1, offsets
113
+ ``s - 1``, ``l - 1``).
114
+ size : (samples, lines)
115
+ Size of the new image.
116
+
117
+ Returns
118
+ -------
119
+ CsmCamera
120
+ A new camera of the same kind.
121
+ """
122
+ if cam.model != LINESCAN:
123
+ raise ValueError(f"Only {LINESCAN} cameras are supported, not {cam.model or 'unknown'}")
124
+ if sample_scale <= 0 or line_scale <= 0:
125
+ raise ValueError("Scales must be positive")
126
+ d = json.loads(json.dumps(cam.data))
127
+ ns, nl = int(size[0]), int(size[1])
128
+
129
+ def new_lines(lines: list[float]) -> list[float]:
130
+ return [(v - 0.5 - line_offset) / line_scale + 0.5 for v in lines]
131
+
132
+ if cam.kind == "isd":
133
+ ss = float(d.get("detector_sample_summing", 1.0))
134
+ d["starting_detector_sample"] = float(d.get("starting_detector_sample", 0.0)) + sample_offset * ss
135
+ _isd_summing(d, "sample", ss * sample_scale)
136
+ _isd_summing(d, "line", float(d.get("detector_line_summing", 1.0)) * line_scale)
137
+ rates = d["line_scan_rate"]
138
+ d["line_scan_rate"] = [
139
+ [new_lines([float(r[0])])[0], float(r[1]), float(r[2]) * line_scale] for r in rates
140
+ ]
141
+ d["image_samples"], d["image_lines"] = ns, nl
142
+ return CsmCamera(d, "isd")
143
+
144
+ ss = float(d["m_detectorSampleSumming"])
145
+ d["m_startingDetectorSample"] = float(d["m_startingDetectorSample"]) + sample_offset * ss
146
+ d["m_detectorSampleSumming"] = ss * sample_scale
147
+ d["m_detectorLineSumming"] = float(d["m_detectorLineSumming"]) * line_scale
148
+ d["m_intTimeLines"] = new_lines([float(v) for v in d["m_intTimeLines"]])
149
+ d["m_intTimes"] = [float(v) * line_scale for v in d["m_intTimes"]]
150
+ d["m_nSamples"], d["m_nLines"] = ns, nl
151
+ if "m_gsd" in d:
152
+ d["m_gsd"] = float(d["m_gsd"]) * math.sqrt(sample_scale * line_scale)
153
+ if "m_halfSwath" in d:
154
+ d["m_halfSwath"] = d["m_gsd"] * ns / 2.0
155
+ starts, times, lines = d["m_intTimeStartTimes"], d["m_intTimes"], d["m_intTimeLines"]
156
+ if "m_halfTime" in d and starts:
157
+ d["m_halfTime"] = (starts[-1] - starts[0] + times[-1] * (nl - lines[-1])) / 2.0
158
+ return CsmCamera(d, "state", cam.header)
159
+
160
+
161
+ def _isd_summing(d: dict[str, Any], axis: str, summing: float) -> None:
162
+ """Set an ISD detector summing; usgscsm reads it as an integer, so a fractional
163
+ remainder ``k`` goes into that axis' focal2pixel row, detector centre and
164
+ starting detector pixel (all divided by ``k``, which leaves focal-plane
165
+ coordinates unchanged)."""
166
+ n = max(1, round(summing))
167
+ d[f"detector_{axis}_summing"] = n
168
+ k = summing / n
169
+ if abs(k - 1.0) < 1e-12:
170
+ return
171
+ key = "lines" if axis == "line" else "samples"
172
+ d[f"focal2pixel_{key}"] = [float(v) / k for v in d[f"focal2pixel_{key}"]]
173
+ centre = dict(d.get("detector_center", {}))
174
+ centre[axis] = float(centre.get(axis, 0.0)) / k
175
+ d["detector_center"] = centre
176
+ d[f"starting_detector_{axis}"] = float(d.get(f"starting_detector_{axis}", 0.0)) / k
177
+
178
+
179
+ def reduce_camera(
180
+ camera: Path | CsmCamera,
181
+ factor: float,
182
+ out_json: Path | None = None,
183
+ size: tuple[int, int] | None = None,
184
+ ) -> CsmCamera:
185
+ """CSM camera of an image averaged by *factor* (ISIS ``reduce``, ``mode=scale``).
186
+
187
+ ``reduce`` output pixel *k* (0-based) averages input pixels
188
+ ``k*f .. k*f + f - 1``, so in CSM coordinates (pixel edges at integers)
189
+ the mapping is a pure scale by *f*: summing and integration times grow
190
+ by *f*, sizes shrink by *f*.
191
+
192
+ Parameters
193
+ ----------
194
+ camera : Path or CsmCamera
195
+ ISD or state of the full-resolution image.
196
+ factor : float
197
+ ``sscale`` = ``lscale``.
198
+ out_json : Path, optional
199
+ Where to write the result (same format as the input).
200
+ size : (samples, lines), optional
201
+ Size of the reduced image; default ``ceil(size / factor)``.
202
+ """
203
+ cam = camera if isinstance(camera, CsmCamera) else read_camera(camera)
204
+ if size is None:
205
+ ns, nl = cam.size
206
+ size = (math.ceil(ns / factor), math.ceil(nl / factor))
207
+ out = transform_camera(cam, factor, 0.0, factor, 0.0, size)
208
+ if out_json is not None:
209
+ write_camera(out, out_json)
210
+ return out
211
+
212
+
213
+ def read_isis_label(cube: Path, max_bytes: int = 1 << 22) -> str:
214
+ """The attached PVL label of an ISIS cube (text up to the closing ``End``)."""
215
+ with open(cube, "rb") as f:
216
+ head = f.read(max_bytes).decode("latin-1")
217
+ m = re.search(r"^End\s*$", head, flags=re.M)
218
+ return head[: m.end()] if m else head
219
+
220
+
221
+ def _group(label: str, name: str) -> dict[str, str] | None:
222
+ m = re.search(rf"^\s*Group\s*=\s*{name}\s*$(.*?)^\s*End_Group", label, flags=re.M | re.S)
223
+ if not m:
224
+ return None
225
+ return dict(re.findall(r"^\s*(\w+)\s*=\s*(\S+)", m.group(1), flags=re.M))
226
+
227
+
228
+ @dataclass(frozen=True)
229
+ class CubeGeometry:
230
+ """Size of an ISIS cube and its pixel mapping onto the original (alpha) image.
231
+
232
+ ``alpha = scale * beta + offset`` per axis in CSM coordinates (pixel
233
+ edges at integers), from the cube's ``AlphaCube`` group (identity when
234
+ absent).
235
+ """
236
+
237
+ samples: int
238
+ lines: int
239
+ alpha_samples: int
240
+ alpha_lines: int
241
+ sample_scale: float = 1.0
242
+ sample_offset: float = 0.0
243
+ line_scale: float = 1.0
244
+ line_offset: float = 0.0
245
+
246
+ @classmethod
247
+ def from_label(cls, label: str) -> CubeGeometry:
248
+ dims = _group(label, "Dimensions")
249
+ if not dims:
250
+ raise ValueError("No Dimensions group in the ISIS label")
251
+ ns, nl = int(dims["Samples"]), int(dims["Lines"])
252
+ a = _group(label, "AlphaCube")
253
+ if not a:
254
+ return cls(ns, nl, ns, nl)
255
+ s0, s1 = float(a["AlphaStartingSample"]), float(a["AlphaEndingSample"])
256
+ l0, l1 = float(a["AlphaStartingLine"]), float(a["AlphaEndingLine"])
257
+ bs, bl = int(a["BetaSamples"]), int(a["BetaLines"])
258
+ return cls(
259
+ ns,
260
+ nl,
261
+ int(a["AlphaSamples"]),
262
+ int(a["AlphaLines"]),
263
+ (s1 - s0) / bs,
264
+ s0 - 0.5,
265
+ (l1 - l0) / bl,
266
+ l0 - 0.5,
267
+ )
268
+
269
+ @classmethod
270
+ def from_cube(cls, cube: Path) -> CubeGeometry:
271
+ return cls.from_label(read_isis_label(cube))
272
+
273
+
274
+ def camera_for_cube(cam: CsmCamera, cube: CubeGeometry, source: CubeGeometry | None = None) -> CsmCamera:
275
+ """Re-map *cam* so it describes *cube* pixel for pixel.
276
+
277
+ *cam* is taken to describe the original (alpha) image when its size
278
+ matches ``cube.alpha_*`` (cameras from ``isd_generate`` on the
279
+ uncropped cube, as in the ASP LRONAC example), else the *source* cube
280
+ that *cube* was derived from (``reduce`` / ``crop`` of a cube whose own
281
+ camera was written for it).
282
+ """
283
+ size = cam.size
284
+ ss, so, ls, lo = cube.sample_scale, cube.sample_offset, cube.line_scale, cube.line_offset
285
+ if size != (cube.alpha_samples, cube.alpha_lines):
286
+ if source is None or size != (source.samples, source.lines):
287
+ raise ValueError(
288
+ f"Camera size {size} matches neither the alpha image "
289
+ f"{(cube.alpha_samples, cube.alpha_lines)} nor the source cube"
290
+ )
291
+ # cube -> alpha, then alpha -> source (inverse of the source's AlphaCube)
292
+ ss, so = ss / source.sample_scale, (so - source.sample_offset) / source.sample_scale
293
+ ls, lo = ls / source.line_scale, (lo - source.line_offset) / source.line_scale
294
+ return transform_camera(cam, ss, so, ls, lo, (cube.samples, cube.lines))
295
+
296
+
297
+ def isis_reduce_with_csm(
298
+ cube: Path,
299
+ camera: Path,
300
+ factor: int,
301
+ out_dir: Path,
302
+ algorithm: str = "average",
303
+ force: bool = False,
304
+ ) -> tuple[Path, Path]:
305
+ """ISIS ``reduce`` a cube by *factor* and write the matching CSM camera.
306
+
307
+ Parameters
308
+ ----------
309
+ cube : Path
310
+ Input ISIS cube (may be a crop: its ``AlphaCube`` is honoured).
311
+ camera : Path
312
+ CSM ISD or state of the original image or of *cube*.
313
+ factor : int
314
+ ``sscale`` = ``lscale``.
315
+ out_dir : Path
316
+ Receives ``<cube stem>.red<factor>.cub`` and ``<camera stem>.red<factor>.json``
317
+ (same CSM format as *camera*).
318
+ algorithm : str
319
+ ``reduce`` algorithm (``average`` as in Bertone et al. 2026, App. A).
320
+ force : bool
321
+ Redo existing outputs.
322
+
323
+ Returns
324
+ -------
325
+ (reduced_cube, reduced_camera)
326
+ """
327
+ out_dir = Path(out_dir)
328
+ stem = Path(cube).stem
329
+ red = out_dir / f"{stem}.red{factor}.cub"
330
+ out_cam = out_dir / f"{Path(camera).stem}.red{factor}.json" # named after its source camera
331
+ if force or not red.exists():
332
+ if not is_dry_run():
333
+ out_dir.mkdir(parents=True, exist_ok=True)
334
+ isis.reduce(cube, red, factor, algorithm=algorithm)
335
+ if is_dry_run():
336
+ return red, out_cam
337
+ if force or not out_cam.exists():
338
+ geom = CubeGeometry.from_cube(red)
339
+ write_camera(camera_for_cube(read_camera(camera), geom, CubeGeometry.from_cube(cube)), out_cam)
340
+ return red, out_cam