Python Script for Validating CRS Alignment Across Utility Layers
Manual layer-by-layer inspection of spatial references does not survive contact with a real enterprise geodatabase. When electric, water, gas, and telecommunications feature classes converge into one workspace, a single mismatched Coordinate Reference System (CRS) introduces sub-meter to multi-meter positional drift that silently corrupts node coincidence, breaks connectivity associations, and triggers false results during topology validation. At hundreds of feature classes per geodatabase, eyeballing the projection dialog is both unscalable and unauditable — and because the drift is geometric rather than syntactic, it passes every schema check while quietly degrading every downstream trace. A deterministic, script-driven audit is the only way to intercept CRS drift before it reaches production tracing, mobile data collection, or a rate-case asset filing where positional accuracy is a compliance obligation.
This page provides a complete, copy-paste Python script that walks every feature class in a workspace, decomposes its spatial reference, compares it against an authoritative baseline, and emits a structured report you can wire directly into a build gate. It is a concrete implementation of the standards described in CRS Alignment & Geodetic Transformations, and it assumes the foundational concepts established across Core Utility GIS Fundamentals & Network Models.
Environment Prerequisites
Misconfigured environments produce silent false-compliance reports — the most dangerous failure mode, because the audit appears to pass while the data is wrong. Lock the following before running anything:
- Python runtime: ArcGIS Pro 3.x bundled Python (
arcgispro-py3, Python 3.9+) atC:\Program Files\ArcGIS\Pro\bin\Python\envs\arcgispro-py3, or a standalone ArcPy-licensed install. Clone the environment withconda create --clone arcgispro-py3 --name un-crs-auditrather than mutating the base. - Licensing: A Standard or Advanced ArcGIS Pro license is required for
arcpy.Describeagainst an SDE/utility-network workspace; Basic will not read enterprise geodatabase metadata. - Dependencies:
pyproj>=3.0andpandas, installed viaconda install -c conda-forge pyproj pandas. Confirm thePROJ_DATA(formerlyPROJ_LIB) environment variable resolves to a directory containing the NTv2/GSB grid shift files, orpyprojfalls back to coarse three-parameter approximations. - Workspace access:
Readon the target.gdb, orSELECTonGDB_ItemsandGDB_GeomColumnsfor an SDE connection file. Run under a dedicated service account, not an editor account holding versioned locks. - Topology precondition: Run the audit against an unversioned snapshot or the
DEFAULTversion with no active edit sessions; reconcile/post before auditing so the metadata you read reflects committed geometry. - Baseline definition: A single authoritative EPSG code for the enterprise CRS (for example
26917for NAD83 / UTM zone 17N). Parameterize it — a wrong baseline manufactures cascading false positives across compliant layers.
Schema-Aware Validation Protocol — Run Before the Main Script
The most common cause of a misleading audit is not drift in the data but a fault in the audit setup itself. Work this ordered checklist first; the earliest item is the most frequent culprit.
- Confirm
PROJ_DATAresolves to real grid files. If the directory is missing or empty,pyproj.Transformerstill constructs but selects a low-accuracy pipeline, so aDRIFTlayer can be reported as transformable when it actually needs a grid you do not have. Verify withpython -c "import pyproj; print(pyproj.datadir.get_data_dir())"and check that.gsb/.tiffiles are present. - Verify the workspace opens read-only and is lock-free. A
RuntimeErroronarcpy.Describealmost always means an active edit session, a versioned state conflict, or a stale.lockfile — not corrupt data. Clear idle connections witharcpy.management.DisconnectUserbefore assuming the geometry is bad. - Distinguish undefined from unknown spatial references. A feature class with
factoryCode == 0or anameofUnknownhas no CRS metadata at all. Never auto-reproject these —DefineProjectionwrites metadata only and will silently corrupt coordinates if the assumed CRS is wrong. - Separate horizontal drift from vertical datum mismatch. Two layers can share the same horizontal EPSG yet carry different vertical CRS values. Gravity-fed water and gas pressure-zone models depend on orthometric height, so the audit must report the vertical CRS independently of the horizontal factory code.
- Watch for inherited feature-dataset references. Feature classes inside a feature dataset inherit its spatial reference; an orphaned standalone table with a mismatched geometry column is the classic source of “passes the dataset check, fails at trace time.”
Minimal Reproducible Implementation
The following script uses arcpy for geodatabase traversal and pyproj for rigorous CRS decomposition and transformation-feasibility checks. It handles undefined references, catches schema-lock RuntimeErrors per feature class so one bad layer does not abort the run, and writes a structured CSV plus a non-zero process exit code suitable for a build gate.
import arcpy
import pyproj
import csv
import sys
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
handlers=[logging.StreamHandler()],
)
FIELDNAMES = ["Layer", "EPSG", "Datum", "Projection", "Vertical_CRS", "Status", "Action"]
def _describe_row(fc, baseline_epsg, baseline_crs):
"""Decompose one feature class's spatial reference into a report row."""
desc = arcpy.Describe(fc)
sr = desc.spatialReference
# Undefined / unknown references carry no CRS metadata and must never be
# auto-reprojected: DefineProjection writes metadata only, it does not move points.
if sr.factoryCode == 0 or sr.name.lower() in ("unknown", "undefined"):
return {
"Layer": fc, "EPSG": "Undefined", "Datum": "Undefined",
"Projection": "Undefined", "Vertical_CRS": "Undefined", "Status": "FAIL",
"Action": "Define projection ONLY if source is confirmed to match baseline",
}
epsg_code = sr.factoryCode
datum_name = sr.GCS.datumName if getattr(sr, "GCS", None) else "N/A"
vcs = sr.verticalCoordinateSystem
has_vertical = vcs is not None and vcs.name not in ("", "Unknown")
status, action = "PASS", "No action required"
if epsg_code != baseline_epsg:
status = "DRIFT"
try:
transformer = pyproj.Transformer.from_crs(
pyproj.CRS.from_epsg(epsg_code), baseline_crs, always_xy=True
)
action = f"Project to EPSG:{baseline_epsg} using '{transformer.description}'"
except Exception as exc: # noqa: BLE001 - report, never abort the run
action = f"Manual geodetic transformation required: {exc}"
return {
"Layer": fc, "EPSG": epsg_code, "Datum": datum_name,
"Projection": sr.name, "Vertical_CRS": vcs.name if has_vertical else "None",
"Status": status, "Action": action,
}
def validate_crs_alignment(workspace: str, baseline_epsg: int, output_csv: str) -> int:
"""Audit every feature class against a baseline EPSG.
Returns the count of FAIL rows so a CI/CD step can gate on the exit code.
"""
if not arcpy.Exists(workspace):
raise FileNotFoundError(f"Workspace not found: {workspace}")
arcpy.env.workspace = workspace
baseline_crs = pyproj.CRS.from_epsg(baseline_epsg) # raises early on a bad baseline
results = []
feature_classes = list(arcpy.ListFeatureClasses() or [])
# Include feature classes nested inside feature datasets, which inherit the SR.
for ds in arcpy.ListDatasets(feature_type="Feature") or []:
feature_classes += [
f"{ds}/{fc}" for fc in (arcpy.ListFeatureClasses(feature_dataset=ds) or [])
]
if not feature_classes:
logging.warning("No feature classes detected in workspace.")
return 0
for fc in feature_classes:
try:
results.append(_describe_row(fc, baseline_epsg, baseline_crs))
except RuntimeError as exc:
# Schema lock, versioned-state conflict, or corrupted metadata: report and continue.
logging.error("Schema/permission failure on %s: %s", fc, exc)
results.append({
"Layer": fc, "EPSG": "Error", "Datum": "Error", "Projection": "Error",
"Vertical_CRS": "Error", "Status": "FAIL",
"Action": f"Clear locks / check permissions: {exc}",
})
with open(output_csv, "w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=FIELDNAMES)
writer.writeheader()
writer.writerows(results)
counts = {s: sum(1 for r in results if r["Status"] == s) for s in ("PASS", "DRIFT", "FAIL")}
logging.info("Report written to %s", output_csv)
logging.info("Summary: %d PASS, %d DRIFT, %d FAIL", counts["PASS"], counts["DRIFT"], counts["FAIL"])
return counts["FAIL"]
if __name__ == "__main__":
fails = validate_crs_alignment(r"C:\GIS\UtilityNetwork.gdb", 26917, "crs_audit_report.csv")
# Non-zero exit fails a CI/CD build when any layer has no usable spatial reference.
sys.exit(1 if fails else 0)
Two implementation notes that prevent the most common review comments. First, transformer.description is the human-readable operation name in pyproj 3.x (the older .name attribute is unreliable across versions). Second, the per-feature-class try/except deliberately catches and records failures instead of letting one locked layer abort the whole audit — a single orphaned table must not blind you to the other 300 feature classes.
Production Deployment Pattern
A one-off script is a diagnostic; an enforced gate is an engineering control. Promote the audit into the data lifecycle as follows:
- Run against a versioned, isolated snapshot. Point the script at the
DEFAULTversion or a read-only replica, never an actively edited version. This avoids lock contention and guarantees the audit reflects committed, reconcilable geometry — the same discipline applied in any data ingestion pipeline for utility assets. - Wire it as a pre-ingestion gate. Invoke the script from GitHub Actions, Azure DevOps, or Jenkins on every dataset commit and on a nightly sync. The non-zero exit code fails the build on any
FAIL; parse the CSV withpandasto open remediation tickets forDRIFTrows without blocking the merge. - Apply backoff on transient locks. Enterprise geodatabase reads can fail intermittently under load. Wrap the workspace open in a bounded retry (for example three attempts with exponential backoff) so a momentary lock does not produce a false
FAILand a noisy red build. - Route remediation by status, deterministically. For
DRIFTwith a valid transformer, queuearcpy.management.Projectwith the explicit transformation method (useNAD_1927_To_NAD_1983_NADCONor a region-specific NTv2 grid for legacy NAD27 sources). For vertical mismatches, align to NAVD88 via the appropriate geoid model before enabling pressure-zone analysis. Never callDefineProjectionto force a mismatch — it relabels metadata without moving coordinates. - Persist an audit trail. Append each run’s CSV, the baseline EPSG, the
pyprojversion, and the grid-shift file versions to a timestamped, version-controlled log. This chain of custody is what satisfies OGC coordinate-transformation and ISO 19111 metadata expectations during regulatory review.
Conclusion
This script replaces manual projection inspection with a deterministic, schema-aware audit that flags undefined references, horizontal drift, and vertical datum mismatches across every feature class in a single pass — and gates a CI/CD pipeline on the result. Enforcing it before ingestion keeps node coincidence exact, prevents false trace results, and produces the version-stamped audit trail that rate-case and cross-jurisdictional reporting require. The natural next step is to standardize the remediation side: decide, per asset class, which transformation grids and geoid models are authoritative, and codify that decision so arcpy.management.Project runs the same way for every engineer.
Related
- Up to the parent topic: CRS Alignment & Geodetic Transformations
- Up to the section: Core Utility GIS Fundamentals & Network Models
- Best Practices for Handling Precision Drift in CAD to GIS Conversions
- How to Configure UN Network Datasets in ArcGIS Pro
- Step-by-Step Guide to Building Asset Hierarchies for Gas Networks
For authoritative reference, consult the ArcGIS Pro coordinate systems documentation and the pyproj documentation.