Validating CRS Metadata Before a Compliance Run
Every frame error a compliance pipeline can make is cheap to catch before the run and expensive to find afterwards, because the numbers it produces are plausible. This guide builds a pre-flight gate that inspects every input layer, asserts the four properties that make a measurement valid, and fails the run with a readable message when one is missing — the enforcement half of the standard set out in best practices for CRS standardization in compliance GIS.
Prerequisites
Step-by-step
Step 1: Assert the frame is declared and projected
The first check is the cheapest and catches the most common defect: a layer that declares no frame at all, or declares a geographic one.
def check_declared(gdf, layer: str):
"""A layer with no CRS is a defect, not a default."""
if gdf.crs is None:
raise ValueError(f"{layer}: no CRS declared — quarantine and ask the supplier")
if not gdf.crs.is_projected:
raise ValueError(f"{layer}: {gdf.crs.name} is geographic; "
"distances would be in degrees")
return gdf.crs
Raising rather than reprojecting is deliberate. A layer whose frame is unknown cannot be transformed correctly, and guessing by overlaying it on a known layer can identify an obviously wrong guess but cannot confirm a right one at the metre level.
Step 2: Assert the unit matches the rule set
A projected frame is not enough — the unit decides what every threshold comparison means. Read it from the CRS rather than inferring it from the EPSG code.
def check_unit(crs, expected_unit: str, layer: str):
actual = crs.axis_info[0].unit_name
if actual != expected_unit:
raise ValueError(
f"{layer}: unit is {actual!r}, rule set expects {expected_unit!r} — "
f"a threshold of 20 would be compared against {actual}s")
The error message matters here. “Unit mismatch” sends somebody to the documentation; naming what a threshold of 20 would mean sends them straight to the cause.
Step 3: Resolve the transformation and check its accuracy
Where a layer arrives in a different frame from the working one, the transformation between them is itself a thing to validate. A missing grid file makes PROJ fall back to a lower-accuracy operation that reports no accuracy at all, producing results that differ between machines.
from pyproj.transformer import TransformerGroup
def check_transform(src_crs, dst_crs, layer: str, min_accuracy_m=1.0):
group = TransformerGroup(src_crs, dst_crs, always_xy=True)
if not group.transformers:
raise ValueError(f"{layer}: no transformation available {src_crs} → {dst_crs}")
best = group.transformers[0]
if best.accuracy is None:
raise RuntimeError(
f"{layer}: transformation {best.description!r} reports no accuracy — "
"a PROJ grid file is missing; install proj-data and re-run")
if best.accuracy > min_accuracy_m:
raise RuntimeError(f"{layer}: best transformation accuracy is "
f"{best.accuracy} m, above the {min_accuracy_m} m limit")
return {"operation": best.description, "accuracy_m": best.accuracy}
This is the check most often omitted and the one that differs between a developer laptop and a production container. Failing on a null accuracy converts an inexplicable discrepancy into a deployment error with an obvious fix.
Step 4: Transform control points and assert the result
Metadata can be right while the transformation misbehaves. A handful of control points with known coordinates catches that directly.
from pyproj import Transformer
CONTROLS = [ # (source lon/lat, expected easting/northing)
((-117.1611, 32.7157), (6_281_146.2, 1_842_233.7)),
((-117.0234, 32.8802), (6_323_889.5, 1_902_115.4)),
]
def check_controls(src_crs, dst_crs, layer: str, tol_ft=0.5):
t = Transformer.from_crs(src_crs, dst_crs, always_xy=True)
for (lon, lat), (ex, ey) in CONTROLS:
x, y = t.transform(lon, lat)
err = ((x - ex) ** 2 + (y - ey) ** 2) ** 0.5
if err > tol_ft:
raise RuntimeError(f"{layer}: control point off by {err:.2f} ft "
f"(tolerance {tol_ft}) — grid files or datum wrong")
Step 5: Run the gate over every layer and record the result
The gate is only useful if it runs over the whole input set and writes what it found into the run manifest, so a later reviewer can see that it ran.
def preflight(layers: dict, frame: dict) -> dict:
"""layers: {name: GeoDataFrame}. Returns the CRS block for the run manifest."""
dst = f"EPSG:{frame['epsg']}"
findings = {}
for name, gdf in layers.items():
src = check_declared(gdf, name)
if src.to_epsg() != frame["epsg"]:
findings[name] = check_transform(src, dst, name)
check_controls(src, dst, name)
else:
check_unit(src, frame["unit"], name)
findings[name] = {"operation": "none", "accuracy_m": 0.0}
findings[name]["source_crs"] = str(src)
return {"working_crs": dst, "working_unit": frame["unit"], "layers": findings}
Verification
Prove the gate rejects what it should. Feed it a layer with its CRS stripped, one declared as EPSG:4326, and one in the right zone but the wrong unit variant, and assert that each raises. A gate that has never rejected anything has not been shown to work.
import pytest
def test_gate_rejects_undeclared(sample):
sample.crs = None
with pytest.raises(ValueError, match="no CRS declared"):
check_declared(sample, "parcels")
def test_gate_rejects_geographic(sample):
with pytest.raises(ValueError, match="geographic"):
check_declared(sample.to_crs("EPSG:4326"), "parcels")
def test_gate_rejects_wrong_unit(sample):
with pytest.raises(ValueError, match="unit is"):
check_unit(CRS.from_epsg(26943), "US survey foot", "parcels")
Common Pitfalls
- Running the gate after loading everything. Check each layer as it is read, so the failure names the layer rather than the batch.
- Downgrading the accuracy check to a warning. A warning in a nightly run is a line nobody reads. The whole value of this check is that it stops the run.
- Testing controls in the geographic frame. The point is to validate the transformation into the working frame; comparing degrees proves nothing about feet.
- Letting the gate reproject. Validation and transformation are different jobs. A gate that quietly fixes what it finds stops reporting the upstream problem, and the supplier never hears about it.
Frequently Asked Questions
How many control points are enough?
Two or three, well separated across the study area. Their job is to detect a wrong datum or missing grid, both of which produce errors far larger than the tolerance, rather than to characterise the transformation precisely.
Where do known control coordinates come from?
Published survey monuments in the jurisdiction, or a small set of parcel corners the county has surveyed. Record their source alongside the values, since a control point of unknown provenance validates nothing.
Should the gate run on every run, or only on ingest?
Both, with different scopes. The full check belongs at ingest, where new layers arrive. A lighter assertion — frame and unit only — belongs at the top of every measurement stage, because it costs microseconds and catches a layer that slipped in another way.
What about layers that legitimately arrive in a different frame?
That is the normal case and the reason step 3 exists. The gate does not require every input to be in the working frame; it requires that the transformation into it is available, high-accuracy and recorded.
Does the gate slow the run down?
No. The metadata checks are microseconds, the transformation resolution is a few milliseconds per layer pair and is cached, and the control points are two coordinate transforms. Against a county run measured in minutes, the whole gate is noise — and against the cost of discovering a frame error after a report has gone out, it is free.
What should the gate do about a layer it does not recognise?
Refuse it. A layer that was not declared in the expected-inputs configuration is either a mistake or an undocumented addition, and both deserve a person’s attention before their geometry reaches a measurement. Enumerating the inputs a run expects, and rejecting anything else, is a one-line check that closes a surprisingly common route for unvalidated data to enter a pipeline.
Where should the gate’s findings end up?
In the run manifest, as a block naming each layer’s source frame, the operation used and its accuracy. That block is what a reviewer reads when a measurement is questioned months later, and it is the same set of facts a compliance log needs anyway — so producing it here rather than separately keeps one record instead of two that can disagree.
The gate is also the natural place to record which layers a run actually read, which turns out to be useful for a reason unrelated to coordinates: a run that read four layers when the manifest expected five has a missing input, and nothing else in the pipeline is positioned to notice.
Related
Part of: CRS standardization and datum management
- Choosing a projected CRS for a municipal compliance project — the decision this gate enforces.
- Handling datum shifts from NAD83 to WGS84 — the operation whose accuracy this checks.
- Capturing CRS provenance in validation logs — where the gate’s findings are recorded.
- Zoning layer ingestion strategies — the stage this gate belongs to.