Capturing CRS Provenance in Validation Logs

Every distance, area, and buffer verdict in a compliance pipeline is only as trustworthy as the coordinate transformation that produced it. Recording that a layer “was in EPSG:4326” is not enough — an auditor needs the source CRS, the target CRS, and the exact pyproj transformation pipeline, captured per layer, at the moment the reprojection ran. This guide shows how to extract that provenance and fold it into the structured records defined in Validation Log Design for Compliance Pipelines.

Prerequisites

Step-by-step

Step 1: Read the source CRS before touching geometry

Capture the source CRS as authoritative metadata straight from the GeoDataFrame. Resolve it to both an EPSG code and its WKT so the record is unambiguous even for custom or compound systems.

import geopandas as gpd
from pyproj import CRS

def describe_crs(gdf: gpd.GeoDataFrame) -> dict:
    if gdf.crs is None:
        raise ValueError("Layer has no CRS; provenance cannot be established.")
    crs = CRS.from_user_input(gdf.crs)
    return {
        "epsg": crs.to_epsg(),            # may be None for custom CRS
        "auth": crs.to_authority(),        # ("EPSG", "4326") style tuple
        "is_projected": crs.is_projected,  # False means distances are unreliable
        "unit": crs.axis_info[0].unit_name if crs.axis_info else None,
    }

Step 2: Resolve the exact transformation pipeline

Two CRS codes do not fully define a reprojection — the datum shift can follow several transformation pipelines with different accuracies. Use pyproj.Transformer to record the concrete pipeline PROJ selected, so the exact math is reproducible.

from pyproj import Transformer

def describe_transform(source_crs, target_crs: str) -> dict:
    transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)
    op = transformer.transformer_maker.operations if hasattr(transformer, "transformer_maker") else None
    return {
        "source": str(source_crs),
        "target": target_crs,
        "pipeline": transformer.description,          # human-readable operation name
        "definition": transformer.to_proj4() if hasattr(transformer, "to_proj4") else None,
        "accuracy_m": transformer.accuracy if transformer.accuracy != -1 else None,
    }

The accuracy_m field matters for compliance: a NAD83-to-WGS84 shift carried out with a coarse transformation can move a boundary by a meter, which can flip a marginal setback verdict.

Step 3: Reproject per layer and stamp provenance

Project each layer to the metric CRS required for distance work, and emit a provenance record for that specific layer before any measurement runs. Doing this per layer prevents one dataset’s CRS from being wrongly attributed to another.

def reproject_with_provenance(gdf: gpd.GeoDataFrame, layer_id: str,
                              target_crs: str = "EPSG:2926") -> tuple[gpd.GeoDataFrame, dict]:
    source = describe_crs(gdf)                          # capture before projecting
    transform = describe_transform(gdf.crs, target_crs)
    projected = gdf.to_crs(target_crs)                  # metric CRS for distance/area
    record = {
        "layer_id": layer_id,
        "source_crs": source,
        "target_crs": describe_crs(projected),
        "transform": transform,
        "feature_count": int(len(gdf)),
    }
    return projected, record

Step 4: Fold provenance into the batch log

Attach the per-layer provenance records to the structured log entry for the reprojection stage. The nested crs_provenance list keeps every layer’s lineage attributable within a single batch record.

import logging

audit = logging.getLogger("compliance.audit")

def log_crs_provenance(batch_id: str, provenance_records: list[dict]) -> None:
    audit.info(
        "crs provenance captured",
        extra={
            "batch_id": batch_id,
            "stage": "reproject",
            "crs_provenance": provenance_records,   # one entry per layer
        },
    )

Verification

Confirm that every layer that will be measured is projected and carries a complete provenance record. Assert on the captured fields rather than trusting the pipeline ran correctly.

projected, prov = reproject_with_provenance(parcels, layer_id="parcels_2026")

assert prov["target_crs"]["is_projected"], "target CRS must be projected for distances"
assert prov["source_crs"]["epsg"] is not None, "source EPSG should be resolvable"
print(
    f"{prov['layer_id']}: {prov['source_crs']['epsg']} -> "
    f"{prov['target_crs']['epsg']} via {prov['transform']['pipeline']} "
    f"(accuracy {prov['transform']['accuracy_m']} m)"
)

A populated pipeline string and a projected target CRS confirm the reprojection is both correct and fully documented for audit.

Common Pitfalls

  • Logging only the EPSG codes. Two runs can share source and target EPSG yet use different datum-shift pipelines if the PROJ grid files differ. Record the transformer description and accuracy, not just the endpoints, so the transformation is reproducible.
  • Measuring in a geographic CRS. If is_projected is False, any subsequent buffer or distance is distorted. Treat a non-projected target CRS in the provenance record as a hard failure, not a warning.
  • Capturing CRS after reprojection. Once to_crs() runs, the source CRS is gone from the object. Always call describe_crs before projecting, as in Step 3, or the source lineage is lost.

The Five CRS Facts a Verdict Needs

A measurement is meaningless without its frame, and “EPSG:2263” alone is not the whole frame. Five facts together make a measurement reconstructable.

The five frame facts a measurement needsSource CRS, working CRS, linear unit, transformation operation and its stated accuracy.Source CRS of each inputas declared by the supplier, not assumedWorking CRSwhere the measurement happenedLinear unittwo codes for one zone differ only in thisTransformation operation, by nametwo routes can differ by more than a metreStated accuracylets a margin be compared against its own uncertainty
Together they turn "the setback measured 20.1 ft" into a statement that survives scrutiny.

The source CRS of each input, as declared by the supplier rather than as assumed. The working CRS the measurement was taken in. The linear unit of that working CRS, recorded explicitly because two codes for the same zone differ only in this. The transformation operation PROJ selected, by name, since two routes between the same pair can differ by more than a metre. And its stated accuracy, which is what allows a margin to be compared against the uncertainty in the measurement itself.

Recorded together, these turn “the setback measured 20.1 ft” into “the setback measured 20.1 ft in EPSG:2263, US survey feet, having transformed the parcel layer from EPSG:4269 by NAD83(2011) to NAD83 (accuracy 0.02 m)” — which is a statement that survives scrutiny.

def crs_facts(gdf, source_crs, working_crs, transformer):
    """Everything about the frame that a later reviewer will need."""
    return {
        "source_crs": str(source_crs),
        "working_crs": str(working_crs),
        "working_unit": gdf.crs.axis_info[0].unit_name,
        "operation": transformer.description,
        "accuracy_m": transformer.accuracy,     # None means a fallback grid was used
    }

Catching the Frame Error Before the Report

CRS facts in a log are evidence after the fact; the same facts asserted before measurement are prevention.

Assert before measuring, log afterThe same facts asserted at the top of the measurement stage prevent the error the log would otherwise only record.Is the transformationaccuracy known, and doesthe unit match the ruleset?either failsFail the run with a readable messagea fallback grid produces plausible, machine-dependentnumbersboth holdProceed and record the factsthe measurement can be reconstructed laterEither way, the outcome is in the run manifest
A null transformation accuracy means a grid file is missing — a deployment error, not a mystery discrepancy.

The assertions are cheap and specific: the working CRS is projected, its unit matches what the rule set expects, every layer entering the operation shares it, and the transformation accuracy is not null. Running them at the top of the measurement stage converts a silent factor-of-3.28 error into an exception with a readable message.

The accuracy assertion is the one most often omitted and the one that differs between environments. A container missing its PROJ grid files falls back to a lower-accuracy operation with a null accuracy, produces plausible numbers, and disagrees with the same run on another machine by a metre or two. Failing on a null accuracy makes that a deployment error rather than an inexplicable discrepancy.

Logging Enough Without Logging Everything

CRS facts are properties of a layer and a run, not of a parcel, so they belong in the run manifest rather than repeated on every verdict.

Where each fact belongsFrame facts are properties of a run and a layer; verdicts reference the manifest rather than repeating them.In the run manifestOn each verdictFrame factsAll five, once per layerA run id referenceUnitRecordedRepeated — a few bytes, no ambiguityLayer identityEdition and hashWhich layer the value usedSizeOne record per runMillions, kept small
The one exception is the working unit, which is small and is the field most likely to be misread if absent.

What each verdict needs is a reference to the manifest — the run identifier — plus, where a verdict’s measurement came from a specific layer, that layer’s identity. The manifest holds the frame facts once, the verdict points at it, and a reviewer joins the two. This keeps verdict records small enough to store millions of and complete enough to reconstruct.

The exception worth making is the working unit, which is small and is the field most likely to be misread if absent. Repeating it on every measured value costs a few bytes and removes the most consequential ambiguity in the whole record.

Part of: Validation log design

Frequently Asked Questions

Why record the transformation pipeline instead of just the source and target CRS?

A datum transformation between two coordinate systems can be realized by several distinct operations with different accuracies and grid dependencies. PROJ picks one based on the available grids and the requested area of use. Recording the concrete pipeline and its stated accuracy means a future audit can reproduce the exact coordinates rather than an approximation that might shift a boundary enough to change a verdict.

What should I log when the source CRS is undefined?

Treat an undefined CRS as a fatal provenance gap, not something to paper over with a default. Log the null explicitly, halt the layer before any measurement, and route it to manual review. Assuming a CRS silently is how incorrect distances enter the audit trail unnoticed.

How does this differ from choosing a standard CRS across the pipeline?

Selecting and enforcing a consistent projected CRS is an architecture decision covered by CRS Standardization & Datum Management. This guide is about the evidence layer: recording, per layer and per run, which transformation was actually applied so the standardization can be proven after the fact rather than merely assumed.

Where do these provenance records ultimately live?

They ride inside the same structured entries described in Structured JSON Logging for Geospatial Pipelines, nested under the reprojection stage. From there they inherit the correlation id, append-only storage, and hash chaining that make the broader validation log tamper-evident.