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
@@ -0,0 +1,676 @@
1
+ """Standard SfS DEM products and uncertainty estimates (Bertone et al. 2026, PSJ 7:118)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import math
7
+ import os
8
+ import random
9
+ import warnings
10
+ from collections.abc import Hashable, Iterable, Sequence
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, TypeVar
14
+
15
+ import numpy as np
16
+ import rasterio
17
+
18
+ from . import asp
19
+ from .tools import is_dry_run, reuse_existing, run
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ T = TypeVar("T", bound=Hashable)
24
+
25
+ # Table 1 of Bertone et al. (2026): product key -> GLD code.
26
+ PRODUCT_CODES: dict[str, str] = {
27
+ "elevation": "ELEV",
28
+ "mask": "MASK",
29
+ "uncertainty": "ISGM",
30
+ "diff": "DIFF",
31
+ "slope": "TSLP",
32
+ "roughness": "TRGH",
33
+ "hillshade": "HILL",
34
+ "orthomosaic": "OMOS",
35
+ "best_resolution": "BRES",
36
+ "solar_bins": "SBCT",
37
+ }
38
+
39
+
40
+ def product_name(region_id: str, code: str, resolution: float, *suffix: Any) -> str:
41
+ """Table 1 file name, e.g. ``product_name("SR01", "TSLP", 5, 10)`` → ``SR01_GLDTSLP_5_10.tif``."""
42
+ parts = [region_id, f"GLD{code}", _label(resolution), *(_label(s) for s in suffix)]
43
+ return "_".join(parts) + ".tif"
44
+
45
+
46
+ def _label(value: Any) -> str:
47
+ return f"{value:g}" if isinstance(value, (int, float)) else str(value)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Grid:
52
+ """A georeferenced raster grid (CRS, affine transform, size)."""
53
+
54
+ crs: Any
55
+ transform: Any
56
+ width: int
57
+ height: int
58
+
59
+ @classmethod
60
+ def from_raster(cls, path: Path) -> Grid:
61
+ with rasterio.open(path) as src:
62
+ return cls(src.crs, src.transform, src.width, src.height)
63
+
64
+ @property
65
+ def res(self) -> float:
66
+ """Pixel size (map units); pixels are assumed square."""
67
+ return abs(float(self.transform.a))
68
+
69
+ @property
70
+ def bounds(self) -> tuple[float, float, float, float]:
71
+ """``(xmin, ymin, xmax, ymax)``."""
72
+ t = self.transform
73
+ x0, y0 = t.c, t.f
74
+ x1, y1 = x0 + t.a * self.width, y0 + t.e * self.height
75
+ return min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)
76
+
77
+
78
+ def read_raster(path: Path, grid: Grid | None = None) -> tuple[np.ndarray, Grid]:
79
+ """Band 1 of *path* as float64 with NaN for nodata, optionally warped onto *grid*.
80
+
81
+ Warping averages when the source is finer than *grid* and is bilinear otherwise.
82
+ """
83
+ with rasterio.open(path) as src:
84
+ if grid is None:
85
+ data = src.read(1, masked=True)
86
+ return data.astype(np.float64).filled(np.nan), Grid(src.crs, src.transform, src.width, src.height)
87
+ with _warped(src, grid) as vrt:
88
+ data = vrt.read(1, masked=True)
89
+ return data.astype(np.float64).filled(np.nan), grid
90
+
91
+
92
+ def write_raster(path: Path, array: np.ndarray, grid: Grid, nodata: float | None = np.nan) -> Path:
93
+ """Write *array* as a one-band LZW-compressed GeoTIFF on *grid*."""
94
+ path = Path(path)
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ profile = {
97
+ "driver": "GTiff",
98
+ "width": grid.width,
99
+ "height": grid.height,
100
+ "count": 1,
101
+ "dtype": array.dtype.name,
102
+ "crs": grid.crs,
103
+ "transform": grid.transform,
104
+ "compress": "lzw",
105
+ "nodata": nodata,
106
+ }
107
+ with rasterio.open(path, "w", **profile) as dst:
108
+ dst.write(array, 1)
109
+ return path
110
+
111
+
112
+ def _warped(src: Any, grid: Grid) -> Any:
113
+ finer = abs(src.transform.a) < grid.res
114
+ return rasterio.vrt.WarpedVRT(
115
+ src,
116
+ crs=grid.crs,
117
+ transform=grid.transform,
118
+ width=grid.width,
119
+ height=grid.height,
120
+ resampling=rasterio.enums.Resampling.average if finer else rasterio.enums.Resampling.bilinear,
121
+ )
122
+
123
+
124
+ def baseline_step(baseline: float | None, res: float) -> int:
125
+ """Neighbour offset *k* (pixels) for a slope / roughness baseline of ``2 k res``; at least 1."""
126
+ if baseline is None:
127
+ return 1
128
+ return max(1, round(baseline / (2 * res)))
129
+
130
+
131
+ def _shift(a: np.ndarray, dy: int, dx: int) -> np.ndarray:
132
+ """``out[i, j] = a[i + dy, j + dx]``, NaN outside."""
133
+ out = np.full_like(a, np.nan)
134
+ h, w = a.shape
135
+ out[max(0, -dy) : h - max(0, dy), max(0, -dx) : w - max(0, dx)] = a[
136
+ max(0, dy) : h - max(0, -dy), max(0, dx) : w - max(0, -dx)
137
+ ]
138
+ return out
139
+
140
+
141
+ def gradient(z: np.ndarray, res: float, step: int = 1) -> tuple[np.ndarray, np.ndarray]:
142
+ """Horn (1981) gradient ``(dz/dx, dz/dy)`` with neighbours *step* pixels away (``gdaldem`` at 1).
143
+
144
+ Rows run north to south, so ``dz/dy`` is positive uphill to the north. Edge pixels are NaN.
145
+ """
146
+ k = step
147
+
148
+ def s(dy: int, dx: int) -> np.ndarray:
149
+ return _shift(z, dy, dx)
150
+
151
+ dzdx = ((s(-k, k) + 2 * s(0, k) + s(k, k)) - (s(-k, -k) + 2 * s(0, -k) + s(k, -k))) / (8 * k * res)
152
+ dzdy = ((s(-k, -k) + 2 * s(-k, 0) + s(-k, k)) - (s(k, -k) + 2 * s(k, 0) + s(k, k))) / (8 * k * res)
153
+ return dzdx, dzdy
154
+
155
+
156
+ def slope_deg(z: np.ndarray, res: float, step: int = 1) -> np.ndarray:
157
+ """Slope (degrees) over a baseline of ``2 step res``; ``step=1`` matches ``gdaldem slope``."""
158
+ dzdx, dzdy = gradient(z, res, step)
159
+ return np.degrees(np.arctan(np.hypot(dzdx, dzdy)))
160
+
161
+
162
+ def _box_sum(a: np.ndarray, k: int) -> np.ndarray:
163
+ """Sum over the ``(2k+1)²`` window around each pixel; NaN if any value in it is NaN."""
164
+ n = 2 * k + 1
165
+
166
+ def window(x: np.ndarray) -> np.ndarray:
167
+ c = np.pad(np.pad(x, k).cumsum(0).cumsum(1), ((1, 0), (1, 0)))
168
+ return c[n:, n:] - c[:-n, n:] - c[n:, :-n] + c[:-n, :-n]
169
+
170
+ valid = np.isfinite(a)
171
+ total = window(np.where(valid, a, 0.0))
172
+ full = window(valid.astype(np.float64)) > n * n - 0.5
173
+ return np.where(full, total, np.nan)
174
+
175
+
176
+ def vrm(z: np.ndarray, res: float, step: int = 1) -> np.ndarray:
177
+ """Vector ruggedness measure (Sappington et al. 2007) over a ``(2 step + 1)²`` window.
178
+
179
+ Unit surface normals come from the pixel-scale Horn gradient; VRM is
180
+ ``1 - |Σ n| / N`` over the window: 0 for a plane, up to 1 for rugged terrain.
181
+ """
182
+ p, q = gradient(z, res, 1)
183
+ norm = np.sqrt(1 + p * p + q * q)
184
+ sums = [_box_sum(c, step) for c in (-p / norm, -q / norm, 1 / norm)]
185
+ n = (2 * step + 1) ** 2
186
+ return 1 - np.sqrt(sum(s * s for s in sums)) / n
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class ProductImage:
191
+ """A mapprojected SfS input image, its Sun azimuth (deg from north) and native GSD (m/px)."""
192
+
193
+ path: Path
194
+ sun_azimuth: float
195
+ resolution: float | None = None
196
+
197
+
198
+ def as_product_images(images: Iterable[ProductImage | Sequence[Any]]) -> list[ProductImage]:
199
+ """Accept :class:`ProductImage` or ``(path, sun_azimuth[, resolution])`` tuples."""
200
+ out = []
201
+ for im in images:
202
+ if isinstance(im, ProductImage):
203
+ out.append(im)
204
+ else:
205
+ path, az, *rest = im
206
+ out.append(ProductImage(Path(path), float(az), float(rest[0]) if rest and rest[0] else None))
207
+ return out
208
+
209
+
210
+ def sun_bin(azimuth: float, bin_width: float = 30.0) -> int:
211
+ """Index of the Sun-azimuth bin (``[0, w)``, ``[w, 2w)``, …) holding *azimuth*."""
212
+ return int((azimuth % 360.0) // bin_width)
213
+
214
+
215
+ @dataclass
216
+ class IlluminationMaps:
217
+ """Per-pixel illumination statistics of the SfS inputs on the DEM grid.
218
+
219
+ Attributes
220
+ ----------
221
+ count : ndarray (uint16)
222
+ Number of lit images (value above the shadow threshold).
223
+ solar_bins : ndarray (uint8)
224
+ Number of Sun-azimuth bins with at least one lit image.
225
+ best_resolution : ndarray (float32) or None
226
+ Finest native GSD among the lit images (NaN where none); None when
227
+ some image has no resolution.
228
+ grid : Grid
229
+ """
230
+
231
+ count: np.ndarray
232
+ solar_bins: np.ndarray
233
+ best_resolution: np.ndarray | None
234
+ grid: Grid
235
+
236
+
237
+ def illumination_maps(
238
+ images: Sequence[ProductImage],
239
+ grid: Grid,
240
+ *,
241
+ shadow_threshold: float = 0.005,
242
+ bin_width: float = 30.0,
243
+ block_rows: int = 1024,
244
+ ) -> IlluminationMaps:
245
+ """Lit-image count, solar-bin count and best input resolution per pixel of *grid*.
246
+
247
+ Each image is warped onto *grid* in blocks of *block_rows* rows, so memory
248
+ stays at a few arrays of the grid size.
249
+ """
250
+ nbins = math.ceil(360.0 / bin_width)
251
+ if nbins > 64:
252
+ raise ValueError(f"bin_width {bin_width} gives {nbins} bins; at most 64 are supported")
253
+ shape = (grid.height, grid.width)
254
+ count = np.zeros(shape, np.uint16)
255
+ bits = np.zeros(shape, np.uint64)
256
+ with_res = all(im.resolution for im in images)
257
+ best = np.full(shape, np.inf, np.float32) if with_res else None
258
+ for im in images:
259
+ bit = np.uint64(1) << np.uint64(sun_bin(im.sun_azimuth, bin_width))
260
+ with rasterio.open(im.path) as src, _warped(src, grid) as vrt:
261
+ for row in range(0, grid.height, block_rows):
262
+ rows = slice(row, min(row + block_rows, grid.height))
263
+ win = rasterio.windows.Window(0, row, grid.width, rows.stop - row)
264
+ data = vrt.read(1, window=win, masked=True)
265
+ lit = np.ma.filled(data > shadow_threshold, False)
266
+ count[rows] += lit
267
+ bits[rows] |= np.where(lit, bit, np.uint64(0))
268
+ if best is not None:
269
+ best[rows] = np.where(lit, np.minimum(best[rows], np.float32(im.resolution)), best[rows])
270
+ bins = np.zeros(shape, np.uint8)
271
+ for b in range(nbins):
272
+ bins += ((bits >> np.uint64(b)) & np.uint64(1)).astype(np.uint8)
273
+ if best is not None:
274
+ best[np.isinf(best)] = np.nan
275
+ return IlluminationMaps(count, bins, best, grid)
276
+
277
+
278
+ def copy_elevation(sfs_dem: Path, out: Path, *, force: bool = False) -> Path:
279
+ """GLD01: the SfS DEM, LZW-compressed under its product name."""
280
+ if not reuse_existing(out, force):
281
+ asp.gdal_translate(sfs_dem, out)
282
+ return Path(out)
283
+
284
+
285
+ def copy_height_error(height_error: Path, out: Path, *, force: bool = False) -> Path:
286
+ """GLD03: ASP's ``sfs --estimate-height-errors`` map under its product name."""
287
+ if not reuse_existing(out, force):
288
+ asp.gdal_translate(height_error, out)
289
+ return Path(out)
290
+
291
+
292
+ def difference(sfs_dem: Path, prior_dem: Path, out: Path, *, force: bool = False) -> Path:
293
+ """GLD04: ``sfs_dem - prior_dem`` on the SfS grid (``geodiff``)."""
294
+ out = Path(out)
295
+ if reuse_existing(out, force):
296
+ return out
297
+ prefix = out.with_suffix("")
298
+ diff = asp.geodiff(sfs_dem, prior_dem, prefix)
299
+ if not is_dry_run():
300
+ os.replace(diff, out)
301
+ return out
302
+
303
+
304
+ def hillshade(
305
+ dem: Path, out: Path, *, azimuth: float = 315, elevation: float = 45, force: bool = False
306
+ ) -> Path:
307
+ """GLD07: ASP ``hillshade`` (Table 1 default: Sun azimuth 315°, elevation 45°)."""
308
+ if not reuse_existing(out, force):
309
+ asp.hillshade(dem, out, azimuth=azimuth, elevation=elevation)
310
+ return Path(out)
311
+
312
+
313
+ def orthomosaic(
314
+ images: Sequence[Path],
315
+ out: Path,
316
+ *,
317
+ grid: Grid | None = None,
318
+ resolution: float | None = None,
319
+ work_dir: Path | None = None,
320
+ force: bool = False,
321
+ ) -> Path:
322
+ """GLD08: max-lit ``dem_mosaic --max`` of the mapprojected images, warped onto *grid*.
323
+
324
+ ``dem_mosaic`` cannot reproduce an arbitrary target grid exactly, so the
325
+ mosaic is made on the inputs' grid and ``gdalwarp -r average`` puts it on
326
+ the DEM grid (or at *resolution* when *grid* is unknown).
327
+ """
328
+ out = Path(out)
329
+ if reuse_existing(out, force):
330
+ return out
331
+ work_dir = Path(work_dir) if work_dir else out.parent / "work"
332
+ max_lit = work_dir / "max_lit.tif"
333
+ if not reuse_existing(max_lit, force):
334
+ asp.dem_mosaic(images, max_lit, max=True)
335
+ if grid is not None:
336
+ target = ["-te", *grid.bounds, "-ts", grid.width, grid.height]
337
+ elif resolution is not None:
338
+ target = ["-tr", resolution, resolution]
339
+ else:
340
+ raise ValueError("orthomosaic needs a grid or a resolution")
341
+ run("gdalwarp", ["-overwrite", "-r", "average", *target, "-co", "COMPRESS=LZW", max_lit, out])
342
+ return out
343
+
344
+
345
+ def terrain_products(
346
+ sfs_dem: Path,
347
+ out_dir: Path,
348
+ *,
349
+ region_id: str,
350
+ resolution: float,
351
+ baselines: Sequence[float | None] = (None,),
352
+ force: bool = False,
353
+ ) -> dict[str, Path]:
354
+ """GLD05 slope (deg) and GLD06 VRM roughness at each baseline.
355
+
356
+ ``None`` is the pixel scale (3×3 kernel), named with *resolution* as the
357
+ baseline. A baseline *b* (m) uses neighbours ``k = round(b / 2 res)``
358
+ pixels away (:func:`baseline_step`), so baselines up to ``2 res`` equal the
359
+ pixel scale. Keys are ``slope`` / ``roughness`` for the pixel scale and
360
+ ``slope_<b>`` / ``roughness_<b>`` otherwise.
361
+ """
362
+ out: dict[str, Path] = {}
363
+ grid = Grid.from_raster(sfs_dem)
364
+ z: np.ndarray | None = None
365
+ for baseline in baselines:
366
+ k = baseline_step(baseline, grid.res)
367
+ key = "" if baseline is None else f"_{baseline:g}"
368
+ label = resolution if baseline is None else baseline
369
+ for name, func in (("slope", slope_deg), ("roughness", vrm)):
370
+ path = Path(out_dir) / product_name(region_id, PRODUCT_CODES[name], resolution, label)
371
+ out[name + key] = path
372
+ if reuse_existing(path, force):
373
+ continue
374
+ if z is None:
375
+ z = read_raster(sfs_dem)[0]
376
+ write_raster(path, func(z, grid.res, k).astype(np.float32), grid)
377
+ return out
378
+
379
+
380
+ def illumination_products(
381
+ images: Sequence[ProductImage],
382
+ grid: Grid,
383
+ out_dir: Path,
384
+ *,
385
+ region_id: str,
386
+ resolution: float,
387
+ shadow_threshold: float = 0.005,
388
+ bin_width: float = 30.0,
389
+ force: bool = False,
390
+ ) -> dict[str, Path]:
391
+ """GLD02 lit-image count, GLD10 solar-bin count and GLD09 best input resolution (if known)."""
392
+ names = {
393
+ k: Path(out_dir) / product_name(region_id, PRODUCT_CODES[k], resolution)
394
+ for k in ("mask", "solar_bins", "best_resolution")
395
+ }
396
+ with_res = all(im.resolution for im in images)
397
+ if not with_res:
398
+ log.info("Skipping best-resolution product: not every image has a native resolution")
399
+ del names["best_resolution"]
400
+ if all(reuse_existing(p, force) for p in names.values()):
401
+ return names
402
+ maps = illumination_maps(images, grid, shadow_threshold=shadow_threshold, bin_width=bin_width)
403
+ write_raster(names["mask"], maps.count, grid, nodata=None)
404
+ write_raster(names["solar_bins"], maps.solar_bins, grid, nodata=None)
405
+ if maps.best_resolution is not None:
406
+ write_raster(names["best_resolution"], maps.best_resolution, grid)
407
+ return names
408
+
409
+
410
+ def make_products(
411
+ sfs_dem: Path,
412
+ prior_dem: Path,
413
+ out_dir: Path,
414
+ images: Sequence[ProductImage | Sequence[Any]],
415
+ *,
416
+ region_id: str,
417
+ resolution: float,
418
+ height_error: Path | None = None,
419
+ shadow_threshold: float = 0.005,
420
+ bin_width: float = 30.0,
421
+ baselines: Sequence[float | None] = (None,),
422
+ hillshade_sun: tuple[float, float] = (315, 45),
423
+ force: bool = False,
424
+ ) -> dict[str, Path]:
425
+ """Table 1 products GLD01–GLD10 of an SfS DEM, named ``<region_id>_GLD<code>_<resolution>…``.
426
+
427
+ Parameters
428
+ ----------
429
+ sfs_dem : Path
430
+ The SfS DEM (e.g. ``sfs/run-DEM-final.tif``).
431
+ prior_dem : Path
432
+ Reference DEM the SfS DEM is differenced against (e.g. the LOLA LDEM).
433
+ out_dir : Path
434
+ Destination; intermediates go to ``out_dir/work``.
435
+ images : sequence
436
+ Mapprojected SfS input images, as :class:`ProductImage` or
437
+ ``(path, sun_azimuth[, native_resolution])``.
438
+ region_id : str
439
+ ``ROIID`` of the file names.
440
+ resolution : float
441
+ ``RES`` of the file names (m/px).
442
+ height_error : Path, optional
443
+ ``<prefix>-height-error.tif`` from ``sfs --estimate-height-errors``;
444
+ GLD03 is skipped without it.
445
+ shadow_threshold : float
446
+ Image values at or below this are shadow (as ``sfs --shadow-threshold``).
447
+ bin_width : float
448
+ Sun-azimuth bin width (deg) for the solar-bin count.
449
+ baselines : sequence
450
+ Slope / roughness baselines (m); ``None`` is the pixel scale.
451
+ hillshade_sun : (azimuth, elevation)
452
+ Hillshade illumination (deg).
453
+ force : bool
454
+ Recompute products that already exist.
455
+
456
+ Returns
457
+ -------
458
+ dict
459
+ Product key (:data:`PRODUCT_CODES`, plus ``slope_<b>`` / ``roughness_<b>``)
460
+ → path. Under :func:`~lunar_toolkit.tools.dry_run` only the CLI-tool
461
+ products are planned; the raster-math ones need the real DEM.
462
+ """
463
+ out_dir = Path(out_dir)
464
+ if not is_dry_run():
465
+ out_dir.mkdir(parents=True, exist_ok=True)
466
+ ims = as_product_images(images)
467
+
468
+ def name(key: str, *suffix: Any) -> Path:
469
+ return out_dir / product_name(region_id, PRODUCT_CODES[key], resolution, *suffix)
470
+
471
+ out: dict[str, Path] = {"elevation": copy_elevation(sfs_dem, name("elevation"), force=force)}
472
+ if height_error is not None and (is_dry_run() or Path(height_error).exists()):
473
+ out["uncertainty"] = copy_height_error(height_error, name("uncertainty"), force=force)
474
+ else:
475
+ log.info("Skipping elevation uncertainty: no SfS height-error map")
476
+ out["diff"] = difference(sfs_dem, prior_dem, name("diff"), force=force)
477
+ az, el = hillshade_sun
478
+ out["hillshade"] = hillshade(sfs_dem, name("hillshade", az, el), azimuth=az, elevation=el, force=force)
479
+ grid = Grid.from_raster(sfs_dem) if Path(sfs_dem).exists() else None
480
+ if ims:
481
+ out["orthomosaic"] = orthomosaic(
482
+ [im.path for im in ims],
483
+ name("orthomosaic"),
484
+ grid=grid,
485
+ resolution=resolution,
486
+ work_dir=out_dir / "work",
487
+ force=force,
488
+ )
489
+ if is_dry_run() or grid is None:
490
+ log.info("Skipping raster-math products (dry run or missing SfS DEM)")
491
+ return out
492
+ out.update(
493
+ terrain_products(
494
+ sfs_dem, out_dir, region_id=region_id, resolution=resolution, baselines=baselines, force=force
495
+ )
496
+ )
497
+ if ims:
498
+ out.update(
499
+ illumination_products(
500
+ ims,
501
+ grid,
502
+ out_dir,
503
+ region_id=region_id,
504
+ resolution=resolution,
505
+ shadow_threshold=shadow_threshold,
506
+ bin_width=bin_width,
507
+ force=force,
508
+ )
509
+ )
510
+ return out
511
+
512
+
513
+ def crop_window(x: float, y: float, size: float = 2500.0) -> tuple[float, float, float, float]:
514
+ """``(ulx, uly, lrx, lry)`` of a *size* × *size* square centred on ``(x, y)``."""
515
+ h = size / 2
516
+ return x - h, y + h, x + h, y - h
517
+
518
+
519
+ def ensemble_subsets(
520
+ pool: Sequence[T],
521
+ n_subsets: int,
522
+ size: int,
523
+ *,
524
+ max_overlap: float = 0.4,
525
+ seed: int = 0,
526
+ max_tries: int = 10000,
527
+ ) -> list[list[T]]:
528
+ """Random subsets of *pool* sharing at most ``max_overlap * size`` items pairwise.
529
+
530
+ Bertone et al. (2026, §3.2) limit the overlap of any two SfS ensemble
531
+ subsets to 40% of their images. Subsets keep the order of *pool* (e.g.
532
+ sorted by Sun azimuth). Fewer than *n_subsets* are returned, with a
533
+ warning, if none more fit within *max_tries* draws.
534
+ """
535
+ if size > len(pool):
536
+ raise ValueError(f"subset size {size} exceeds the pool of {len(pool)}")
537
+ rng = random.Random(seed)
538
+ limit = math.floor(max_overlap * size + 1e-9)
539
+ chosen: list[set[int]] = []
540
+ for _ in range(max_tries):
541
+ if len(chosen) == n_subsets:
542
+ break
543
+ cand = set(rng.sample(range(len(pool)), size))
544
+ if all(len(cand & c) <= limit for c in chosen):
545
+ chosen.append(cand)
546
+ if len(chosen) < n_subsets:
547
+ log.warning("Only %d of %d subsets with <= %d shared images", len(chosen), n_subsets, limit)
548
+ return [[pool[i] for i in sorted(c)] for c in chosen]
549
+
550
+
551
+ @dataclass
552
+ class EnsembleStats:
553
+ """Per-pixel spread of an ensemble of SfS DEMs on a common grid.
554
+
555
+ Attributes
556
+ ----------
557
+ elevation_std, slope_std : ndarray
558
+ Sample standard deviation (``ddof=1``) of elevation (m) and pixel-scale
559
+ slope (deg); NaN where fewer than two members have data.
560
+ count : ndarray
561
+ Members with a valid elevation at each pixel.
562
+ grid : Grid
563
+ """
564
+
565
+ elevation_std: np.ndarray
566
+ slope_std: np.ndarray
567
+ count: np.ndarray
568
+ grid: Grid
569
+
570
+
571
+ def ensemble_stats(members: Sequence[Path], grid: Grid | None = None) -> EnsembleStats:
572
+ """Elevation and slope spread of ensemble DEMs, warped onto *grid* (default: the first's)."""
573
+ if len(members) < 2:
574
+ raise ValueError("an ensemble needs at least two DEMs")
575
+ grid = grid or Grid.from_raster(members[0])
576
+ z = np.stack([read_raster(m, grid)[0] for m in members])
577
+ s = np.stack([slope_deg(zi, grid.res) for zi in z])
578
+
579
+ def std(a: np.ndarray) -> np.ndarray:
580
+ with warnings.catch_warnings():
581
+ warnings.simplefilter("ignore", RuntimeWarning)
582
+ return np.nanstd(a, axis=0, ddof=1)
583
+
584
+ return EnsembleStats(std(z), std(s), np.isfinite(z).sum(0).astype(np.uint16), grid)
585
+
586
+
587
+ def ensemble_uncertainty(
588
+ members: Sequence[Path],
589
+ out_dir: Path,
590
+ *,
591
+ region_id: str,
592
+ resolution: float,
593
+ reference: Path | None = None,
594
+ force: bool = False,
595
+ ) -> dict[str, Path]:
596
+ """Ensemble elevation / slope uncertainty maps (Bertone et al. 2026, §3.2).
597
+
598
+ Parameters
599
+ ----------
600
+ members : sequence of Path
601
+ SfS DEMs of the same crop (e.g. 2.5 km, :func:`crop_window`) from image
602
+ subsets (:func:`ensemble_subsets`).
603
+ out_dir : Path
604
+ region_id : str
605
+ resolution : float
606
+ ``RES`` of the file names (m/px).
607
+ reference : Path, optional
608
+ Raster whose grid the members are warped onto (e.g. the cropped prior
609
+ DEM); default the first member's grid.
610
+ force : bool
611
+
612
+ Returns
613
+ -------
614
+ dict
615
+ ``elevation_std`` → ``<ROI>_GLDISGM_<RES>_ENS.tif`` (m) and
616
+ ``slope_std`` → ``<ROI>_GLDSSGM_<RES>_ENS.tif`` (deg).
617
+ """
618
+ out = {
619
+ "elevation_std": Path(out_dir) / product_name(region_id, "ISGM", resolution, "ENS"),
620
+ "slope_std": Path(out_dir) / product_name(region_id, "SSGM", resolution, "ENS"),
621
+ }
622
+ if all(reuse_existing(p, force) for p in out.values()):
623
+ return out
624
+ stats = ensemble_stats(members, Grid.from_raster(reference) if reference else None)
625
+ write_raster(out["elevation_std"], stats.elevation_std.astype(np.float32), stats.grid)
626
+ write_raster(out["slope_std"], stats.slope_std.astype(np.float32), stats.grid)
627
+ return out
628
+
629
+
630
+ @dataclass
631
+ class PointComparison:
632
+ """DEM minus reference points, as written by ``geodiff`` (lon, lat in degrees; diff in m)."""
633
+
634
+ lon: np.ndarray
635
+ lat: np.ndarray
636
+ diff: np.ndarray
637
+
638
+ def stats(self) -> dict[str, float]:
639
+ d = self.diff[np.isfinite(self.diff)]
640
+ if d.size == 0:
641
+ return {"count": 0}
642
+ med = float(np.median(d))
643
+ return {
644
+ "count": int(d.size),
645
+ "mean": float(d.mean()),
646
+ "median": med,
647
+ "std": float(d.std()),
648
+ "rms": float(np.sqrt(np.mean(d * d))),
649
+ "nmad": float(1.4826 * np.median(np.abs(d - med))),
650
+ "min": float(d.min()),
651
+ "max": float(d.max()),
652
+ }
653
+
654
+
655
+ def compare_to_points(
656
+ dem: Path,
657
+ points_csv: Path,
658
+ out_prefix: Path,
659
+ *,
660
+ csv_format: str = "1:lon 2:lat 3:height_above_datum",
661
+ csv_srs: str | None = None,
662
+ force: bool = False,
663
+ ) -> PointComparison:
664
+ """Validate *dem* against altimetry points (e.g. LOLA RDR) with ``geodiff --csv-format``.
665
+
666
+ Points off the DEM are dropped by ``geodiff``. Returns an empty
667
+ comparison under :func:`~lunar_toolkit.tools.dry_run`.
668
+ """
669
+ diff_csv = Path(f"{out_prefix}-diff.csv")
670
+ if not reuse_existing(diff_csv, force):
671
+ asp.geodiff(dem, points_csv, out_prefix, csv_format=csv_format, csv_srs=csv_srs)
672
+ if is_dry_run():
673
+ empty = np.empty(0)
674
+ return PointComparison(empty, empty, empty)
675
+ data = np.loadtxt(diff_csv, delimiter=",", comments="#", ndmin=2)
676
+ return PointComparison(data[:, 0], data[:, 1], data[:, 2])