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+) at C:\Program Files\ArcGIS\Pro\bin\Python\envs\arcgispro-py3, or a standalone ArcPy-licensed install. Clone the environment with conda create --clone arcgispro-py3 --name un-crs-audit rather than mutating the base.
  • Licensing: A Standard or Advanced ArcGIS Pro license is required for arcpy.Describe against an SDE/utility-network workspace; Basic will not read enterprise geodatabase metadata.
  • Dependencies: pyproj>=3.0 and pandas, installed via conda install -c conda-forge pyproj pandas. Confirm the PROJ_DATA (formerly PROJ_LIB) environment variable resolves to a directory containing the NTv2/GSB grid shift files, or pyproj falls back to coarse three-parameter approximations.
  • Workspace access: Read on the target .gdb, or SELECT on GDB_Items and GDB_GeomColumns for 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 DEFAULT version 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 26917 for 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.

  1. Confirm PROJ_DATA resolves to real grid files. If the directory is missing or empty, pyproj.Transformer still constructs but selects a low-accuracy pipeline, so a DRIFT layer can be reported as transformable when it actually needs a grid you do not have. Verify with python -c "import pyproj; print(pyproj.datadir.get_data_dir())" and check that .gsb/.tif files are present.
  2. Verify the workspace opens read-only and is lock-free. A RuntimeError on arcpy.Describe almost always means an active edit session, a versioned state conflict, or a stale .lock file — not corrupt data. Clear idle connections with arcpy.management.DisconnectUser before assuming the geometry is bad.
  3. Distinguish undefined from unknown spatial references. A feature class with factoryCode == 0 or a name of Unknown has no CRS metadata at all. Never auto-reproject these — DefineProjection writes metadata only and will silently corrupt coordinates if the assumed CRS is wrong.
  4. 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.
  5. 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)
CRS alignment audit flow Workspace traversal feeds each feature class into spatial-reference decomposition and a baseline EPSG comparison. The comparison branches into three statuses — PASS, DRIFT, and FAIL — which all write rows into a single CSV report; that report gates the CI/CD pipeline, failing the build when the FAIL count is non-zero. Workspace (.gdb / SDE) ListFeatureClasses + datasets For each feature class arcpy.Describe → spatialRef Decompose CRS EPSG · datum · vertical EPSG == baseline? PASS No action required DRIFT Project via pyproj operation FAIL Undefined CRS / lock error CSV report (all rows) PASS · DRIFT · FAIL CI/CD gate exit 1 if FAIL > 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:

  1. Run against a versioned, isolated snapshot. Point the script at the DEFAULT version 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.
  2. 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 with pandas to open remediation tickets for DRIFT rows without blocking the merge.
  3. 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 FAIL and a noisy red build.
  4. Route remediation by status, deterministically. For DRIFT with a valid transformer, queue arcpy.management.Project with the explicit transformation method (use NAD_1927_To_NAD_1983_NADCON or 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 call DefineProjection to force a mismatch — it relabels metadata without moving coordinates.
  5. Persist an audit trail. Append each run’s CSV, the baseline EPSG, the pyproj version, 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.

For authoritative reference, consult the ArcGIS Pro coordinate systems documentation and the pyproj documentation.