Debugging Polynomial Shift Residuals in GIS
Debugging polynomial shift residuals in GIS is the targeted operation of decomposing the vector difference between observed and model-predicted control coordinates, then routing the result against a survey-grade tolerance — typically a maximum radial residual of 0.02 m for Class I cadastral control under ISO 19111 coordinate-operation accuracy reporting. This page extends the geodetic conversion math for ellipsoid-to-Cartesian pipelines, where a misaligned forward conversion silently masquerades as polynomial drift; here we isolate the residual itself as the primary diagnostic and treat its spatial structure, not its mean magnitude, as the signal worth reading.
Concept: What a Shift Residual Actually Measures
A polynomial shift model approximates non-rigid deformation across a control network by fitting low-order surfaces to the East and North components independently. For a second-order model, each predicted coordinate is a linear combination of the design-row basis
The radial magnitude
Residual anomalies also frequently originate upstream of the solver. Before trusting any number below, confirm every coordinate sits in one consistent projected CRS with explicit linear units — mixing US survey feet with international metres scales residuals by 3.28084 and invalidates the tolerance outright. Vertical and epoch consistency belongs to validating datum alignment with control points; resolve those first so the residual reflects model error rather than registration error.
Complete Runnable Implementation
The following self-contained function enforces explicit float64 precision, validates matrix conditioning, computes per-component residuals, and routes to a lower-order affine fallback when the design matrix is ill-conditioned. It is runnable as-is on Python 3.10+ with NumPy.
import numpy as np
from typing import Any, Dict, Tuple
import warnings
def compute_polynomial_residuals(
observed: np.ndarray,
modeled: np.ndarray,
order: int = 2,
tolerance_m: float = 0.02,
fallback_order: int = 1,
max_condition_number: float = 1e12,
) -> Dict[str, Any]:
"""Compute polynomial shift residuals with deterministic fallback routing.
Residuals are the (E, N) differences between observed control coordinates
and the polynomial-predicted coordinates. Accuracy reporting follows the
ISO 19111:2019 coordinate-operation accuracy model. East and North are
solved independently in explicit IEEE 754 float64.
"""
obs = np.asarray(observed, dtype=np.float64)
mod = np.asarray(modeled, dtype=np.float64)
if obs.shape != mod.shape or obs.ndim != 2 or obs.shape[1] != 2:
raise ValueError("Input arrays must be Nx2 (E, N) matrices.")
n_points = obs.shape[0]
def _design(coords: np.ndarray, poly_order: int) -> np.ndarray:
E, N = coords[:, 0], coords[:, 1]
if poly_order == 1: # affine basis (ISO 19111 polynomial order 1)
return np.column_stack([np.ones(n_points), E, N])
if poly_order == 2: # quadratic basis: 1, E, N, E^2, EN, N^2
return np.column_stack(
[np.ones(n_points), E, N, E ** 2, E * N, N ** 2]
)
raise NotImplementedError("Only 1st and 2nd order shifts are supported.")
def _solve(A: np.ndarray, b: np.ndarray) -> Tuple[np.ndarray, float]:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
coeffs, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
return coeffs, float(np.linalg.cond(A))
A = _design(mod, order)
try:
coeffs_e, cond_e = _solve(A, obs[:, 0])
coeffs_n, cond_n = _solve(A, obs[:, 1])
if max(cond_e, cond_n) > max_condition_number:
warnings.warn(
f"Condition number {max(cond_e, cond_n):.2e} exceeds limit; "
f"routing to order {fallback_order}."
)
return compute_polynomial_residuals(
obs, mod, order=fallback_order, tolerance_m=tolerance_m
)
except np.linalg.LinAlgError:
warnings.warn("Singular matrix; routing to order 1 (affine).")
return compute_polynomial_residuals(
obs, mod, order=1, tolerance_m=tolerance_m
)
delta_e = obs[:, 0] - A @ coeffs_e
delta_n = obs[:, 1] - A @ coeffs_n
radial = np.sqrt(delta_e ** 2 + delta_n ** 2)
max_residual = float(np.max(radial))
rmse = float(np.sqrt(np.mean(radial ** 2)))
return {
"coefficients": {"E": coeffs_e, "N": coeffs_n},
"residuals": {"dE": delta_e, "dN": delta_n, "R": radial},
"diagnostics": {
"order": order,
"rank": int(np.linalg.matrix_rank(A)),
"condition_number": max(cond_e, cond_n),
"max_residual_m": max_residual,
"rmse_m": rmse,
"passes_tolerance": max_residual <= tolerance_m,
},
}
The fallback recursion is the same defensive pattern used when implementing affine transformations for local grids: an order-1 model is the deterministic safe harbour when the higher-order coefficient matrix cannot be solved stably.
Parameter and return reference
| Name | Type | Units | Valid range / meaning |
|---|---|---|---|
observed |
np.ndarray Nx2 |
metres | Field-surveyed control (E, N); N ≥ 6 for order 2 |
modeled |
np.ndarray Nx2 |
metres | Source-frame coordinates fed to the polynomial |
order |
int |
— | 1 (affine) or 2 (quadratic) |
tolerance_m |
float |
metres | Max radial residual gate; 0.02 Class I, 0.05 Class II |
max_condition_number |
float |
— | Triggers fallback above 1e12 |
diagnostics.max_residual_m |
float |
metres | Worst per-point radial residual |
diagnostics.rmse_m |
float |
metres | Network root-mean-square residual |
diagnostics.passes_tolerance |
bool |
— | True when max_residual_m ≤ tolerance_m |
Minimal Worked Example
Generate a network whose observed coordinates follow an exact second-order surface of the modeled coordinates, then recover it and read the diagnostics:
import numpy as np
mod = np.array(
[[0.0, 0.0], [1000.0, 0.0], [0.0, 1000.0], [1000.0, 1000.0],
[500.0, 250.0], [250.0, 750.0], [750.0, 500.0]],
dtype=np.float64,
)
E, N = mod[:, 0], mod[:, 1]
# Known true quadratic shift surface (sub-metre coefficients):
obs_e = 12.000 + 1.0000020 * E + 0.0000010 * N + 1e-9 * E * N
obs_n = -8.000 + 0.9999980 * N + 0.0000015 * E + 1e-9 * E ** 2
obs = np.column_stack([obs_e, obs_n])
result = compute_polynomial_residuals(obs, mod, order=2, tolerance_m=0.02)
print(round(result["diagnostics"]["rmse_m"], 6),
result["diagnostics"]["passes_tolerance"])
# -> 0.0 True (residuals at machine precision; the surface is recovered exactly)
Because the observed coordinates were produced by a genuine quadratic surface, the least-squares solver reconstructs the coefficients and the radial residuals collapse to the float64 noise floor — max_residual_m on the order of 1e-10 m. Perturb a single monument (for example obs[4] += [0.08, 0.0]) and the least-squares fit smears that 0.08 m displacement across the surface, lifting max_residual_m just past the 0.02 m gate (about 0.021 m) so passes_tolerance flips to False: the classic signature of a disturbed control mark, which the per-point vector field localises far more sharply than the aggregate RMSE.
Validation Check
Gate the diagnostics with an explicit survey-grade assertion before any coordinate is written downstream:
diag = result["diagnostics"]
assert diag["rank"] >= 6, "Rank deficient: control geometry cannot support order 2."
assert diag["passes_tolerance"], (
f"Max residual {diag['max_residual_m']:.4f} m exceeds tolerance."
)
For a network that genuinely sits at the threshold, route the three lanes deterministically: pass when max(R) ≤ tolerance_m; soft fail when tolerance_m < max(R) ≤ 2 × tolerance_m, where you flag and re-measure the offending monuments and re-solve on the cleaned set; and hard fail when max(R) > 2 × tolerance_m or the rank check fails, where you abort and hand the network to a rigorous least-squares adjustment for control networks. Whichever lane fires, persist an audit record carrying the order, condition number, RMSE, pass/fail status, and the source/target EPSG codes so the result is reproducible and legally defensible.
Reading the Residual Vector Field
Plot the per-point dE/dN vectors with graduated symbology before aggregating — the orientation pattern names the defect:
- Uniform directional bias. A consistent vector orientation across the whole network is a false-origin offset or datum translation; remove it with a rigid shift before fitting, which is what the surrounding polynomial shift algorithms for regional adjustments assume as a precondition.
- Rotational pattern. Tangential vectors circulating a pivot indicate azimuth or grid-convergence mismatch; verify the projection central meridian and scale factor against the projection math fundamentals for cadastral surveys.
- Radial expansion or contraction. Vectors diverging from a centroid signal a global scale-factor discrepancy; correct the linear scale parameter rather than raising the polynomial order.
- Reversing curvature. Residuals that flip direction across the polygon are real higher-order distortion or epoch drift; a second-order surface usually absorbs it, and if clustered residuals persist, inspect monument metadata, which is the failure mode covered by error-distribution modeling in Python.
Common Mistakes
Aggregating to RMSE before inspecting the vector field
Letting float32 promotion erode sub-centimetre residuals
Fitting a second-order surface to collinear or clustered control
Related References
- Geodetic conversion math: ellipsoid to Cartesian — the parent conversion whose misalignment masquerades as polynomial drift.
- Algorithmic math and geodetic workflows — the overarching reference on deterministic, audit-ready transformation pipelines.
- Polynomial shift algorithms for regional adjustments — fitting the surfaces whose residuals this page debugs.
- Least squares adjustment for control networks — the rigorous fallback when a network hard-fails tolerance.
- Validating datum alignment with control points — clearing upstream registration error before reading residuals.