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,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])
|