@@ -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