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_projectedisFalse, 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 calldescribe_crsbefore projecting, as in Step 3, or the source lineage is lost.
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.