Handling Grid Edges and Out-of-Extent Queries in Python
The interesting half of a grid reader is what it does with the queries it cannot serve, and the correct answer — refuse — is the one that takes discipline to implement, because every alternative produces a number that looks like a coordinate. This guide, part of interpolation methods for grid shift surfaces, covers the four boundary cases a production reader meets: a query outside the grid entirely, a query in the outermost ring of cells, a query in the overlap between nested sub-grids, and a query exactly on a node or edge.
The Four Cases, and Why They Are Different
Outside the extent. The surface has no value there. Clamping to the nearest edge node returns a shift computed for a point that may be a hundred kilometres away, and does so silently. The correct response is to raise and let the routing layer decide, exactly as fallback routing strategies for missing grid files describes for a missing grid.
Figure — four boundary cases, each with a different correct answer.
In the boundary ring. Bilinear interpolation works right up to the edge, because it only needs the cell it is in. A bicubic kernel does not: it needs a node of margin, so the outermost ring of cells is unservable. Falling back to bilinear there is the standard answer and must be recorded.
In a sub-grid overlap. NTv2 grids nest: a finer sub-grid refines part of its parent. A point inside a child must be served by the child, not the parent, and a reader that stops at the first matching sub-grid in file order can pick the wrong one. The rule is deepest-match-wins.
Exactly on a node or edge. A query on the boundary between two cells must give the same answer whichever cell serves it. Bilinear does this naturally when the weights are formed consistently; it is the floating-point comparison that decides which cell is chosen that breaks, and it breaks asymmetrically — a query at exactly the last node can index one past the end.
Complete Runnable Implementation
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
class OutOfExtent(ValueError):
"""Raised when no sub-grid covers a query position."""
@dataclass(frozen=True)
class SubGrid:
"""One NTv2-style sub-grid, with the parent it refines (None for a root)."""
name: str
parent: str | None
lat0: float
lon0: float
dlat: float
dlon: float
values: np.ndarray
depth: int = 0 # 0 for a root grid, +1 per nesting level
@property
def extent(self) -> tuple[float, float, float, float]:
nlat, nlon = self.values.shape
return (self.lat0, self.lat0 + (nlat - 1) * self.dlat,
self.lon0, self.lon0 + (nlon - 1) * self.dlon)
def covers(self, lat: float, lon: float, eps: float = 1e-12) -> bool:
"""Inclusive of both edges, with a tolerance so a node is never 'outside'."""
s, n, w, e = self.extent
return (s - eps) <= lat <= (n + eps) and (w - eps) <= lon <= (e + eps)
@dataclass
class GridSet:
"""A collection of nested sub-grids, resolved deepest-first."""
grids: list[SubGrid] = field(default_factory=list)
def select(self, lat: float, lon: float) -> SubGrid:
"""The most refined sub-grid covering the position.
Deepest match wins: a point inside a child must be served by the child,
because that is the whole reason the child exists.
"""
candidates = [g for g in self.grids if g.covers(lat, lon)]
if not candidates:
raise OutOfExtent(
f"({lat:.8f}, {lon:.8f}) is not covered by any sub-grid in this file"
)
return max(candidates, key=lambda g: (g.depth, -g.dlat))
def sample(self, lat: float, lon: float) -> tuple[float, str, str]:
"""Bilinear shift, plus the sub-grid and kernel actually used.
Returning the provenance alongside the value is what lets a deliverable
say WHICH grid produced a given coordinate — the question a reviewer asks
first when two neighbouring parcels disagree.
"""
grid = self.select(lat, lon)
nlat, nlon = grid.values.shape
fi = (lat - grid.lat0) / grid.dlat
fj = (lon - grid.lon0) / grid.dlon
# Clamp the INDEX, never the position: a query exactly on the last node is
# inside the extent and must be served by the last cell, not rejected.
i = min(max(int(np.floor(fi)), 0), nlat - 2)
j = min(max(int(np.floor(fj)), 0), nlon - 2)
u, v = fi - i, fj - j
block = grid.values[i:i + 2, j:j + 2]
if np.any(block == -999.0) or not np.all(np.isfinite(block)):
raise OutOfExtent(
f"({lat:.8f}, {lon:.8f}) sits on an unmodelled cell of {grid.name}"
)
w = np.array([(1 - u) * (1 - v), (1 - u) * v, u * (1 - v), u * v])
return float(w @ block.reshape(4)), grid.name, "bilinear"
Parameter Reference
| Name | Type | Units | Note |
|---|---|---|---|
lat, lon |
float |
degrees | Longitude sign convention must match the grid’s |
eps |
float |
degrees | 1e-12 ≈ 0.1 µm; guards float equality at the edge |
depth |
int |
— | Nesting level; deepest match wins |
return (shift, grid, kernel) |
tuple |
arc-seconds, str, str | Provenance travels with the value |
OutOfExtent |
exception | — | A routing signal, not a bug |
Worked Example
import numpy as np
parent = SubGrid("CANADA", None, 45.0, -124.0, 0.5, 0.5,
np.full((5, 5), 0.2000), depth=0)
child = SubGrid("VANCOUVER", "CANADA", 45.5, -123.5, 0.05, 0.05,
np.full((5, 5), 0.2075), depth=1)
gs = GridSet([parent, child])
print(gs.sample(45.60, -123.40)) # inside the child
print(gs.sample(46.40, -121.00)) # parent only
try:
gs.sample(50.00, -100.00)
except OutOfExtent as exc:
print(f"refused: {exc}")
# (0.2075, 'VANCOUVER', 'bilinear')
# (0.2, 'CANADA', 'bilinear')
# refused: (50.00000000, -100.00000000) is not covered by any sub-grid in this file
The middle result is the one to check in a real file: a point that falls inside the parent but outside every child must be served by the parent, and a reader that only ever consults the deepest grid in the file will reject it.
Figure — extrapolating past the edge: error against distance beyond the extent.
Validation Check
def check_edges(gs: GridSet, grid: SubGrid) -> None:
"""A query exactly on each corner node must be served, not rejected."""
s, n, w, e = grid.extent
for lat, lon in ((s, w), (s, e), (n, w), (n, e)):
value, used, _ = gs.sample(lat, lon)
assert np.isfinite(value), f"corner ({lat}, {lon}) was not served"
Corner queries are the cheapest regression test for an off-by-one in the index clamp, and they fail loudly on the exact input a naive int(floor(...)) gets wrong.
Common Mistakes
Clamping the position instead of the index. Clamping the index keeps a query on the last node inside the last cell, which is correct. Clamping the position moves an out-of-extent query onto the boundary and returns a shift for somewhere else entirely — with no error, no warning, and a plausible-looking result.
Figure — test extents over the whole batch first, not per point halfway through the run.
First-match sub-grid selection. Iterating the sub-grids in file order and taking the first that covers the point picks the parent whenever the parent comes first, which is usually. The nested child exists because the parent is not accurate enough there, so this silently discards the refinement the grid author went to the trouble of publishing.
Extent compared with a bare float equality. A query at exactly the northern limit can fail a strict < test by one unit in the last place after the extent was computed from lat0 + (n-1) * dlat. The tolerance in covers is there for that, and it is one part in 10¹² of a degree — far below any real position and far above float noise.
Frequently Asked Questions
Should an out-of-extent query be an exception or a sentinel return?
An exception, in a per-point API. A sentinel has to be checked by every caller and eventually will not be, whereas an exception propagates until something handles it deliberately. In a batch API the calculus changes: raise for the batch, but report which rows were affected, so the caller can route them rather than losing them.
What if a point is just barely outside the grid?
It is outside. There is no principled distance at which extrapolation becomes acceptable, and “just barely” is where the temptation is strongest. Route it to the fallback chain and record that the fallback was used — that is a defensible decision, and a two-metre extrapolation labelled as a grid shift is not.
How should overlapping sub-grids of the same depth be handled?
They should not overlap, and a file where they do is malformed — worth reporting rather than resolving silently. If one must be chosen, prefer the finer node spacing and record the choice; the tie-break in select does exactly that, and it is a diagnostic, not a feature.
Does rejecting queries slow down a large batch?
Not measurably, because the extent test is a handful of comparisons per point and vectorises trivially. What does cost time is discovering at the end of a two-hour run that 0.3 per cent of the rows were unservable. Test extents up front over the whole batch, before any transformation runs — the same “cheapest rejection first” ordering used in handling CRS mismatches in cadastral datasets.