Detecting Null Shift Sentinels in Grid Files

A node carrying a no-data sentinel is not a shift of −999 arc-seconds; it is the model saying it has nothing to offer at that position, and a reader that treats it as a number moves the point several kilometres or, worse, a few hundred metres. This guide, part of interpolation methods for grid shift surfaces, covers how sentinels appear in the formats a cadastral pipeline meets, how to detect them robustly across float representations, and why the correct response is a refusal rather than a substitution.

What a Sentinel Means and How It Is Stored

Grid formats predate any convention for missing data in binary arrays, so they encode it in-band with a magic value. NTv2 and NADCON both use -999.0; some vertical models use -88.8888, others use large positive values, and a few modern products use IEEE NaN, which is the only self-describing choice of the four. The magic value is stored in the same float field as a real shift, so nothing but a comparison distinguishes them.

Displacement caused by one sentinel corner, by its weight Bar chart of the resulting displacement in metres when one corner carrying the minus-999 sentinel is averaged into a bilinear interpolation, at four weights: 0.05, 0.25, 0.50 and 1.00. The displacement runs from about 1.5 kilometres at a weight of 0.05 to about 30 kilometres at full weight. The small-weight case is the dangerous one: it is large enough to be badly wrong and small enough to be mistaken for a datum problem. 1000 10000 100000 m 1540 w=0.05 7710 w=0.25 15420 w=0.50 30840 w=1.00

Figure — what a single unnoticed sentinel corner does, by its interpolation weight.

Two properties make the comparison less trivial than it looks. The value is usually stored as a 32-bit float, so the exact bit pattern read back is -999.0 only if the writer wrote exactly that; a value written as -999.0000001 in a producer’s double precision arrives as something a strict equality test misses. And in an array promoted to float64 for interpolation, a float32 -999.0 converts exactly — but a scaled array, one where the reader has already converted arc-seconds to radians or metres, no longer contains anything resembling −999 at all. Detect sentinels before any scaling, and detect them with a tolerance.

Complete Runnable Implementation

from __future__ import annotations

from dataclasses import dataclass

import numpy as np

# Sentinels seen in the grid formats a cadastral pipeline actually meets.
KNOWN_SENTINELS = (-999.0, -88.8888, -9999.0)
SENTINEL_TOL = 1e-3          # generous: a real shift is never within 1e-3 of these


@dataclass(frozen=True)
class SentinelPolicy:
    """How a reader recognises and reports unmodelled nodes."""

    values: tuple[float, ...] = KNOWN_SENTINELS
    tol: float = SENTINEL_TOL

    def mask(self, arr: np.ndarray) -> np.ndarray:
        """Boolean mask of unmodelled nodes — True where the node has no value.

        Applied to the RAW array, before any unit conversion: once arc-seconds have
        been scaled to radians the sentinel is no longer recognisable.
        """
        if arr.dtype not in (np.float32, np.float64):
            raise TypeError("apply the sentinel mask to the raw float array")
        out = ~np.isfinite(arr)
        for s in self.values:
            out |= np.isclose(arr, s, rtol=0.0, atol=self.tol)
        return out


class UnmodelledNode(ValueError):
    """Raised when an interpolation cell touches a node with no value."""


def sanitise(arr: np.ndarray, policy: SentinelPolicy = SentinelPolicy()) -> np.ndarray:
    """Return a float64 copy with unmodelled nodes replaced by NaN.

    NaN is self-describing and propagates through arithmetic instead of silently
    contributing a number, so a bug downstream produces NaN rather than a
    plausible coordinate. The mask is taken BEFORE the promotion.
    """
    bad = policy.mask(arr)
    out = arr.astype(np.float64, copy=True)
    out[bad] = np.nan
    return out


def bilinear_strict(values: np.ndarray, fi: float, fj: float) -> float:
    """Bilinear sample that refuses any cell touching an unmodelled node."""
    nlat, nlon = values.shape
    i = min(max(int(np.floor(fi)), 0), nlat - 2)
    j = min(max(int(np.floor(fj)), 0), nlon - 2)
    block = values[i:i + 2, j:j + 2]
    if not np.all(np.isfinite(block)):
        n_bad = int((~np.isfinite(block)).sum())
        raise UnmodelledNode(
            f"cell ({i}, {j}) has {n_bad} unmodelled corner(s); the position is "
            f"outside the modelled area of this grid"
        )
    u, v = fi - i, fj - j
    w = np.array([(1 - u) * (1 - v), (1 - u) * v, u * (1 - v), u * v])
    return float(w @ block.reshape(4))


def coverage_report(values: np.ndarray) -> dict[str, float]:
    """Summary a pipeline can log once per grid, rather than per query."""
    bad = ~np.isfinite(values)
    return {
        "nodes": float(values.size),
        "unmodelled": float(bad.sum()),
        "unmodelled_fraction": float(bad.mean()),
    }

Parameter Reference

Name Type Units Note
arr np.ndarray arc-seconds (raw) Mask before any unit conversion
policy.values tuple[float, ...] same as arr Extend per format, do not guess
policy.tol float same as arr 1e-3; a real shift is never this close
sanitise return np.ndarray arc-seconds float64 with NaN for unmodelled
coverage_report dict Log once per grid load

Worked Example

import numpy as np

raw = np.array([
    [0.2031, 0.2044, -999.0],
    [0.2078, 0.2091, -999.0],
    [0.2115, 0.2128, 0.2141],
], dtype=np.float32)

clean = sanitise(raw)
print(coverage_report(clean))
print(f"served cell: {bilinear_strict(clean, 1.5, 0.5):.5f}")
try:
    bilinear_strict(clean, 0.5, 1.5)
except UnmodelledNode as exc:
    print(f"refused: {exc}")
# {'nodes': 9.0, 'unmodelled': 2.0, 'unmodelled_fraction': 0.2222...}
# served cell: 0.20955
# refused: cell (0, 1) has 2 unmodelled corner(s); the position is outside ...

The refused cell is the one worth studying. Averaging its four corners — two real shifts near 0.204 and two sentinels — gives about −499.4 arc-seconds, which is roughly 15 kilometres of latitude. A reader that skipped the check would produce that number without comment, and a coordinate 15 km out is at least obvious. The dangerous variant is a single sentinel corner weighted at 0.05, which moves the point about 750 metres — enough to be badly wrong and small enough to be mistaken for a datum problem.

Order of masking and unit conversion when loading a grid The raw array is read in the file order and units. The sentinel mask is applied first, while the values still look like minus 999, and matched positions become NaN. Only then is the array promoted to float64 and converted to working units. Reversing the two steps turns the sentinel into an ordinary-looking number — minus 0.004847 radians, for arc-seconds converted to radians — which matches no sentinel list and passes every later check. Raw array file order, file units Apply the sentinel mask to the RAW values Unmodelled nodes -> NaN self-describing from here on Promote to float64 + convert units Masking after conversion sentinel is now -0.004847 wrong order

Figure — mask before converting units; after the conversion the sentinel is unrecognisable.

Validation Check

def assert_no_sentinel_leak(values: np.ndarray) -> None:
    """After sanitising, no recognisable sentinel may remain as a finite value."""
    finite = values[np.isfinite(values)]
    for s in KNOWN_SENTINELS:
        assert not np.any(np.isclose(finite, s, rtol=0.0, atol=SENTINEL_TOL)), (
            f"sentinel {s} survived sanitisation — check the raw dtype and the "
            f"order of masking and unit conversion"
        )

Common Mistakes

Testing equality against -999.0 on a float32 array. The comparison usually works and occasionally does not, depending on how the producer wrote the value and whether the array has been through any arithmetic. A tolerance of 1e-3 costs nothing — no genuine arc-second shift is within a thousandth of −999 — and removes the whole class of near-miss failures.

No-data conventions across the formats a cadastral pipeline meets Four conventions. NTv2 and NADCON use minus 999, detected by comparison with a tolerance. Some vertical models use minus 88.8888, detected the same way. Large positive magic values appear in a few products and need a range test. IEEE NaN is self-describing and is detected by a finiteness test — the only one of the four that cannot be mistaken for a value. Value Detected by NTv2 / NADCON -999.0 tolerance compare Some vertical models -88.8888 tolerance compare Large magic values 1e30 and similar range test IEEE NaN NaN finiteness test

Figure — the sentinels a cadastral reader actually meets, and how each is detected.

Masking after unit conversion. Convert arc-seconds to radians first and the sentinel becomes −0.004847, which matches nothing in the sentinel list and passes every check. Mask on the raw array, then convert, and let NaN carry the information forward.

Substituting zero for a missing node. A zero shift is a statement: it says the two datums coincide at that node, which is almost never true and is precisely the claim the sentinel exists to avoid making. Substituting zero produces coordinates that look transformed and are not, and the error equals the full datum offset — metres. Refuse instead and let the fallback chain in fallback routing strategies for missing grid files decide.

Reporting Coverage Before the Batch Runs

Sentinel handling per query is correct and late. A pipeline that transforms a million parcels should test coverage over the whole input against the sanitised grid before the first coordinate is transformed: intersect the input bounding box with the modelled area, count the rows whose cell touches an unmodelled node, and report the number up front. Discovering at the end of a long run that 0.4 per cent of the rows were unservable costs the whole run; discovering it in the first second costs nothing and turns an outage into a routing decision. The same argument, applied to grid extents rather than sentinels, is made in handling grid edges and out-of-extent queries in Python.

Frequently Asked Questions

Why not interpolate using only the valid corners?

Because the result would be an average of a smaller neighbourhood with different weights, which is a different estimator with a different and unquantified error — presented as though it were the same bilinear shift. If a reduced-neighbourhood estimate is genuinely wanted, it must be recorded as such, with its own uncertainty. In practice, a cell with a missing corner is at the edge of the modelled area, and the honest answer is that the model does not cover the point.

Should the accuracy nodes be checked for sentinels too?

Yes. NTv2 records carry latitude and longitude accuracy values alongside the shifts, and those fields have their own sentinels. A pipeline that reports per-node accuracy without checking them will publish −999 as an accuracy figure, which is at least self-evidently wrong — but a mixed record where the shift is valid and the accuracy is not needs a decision, and the defensible one is to report the accuracy as unknown rather than to invent it.

How large a sentinel fraction should raise a flag?

Any nonzero fraction is worth logging at load time; a fraction above a few per cent is worth a second look at whether the right grid was selected. A national grid whose coverage over your working area is mostly unmodelled is usually the wrong grid, and finding that out when the file is opened is much cheaper than finding it out per query, halfway through a batch.

Do modern formats still use magic numbers?

Some do, and legacy files certainly do — and a cadastral pipeline reads legacy files for decades. Where a format uses NaN the detection is free and unambiguous, which is why the sanitiser converts everything to NaN internally: one representation to check for downstream, whatever the file used.