Skip to content

Commit 4d074ba

Browse files
committed
Fix and harden elevation-uncertainty propagation
- Enable the sqrt(2) "same precision" correction (Hugonnet 2022, Eq. 7-8) in the heteroscedasticity step; it was commented out, so precision_of_other="same" was a no-op. Default "finer" is unchanged, and an unsupported value now raises. - Add an opt-in precoreg flag to co-register before inferring the error structure, so it is estimated on the aligned residual rather than the raw inputs. Default False (unchanged). - Skip a non-converging Monte Carlo simulation with a warning instead of aborting the whole run, with a minimum-successful-simulations guard. - Add tests on the Longyearbyen sample data (sqrt(2) scaling, precoreg equivalence and determinism, diverged-simulation skip, and the variogram-plotting invariant).
1 parent 5c9d8c3 commit 4d074ba

2 files changed

Lines changed: 281 additions & 11 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""
2+
Tests for the elevation-uncertainty module, derived from the experimental notebook
3+
``~/src/vantor/notebooks/xdem_uncertainty_test.ipynb`` (Longyearbyen sample data).
4+
5+
They target three fixes to the uncertainty propagation:
6+
1. the sqrt(2) "same precision" correction (Hugonnet et al., 2022, Eq. 7-8),
7+
2. pre-coregistration before inferring the error structure,
8+
3. correct variogram plotting via the (already public) variogram model function.
9+
10+
Cropped sample data (``get_path_test``) and a small ``nsim`` are used for CI speed.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import logging
16+
import warnings
17+
from typing import Any, Callable
18+
19+
import numpy as np
20+
import pandas as pd
21+
import pytest
22+
23+
import xdem
24+
from xdem import coreg
25+
from xdem.dem import DEM
26+
from xdem.uncertainty.uncertainty import _infer_uncertainty, _propag_uncertainty_coreg
27+
28+
SEED = 42
29+
30+
31+
def _finite_array(elev: object) -> np.ndarray:
32+
"""Return a 1D float array of values from a Raster or PointCloud error field (masked -> NaN)."""
33+
data = elev.data # type: ignore[attr-defined]
34+
if np.ma.isMaskedArray(data):
35+
arr = data.filled(np.nan)
36+
else:
37+
arr = np.asarray(data, dtype=float)
38+
return np.asarray(arr, dtype=float).ravel()
39+
40+
41+
def _load_dems() -> tuple[DEM, DEM]:
42+
fn_ref = xdem.examples.get_path_test("longyearbyen_ref_dem")
43+
fn_tba = xdem.examples.get_path_test("longyearbyen_tba_dem")
44+
return DEM(fn_ref), DEM(fn_tba)
45+
46+
47+
class TestUncertaintyNotebook:
48+
49+
def test_precision_of_other_sqrt2(self) -> None:
50+
"""precision_of_other='same' divides the inferred error by sqrt(2) vs 'finer' (Eq. 7-8)."""
51+
pytest.importorskip("skgstat")
52+
warnings.filterwarnings("ignore", category=UserWarning)
53+
dem_ref, dem_tba = _load_dems()
54+
55+
het_finer, _ = _infer_uncertainty(dem_ref, dem_tba, precision_of_other="finer", random_state=SEED)
56+
het_same, _ = _infer_uncertainty(dem_ref, dem_tba, precision_of_other="same", random_state=SEED)
57+
58+
sig_finer = _finite_array(het_finer[0])
59+
sig_same = _finite_array(het_same[0])
60+
61+
m = np.isfinite(sig_finer) & np.isfinite(sig_same)
62+
assert m.sum() > 0
63+
# Same random_state -> identical binning/fit, so the only difference is the sqrt(2) scaling.
64+
np.testing.assert_allclose(sig_same[m], sig_finer[m] / np.sqrt(2), rtol=1e-5)
65+
66+
def test_precision_of_other_invalid_raises(self) -> None:
67+
"""An unsupported precision (e.g. a coarser 'worse' other) raises instead of silently acting like 'finer'."""
68+
pytest.importorskip("skgstat")
69+
dem_ref, dem_tba = _load_dems()
70+
with pytest.raises(ValueError, match="precision_of_other"):
71+
_infer_uncertainty(dem_ref, dem_tba, precision_of_other="worse") # type: ignore[arg-type]
72+
73+
def test_precoreg_equivalence(self) -> None:
74+
"""precoreg=True equals a manual fit->apply->propagate(precoreg=False) with a matched RNG stream."""
75+
pytest.importorskip("skgstat")
76+
warnings.filterwarnings("ignore", category=UserWarning)
77+
dem_ref, dem_tba = _load_dems()
78+
method = coreg.LZD()
79+
nsim = 5
80+
81+
# Auto: precoreg performs the initial fit+apply internally, consuming the shared rng first.
82+
auto = _propag_uncertainty_coreg(
83+
reference_elev=dem_ref,
84+
to_be_aligned_elev=dem_tba,
85+
coreg_method=method,
86+
nsim=nsim,
87+
error_applied_to="ref",
88+
precoreg=True,
89+
random_state=SEED,
90+
)[0]
91+
92+
# Manual mirror: advance an rng with the SAME initial fit + apply, then propagate with
93+
# precoreg=False passing the *advanced* generator so the random stream continues identically.
94+
rng = np.random.default_rng(SEED)
95+
c0 = method.copy()
96+
c0.fit(reference_elev=dem_ref, to_be_aligned_elev=dem_tba, inlier_mask=None, random_state=rng)
97+
dem_tba_align = c0.apply(dem_tba)
98+
manual = _propag_uncertainty_coreg(
99+
reference_elev=dem_ref,
100+
to_be_aligned_elev=dem_tba_align,
101+
coreg_method=method,
102+
nsim=nsim,
103+
error_applied_to="ref",
104+
precoreg=False,
105+
random_state=rng,
106+
)[0]
107+
108+
pd.testing.assert_frame_equal(auto, manual, check_exact=False, rtol=1e-6, atol=1e-6)
109+
110+
def test_precoreg_deterministic(self) -> None:
111+
"""Two precoreg=True runs with the same seed give identical reports."""
112+
pytest.importorskip("skgstat")
113+
warnings.filterwarnings("ignore", category=UserWarning)
114+
dem_ref, dem_tba = _load_dems()
115+
method = coreg.NuthKaab()
116+
nsim = 4
117+
118+
kw = dict(
119+
reference_elev=dem_ref,
120+
to_be_aligned_elev=dem_tba,
121+
coreg_method=method,
122+
nsim=nsim,
123+
error_applied_to="tba",
124+
precoreg=True,
125+
random_state=123,
126+
)
127+
r1 = _propag_uncertainty_coreg(**kw)[0]
128+
r2 = _propag_uncertainty_coreg(**kw)[0]
129+
pd.testing.assert_frame_equal(r1, r2, check_exact=False, rtol=0, atol=0)
130+
131+
def test_diverged_simulation_is_skipped(
132+
self,
133+
monkeypatch: pytest.MonkeyPatch,
134+
caplog: Any,
135+
assert_and_allow_log: Callable[..., None],
136+
) -> None:
137+
"""A simulation whose coregistration fails to converge is skipped (with a warning) rather than
138+
aborting the whole Monte Carlo run; the remaining simulations still yield a finite report.
139+
140+
A real divergence (e.g. NuthKaab exhausting its subsample on a small/pre-aligned extent) is
141+
numerics- and version-dependent, so the failure is forced deterministically here instead.
142+
"""
143+
pytest.importorskip("skgstat")
144+
warnings.filterwarnings("ignore", category=UserWarning)
145+
dem_ref, dem_tba = _load_dems()
146+
147+
# Make exactly one simulation's coregistration raise, independent of platform/library versions.
148+
original_fit = coreg.NuthKaab.fit
149+
state = {"n": 0}
150+
151+
def flaky_fit(self: coreg.NuthKaab, *args: object, **kwargs: object) -> object:
152+
state["n"] += 1
153+
if state["n"] == 2:
154+
raise ValueError("forced non-convergence for test")
155+
return original_fit(self, *args, **kwargs)
156+
157+
monkeypatch.setattr(coreg.NuthKaab, "fit", flaky_fit)
158+
159+
with caplog.at_level(logging.WARNING):
160+
summary = _propag_uncertainty_coreg(
161+
reference_elev=dem_ref,
162+
to_be_aligned_elev=dem_tba,
163+
coreg_method=coreg.NuthKaab(),
164+
nsim=4,
165+
error_applied_to="tba",
166+
precoreg=False,
167+
random_state=SEED,
168+
)[0]
169+
170+
# The skip is expected: confirm it was logged (and allow it past the global log-warning collector).
171+
assert_and_allow_log(caplog, level=logging.WARNING, match="skipped")
172+
for k in ("tx", "ty", "tz"):
173+
assert np.isfinite(summary.loc[k, "std"])
174+
175+
def test_variogram_plotting_invariant(self) -> None:
176+
"""Fitted variogram (rises 0->sill) and correlation (falls 1->0) satisfy gamma = sill*(1 - rho).
177+
178+
This is the hack-free basis for plotting the empirical + fitted variogram together:
179+
``plot_variogram(corr_out[0], [xdem.spatialstats.get_variogram_model_func(corr_out[1])])``.
180+
"""
181+
pytest.importorskip("skgstat")
182+
warnings.filterwarnings("ignore", category=UserWarning)
183+
dem_ref, dem_tba = _load_dems()
184+
185+
_, corr_out = _infer_uncertainty(dem_ref, dem_tba, random_state=SEED)
186+
df_emp, params, corr_func = corr_out
187+
188+
# Reconstruct the variogram model function from the already-public helper (the correct input for
189+
# plot_variogram, which expects a variogram rather than the returned correlation function).
190+
vario_func = xdem.spatialstats.get_variogram_model_func(params)
191+
total_sill = float(params["psill"].sum())
192+
assert total_sill > 0
193+
max_range = float(params["range"].max())
194+
195+
h = np.array([0.0, max_range, 5.0 * max_range])
196+
rho = np.asarray(corr_func(h), dtype=float)
197+
gamma = np.asarray(vario_func(h), dtype=float)
198+
199+
# Correlation starts at 1 and decays to ~0; variogram starts at 0 and rises to ~sill.
200+
np.testing.assert_allclose(rho[0], 1.0, atol=1e-6)
201+
np.testing.assert_allclose(gamma[0], 0.0, atol=1e-9 + 1e-6 * total_sill)
202+
assert rho[-1] < 1e-2
203+
np.testing.assert_allclose(gamma[-1], total_sill, rtol=1e-2)
204+
205+
# Exact invariant linking the two representations (what makes the plot render correctly).
206+
np.testing.assert_allclose(gamma, total_sill * (1.0 - rho), rtol=1e-9, atol=1e-12)

xdem/uncertainty/uncertainty.py

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def _propag_uncertainty_coreg(
8181
coreg_method: coreg.Coreg,
8282
nsim: int = 30,
8383
error_applied_to: Literal["ref", "tba"] = "tba",
84+
precoreg: bool = False,
8485
inlier_mask: Raster | NDArrayb = None,
8586
random_state: int | np.random.Generator | None = None,
8687
kwargs_coreg_fit: dict[str, Any] | None = None,
@@ -96,6 +97,16 @@ def _propag_uncertainty_coreg(
9697
:param to_be_aligned_elev: To-be-aligned elevation.
9798
:param coreg_method: Coregistration method.
9899
:param nsim: Number of simulations to perform.
100+
:param error_applied_to: Which input the simulated error field is applied to ("ref" or "tba").
101+
:param precoreg: If True, co-register the inputs once before inferring the error structure and
102+
running the simulations, so the error structure is estimated from the aligned residual rather
103+
than from the raw (mis-aligned) inputs (which would otherwise conflate true error with
104+
misalignment, terrain and real change). Requires an affine coregistration method. With
105+
precoreg=True the reported per-parameter mean is the *residual* transform (~0 after alignment),
106+
and the pre-alignment's own uncertainty is not itself propagated; this is a single pre-alignment
107+
pass. Defaults to False (infer from the raw inputs, the previous behaviour). Note that
108+
precoreg=True and False are not comparable for a given seed, as the pre-coregistration consumes
109+
the random state.
99110
:param inlier_mask: Inlier mask (valid = True).
100111
:param random_state: Random state.
101112
:param kwargs_coreg_fit: Keyword arguments passed to `Coreg.fit`.
@@ -111,6 +122,23 @@ def _propag_uncertainty_coreg(
111122
# Define random state
112123
rng = np.random.default_rng(random_state)
113124

125+
# Optionally co-register once before inferring the error structure, then operate on the aligned
126+
# inputs. Inferring from un-aligned inputs conflates true error with misalignment/terrain/real change;
127+
# pre-aligning de-contaminates the inferred structure. This is a single pre-alignment pass (full
128+
# iterative re-estimation, converging in 1-2 iterations, is a future enhancement). Only an affine
129+
# method can be applied here, and the reported per-parameter mean becomes the residual transform.
130+
if precoreg:
131+
logging.info("Pre-coregistering inputs before inferring uncertainty...")
132+
c_init = coreg_method.copy()
133+
c_init.fit(
134+
reference_elev=reference_elev,
135+
to_be_aligned_elev=to_be_aligned_elev,
136+
inlier_mask=inlier_mask,
137+
random_state=rng,
138+
**kwargs_coreg_fit,
139+
)
140+
to_be_aligned_elev = c_init.apply(to_be_aligned_elev)
141+
114142
# First, infer uncertainty
115143
if error_applied_to == "ref":
116144
source_elev, other_elev = reference_elev, to_be_aligned_elev
@@ -146,16 +174,34 @@ def _propag_uncertainty_coreg(
146174
ref_elev = reference_elev
147175
tba_elev = to_be_aligned_elev + error_field
148176

149-
# Run coreg fit
177+
# Run coreg fit. A single simulation can occasionally fail to converge (e.g. a subsampling
178+
# method such as NuthKaab can exhaust its valid subsample on a small extent); skip it with a
179+
# warning rather than aborting the whole Monte Carlo run.
150180
logging.info(f" Running coregistration fit...")
151181
c = coreg_method.copy() # Avoid carrying over the state over multiple simulations
152-
c.fit(reference_elev=ref_elev, to_be_aligned_elev=tba_elev, inlier_mask=inlier_mask, random_state=rng,
153-
**kwargs_coreg_fit)
182+
try:
183+
c.fit(reference_elev=ref_elev, to_be_aligned_elev=tba_elev, inlier_mask=inlier_mask, random_state=rng,
184+
**kwargs_coreg_fit)
185+
except Exception as err:
186+
logging.warning(f" Simulation {i+1} of {nsim} failed to converge and was skipped: {err}")
187+
continue
154188
df_it = _postproc_coreg_metadata(c)
155189
df_it["nsim"] = i + 1
156190
list_df.append(df_it)
157191
list_coreg.append(c)
158192

193+
# Require at least two successful simulations to estimate a standard deviation
194+
if len(list_df) < 2:
195+
raise RuntimeError(
196+
f"Only {len(list_df)} of {nsim} simulations succeeded; cannot estimate uncertainty. The "
197+
"coregistration repeatedly failed to converge (e.g. a subsampling method such as NuthKaab "
198+
"on a small extent). Try subsample=1 (via kwargs_coreg_fit), a larger extent, a more robust "
199+
"method (LZD/ICP), or precoreg=False."
200+
)
201+
if len(list_df) < nsim:
202+
logging.warning(f"{nsim - len(list_df)} of {nsim} simulations were skipped after failing to "
203+
f"converge; uncertainty estimated from {len(list_df)} simulations.")
204+
159205
# Finally, estimate errors for all the translations/rotations in the simulations
160206
df = pd.concat(list_df, ignore_index=True)
161207
t_r_names = ["tx", "ty", "tz", "rx", "ry", "rz"]
@@ -313,21 +359,22 @@ def _infer_uncertainty(
313359
Tuple of (Empirical variogram dataframe, Model parameters dataframe, Spatial error correlation function).
314360
"""
315361

362+
# Validate the precision assumption: only 'finer' or 'same' are invertible from the difference alone.
363+
if precision_of_other not in ("finer", "same"):
364+
raise ValueError(
365+
f"`precision_of_other` must be 'finer' or 'same', got {precision_of_other!r}. A coarser "
366+
"('worse') other dataset is not supported, as the source error cannot be isolated from the "
367+
"elevation difference alone. To characterize the less precise dataset, pass it as "
368+
"`source_elev` and the more precise one as `other_elev` (precision_of_other='finer')."
369+
)
370+
316371
# Summarize approach steps
317372
approach_dict = {
318373
"H2022": {"heterosc": True, "multi_range": True},
319374
"R2009": {"heterosc": False, "multi_range": True},
320375
"Basic": {"heterosc": False, "multi_range": False},
321376
}
322377

323-
# # Difference the two datasets
324-
# dh = _difference(source_elev, other_elev)
325-
326-
# # If the precision of the other Raster is the same, divide the dh values by sqrt(2)
327-
# # See Equation 7 and 8 of Hugonnet et al. (2022)
328-
# if precision_of_other == "same":
329-
# dh = dh / np.sqrt(2)
330-
331378
logging.info(f"Starting heteroscedasticity inference.")
332379
# Heteroscedasticity
333380
sig_dh, df_bin, fun_bin = _infer_heteroscedasticity(
@@ -339,6 +386,7 @@ def _infer_uncertainty(
339386
z_name=z_name,
340387
subsample_hetesc=subsample_hetesc,
341388
spread_statistic=spread_estimator,
389+
precision_of_other=precision_of_other,
342390
)
343391

344392
logging.info(f"Starting spatial correlation inference.")
@@ -349,6 +397,7 @@ def _infer_uncertainty(
349397
inlier_mask=stable_terrain,
350398
errors=sig_dh,
351399
estimator=variogram_estimator,
400+
precision_of_other=precision_of_other,
352401
random_state=random_state,
353402
list_models=vario_model,
354403
subsample=subsample_pairs_vario,
@@ -367,6 +416,8 @@ def _infer_heteroscedasticity(
367416
vector_mask_mode: Literal["inside", "outside"] = "inside",
368417
# Whether to infer a variable error (default) or constant
369418
heterosc: bool = True,
419+
# Precision of the other dataset relative to the source (Hugonnet 2022, Eq. 7-8)
420+
precision_of_other: Literal["finer", "same"] = "finer",
370421
# Heteroscedastic predictors
371422
hetesc_vars: (
372423
tuple[Raster | np.ndarray | str, ...]
@@ -452,6 +503,13 @@ def _infer_heteroscedasticity(
452503
# Elevation difference of the subsample
453504
dvalues_fit = rp1_fit - rp2_fit
454505

506+
# If the other dataset is of similar (not finer) precision, the difference variance is the sum of
507+
# both errors (var(dh) = 2*sigma^2); divide by sqrt(2) to recover the single-dataset error and avoid
508+
# double-counting. See Hugonnet et al. (2022), Eq. 7-8. Applied before binning so it propagates to
509+
# both the heteroscedastic and the constant-error paths below.
510+
if precision_of_other == "same":
511+
dvalues_fit = dvalues_fit / np.sqrt(2)
512+
455513
# 3) Perform binning and function fit on array inputs
456514

457515
# 3A) If heteroscedastic, perform binning and fit
@@ -583,6 +641,7 @@ def _infer_spatial_correlation(
583641
vector_mask_mode: Literal["inside", "outside"] = "inside",
584642
errors: NDArrayf | Raster | None = None,
585643
estimator: Literal["matheron", "cressie", "genton", "dowd"] = "dowd",
644+
precision_of_other: Literal["finer", "same"] = "finer",
586645
sampling: Literal["loglag", "random_xy"] = "loglag",
587646
subsample: int | float = 1,
588647
random_state: int | np.random.Generator | None = None,
@@ -654,6 +713,11 @@ def _infer_spatial_correlation(
654713
# Difference and standardize
655714
logging.info(f" Step 2: Standardizing elevation differences...")
656715
dh_vals = rp1 - rp2
716+
# Same-precision correction (Hugonnet 2022, Eq. 7-8), applied to the raw difference for consistency
717+
# with the heteroscedasticity step and the legacy dem.py path. The returned correlation function is
718+
# scale-invariant (rho = cov/total_sill), so this only affects the reported variogram magnitude.
719+
if precision_of_other == "same":
720+
dh_vals = dh_vals / np.sqrt(2)
657721
if errors is not None:
658722
dh_vals = dh_vals / aux_e["err"]
659723

0 commit comments

Comments
 (0)