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:
Figure — four residual field signatures and the fault each one points to.
- 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
Frequently Asked Questions
My residuals shrank when I raised the polynomial degree. Is that an improvement?
Only if the hold-out residuals shrank too. Fit residuals always fall as degree rises, because a more flexible surface can pass closer to the points it was fitted to; that number cannot tell you anything about the points it has not seen. Split the control set, or cross-validate, and watch the hold-out error — the degree at which it stops falling is the degree to use, and it is usually two or three.
What does a residual field that swirls around a centre mean?
An unmodelled rotation. If the vectors circulate about a point, the fitted model has no term that can express the rotation between the two frames, and every point is being corrected in the wrong direction by an amount proportional to its distance from the centre. Adding a similarity or affine term absorbs it. Raising the polynomial degree will also reduce the residuals, but by fitting the rotation piecewise — which works inside the control extent and fails immediately outside it.
Can I use residuals to decide whether a control point is bad?
Cautiously, and only with normalised residuals. A raw residual is large partly because the observation is bad and partly because that point is weakly controlled, and the two cannot be separated by eye. Normalising by the residual’s own standard deviation accounts for the geometry, and the resulting statistic can be tested against a critical value. Even then, exclude one point per pass and re-fit, because the worst point distorts the estimates that judge the others.
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.