Validating CRS Transforms with Round-Trip Assertions
A coordinate transform is one of the few pipeline stages that can be completely wrong and still return plausible numbers. Swap the axis order and Amsterdam lands in the Indian Ocean, but the code raises nothing. Point at the wrong UTM zone and every position is a few hundred kilometres east, which looks like a valid easting. Configure source and target to the same authority code and the transform becomes a no-op that quietly hands degrees to a stage expecting metres.
None of those failures is caught by unit tests of the surrounding code, because the surrounding code is correct. They are caught by assertions on the transform itself, and there are four that between them cover the realistic failure space. This page implements all four as a harness you can run in CI and again at pipeline start-up, extending the configuration work in coordinate reference system mapping for fleet data.
Compatibility and Configuration Requirements
| Requirement | Value | Notes |
|---|---|---|
| pyproj | ≥ 3.6 | Transformer.from_crs(..., always_xy=True) and accuracy reporting |
| PROJ data | Grids installed, or network enabled | A missing grid silently degrades to a lower-accuracy path |
| Landmarks | At least three, spanning the operating area | One landmark cannot distinguish a rotation from a translation |
| Bounding box | The fleet’s actual operating extent, generously padded | Too tight and it fires on a legitimate cross-border trip |
| Runtime | CI plus pipeline start-up | Configuration drifts between the two more often than anyone expects |
The Assertion Harness
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from pyproj import CRS, Transformer
@dataclass(frozen=True)
class Landmark:
name: str
lon: float
lat: float
x: float # published easting in the target CRS
y: float # published northing in the target CRS
class TransformValidator:
"""Four assertions that between them catch the realistic transform failures."""
def __init__(self, src: str, dst: str, bbox_xy: tuple[float, float, float, float]):
self.src_crs = CRS.from_user_input(src)
self.dst_crs = CRS.from_user_input(dst)
if self.src_crs.equals(self.dst_crs):
raise ValueError(f"source and target resolve to the same CRS: {src} == {dst}")
self.fwd = Transformer.from_crs(self.src_crs, self.dst_crs, always_xy=True)
self.inv = Transformer.from_crs(self.dst_crs, self.src_crs, always_xy=True)
self.bbox_xy = bbox_xy
def assert_round_trip(self, lon: np.ndarray, lat: np.ndarray, tol_m: float = 0.001) -> None:
"""Numerical health: forward then inverse must return to the start."""
x, y = self.fwd.transform(lon, lat)
lon2, lat2 = self.inv.transform(x, y)
# Compare in the projected plane so the tolerance is metres, not degrees.
x2, y2 = self.fwd.transform(lon2, lat2)
resid = np.hypot(x2 - x, y2 - y)
worst = float(np.nanmax(resid))
if worst > tol_m:
raise AssertionError(f"round-trip residual {worst:.4f} m exceeds {tol_m} m")
def assert_landmarks(self, landmarks: list[Landmark], tol_m: float = 0.5) -> None:
"""Correct destination: published coordinates must be reproduced."""
for lm in landmarks:
x, y = self.fwd.transform(lm.lon, lm.lat)
err = float(np.hypot(x - lm.x, y - lm.y))
if err > tol_m:
raise AssertionError(f"{lm.name}: {err:.3f} m from its published position")
def assert_in_bbox(self, lon: np.ndarray, lat: np.ndarray) -> None:
"""Axis-order and zone sanity: output must land in the operating area."""
x, y = self.fwd.transform(lon, lat)
x0, y0, x1, y1 = self.bbox_xy
outside = (x < x0) | (x > x1) | (y < y0) | (y > y1)
if outside.any():
n = int(outside.sum())
raise AssertionError(f"{n} of {outside.size} transformed points fall outside the bbox")
def assert_not_identity(self, lon: np.ndarray, lat: np.ndarray, min_shift_m: float = 1.0) -> None:
"""Configuration sanity: the transform must actually do something."""
x, y = self.fwd.transform(lon, lat)
if np.allclose(x, lon, atol=1e-6) and np.allclose(y, lat, atol=1e-6):
raise AssertionError("transform is a no-op — check the source and target codes")
Each method exists because the others do not catch its failure. The dependency is worth stating explicitly, because a harness that runs only the first assertion is very common and covers the least.
Execution and Tuning Guidelines
Wire the harness into both CI and start-up:
VALIDATOR = TransformValidator(
src="EPSG:4326",
dst="EPSG:32631",
bbox_xy=(400_000, 5_600_000, 800_000, 6_100_000), # generous, Benelux
)
LANDMARKS = [
Landmark("Amsterdam Centraal", 4.90022, 52.37892, 628_437.0, 5_805_130.0),
Landmark("Rotterdam Centraal", 4.46915, 51.92503, 596_070.0, 5_753_760.0),
Landmark("Eindhoven Centraal", 5.48148, 51.44329, 671_260.0, 5_701_690.0),
]
def preflight(sample_lon, sample_lat) -> None:
VALIDATOR.assert_not_identity(sample_lon, sample_lat)
VALIDATOR.assert_round_trip(sample_lon, sample_lat)
VALIDATOR.assert_landmarks(LANDMARKS)
VALIDATOR.assert_in_bbox(sample_lon, sample_lat)
Run the assertions in that order. The identity check is the cheapest and the most likely configuration error; the bounding box is the most expensive because it runs over the whole sample. Failing fast on the cheap one keeps the CI feedback loop short.
Pad the bounding box generously. A box drawn tightly around last quarter’s traces will fire the first time a driver takes an unexpected detour, and a check that cries wolf gets disabled. Pad it to the operating region rather than the observed extent, and treat a genuine excursion as a data question rather than a transform failure.
Give landmarks a half-metre tolerance, not a millimetre one. Published coordinates are usually quoted to the nearest metre and sometimes refer to a slightly different point on the same structure. Half a metre is tight enough to catch a wrong zone, which is off by hundreds of kilometres, and loose enough not to fail on a rounding difference.
Assert the transform’s own accuracy where pyproj reports it. Transformer.accuracy returns the
expected error of the chosen pipeline; a value that jumps from 0.01 to 2.0 means PROJ fell back to a
lower-accuracy path because a grid file is missing.
Common Pitfalls Specific to This Technique
Measuring the round-trip residual in degrees. A residual of 1e-7 degrees sounds tiny and is
about a centimetre of latitude — and a different distance of longitude depending on where you are. The
implementation above deliberately measures the residual in the projected plane so the tolerance means
one thing everywhere.
Testing with a single landmark. One point cannot distinguish a correct transform from one with a compensating rotation, and a lot of real misconfigurations are exactly that. Three points spanning the operating area is the practical minimum.
Running the assertions only in CI. The failure that actually reaches production is a container built without the PROJ grid data, and CI usually has it. Run the same preflight at pipeline start-up, where the environment is the one that matters.
Choosing and Maintaining Landmarks
Landmarks are the strongest of the four assertions and the only one that needs data you cannot derive from the CRS definitions. They are worth a little care.
Pick features that are unambiguous and durable. Survey monuments, national grid reference points and the published coordinates of major railway stations all work. Building corners and road junctions do not: they move when the map is redrawn, and nobody records why the assertion started failing.
Spread them across the operating area. Three points near one another cannot distinguish a correct transform from one with a small rotation, because a rotation about a nearby origin barely moves any of them. Put one near each extreme of the region and one in the middle.
Record the source with each landmark. A coordinate with no provenance becomes untouchable: nobody will change it in case it is right, and nobody will trust it either. A URL and a retrieval date in a comment is enough.
LANDMARKS = [
# Source: national geodetic register, retrieved 2026-08-06.
Landmark("Westertoren", 4.88379, 52.37451, 626_320.0, 5_804_640.0),
Landmark("Domtoren", 5.12142, 52.09070, 643_400.0, 5_772_960.0),
Landmark("Sint-Jan", 5.30861, 51.68910, 657_050.0, 5_728_170.0),
]
Keeping the harness honest
A validation harness that has never failed is indistinguishable from one that does nothing. Include a deliberately broken configuration in the test suite and assert that each check rejects it:
def test_bbox_catches_axis_swap():
v = TransformValidator("EPSG:4326", "EPSG:32631", bbox_xy=BBOX)
lon, lat = np.array([4.90]), np.array([52.37])
with pytest.raises(AssertionError):
v.assert_in_bbox(lat, lon) # arguments deliberately swapped
def test_identity_check_catches_same_crs():
with pytest.raises(ValueError):
TransformValidator("EPSG:4326", "EPSG:4326", bbox_xy=BBOX)
Two tests, two seconds, and the harness now has evidence that it can reject something. That evidence is what makes a passing run meaningful — without it, a green build says only that the assertions did not raise, which is also what a harness with a typo in the condition does.
Version the landmark set alongside the pipeline. When the fleet expands into a new country, the landmarks and the bounding box both need extending, and doing it as one reviewed change keeps the two consistent. A bounding box that covers the new region while the landmarks still only cover the old one is a harness that has quietly stopped checking most of the operating area.
Related
- Coordinate reference system mapping for fleet data — parent topic covering CRS selection
- Python script to convert WGS84 to a local CRS for fleet routing — the transform this harness validates
- Choosing UTM zones for cross-border fleet routes — the zone decision the bounding-box check enforces
- Regression testing a map matcher in CI — the same preflight discipline one stage later
- GPS Data Preprocessing & Cleaning Fundamentals — parent section