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.

The pre-flight gate, check by checkEach input layer is checked for a declared projected frame, the expected unit, an available high-accuracy transformation, and control points that land where they should.A CRS is declaredno CRS is a defect; quarantine rather than guessThe frame is projectedor every distance is an angleThe unit matches the rule setread from axis_info, not inferred from the codeThe transformation reports an accuracya null accuracy means a missing grid fileControl points land within tolerancemetadata can be right while the transform misbehaves
Four checks, microseconds each, converting a silent factor-of-3.28 error into a startup failure with a readable message.

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")
What a control-point check catchesKnown coordinates transformed through the configured pipeline reveal a wrong datum or a missing grid immediately, both of which produce errors far above the tolerance.Known pointsurveyed monumentConfigured transformthe run’s own pipelineComputed positionin the working frameResidual vs tolerancepass, or stop the run
Two or three well-separated points are enough: the failures this detects are large, not subtle.

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")
What the gate must reject, and how it is testedThree deliberately broken inputs — no CRS, geographic frame, wrong unit variant — each with the assertion that proves the gate catches it.Broken inputExpected failureCRS strippedgdf.crs is NoneValueError: no CRS declaredReprojected toEPSG:4326Projected check failsValueError: geographicRight zone, wrong unitvariantUnit check failsValueError naming both unitsPROJ grids removedAccuracy is NoneRuntimeError: install proj-data
A gate that has never rejected anything has not been shown to work.

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.

Part of: CRS standardization and datum management