Verifying a Reprojection with Known Control Points
Transform a handful of points whose coordinates you hold in both reference systems, measure the geodesic distance between the result and the known value, and assert that every residual is below a threshold you chose in advance. A residual of a few centimetres means the accurate route ran; one of a metre or two means it did not, and no other check will tell you.
This page belongs to the Datum Transformations & Projection Accuracy guide.
Why This Matters
Every other check in a spatial pipeline verifies structure. The registry rows are present, the reference system is declared, the geometry decodes, the index matches the data — and all of those pass on a container whose coordinates were transformed by the wrong route and sit a metre and a half from where they should.
Control points are the only mechanism that measures the output rather than the configuration. They convert “PROJ says a grid-based route is available” into “the coordinates this pipeline produced are within five centimetres of the published values”, which is a claim about the data rather than about the machine.
Prerequisites
- At least three control points spread across the working extent, with published coordinates in both systems
pyproj3.4+ with a workingproj.db- A threshold decided from the capture accuracy, not from what the first run happened to produce
- The route pinning described in the parent guide
Primary Method
# Residual check against published control points
from dataclasses import dataclass
from pyproj import Transformer, Geod
@dataclass(frozen=True)
class ControlPoint:
name: str
source: tuple[float, float] # coordinates in the source system
expected: tuple[float, float] # published coordinates in the target system
def residuals(points: list[ControlPoint], src_crs: str, dst_crs: str):
"""Geodesic distance, in metres, between each transformed point and its
published position. Works for any target system, because the comparison
is done geodesically rather than in the target's own units."""
t = Transformer.from_crs(src_crs, dst_crs, always_xy=True)
geod = Geod(ellps="WGS84")
to_wgs = Transformer.from_crs(dst_crs, "EPSG:4326", always_xy=True)
out = []
for p in points:
got = t.transform(*p.source)
got_ll = to_wgs.transform(*got)
exp_ll = to_wgs.transform(*p.expected)
_, _, metres = geod.inv(*got_ll, *exp_ll)
out.append((p.name, metres))
return out
Measuring geodesically rather than in the target system’s own units is what makes the routine reusable. A residual of “0.043” means nothing until you know whether the units are metres or degrees; a geodesic distance is always metres.
Step-by-Step Walkthrough
1. Choose control points that cover the extent
Three points is a working minimum and they should be spread rather than clustered, because the datum shift varies across the area. Points at the corners and the middle of the working extent catch a route that is accurate in one region and not another — which is exactly what a grid with a mismatched area of use produces.
CONTROLS = [
ControlPoint("SW corner", (170_000.0, 30_000.0), (-5.2011, 50.0930)),
ControlPoint("centre", (400_000.0, 300_000.0), (-1.9932, 52.1859)),
ControlPoint("NE corner", (620_000.0, 480_000.0), ( 1.0342, 53.7842)),
]
Published values are what make these control points rather than fixtures. Coordinates you generated by running the transformation you are trying to verify prove only that the code is deterministic.
2. Set the threshold from the capture accuracy
The threshold is a decision about acceptable error, and it should be tighter than the accuracy the data claims. A rule that has held up well: set it at a quarter of the capture accuracy, so the transformation contributes a small fraction of the total error budget.
CAPTURE_ACCURACY_M = 0.20 # what the survey equipment delivers
THRESHOLD_M = CAPTURE_ACCURACY_M / 4
Setting it from the first run’s observed residual is the anti-pattern. That bakes in whatever route happened to be installed that day, including a wrong one.
3. Run the check and report every point
failures = []
for name, metres in residuals(CONTROLS, "EPSG:27700", "EPSG:4326"):
status = "ok" if metres <= THRESHOLD_M else "FAIL"
print(f"{status:>4} {name:<12} residual {metres:7.4f} m")
if metres > THRESHOLD_M:
failures.append((name, metres))
if failures:
raise AssertionError(
f"{len(failures)} control point(s) beyond {THRESHOLD_M} m: {failures}"
)
Reporting all of them, rather than raising on the first, is what makes the output diagnostic. One point failing and two passing is a different problem from all three failing by a similar amount.
4. Verify the container, not only the transformer
The check above verifies the transformer object. To verify what the pipeline actually wrote, put a control point into the container as a feature and read it back.
# A control point stored as a feature, checked after the reprojection pass
import sqlite3
from shapely import from_wkb
conn = sqlite3.connect("file:survey.gpkg?mode=ro", uri=True)
row = conn.execute(
"SELECT geom FROM control_points WHERE name = 'centre'"
).fetchone()
pt = from_wkb(gpb_to_wkb(row[0]))
geod = Geod(ellps="WGS84")
_, _, metres = geod.inv(pt.x, pt.y, -1.9932, 52.1859)
assert metres <= THRESHOLD_M, f"stored control point is {metres:.4f} m out"
This is the version worth putting in the publication gate. It survives every step of the pipeline, so it catches a reprojection that ran correctly and a later stage that re-wrote the geometry wrongly.
5. Record the result with the artefact
A residual measured and then discarded proves the run was correct and leaves no evidence. Write the figures into the manifest the pipeline already emits, alongside the PROJ version and the operation used.
manifest["crs_verification"] = {
"source": "EPSG:27700",
"target": "EPSG:4326",
"operation": best.description,
"proj_version": pyproj.proj_version_str,
"threshold_m": THRESHOLD_M,
"residuals_m": {name: round(m, 4) for name, m in results},
}
Verification
The check needs its own check: confirm it fails when it should, by running it against a deliberately degraded route.
# Force the approximate route and confirm the gate rejects it
from pyproj import Transformer
approximate = Transformer.from_pipeline(
"+proj=pipeline +step +inv +proj=tmerc +lat_0=49 +lon_0=-2 "
"+k=0.9996012717 +x_0=400000 +y_0=-100000 +ellps=airy "
"+step +proj=helmert +x=446.448 +y=-125.157 +z=542.06 "
"+step +proj=unitconvert +xy_in=rad +xy_out=deg"
)
got = approximate.transform(400_000.0, 300_000.0)
_, _, metres = Geod(ellps="WGS84").inv(*got, -1.9932, 52.1859)
assert metres > THRESHOLD_M, (
"the gate would not have caught a three-parameter fallback"
)
print(f"degraded route residual {metres:.3f} m — correctly rejected")
That assertion is the one that keeps the gate honest. A threshold set too loosely passes both routes and gives false confidence, and this test is what reveals it.
Alternative Approaches or Edge Cases
Where no published control points exist. Use a reference dataset instead: a national mapping agency layer whose positions are authoritative, and compare a sample of features rather than points. The residual is noisier because the features themselves have capture error, but the pattern still distinguishes a route problem from a fixture problem.
Verifying a round trip instead. Transforming out and back and checking closure is cheaper and much weaker — a consistently wrong route round-trips perfectly. It is a useful check for asymmetric grid availability and no substitute for absolute verification.
Extents crossing a grid boundary. Where the working area spans two grids, place control points in each and expect the residuals to differ. A single threshold still applies; what changes is that a failure in one region and not the other is informative rather than confusing.
Troubleshooting
Residuals are around 100 metres
Cause: A datum was ignored entirely — the coordinates were relabelled rather than transformed, or the source system was misidentified. Fix: Check the source reference system against the data’s provenance; a residual of this magnitude is a datum, not a route, as described in Datum Shifts and Why Coordinates Move by Metres.
Residuals are enormous and vary wildly
Cause: Axis order. Longitude and latitude swapped produces residuals that are large and have no spatial pattern. Fix: Confirm always_xy=True is set consistently on every transformer in the path, including the ones inside helper functions.
The check passes in CI and fails in production
Cause: The two environments have different grid availability — the exact failure the check exists to catch, appearing where it should. Fix: Bundle the grids into the runtime image as described in How to Bundle PROJ Grid Files with an Application, and run the check against the runtime image rather than a separate test image.
Frequently Asked Questions
How many control points are enough?
Three to cover an extent and distinguish the failure patterns; five or six if the extent is large or crosses a grid boundary. Beyond that the marginal value falls quickly, because additional points in the same region tell you what the existing ones already did. What matters far more than count is spread — five clustered points are weaker than three spread ones.
Where do published control-point coordinates come from?
National mapping agencies publish coordinates for their geodetic control networks, usually in both the national system and a modern global one, precisely so transformations can be validated. Those are the values to use. Coordinates from a web map, a handheld receiver, or a previous run of your own pipeline are not control points, however precise they look.
Should this run on every build or only on release?
Every build, because it costs milliseconds and the thing it catches — a base-image change altering grid availability — happens without anyone touching the code. It is one of the cheapest checks in a spatial pipeline and the only one that would notice a silent metre-scale shift, which makes the case for running it constantly fairly easy.
Related
- Datum Transformations & Projection Accuracy — parent guide: pinning the route and recording it
- How to Bundle PROJ Grid Files with an Application — making the accurate route available in the first place
- Datum Shifts and Why Coordinates Move by Metres — what the residual is measuring
- Data Quality Gates & Monitoring for Field Datasets — where a residual check sits in a publication gate