Combining Grid and Parameter Uncertainty in Python

A grid-based shift and a parametric one fail in different ways, and a pipeline that can use either has to combine their uncertainties without pretending they are the same kind of number. This guide, part of uncertainty propagation through transformation chains, assembles the four terms a real chain contributes — grid accuracy nodes, interpolation error, parameter covariance and observation noise — decides which of them are independent, and produces a single covariance a deliverable can quote.

Which Terms Are Independent

Quadrature addition assumes independence, so the first job is to decide what is independent of what. Four terms, four answers:

Correlation scale of each uncertainty term Four terms with the spatial scale over which they stay correlated and what that means for an aggregate. Observation noise is independent point to point and averages away. Interpolation error is correlated within a grid cell, so neighbouring points share it. Grid accuracy is correlated over the scale of the network that produced the grid, typically tens of kilometres. Parameter uncertainty is common across the whole area the parameter set covers and never averages away at all. Correlated over Averages away? Observation noise nothing yes, as 1/sqrt(n) Interpolation error one grid cell partly Grid accuracy tens of km barely Parameter uncertainty the whole area never

Figure — over what distance each term stays correlated, and what that implies.

  • Observation noise is independent between points and between stages. It adds cleanly.
  • Grid accuracy — the per-node accuracy values NTv2 records carry — describes how well the grid models the true distortion at that node. Between points in different cells it is largely independent; between points in the same cell it is essentially identical.
  • Interpolation error is spatially correlated in exactly the same way, and for the same reason: it is a property of the cell, not of the point.
  • Parameter uncertainty, where a parametric fallback ran, is common to every point transformed with that parameter set.

The pattern is that everything except observation noise is correlated over some spatial scale. That does not change the arithmetic for a single point — all four terms simply add in quadrature — but it changes what may be said about aggregates, and it is the reason the implementation below keeps the terms separate rather than summing them immediately.

σpoint2=σobs2+σgrid2+σinterp2+σparam2\sigma_{\text{point}}^2 = \sigma_{\text{obs}}^2 + \sigma_{\text{grid}}^2 + \sigma_{\text{interp}}^2 + \sigma_{\text{param}}^2

Complete Runnable Implementation

from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np


@dataclass(frozen=True)
class Budget:
    """The uncertainty terms of one transformed point, kept separate.

    All values are one-sigma in metres. `independent` terms shrink over an
    aggregate; `correlated` terms do not, which is why they are not summed here.
    """

    observation: float
    interpolation: float
    grid_accuracy: float
    parameters: float = 0.0
    method: str = "grid"

    def __post_init__(self) -> None:
        for name in ("observation", "interpolation", "grid_accuracy", "parameters"):
            v = getattr(self, name)
            if v < 0.0 or not np.isfinite(v):
                raise ValueError(f"{name} must be a finite, non-negative sigma")
        if self.method not in ("grid", "parametric"):
            raise ValueError("method must be 'grid' or 'parametric'")

    @property
    def independent(self) -> float:
        """Terms that differ point to point — the relative-accuracy part."""
        return float(np.hypot(self.observation, self.interpolation))

    @property
    def correlated(self) -> float:
        """Terms shared across points — the systematic part."""
        return float(np.hypot(self.grid_accuracy, self.parameters))

    @property
    def total(self) -> float:
        """One-sigma total for the absolute position of this point."""
        return float(np.hypot(self.independent, self.correlated))

    def scaled(self, factor: float) -> float:
        """Total at a stated confidence factor (1.96 in 1-D, 2.4477 in 2-D at 95%)."""
        return self.total * factor


def aggregate(budgets: list[Budget]) -> dict[str, float]:
    """Dataset-level statement from per-point budgets.

    The independent part shrinks as 1/sqrt(n) over the set; the correlated part
    does not shrink at all. Reporting a dataset uncertainty that ignores this
    understates it by roughly sqrt(n).
    """
    if not budgets:
        raise ValueError("no budgets to aggregate")
    n = len(budgets)
    ind = np.sqrt(np.mean([b.independent ** 2 for b in budgets]) / n)
    cor = float(np.max([b.correlated for b in budgets]))
    return {
        "n": float(n),
        "independent_mean_m": float(ind),
        "correlated_m": cor,
        "dataset_sigma_m": float(np.hypot(ind, cor)),
        "worst_point_sigma_m": float(max(b.total for b in budgets)),
    }

Parameter Reference

Name Type Units Source
observation float m The adjustment that produced the input
interpolation float m Withheld-node measurement on the grid
grid_accuracy float m Per-node accuracy fields, or the grid’s accuracy statement
parameters float m Propagated parameter covariance; 0 for a pure grid shift
method str Recorded so a mixed dataset is explainable
factor float 1.96 one-dimensional, 2.4477 two-dimensional, at 95%

Worked Example

Two points from the same batch — one served by the grid, one by the parametric fallback because it fell outside the grid extent:

Per-point uncertainty for grid-served and fallback-served points Grouped bars of the one-sigma uncertainty in millimetres for two populations in one batch, broken into their terms. Grid-served points carry 12 observation, 4 interpolation and 10 grid accuracy, totalling 16.5 millimetres. Fallback-served points carry 12 observation and 55 parameter, totalling 56.3. A single figure for the batch either flatters the second population or unfairly penalises the first. 0 20 40 mm 12 12 observation 4 0 interpolation 10 55 grid / parameter 16.5 56.3 total grid-served (mm) fallback (mm)

Figure — a mixed batch has two populations, and one number describes neither.

import numpy as np

inside = Budget(observation=0.012, interpolation=0.004,
                grid_accuracy=0.010, method="grid")
outside = Budget(observation=0.012, interpolation=0.0,
                 grid_accuracy=0.0, parameters=0.055, method="parametric")

for name, b in (("grid", inside), ("fallback", outside)):
    print(f"{name:9s} total {b.total * 1000:5.1f} mm  "
          f"95% 2-D {b.scaled(2.4477) * 1000:5.1f} mm")
print(aggregate([inside] * 900 + [outside] * 100))
# grid      total  16.5 mm  95% 2-D  40.4 mm
# fallback  total  56.3 mm  95% 2-D 137.8 mm
# {'n': 1000.0, 'independent_mean_m': 0.00040..., 'correlated_m': 0.055,
#  'dataset_sigma_m': 0.05500..., 'worst_point_sigma_m': 0.0563...}

The aggregate line is the one worth reading twice. Averaging a thousand points drives the independent part down to under half a millimetre — and the dataset uncertainty barely moves from 55 mm, because the correlated term from the hundred fallback points does not average away. A pipeline that reported the mean of the per-point totals would have claimed about 20 mm for the dataset, understating it by nearly a factor of three.

Validation Check

def assert_terms_present(b: Budget) -> None:
    """A budget with a missing term is the failure this guide exists to prevent."""
    if b.method == "grid":
        assert b.grid_accuracy > 0.0, "grid shift with no grid accuracy term"
        assert b.interpolation > 0.0, "grid shift with no interpolation term"
    else:
        assert b.parameters > 0.0, "parametric shift with no parameter term"
    assert b.observation > 0.0, "no observation uncertainty — was it defaulted to zero?"

Every one of those assertions fires on a zero that was never filled in, which is the realistic failure mode: an uncertainty pipeline usually breaks by omission rather than by miscalculation.

Common Mistakes

Grid accuracy confused with interpolation error. They are different quantities from different sources. Grid accuracy is how well the modelled surface matches reality at a node, published with the grid; interpolation error is how well the value between nodes is reconstructed, measured as in measuring interpolation error against control points. Using one for both leaves a real term out of the budget.

A mixed dataset reported with one uncertainty. When some points were served by the grid and some by a fallback, their uncertainties differ by a factor of three or more. A single figure for the whole deliverable either flatters the fallback points or unfairly penalises the grid ones — and the fix is not a compromise number but a per-point figure plus a summary of both populations.

The correlated part averaged away. Dividing every term by the square root of the point count is the reflex, and it is wrong for anything shared between points. The aggregate function above shrinks only the independent part, which is the whole reason the terms are kept apart.

Recording the Budget Alongside the Coordinates

A budget that exists only inside the pipeline is not evidence. The four terms, their sources and the method that produced each point should be written next to the coordinates — four floats and a short string per row — so a reviewer can see not just how uncertain a point is but why. That per-row record is also what makes a mixed dataset explainable: the hundred fallback points in the example above are not a defect, they are a documented consequence of the grid extent, and the record is the difference between those two readings of the same file.

What to record per row alongside the coordinates Five values per transformed row. The observation sigma comes from the adjustment that produced the input. The interpolation sigma comes from the withheld-node measurement on the grid. The grid accuracy comes from the per-node accuracy fields or the grid accuracy statement. The parameter sigma is present only where a parametric fallback ran. The method string says which of the two paths produced this row, which is what makes a mixed dataset explainable rather than merely mixed. sigma_obs m from the input adjustment sigma_interp m from the withheld-node measurement sigma_grid m per-node accuracy, or the grid statement sigma_param m only when a fallback ran method grid | parametric makes the mix explainable

Figure — five values per row that make an uncertainty statement reviewable.

Frequently Asked Questions

Where do NTv2 per-node accuracy values come from?

They are produced by the agency that computed the grid, usually from the residuals of the adjustment that generated it, and they vary across the surface — a well-surveyed urban area has smaller values than a sparsely controlled one. Reading them requires parsing the third and fourth floats of each node record, which is one of the few genuine reasons to write a manual grid reader rather than use the library, as when to use manual grid interpolation over pyproj discusses.

What if the grid publishes no accuracy values?

Fall back to the operation’s declared accuracy, which is a single figure for the whole grid, and record that the per-node values were unavailable. That is coarser but honest. What should not happen is the term being dropped because there was no obvious number to put in it.

How do I decide the spatial scale over which a term is correlated?

For interpolation error, the cell size — points in the same cell share nearly the same error, points several cells apart do not. For grid accuracy, the scale of the control network that produced the grid, which is usually tens of kilometres. For parameter uncertainty, the whole area the parameter set covers. Exact treatment requires a spatial covariance model; treating a term as fully correlated within its scale and independent beyond it is the conservative simplification and is what most specifications assume.

Does this change if the pipeline runs the operations in a different order?

The total does not — quadrature addition is order-independent, and the coordinate stages contribute no uncertainty of their own. What changes is which stage a term is attributed to, and that matters only for the readability of the record. Keep the attribution stable across runs so two reports can be compared line by line.