Fallback Routing Strategies for Missing Grid Files

Statutory boundary determination and cadastral surveying require deterministic coordinate operations with documented uncertainty budgets, the same audit-ready discipline established across Core Transformation Fundamentals & Standards. When a transformation pipeline encounters a missing grid shift file, silent degradation to a parametric approximation or an unhandled exception breaks ISO 19111 accuracy reporting and destroys auditability. A deterministic fallback routing strategy intercepts the missing asset, evaluates spatial coverage, enforces tolerance thresholds, and routes to a pre-validated transformation method before any coordinate enters a production dataset. This sub-task sits directly under the standards framework, where CRS definitions, operation methods, and grid availability are treated as explicit pipeline constraints rather than runtime afterthoughts.

Grid shift files (NTv2 .gsb, NADCON .las/.los, vertical .gtx) encode localized deformation surfaces that correct for crustal movement, historical network adjustments, and regional geodetic realignments. When the primary EPSG-registered grid is absent from the PROJ_DATA directory, the engine must traverse a compliance-driven routing table rather than guessing. The precedence is fixed: (1) the highest-resolution national or regional grid, (2) a coarser or legacy grid whose extent still covers the dataset, then (3) a parametric Helmert or Molodensky-Badekas transformation, accepted only while projected positional uncertainty stays within agency-defined tolerances. Cadastral workflows typically enforce ±0.025 m horizontal and ±0.050 m vertical limits; exceeding either triggers a hard pipeline failure that prevents contaminated geometries from propagating into statutory records. Which grid wins each tier depends on jurisdictional mandate and interpolation behaviour, the trade-off analysed in NADCON vs NTv2: choosing the right datum shift.

Compliance-driven grid precedence chain Decision flow for a transformation request. If the primary national or regional grid is available it is applied at roughly 0.01 metre accuracy; if it is missing the router tries a secondary or legacy grid at roughly 0.03 metre accuracy; if that is also missing it tests whether a parametric Helmert fallback stays within tolerance — applying the seven-parameter Helmert transformation when it does, and hard-failing to halt the pipeline when it does not. available available no missing missing yes Transformation request Primary / regional grid? Secondary / legacy grid? Parametric u ≤ tolerance? Apply primary grid ≈ 0.01 m Apply secondary grid ≈ 0.03 m HARD FAIL pipeline halts 7-parameter Helmert fallback

Figure — compliance-driven grid precedence: primary → secondary → parametric → hard fail.

How the Router Decides: Spec and Tolerance Math

Before writing code, the routing rule must be expressed numerically so the decision is reproducible across machines. PROJ resolves a coordinate operation by searching PROJ_DATA (and any path in the PROJ_DATA/PROJ_LIB environment variables) for the grid named in the EPSG operation. If the grid is absent, PROJ may still return a ballpark operation — a rigid parametric shift with no local distortion modelling — which is exactly the silent degradation cadastral work cannot tolerate. The router therefore evaluates each candidate operation against a tolerance gate instead of trusting the library default. Building the candidate list with explicit accuracy values is covered in automating datum fallback chains in pyproj.

The total positional uncertainty of any fallback candidate is the root-sum-square of the operation’s own accuracy and the residual uncertainty of the control network it is tied to:

utotal=uop2+unet2u_{total} = \sqrt{u_{op}^{2} + u_{net}^{2}}

A candidate is admissible only when its total horizontal and vertical uncertainty both stay at or below the statutory tolerances:

utotalτhandutotalτvu_{total} \le \tau_h \quad \text{and} \quad u_{total} \le \tau_v

where τh=0.025m\tau_h = 0.025\,\text{m} and τv=0.050m\tau_v = 0.050\,\text{m} for typical cadastral deliverables. Operation accuracy values come from the EPSG registry (the accuracy attribute of each coordinate operation), so the comparison is auditable rather than heuristic. Pinning unetu_{net} to a real number requires independent check points, the workflow described in validating datum alignment with control points.

Step-by-Step Implementation

The pipeline is assembled in three steps: validate the grid asset, encode the tolerance gate, then route through the precedence chain. Every snippet is self-contained and runnable on Python 3.10+ with pyproj and numpy installed.

Step 1 — Validate grid availability deterministically

A missing file and a zero-byte or wrong-format file must both be rejected before PROJ is ever invoked. The check is purely local so it cannot itself introduce positional error.

from pathlib import Path

# ISO 19111-1:2019 §C.5 — a coordinate operation must reference a usable
# transformation resource; a missing or empty grid invalidates the operation.
VALID_GRID_SUFFIXES: frozenset[str] = frozenset(
    {".gsb", ".las", ".los", ".gtx", ".tif"}
)


def validate_grid_path(grid_path: Path) -> bool:
    """Return True only if the grid exists, is non-empty, and is a known format."""
    if not grid_path.exists() or grid_path.stat().st_size == 0:
        return False  # missing or truncated asset — never route to it
    return grid_path.suffix.lower() in VALID_GRID_SUFFIXES

Step 2 — Encode the tolerance gate

The gate compares a candidate’s total uncertainty against the statutory limits using an explicit epsilon so a value sitting exactly on the boundary is treated deterministically rather than at the mercy of float rounding.

import math

EPSILON: float = 1e-9  # machine-precision slack for boundary equality


def within_tolerance(
    uncertainty_m: float, tolerance_h: float, tolerance_v: float
) -> bool:
    """ISO 19111 accuracy gate: accept a candidate only if it meets both limits."""
    # math.isclose handles the on-the-boundary case; the `<` covers clearly-inside.
    h_ok = math.isclose(uncertainty_m, tolerance_h, abs_tol=EPSILON) or uncertainty_m < tolerance_h
    v_ok = math.isclose(uncertainty_m, tolerance_v, abs_tol=EPSILON) or uncertainty_m < tolerance_v
    return h_ok and v_ok

Step 3 — Route through the precedence chain

The router walks primary → secondary → parametric, applying validate_grid_path and within_tolerance at each tier and raising on tolerance exceedance so contaminated coordinates can never reach the dataset. Coordinates are rounded with explicit precision control to eliminate floating-point drift between runs.

import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Sequence, Tuple
import numpy as np
from pyproj import CRS, Transformer
from pyproj.exceptions import ProjError

logging.basicConfig(format="%(asctime)s | %(levelname)s | %(message)s", level=logging.INFO)
logger = logging.getLogger("grid_fallback_router")

COORDINATE_PRECISION: int = 3  # millimetre resolution (1e-3 m) for survey-grade output


@dataclass(frozen=True)
class TransformationResult:
    coordinates: np.ndarray
    method: str
    uncertainty_m: float
    grid_file: Optional[str]
    iso_19111_compliant: bool


class GridFallbackRouter:
    def __init__(self, tolerance_horizontal: float = 0.025, tolerance_vertical: float = 0.050) -> None:
        self.tolerance_h = float(tolerance_horizontal)
        self.tolerance_v = float(tolerance_vertical)

    @staticmethod
    def _estimate_parametric_uncertainty(crs_from: CRS, crs_to: CRS) -> float:
        """Conservative u_total for a 7-parameter Helmert fallback (no local grid).

        Production code should read the EPSG operation `accuracy` attribute and
        combine it with the network uncertainty via root-sum-square.
        """
        return 0.150  # metres — deliberately pessimistic so the gate stays strict

    def _round_precision(self, coords: np.ndarray) -> np.ndarray:
        """Deterministic rounding to survey-grade precision (eliminates FP drift)."""
        return np.around(coords, decimals=COORDINATE_PRECISION)

    def _grid_transform(
        self,
        crs_src: CRS,
        crs_tgt: CRS,
        coords: np.ndarray,
        method: str,
        uncertainty_m: float,
        grid: Path,
    ) -> TransformationResult:
        # ISO 19111-1:2019 §10 — apply the declared grid-based operation.
        transformer = Transformer.from_crs(crs_src, crs_tgt, always_xy=True)
        x, y, z = transformer.transform(coords[:, 0], coords[:, 1], coords[:, 2])
        out = self._round_precision(np.column_stack((x, y, z)))
        logger.info("%s transformation succeeded via %s", method, grid.name)
        return TransformationResult(out, method, uncertainty_m, str(grid), True)

    def route_transformation(
        self,
        source_crs: str,
        target_crs: str,
        coordinates: Sequence[Tuple[float, float, Optional[float]]],
        primary_grid: Optional[Path] = None,
        secondary_grid: Optional[Path] = None,
    ) -> TransformationResult:
        coords = np.array(coordinates, dtype=np.float64)
        crs_src, crs_tgt = CRS.from_user_input(source_crs), CRS.from_user_input(target_crs)

        # Tier 1 — highest-resolution primary grid.
        if primary_grid is not None and validate_grid_path(primary_grid):
            try:
                return self._grid_transform(crs_src, crs_tgt, coords, "grid_shift_primary", 0.01, primary_grid)
            except ProjError:
                logger.warning("Primary grid load failed; evaluating secondary tier.")

        # Tier 2 — coarser/legacy grid with overlapping extent.
        if secondary_grid is not None and validate_grid_path(secondary_grid):
            try:
                return self._grid_transform(crs_src, crs_tgt, coords, "grid_shift_secondary", 0.03, secondary_grid)
            except ProjError:
                logger.warning("Secondary grid load failed; evaluating parametric tier.")

        # Tier 3 — parametric Helmert fallback, gated on tolerance.
        u_param = self._estimate_parametric_uncertainty(crs_src, crs_tgt)
        if within_tolerance(u_param, self.tolerance_h, self.tolerance_v):
            transformer = Transformer.from_crs(crs_src, crs_tgt, always_xy=True)
            x, y, z = transformer.transform(coords[:, 0], coords[:, 1], coords[:, 2])
            out = self._round_precision(np.column_stack((x, y, z)))
            logger.info("Parametric fallback accepted (u=%.4f m).", u_param)
            return TransformationResult(out, "parametric_helmert", u_param, None, True)

        # Tier 4 — hard fail: ISO 19111 forbids committing an out-of-tolerance result.
        raise RuntimeError(
            f"Transformation aborted: projected uncertainty {u_param:.4f} m exceeds "
            f"statutory tolerance (H {self.tolerance_h} m / V {self.tolerance_v} m). "
            "No valid grid shift file available; pipeline halted to preserve dataset integrity."
        )

Parameter and Return-Value Reference

Every routing decision is driven by the inputs below. Units and valid ranges are fixed so the behaviour is reproducible in an audit.

Field Type Units Valid range Cadastral significance
source_crs / target_crs str EPSG URN or WKT2 any registered CRS Defines the datum pair; resolve to WKT2:2019 to lock axis order
coordinates Sequence[(x, y, z)] degrees or metres within grid extent Input geometry; z may be None for 2-D shifts
primary_grid Path .gsb/.gtx path existing, non-empty Highest-resolution correction surface (≈0.01 m)
secondary_grid Path .las/.los path existing, non-empty Legacy/coarser fallback grid (≈0.03 m)
tolerance_horizontal float metres 0.005–0.100 Statutory horizontal limit (τh\tau_h), default 0.025
tolerance_vertical float metres 0.010–0.200 Statutory vertical limit (τv\tau_v), default 0.050
TransformationResult.method str enum grid_shift_primaryparametric_helmert Operation actually applied — required audit field
TransformationResult.uncertainty_m float metres ≥ 0 Reported accuracy attached to the output
TransformationResult.iso_19111_compliant bool True on success Asserts the operation carried a declared accuracy

Worked Example: NAD83(2011) → NAD83(HARN) in Oregon

Consider a parcel corner at longitude −123.0294°, latitude 44.0521° (EPSG:6318, NAD83 2011) that must be expressed in NAD83(HARN) (EPSG:4152) for a county recorder who pins legacy plats to the HARN realization. The published NTv2 grid for this pair is us_noaa_nad83_2011_to_nad83_harn_or.gsb. With the grid present, the router applies it directly; with the grid missing, it must drop to the parametric tier and prove the result still clears tolerance.

from pathlib import Path

router = GridFallbackRouter(tolerance_horizontal=0.025, tolerance_vertical=0.050)

result = router.route_transformation(
    source_crs="EPSG:6318",   # NAD83(2011) geographic
    target_crs="EPSG:4152",   # NAD83(HARN) geographic
    coordinates=[(-123.0294, 44.0521, 95.250)],
    primary_grid=Path("/srv/proj_data/us_noaa_nad83_2011_to_nad83_harn_or.gsb"),
    secondary_grid=None,
)

print(result.method, result.uncertainty_m, result.grid_file)
# grid_shift_primary 0.01 /srv/proj_data/us_noaa_nad83_2011_to_nad83_harn_or.gsb

Against the published HARN monument for this corner, the grid-shifted longitude/latitude reproduce the control value to within ≈0.008 m — comfortably inside τh\tau_h. If the .gsb is deleted, validate_grid_path returns False, the primary and secondary tiers are skipped, and the router evaluates the 0.150 m parametric estimate against the 0.025 m gate. Because 0.150 m > 0.025 m, within_tolerance returns False and the router raises rather than emit a coordinate that would fail a later check against the monument. The mathematics behind that grid-shift correction is detailed in projection math fundamentals for cadastral surveys.

Verification and Residual Analysis

A routed coordinate is only defensible once it has been checked against an independent monument and the residual logged as a structured audit record. The snippet below computes the horizontal residual magnitude, compares it to tolerance, and emits a machine-parseable log line.

import json
import logging
import numpy as np

logger = logging.getLogger("fallback_audit")


def verify_against_control(
    transformed_xy: tuple[float, float],
    control_xy: tuple[float, float],
    method: str,
    tolerance_h: float = 0.025,
) -> bool:
    """Compare a routed point to a control monument and log an audit record."""
    dx = transformed_xy[0] - control_xy[0]
    dy = transformed_xy[1] - control_xy[1]
    residual_m = float(np.hypot(dx, dy))  # RSS horizontal residual, metres
    passed = residual_m <= tolerance_h

    logger.info(
        json.dumps(
            {
                "method": method,
                "residual_m": round(residual_m, 4),
                "tolerance_h_m": tolerance_h,
                "within_tolerance": passed,
            }
        )
    )
    return passed


# Monument check for the worked example (projected metres, EPSG:6339 zone):
assert verify_against_control(
    transformed_xy=(497_882.114, 4_878_233.502),
    control_xy=(497_882.121, 4_878_233.498),
    method="grid_shift_primary",
)  # residual ≈ 0.008 m ≤ 0.025 m

For datasets spanning several monuments, aggregate the residual magnitudes into an RMSE and confirm the maximum residual stays below twice the RMSE before committing — the rigorous network treatment is covered in least-squares adjustment for control networks. Reference the ISO 19111:2019 geospatial information standard for the metadata fields each audit record must carry.

Troubleshooting and Gotchas

PROJ keeps returning a ballpark result instead of failing
PROJ falls back to a low-accuracy parametric operation when it cannot find the grid named in the EPSG operation. Set only_best=True (or inspect the operation accuracy via a TransformerGroup) and reject any operation whose reported accuracy exceeds your tolerance. Never assume a returned transformer used the grid you expected.
The grid file exists but PROJ still cannot find it
PROJ only searches the directories in PROJ_DATA (named PROJ_LIB on older builds). A grid sitting next to your script is invisible unless that path is registered. Export PROJ_DATA or call pyproj.datadir.set_data_dir() before building any transformer, and confirm with pyproj.datadir.get_data_dir().
Results drift in the last millimetre between machines
Unrounded float64 output serialises differently across platforms. Apply deterministic rounding (the COORDINATE_PRECISION step) before output, and pin the grid binary by SHA-256 so two runs use byte-identical correction surfaces.
A coordinate near the grid edge produces a wild shift
Bicubic interpolation extrapolates badly beyond the grid boundary. Validate that every input lies inside the grid's declared extent before transforming; if a point falls outside, route it to the next tier rather than letting the interpolator extrapolate.
The vertical component passes but horizontal fails (or vice versa)
Horizontal and vertical grids are independent assets with independent accuracies. Gate each axis against its own tolerance ($\tau_h$ and $\tau_v$) and report them separately; a passing 2-D shift does not license a missing or out-of-tolerance vertical grid.

Frequently Asked Questions

Why hard-fail instead of returning the best available approximation?
In cadastral and statutory work a coordinate carries legal weight. Emitting an out-of-tolerance approximation silently injects a defect into the record of survey. Halting forces a human decision — load the correct grid, relax the documented tolerance, or flag the parcel — and keeps the failure visible in the audit trail.
When is the 7-parameter Helmert fallback actually acceptable?
Only when no published grid exists for the datum pair and area, and the combined uncertainty $u_{total}$ still clears tolerance. For modern cadastral pairs a grid almost always exists, so the Helmert tier should be a rare, explicitly flagged event rather than a routine path.
How do I record which tier was used for a given parcel?
Persist TransformationResult.method, uncertainty_m, and grid_file alongside the source/target EPSG codes and a SHA-256 of the grid binary. That tuple lets an auditor reproduce the exact operation and confirm it met tolerance at the time of transform.