Projection Normalization Workflows Jump to heading
Automated geospatial pipelines fail quietly when coordinate reference systems drift across ingestion sources: a county parcel layer arrives in a State Plane foot system, an aerial tile in Web Mercator, and a survey extract in a legacy ESRI datum, and a naive spatial join silently produces metre-scale offsets. A projection normalization workflow is the discrete pipeline stage that enforces deterministic CRS alignment — resolving every source to a canonical target EPSG code, gating against projection validity domains, and reprojecting with auditable, tolerance-checked transforms — before any spatial join, tiling, or index generation runs. This stage operates at the core of CRS Normalization & Sync, the discipline responsible for guaranteeing that every geometry in the system shares a coherent spatial reference before downstream schema operations begin.
This page is scoped to the resolution-and-reprojection contract: how to declare the source-to-target mapping, sanitize heterogeneous CRS definitions, gate against the target projection’s valid extent, and reproject deterministically. Choosing which datum transformation path runs when source and target datums differ — and how to fall back when a grid is missing — belongs to Datum Transformation Fallback Chains. Applying numeric deviation gates and coordinate snapping once a transform is selected is the job of Unit Conversion & Tolerance Thresholds. Merging the normalized outputs of several authority sources into one coherent dataset is covered by Multi-CRS Dataset Harmonization. Here we focus on replacing ad-hoc GDAL one-liners with a schema-driven, reproducible stage.
Declarative CRS Mapping Manifest Jump to heading
The stage is driven by a version-controlled manifest rather than imperative scripts, so that every CRS decision is reviewable in a pull request and reproducible across runs. The manifest declares each expected source identifier, the canonical target EPSG code, and whether resolution must be strict (exact authority match) or may fall back to heuristics. Mandatory fields are non-negotiable to prevent silent fallbacks or undefined behaviour during CI/CD execution; optional fields tune datum routing and precision.
| Field | Requirement | Type | Description |
|---|---|---|---|
source_identifier |
Mandatory | string |
Raw CRS input (WKT v1/v2, PROJ string, ESRI code, or EPSG alias) |
target_epsg |
Mandatory | integer |
Canonical EPSG code for the output projection |
strict_validation |
Mandatory | boolean |
Enforces exact authority matching; rejects heuristic guesses |
authority |
Optional | string |
Explicit authority override (EPSG, ESRI, IGNF). Defaults to EPSG |
tolerance_meters |
Optional | float |
Maximum permitted residual deviation after reprojection. Defaults to 0.0 (exact) |
fallback_datums |
Optional | array |
Ordered datum paths handed to the fallback-chain stage when datums differ |
# crs_mapping.yaml — projection normalization manifest (pyyaml >=6.0)
normalization_rules:
- source_identifier: "ESRI:102003" # USA Contiguous Albers (ESRI alias)
target_epsg: 5070 # NAD83 / Conus Albers (canonical)
strict_validation: true
authority: EPSG
tolerance_meters: 0.001
- source_identifier: "PROJ:+proj=aea +lat_1=50 +lat_2=58.5 +lat_0=45"
target_epsg: 3005 # NAD83 / BC Albers
strict_validation: true
fallback_datums: ["NAD83", "WGS84"] # consumed by the fallback-chain stage
A small pydantic or voluptuous schema validates this manifest before the pipeline starts. Treat an unparseable manifest as a hard build failure, never a runtime warning — a malformed target_epsg must not be allowed to reach a production reprojection.
Preprocessing Requirements: Sanitizing Heterogeneous CRS Input Jump to heading
Before the manifest’s rules can be applied, source CRS metadata has to be reduced to a single canonical form. Raw inputs arrive as PROJ strings, WKT v1 and v2, deprecated ESRI identifiers, or bare srs_name attributes, and many carry deprecated parameters (+towgs84 blocks, axis-order ambiguity, or unit drift). The resolver parses each input with pyproj.CRS.from_user_input(), strips deprecated parameters, and confirms the result against the EPSG registry. Inputs that resolve ambiguously — or not at all — are routed to a quarantine manifest rather than guessed at. When an ingestion batch mixes formats and missing declarations, the deeper step-by-step EPSG code normalization for mixed datasets procedure walks through inventory extraction, metadata sanitization, and per-record resolution logging in detail.
# crs_resolve.py — canonicalize a heterogeneous CRS string (pyproj >=3.6)
from pyproj import CRS
from pyproj.exceptions import CRSError
def resolve_to_epsg(source_identifier: str, strict: bool = True) -> int:
"""Return a canonical EPSG code or raise. No heuristic guessing under strict."""
try:
crs = CRS.from_user_input(source_identifier)
except CRSError as exc:
raise CRSError(f"unparseable CRS: {source_identifier!r}") from exc
# min_confidence=100 demands an exact authority match under strict resolution
matches = crs.list_authority(auth_name="EPSG")
if strict and not matches:
raise CRSError(f"no exact EPSG authority match for {source_identifier!r}")
epsg = crs.to_epsg(min_confidence=100 if strict else 70)
if epsg is None:
raise CRSError(f"could not assign EPSG code to {source_identifier!r}")
return epsg
Execution Engine & Precision Guards Jump to heading
Once a source is canonicalized, the engine gates against the target projection’s valid domain, then reprojects with an explicit transform. Blind transformation introduces topology corruption when source coordinates exceed the valid area of use of the target projection — Web Mercator beyond ~85° latitude, or a State Plane zone applied outside its county band, produces mathematically unstable shifts. The validity gate compares the geometry envelope against the EPSG-defined area_of_use and quarantines out-of-domain records instead of forcing the transform.
# normalize.py — validity gate + audited reprojection (pyproj >=3.6, shapely >=2.0)
import logging
from pyproj import CRS, Transformer
from pyproj.exceptions import ProjError
from shapely.ops import transform as shp_transform
logger = logging.getLogger("crs.normalize")
def gate_and_reproject(geometry, src_crs: CRS, target_epsg: int,
tolerance_m: float = 0.0) -> tuple:
target_crs = CRS.from_epsg(target_epsg)
aou = target_crs.area_of_use
if aou is None:
raise ValueError(f"target EPSG:{target_epsg} has no defined area of use")
minx, miny, maxx, maxy = geometry.bounds # geographic-degree envelope assumed
if minx > aou.east or maxx < aou.west or miny > aou.north or maxy < aou.south:
logger.warning("envelope outside EPSG:%s area of use -> quarantine", target_epsg)
return None, {"outcome": "QUARANTINE", "reason": "outside_area_of_use"}
try:
# always_xy pins lon/lat axis order across geographic and projected CRS
tf = Transformer.from_crs(src_crs, target_crs, always_xy=True)
out = shp_transform(tf.transform, geometry)
except ProjError as exc:
logger.error("PROJ transform failed src=%s -> %s: %s",
src_crs.to_authority(), target_epsg, exc)
return None, {"outcome": "REJECT", "reason": f"proj_error:{exc}"}
audit = {
"outcome": "PASS",
"src_crs": src_crs.to_authority(), # e.g. ('EPSG', '102003')
"target_epsg": target_epsg,
"transformer_name": tf.description,
"accuracy_m": tf.accuracy if tf.accuracy is not None else -1.0,
"tolerance_m": tolerance_m,
}
# Tolerance enforcement: reported transform accuracy must be within budget
if tolerance_m > 0.0 and audit["accuracy_m"] > tolerance_m:
audit["outcome"] = "REJECT"
audit["reason"] = "accuracy_exceeds_tolerance"
out = None
return out, audit
Every transform is built explicitly with Transformer.from_crs(...) rather than relying on GeoPandas’ implicit .to_crs(), so the chosen pipeline and its reported accuracy are captured for the audit trail. When the source and target datums differ, hand the fallback_datums list to Datum Transformation Fallback Chains to pick a deterministic grid path; enforce the numeric tolerance_meters budget through Unit Conversion & Tolerance Thresholds. Consult the PROJ documentation for transformation-grid management and accuracy semantics.
Failure Modes & Fallback Routing Jump to heading
No record exits this stage in an undefined state. Each failure type maps to one deterministic recovery action, and every routed record writes a reason code to the audit trail.
| Failure mode | Likely cause | Deterministic recovery |
|---|---|---|
CRSError on resolve |
Malformed WKT, deprecated ESRI alias, or no EPSG match under strict mode | Route to quarantine manifest; do not guess a code |
| Envelope outside area of use | Source coordinates beyond target projection’s valid latitude/longitude band | Quarantine record; flag for manual zone reassignment |
ProjError on transform |
Missing PROJ grid (NTv2/NADCON) or unsupported pipeline | Reject; hand to fallback-chain stage for an alternate datum path |
| Accuracy exceeds tolerance | Selected transform’s reported accuracy above tolerance_meters |
Reject record; emit accuracy_exceeds_tolerance to reject log |
| Axis-order ambiguity | Geographic CRS reprojected without always_xy |
Re-run with always_xy=True; assert lon/lat ordering in tests |
Transient causes — a grid file that has not finished downloading, a temporarily unreachable PROJ CDN — are not the resolver’s concern. Wrap the engine with the Error Handling & Retry Logic patterns (exponential backoff, circuit breakers) so a transient grid fetch is retried rather than recorded as a permanent rejection.
Compliance Reporting Output Jump to heading
The stage writes one audit record per input geometry to a newline-delimited JSON (NDJSON) lineage log, so a reviewer can later prove which transform ran for any feature. Each PASS record carries the resolved source authority, target EPSG, the named transformer pipeline, its reported accuracy, and the tolerance budget applied. QUARANTINE and REJECT records carry the same identifiers plus a machine-readable reason field, and are also appended to a separate rejection log that downstream reconciliation jobs consume.
# audit_sink.py — append-only lineage and rejection logs (Python 3.10+)
import json
from pathlib import Path
def write_audit(record_id: str, audit: dict,
lineage: Path, rejects: Path) -> None:
row = {"record_id": record_id, **audit}
line = json.dumps(row, sort_keys=True)
lineage.open("a").write(line + "\n")
if audit["outcome"] in {"QUARANTINE", "REJECT"}:
rejects.open("a").write(line + "\n")
This lineage feeds the broader compliance dashboard: the transformer_name and accuracy_m fields satisfy ISO 19115 positional-accuracy lineage requirements, and the rejection log gives auditors a defensible record of every geometry that did not pass and exactly why.
CI Integration Jump to heading
Embed the manifest and resolver checks directly into version control so CRS drift cannot reach production. Gate two things on every pull request: that the crs_mapping.yaml manifest still validates against its schema, and that the resolver assigns the expected canonical EPSG codes for a fixed set of golden inputs.
# .github/workflows/crs-normalization.yml
name: CRS Normalization Validation
on:
pull_request:
paths:
- "schemas/crs_mapping.yaml"
- "src/normalization/**"
- "tests/test_normalization.py"
jobs:
validate-crs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install "pyproj>=3.6" "shapely>=2.0" "pyyaml>=6.0" "pytest>=7" proj-data
- name: Validate CRS mapping manifest
run: python -m src.normalization.validate --config schemas/crs_mapping.yaml --strict
- name: Run resolver + validity-gate tests
run: pytest tests/test_normalization.py -v --tb=short
Pair the GitHub Actions gate with a pytest fixture that asserts each manifest rule round-trips to its expected target_epsg, plus a deliberately out-of-domain geometry that must route to QUARANTINE. A pre-commit hook running the manifest schema validator catches malformed edits before they ever reach CI.
Frequently Asked Questions Jump to heading
Why build transforms with Transformer.from_crs() instead of GeoPandas .to_crs()?
.to_crs() selects a transformation pipeline implicitly and throws away its identity and reported accuracy. Building the transform explicitly with Transformer.from_crs(..., always_xy=True) captures the pipeline name (tf.description) and tf.accuracy for the audit trail, and lets you enforce a per-record tolerance_meters budget that an implicit reproject cannot express.
Why does the area-of-use gate run before reprojection rather than after?
Reprojecting coordinates that fall outside the target projection’s valid domain produces mathematically unstable shifts — Web Mercator beyond ~85° latitude, or a State Plane zone applied outside its county band. Gating the geometry envelope against the EPSG-defined area_of_use first quarantines out-of-domain records, instead of writing corrupted geometry that downstream joins would silently treat as valid.
What is the difference between a QUARANTINE and a REJECT outcome?
QUARANTINE marks records that may still be recoverable by a human or a different rule — an unparseable CRS, or an envelope outside the target’s area of use — and holds them for manual zone reassignment. REJECT marks records that failed the transform itself: a missing PROJ grid raising ProjError, or a transform whose reported accuracy exceeds the configured tolerance. Rejected records are handed to the Datum Transformation Fallback Chains stage for an alternate datum path or written to the reject log.
Why pass always_xy=True to every transformer?
Many geographic EPSG definitions declare a latitude-first axis order, while most pipeline code assumes lon/lat. always_xy=True pins longitude-then-latitude ordering across both geographic and projected CRS, eliminating the silent coordinate swap that otherwise produces transposed geometry — the failure mode listed as “axis-order ambiguity” in the table above.
Deeper Implementations Jump to heading
- Step-by-step EPSG code normalization for mixed datasets — inventory extraction, metadata sanitization, and per-record resolution logging for batches that mix shapefiles, GeoJSON, and PostGIS exports.
Related Jump to heading
- Datum Transformation Fallback Chains — deterministic path selection when source and target datums differ or a grid is missing
- Unit Conversion & Tolerance Thresholds — numeric deviation gates and coordinate snapping applied after a transform is chosen
- Multi-CRS Dataset Harmonization — merging normalized outputs from several authority sources into one coherent dataset
- Error Handling & Retry Logic — exponential backoff and circuit breakers for transient grid-download failures
- CRS Normalization & Sync — the parent discipline governing spatial-reference coherence across the pipeline