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.

Which assertion catches which failure Four failure modes — axis swap, wrong zone, missing datum grid, and an identity no-op — checked against four assertions. Round-tripping alone catches only the missing grid. The bounding box catches the axis swap and the wrong zone. Only the identity check catches the no-op. Failure modes down, assertions across round trip landmarks bbox not identity axis swapped · catches catches · wrong UTM zone · catches catches · missing datum grid catches catches · · identity no-op · · · catches A round trip is the assertion everyone writes first and the one that covers the least — three of four failures walk straight past it. Landmarks are the strongest single check, and the only one that needs data you have to look up rather than derive.

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.

What a missing grid file looks like in the residual Round-trip residual against latitude for a transform involving a national datum. With the grid installed the residual stays under a tenth of a millimetre everywhere. Without it, PROJ substitutes a seven-parameter approximation and the residual rises to several centimetres, varying systematically with latitude. Round-trip residual, transform via a national datum 8 cm 4 cm 0 grid installed — flat, sub-millimetre grid missing — approximation used silently 1 mm tolerance 50.8° N 53.6° N Several centimetres is harmless for fleet positioning and fatal for anything joined against a cadastral or survey dataset. The systematic shape — largest in the middle of the region — is the signature of a fitted approximation replacing a grid. Pin the PROJ data version in the container image so this cannot change under you between deployments.

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.

Why the landmarks have to be spread out The same small rotation applied to two landmark arrangements. Three landmarks clustered near the rotation origin move by under a metre and the assertion passes. Three landmarks spread across the operating area move by tens of metres and the assertion fires. The same 0.02° rotation, two landmark sets clustered — assertion passes worst shift 0.4 m spread — assertion fires worst shift 38 m Filled markers are the published positions; outlines are where the faulty transform puts them. A rotation is not a hypothetical failure: it is what a mismatched datum looks like over a region-sized area.