Coverage for lunar_toolkit/sfs_products.py: 94%

295 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 02:58 +0000

1"""Standard SfS DEM products and uncertainty estimates (Bertone et al. 2026, PSJ 7:118).""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import math 

7import os 

8import random 

9import warnings 

10from collections.abc import Hashable, Iterable, Sequence 

11from dataclasses import dataclass 

12from pathlib import Path 

13from typing import Any, TypeVar 

14 

15import numpy as np 

16import rasterio 

17 

18from . import asp 

19from .tools import is_dry_run, reuse_existing, run 

20 

21log = logging.getLogger(__name__) 

22 

23T = TypeVar("T", bound=Hashable) 

24 

25# Table 1 of Bertone et al. (2026): product key -> GLD code. 

26PRODUCT_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 

40def 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 

46def _label(value: Any) -> str: 

47 return f"{value:g}" if isinstance(value, (int, float)) else str(value) 

48 

49 

50@dataclass(frozen=True) 

51class 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 

78def 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 

92def 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 

112def _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 

124def 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 

131def _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 

141def 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 

156def 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 

162def _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 

176def 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) 

190class 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 

198def as_product_images(images: Iterable[ProductImage | Sequence[Any]]) -> list[ProductImage]: 

199 """Accept [`ProductImage`][lunar_toolkit.sfs_products.ProductImage] or 

200 ``(path, sun_azimuth[, resolution])`` tuples.""" 

201 out = [] 

202 for im in images: 

203 if isinstance(im, ProductImage): 

204 out.append(im) 

205 else: 

206 path, az, *rest = im 

207 out.append(ProductImage(Path(path), float(az), float(rest[0]) if rest and rest[0] else None)) 

208 return out 

209 

210 

211def sun_bin(azimuth: float, bin_width: float = 30.0) -> int: 

212 """Index of the Sun-azimuth bin (``[0, w)``, ``[w, 2w)``, …) holding *azimuth*.""" 

213 return int((azimuth % 360.0) // bin_width) 

214 

215 

216@dataclass 

217class IlluminationMaps: 

218 """Per-pixel illumination statistics of the SfS inputs on the DEM grid. 

219 

220 Attributes 

221 ---------- 

222 count : ndarray (uint16) 

223 Number of lit images (value above the shadow threshold). 

224 solar_bins : ndarray (uint8) 

225 Number of Sun-azimuth bins with at least one lit image. 

226 best_resolution : ndarray (float32) or None 

227 Finest native GSD among the lit images (NaN where none); None when 

228 some image has no resolution. 

229 grid : Grid 

230 """ 

231 

232 count: np.ndarray 

233 solar_bins: np.ndarray 

234 best_resolution: np.ndarray | None 

235 grid: Grid 

236 

237 

238def illumination_maps( 

239 images: Sequence[ProductImage], 

240 grid: Grid, 

241 *, 

242 shadow_threshold: float = 0.005, 

243 bin_width: float = 30.0, 

244 block_rows: int = 1024, 

245) -> IlluminationMaps: 

246 """Lit-image count, solar-bin count and best input resolution per pixel of *grid*. 

247 

248 Each image is warped onto *grid* in blocks of *block_rows* rows, so memory 

249 stays at a few arrays of the grid size. 

250 """ 

251 nbins = math.ceil(360.0 / bin_width) 

252 if nbins > 64: 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true

253 raise ValueError(f"bin_width {bin_width} gives {nbins} bins; at most 64 are supported") 

254 shape = (grid.height, grid.width) 

255 count = np.zeros(shape, np.uint16) 

256 bits = np.zeros(shape, np.uint64) 

257 with_res = all(im.resolution for im in images) 

258 best = np.full(shape, np.inf, np.float32) if with_res else None 

259 for im in images: 

260 bit = np.uint64(1) << np.uint64(sun_bin(im.sun_azimuth, bin_width)) 

261 with rasterio.open(im.path) as src, _warped(src, grid) as vrt: 

262 for row in range(0, grid.height, block_rows): 

263 rows = slice(row, min(row + block_rows, grid.height)) 

264 win = rasterio.windows.Window(0, row, grid.width, rows.stop - row) 

265 data = vrt.read(1, window=win, masked=True) 

266 lit = np.ma.filled(data > shadow_threshold, False) 

267 count[rows] += lit 

268 bits[rows] |= np.where(lit, bit, np.uint64(0)) 

269 if best is not None: 

270 best[rows] = np.where(lit, np.minimum(best[rows], np.float32(im.resolution)), best[rows]) 

271 bins = np.zeros(shape, np.uint8) 

272 for b in range(nbins): 

273 bins += ((bits >> np.uint64(b)) & np.uint64(1)).astype(np.uint8) 

274 if best is not None: 

275 best[np.isinf(best)] = np.nan 

276 return IlluminationMaps(count, bins, best, grid) 

277 

278 

279def copy_elevation(sfs_dem: Path, out: Path, *, force: bool = False) -> Path: 

280 """GLD01: the SfS DEM, LZW-compressed under its product name.""" 

281 if not reuse_existing(out, force): 

282 asp.gdal_translate(sfs_dem, out) 

283 return Path(out) 

284 

285 

286def copy_height_error(height_error: Path, out: Path, *, force: bool = False) -> Path: 

287 """GLD03: ASP's ``sfs --estimate-height-errors`` map under its product name.""" 

288 if not reuse_existing(out, force): 288 ↛ 290line 288 didn't jump to line 290 because the condition on line 288 was always true

289 asp.gdal_translate(height_error, out) 

290 return Path(out) 

291 

292 

293def difference(sfs_dem: Path, prior_dem: Path, out: Path, *, force: bool = False) -> Path: 

294 """GLD04: ``sfs_dem - prior_dem`` on the SfS grid (``geodiff``).""" 

295 out = Path(out) 

296 if reuse_existing(out, force): 

297 return out 

298 prefix = out.with_suffix("") 

299 diff = asp.geodiff(sfs_dem, prior_dem, prefix) 

300 if not is_dry_run(): 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true

301 os.replace(diff, out) 

302 return out 

303 

304 

305def hillshade( 

306 dem: Path, out: Path, *, azimuth: float = 315, elevation: float = 45, force: bool = False 

307) -> Path: 

308 """GLD07: ASP ``hillshade`` (Table 1 default: Sun azimuth 315°, elevation 45°).""" 

309 if not reuse_existing(out, force): 

310 asp.hillshade(dem, out, azimuth=azimuth, elevation=elevation) 

311 return Path(out) 

312 

313 

314def orthomosaic( 

315 images: Sequence[Path], 

316 out: Path, 

317 *, 

318 grid: Grid | None = None, 

319 resolution: float | None = None, 

320 work_dir: Path | None = None, 

321 force: bool = False, 

322) -> Path: 

323 """GLD08: max-lit ``dem_mosaic --max`` of the mapprojected images, warped onto *grid*. 

324 

325 ``dem_mosaic`` cannot reproduce an arbitrary target grid exactly, so the 

326 mosaic is made on the inputs' grid and ``gdalwarp -r average`` puts it on 

327 the DEM grid (or at *resolution* when *grid* is unknown). 

328 """ 

329 out = Path(out) 

330 if reuse_existing(out, force): 

331 return out 

332 work_dir = Path(work_dir) if work_dir else out.parent / "work" 

333 max_lit = work_dir / "max_lit.tif" 

334 if not reuse_existing(max_lit, force): 334 ↛ 336line 334 didn't jump to line 336 because the condition on line 334 was always true

335 asp.dem_mosaic(images, max_lit, max=True) 

336 if grid is not None: 

337 target = ["-te", *grid.bounds, "-ts", grid.width, grid.height] 

338 elif resolution is not None: 338 ↛ 341line 338 didn't jump to line 341 because the condition on line 338 was always true

339 target = ["-tr", resolution, resolution] 

340 else: 

341 raise ValueError("orthomosaic needs a grid or a resolution") 

342 run("gdalwarp", ["-overwrite", "-r", "average", *target, "-co", "COMPRESS=LZW", max_lit, out]) 

343 return out 

344 

345 

346def terrain_products( 

347 sfs_dem: Path, 

348 out_dir: Path, 

349 *, 

350 region_id: str, 

351 resolution: float, 

352 baselines: Sequence[float | None] = (None,), 

353 force: bool = False, 

354) -> dict[str, Path]: 

355 """GLD05 slope (deg) and GLD06 VRM roughness at each baseline. 

356 

357 ``None`` is the pixel scale (3×3 kernel), named with *resolution* as the 

358 baseline. A baseline *b* (m) uses neighbours ``k = round(b / 2 res)`` 

359 pixels away ([`baseline_step`][lunar_toolkit.sfs_products.baseline_step]), so baselines up to ``2 res`` 

360 equal the pixel scale. Keys are ``slope`` / ``roughness`` for the pixel scale and 

361 ``slope_<b>`` / ``roughness_<b>`` otherwise. 

362 """ 

363 out: dict[str, Path] = {} 

364 grid = Grid.from_raster(sfs_dem) 

365 z: np.ndarray | None = None 

366 for baseline in baselines: 

367 k = baseline_step(baseline, grid.res) 

368 key = "" if baseline is None else f"_{baseline:g}" 

369 label = resolution if baseline is None else baseline 

370 for name, func in (("slope", slope_deg), ("roughness", vrm)): 

371 path = Path(out_dir) / product_name(region_id, PRODUCT_CODES[name], resolution, label) 

372 out[name + key] = path 

373 if reuse_existing(path, force): 

374 continue 

375 if z is None: 

376 z = read_raster(sfs_dem)[0] 

377 write_raster(path, func(z, grid.res, k).astype(np.float32), grid) 

378 return out 

379 

380 

381def illumination_products( 

382 images: Sequence[ProductImage], 

383 grid: Grid, 

384 out_dir: Path, 

385 *, 

386 region_id: str, 

387 resolution: float, 

388 shadow_threshold: float = 0.005, 

389 bin_width: float = 30.0, 

390 force: bool = False, 

391) -> dict[str, Path]: 

392 """GLD02 lit-image count, GLD10 solar-bin count and GLD09 best input resolution (if known).""" 

393 names = { 

394 k: Path(out_dir) / product_name(region_id, PRODUCT_CODES[k], resolution) 

395 for k in ("mask", "solar_bins", "best_resolution") 

396 } 

397 with_res = all(im.resolution for im in images) 

398 if not with_res: 

399 log.info("Skipping best-resolution product: not every image has a native resolution") 

400 del names["best_resolution"] 

401 if all(reuse_existing(p, force) for p in names.values()): 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true

402 return names 

403 maps = illumination_maps(images, grid, shadow_threshold=shadow_threshold, bin_width=bin_width) 

404 write_raster(names["mask"], maps.count, grid, nodata=None) 

405 write_raster(names["solar_bins"], maps.solar_bins, grid, nodata=None) 

406 if maps.best_resolution is not None: 

407 write_raster(names["best_resolution"], maps.best_resolution, grid) 

408 return names 

409 

410 

411def make_products( 

412 sfs_dem: Path, 

413 prior_dem: Path, 

414 out_dir: Path, 

415 images: Sequence[ProductImage | Sequence[Any]], 

416 *, 

417 region_id: str, 

418 resolution: float, 

419 height_error: Path | None = None, 

420 shadow_threshold: float = 0.005, 

421 bin_width: float = 30.0, 

422 baselines: Sequence[float | None] = (None,), 

423 hillshade_sun: tuple[float, float] = (315, 45), 

424 force: bool = False, 

425) -> dict[str, Path]: 

426 """Table 1 products GLD01–GLD10 of an SfS DEM, named ``<region_id>_GLD<code>_<resolution>…``. 

427 

428 Parameters 

429 ---------- 

430 sfs_dem : Path 

431 The SfS DEM (e.g. ``sfs/run-DEM-final.tif``). 

432 prior_dem : Path 

433 Reference DEM the SfS DEM is differenced against (e.g. the LOLA LDEM). 

434 out_dir : Path 

435 Destination; intermediates go to ``out_dir/work``. 

436 images : sequence 

437 Mapprojected SfS input images, as [`ProductImage`][lunar_toolkit.sfs_products.ProductImage] or 

438 ``(path, sun_azimuth[, native_resolution])``. 

439 region_id : str 

440 ``ROIID`` of the file names. 

441 resolution : float 

442 ``RES`` of the file names (m/px). 

443 height_error : Path, optional 

444 ``<prefix>-height-error.tif`` from ``sfs --estimate-height-errors``; 

445 GLD03 is skipped without it. 

446 shadow_threshold : float 

447 Image values at or below this are shadow (as ``sfs --shadow-threshold``). 

448 bin_width : float 

449 Sun-azimuth bin width (deg) for the solar-bin count. 

450 baselines : sequence 

451 Slope / roughness baselines (m); ``None`` is the pixel scale. 

452 hillshade_sun : (azimuth, elevation) 

453 Hillshade illumination (deg). 

454 force : bool 

455 Recompute products that already exist. 

456 

457 Returns 

458 ------- 

459 dict 

460 Product key (`PRODUCT_CODES`, plus ``slope_<b>`` / ``roughness_<b>``) 

461 → path. Under [`dry_run`][lunar_toolkit.tools.dry_run] only the CLI-tool 

462 products are planned; the raster-math ones need the real DEM. 

463 """ 

464 out_dir = Path(out_dir) 

465 if not is_dry_run(): 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true

466 out_dir.mkdir(parents=True, exist_ok=True) 

467 ims = as_product_images(images) 

468 

469 def name(key: str, *suffix: Any) -> Path: 

470 return out_dir / product_name(region_id, PRODUCT_CODES[key], resolution, *suffix) 

471 

472 out: dict[str, Path] = {"elevation": copy_elevation(sfs_dem, name("elevation"), force=force)} 

473 if height_error is not None and (is_dry_run() or Path(height_error).exists()): 

474 out["uncertainty"] = copy_height_error(height_error, name("uncertainty"), force=force) 

475 else: 

476 log.info("Skipping elevation uncertainty: no SfS height-error map") 

477 out["diff"] = difference(sfs_dem, prior_dem, name("diff"), force=force) 

478 az, el = hillshade_sun 

479 out["hillshade"] = hillshade(sfs_dem, name("hillshade", az, el), azimuth=az, elevation=el, force=force) 

480 grid = Grid.from_raster(sfs_dem) if Path(sfs_dem).exists() else None 

481 if ims: 

482 out["orthomosaic"] = orthomosaic( 

483 [im.path for im in ims], 

484 name("orthomosaic"), 

485 grid=grid, 

486 resolution=resolution, 

487 work_dir=out_dir / "work", 

488 force=force, 

489 ) 

490 if is_dry_run() or grid is None: 490 ↛ 493line 490 didn't jump to line 493 because the condition on line 490 was always true

491 log.info("Skipping raster-math products (dry run or missing SfS DEM)") 

492 return out 

493 out.update( 

494 terrain_products( 

495 sfs_dem, out_dir, region_id=region_id, resolution=resolution, baselines=baselines, force=force 

496 ) 

497 ) 

498 if ims: 

499 out.update( 

500 illumination_products( 

501 ims, 

502 grid, 

503 out_dir, 

504 region_id=region_id, 

505 resolution=resolution, 

506 shadow_threshold=shadow_threshold, 

507 bin_width=bin_width, 

508 force=force, 

509 ) 

510 ) 

511 return out 

512 

513 

514def crop_window(x: float, y: float, size: float = 2500.0) -> tuple[float, float, float, float]: 

515 """``(ulx, uly, lrx, lry)`` of a *size* × *size* square centred on ``(x, y)``.""" 

516 h = size / 2 

517 return x - h, y + h, x + h, y - h 

518 

519 

520def ensemble_subsets( 

521 pool: Sequence[T], 

522 n_subsets: int, 

523 size: int, 

524 *, 

525 max_overlap: float = 0.4, 

526 seed: int = 0, 

527 max_tries: int = 10000, 

528) -> list[list[T]]: 

529 """Random subsets of *pool* sharing at most ``max_overlap * size`` items pairwise. 

530 

531 Bertone et al. (2026, §3.2) limit the overlap of any two SfS ensemble 

532 subsets to 40% of their images. Subsets keep the order of *pool* (e.g. 

533 sorted by Sun azimuth). Fewer than *n_subsets* are returned, with a 

534 warning, if none more fit within *max_tries* draws. 

535 """ 

536 if size > len(pool): 

537 raise ValueError(f"subset size {size} exceeds the pool of {len(pool)}") 

538 rng = random.Random(seed) 

539 limit = math.floor(max_overlap * size + 1e-9) 

540 chosen: list[set[int]] = [] 

541 for _ in range(max_tries): 

542 if len(chosen) == n_subsets: 

543 break 

544 cand = set(rng.sample(range(len(pool)), size)) 

545 if all(len(cand & c) <= limit for c in chosen): 

546 chosen.append(cand) 

547 if len(chosen) < n_subsets: 

548 log.warning("Only %d of %d subsets with <= %d shared images", len(chosen), n_subsets, limit) 

549 return [[pool[i] for i in sorted(c)] for c in chosen] 

550 

551 

552@dataclass 

553class EnsembleStats: 

554 """Per-pixel spread of an ensemble of SfS DEMs on a common grid. 

555 

556 Attributes 

557 ---------- 

558 elevation_std, slope_std : ndarray 

559 Sample standard deviation (``ddof=1``) of elevation (m) and pixel-scale 

560 slope (deg); NaN where fewer than two members have data. 

561 count : ndarray 

562 Members with a valid elevation at each pixel. 

563 grid : Grid 

564 """ 

565 

566 elevation_std: np.ndarray 

567 slope_std: np.ndarray 

568 count: np.ndarray 

569 grid: Grid 

570 

571 

572def ensemble_stats(members: Sequence[Path], grid: Grid | None = None) -> EnsembleStats: 

573 """Elevation and slope spread of ensemble DEMs, warped onto *grid* (default: the first's).""" 

574 if len(members) < 2: 

575 raise ValueError("an ensemble needs at least two DEMs") 

576 grid = grid or Grid.from_raster(members[0]) 

577 z = np.stack([read_raster(m, grid)[0] for m in members]) 

578 s = np.stack([slope_deg(zi, grid.res) for zi in z]) 

579 

580 def std(a: np.ndarray) -> np.ndarray: 

581 with warnings.catch_warnings(): 

582 warnings.simplefilter("ignore", RuntimeWarning) 

583 return np.nanstd(a, axis=0, ddof=1) 

584 

585 return EnsembleStats(std(z), std(s), np.isfinite(z).sum(0).astype(np.uint16), grid) 

586 

587 

588def ensemble_uncertainty( 

589 members: Sequence[Path], 

590 out_dir: Path, 

591 *, 

592 region_id: str, 

593 resolution: float, 

594 reference: Path | None = None, 

595 force: bool = False, 

596) -> dict[str, Path]: 

597 """Ensemble elevation / slope uncertainty maps (Bertone et al. 2026, §3.2). 

598 

599 Parameters 

600 ---------- 

601 members : sequence of Path 

602 SfS DEMs of the same crop (e.g. 2.5 km, [`crop_window`][lunar_toolkit.sfs_products.crop_window]) from 

603 image subsets ([`ensemble_subsets`][lunar_toolkit.sfs_products.ensemble_subsets]). 

604 out_dir : Path 

605 region_id : str 

606 resolution : float 

607 ``RES`` of the file names (m/px). 

608 reference : Path, optional 

609 Raster whose grid the members are warped onto (e.g. the cropped prior 

610 DEM); default the first member's grid. 

611 force : bool 

612 

613 Returns 

614 ------- 

615 dict 

616 ``elevation_std`` → ``<ROI>_GLDISGM_<RES>_ENS.tif`` (m) and 

617 ``slope_std`` → ``<ROI>_GLDSSGM_<RES>_ENS.tif`` (deg). 

618 """ 

619 out = { 

620 "elevation_std": Path(out_dir) / product_name(region_id, "ISGM", resolution, "ENS"), 

621 "slope_std": Path(out_dir) / product_name(region_id, "SSGM", resolution, "ENS"), 

622 } 

623 if all(reuse_existing(p, force) for p in out.values()): 623 ↛ 624line 623 didn't jump to line 624 because the condition on line 623 was never true

624 return out 

625 stats = ensemble_stats(members, Grid.from_raster(reference) if reference else None) 

626 write_raster(out["elevation_std"], stats.elevation_std.astype(np.float32), stats.grid) 

627 write_raster(out["slope_std"], stats.slope_std.astype(np.float32), stats.grid) 

628 return out 

629 

630 

631@dataclass 

632class PointComparison: 

633 """DEM minus reference points, as written by ``geodiff`` (lon, lat in degrees; diff in m).""" 

634 

635 lon: np.ndarray 

636 lat: np.ndarray 

637 diff: np.ndarray 

638 

639 def stats(self) -> dict[str, float]: 

640 d = self.diff[np.isfinite(self.diff)] 

641 if d.size == 0: 

642 return {"count": 0} 

643 med = float(np.median(d)) 

644 return { 

645 "count": int(d.size), 

646 "mean": float(d.mean()), 

647 "median": med, 

648 "std": float(d.std()), 

649 "rms": float(np.sqrt(np.mean(d * d))), 

650 "nmad": float(1.4826 * np.median(np.abs(d - med))), 

651 "min": float(d.min()), 

652 "max": float(d.max()), 

653 } 

654 

655 

656def compare_to_points( 

657 dem: Path, 

658 points_csv: Path, 

659 out_prefix: Path, 

660 *, 

661 csv_format: str = "1:lon 2:lat 3:height_above_datum", 

662 csv_srs: str | None = None, 

663 force: bool = False, 

664) -> PointComparison: 

665 """Validate *dem* against altimetry points (e.g. LOLA RDR) with ``geodiff --csv-format``. 

666 

667 Points off the DEM are dropped by ``geodiff``. Returns an empty 

668 comparison under [`dry_run`][lunar_toolkit.tools.dry_run]. 

669 """ 

670 diff_csv = Path(f"{out_prefix}-diff.csv") 

671 if not reuse_existing(diff_csv, force): 

672 asp.geodiff(dem, points_csv, out_prefix, csv_format=csv_format, csv_srs=csv_srs) 

673 if is_dry_run(): 

674 empty = np.empty(0) 

675 return PointComparison(empty, empty, empty) 

676 data = np.loadtxt(diff_csv, delimiter=",", comments="#", ndmin=2) 

677 return PointComparison(data[:, 0], data[:, 1], data[:, 2])