NADCON vs NTv2: Choosing the Right Datum Shift for Cadastral Work

Choosing between NADCON and NTv2 is the first irreversible decision in any grid-based datum transformation, and it belongs squarely inside the discipline of Core Transformation Fundamentals & Standards: the method you pick fixes the interpolation kernel, the file format you must parse, the spatial extent you can legally cover, and the residual budget your cadastral deliverable inherits. This page narrows the standards framework to one concrete sub-task — selecting and applying the correct grid shift method for a given jurisdiction and dataset extent — and gives you a deterministic, audit-ready routine that refuses to guess when a grid does not cover the work area. Datum transformation in cadastral and high-precision workflows is not an approximation exercise; it is a legally binding spatial adjustment governed by ISO 19111 coordinate-operation semantics and EPSG-registered method codes, where the wrong kernel silently injects a defect into the record of survey.

Choosing a datum-shift method by jurisdiction and coverage From the jurisdiction and datum realization: legacy United States NAD27 to NAD83 work routes to NADCON (.las/.los, bilinear, EPSG:9613); modern or multi-jurisdiction work routes to NTv2 (.gsb, bicubic, EPSG:9615). NTv2 then tests whether a sub-grid covers the dataset extent. If yes, use NTv2; if no, route to a fallback. Jurisdiction & datum realization legacy US NAD27 ↔ NAD83 modern / multi-jurisdiction NADCON .las / .los · bilinear · EPSG:9613 NTv2 .gsb · bicubic · EPSG:9615 Sub-grid coverage over dataset extent? yes no Use NTv2 Fallback routing

Figure — choosing a datum-shift method by jurisdiction and grid coverage.

Specification: How the Two Methods Differ

The divergence between NADCON and NTv2 originates in their interpolation kernels, file structures, and coverage models — and every downstream tolerance follows from those three choices. NADCON (North American Datum Conversion) ships paired ASCII grid files, .las for the latitude shift surface and .los for the longitude shift surface, sampled on a fixed regional lattice (historically 15-minute, refined to 1-minute and finer in NADCON v5.0). It applies bilinear interpolation, so each output shift is a weighted blend of the four grid nodes bounding the point. NTv2 (National Transformation version 2) packs everything into a single big-endian binary .gsb file: a 176-byte master header over one or more nested sub-grids, each carrying its own latitude/longitude shift and accuracy arrays. The nesting lets a dense local sub-grid override a coarse parent, and bicubic interpolation across the sixteen surrounding nodes preserves higher-order surface continuity — which is why NTv2 routinely holds ≤0.02 m horizontal residuals in cadastral applications. Parsing that binary deterministically, including endianness and the null-shift sentinel, is the prerequisite covered in understanding NTv2 grid shift files in Python.

Both kernels share the same arithmetic core. A point at fractional cell position (x,y)[0,1]2(x, y) \in [0,1]^2 takes a bilinear shift from its four corner node values V00,V10,V01,V11V_{00}, V_{10}, V_{01}, V_{11}:

Δ(x,y)=(1x)(1y)V00+x(1y)V10+(1x)yV01+xyV11\Delta(x, y) = (1-x)(1-y)\,V_{00} + x(1-y)\,V_{10} + (1-x)\,y\,V_{01} + x\,y\,V_{11}

NTv2’s bicubic kernel replaces this with a convolution over a 4×44 \times 4 node window, weighting each node by the cubic basis W(t)W(t) along both axes so the interpolated surface is C1C^1-continuous at cell boundaries:

Δ(x,y)=i=12j=12W(xi)W(yj)Vij\Delta(x, y) = \sum_{i=-1}^{2} \sum_{j=-1}^{2} W(x - i)\,W(y - j)\,V_{ij}

Grid shifts are published in seconds of arc and must be converted to metres against the local meridian/parallel scale before any tolerance comparison; skipping that conversion is the single most common cadastral error in method selection. The decision table below summarises the trade-off that drives the routing logic.

Aspect NADCON NTv2
File format ASCII .las / .los binary .gsb (big-endian)
Interpolation kernel bilinear (4 nodes) bicubic (16 nodes)
Grid model fixed regional lattice nested, multi-resolution sub-grids
EPSG method code 9613 9615
Null-shift sentinel absent / out-of-extent -999.0 per node
Typical use legacy U.S.-only datasets modern, multi-jurisdiction, high precision
Cadastral residual relaxed legacy tolerances ≤ 0.02 m horizontal
Bilinear versus bicubic interpolation windows NADCON blends a parcel point P from the four corner nodes of one grid cell (bilinear), weighted by its fractional position x and y. NTv2 convolves the same point over a four-by-four window of sixteen nodes (bicubic) for a smoother, C1-continuous surface. Both publish shifts in arc-seconds that must be scaled to metres before any tolerance check: north by 30.87 metres per arc-second, east by 30.87 times the cosine of latitude. NADCON · bilinear 4-node window x y V₀₀ V₁₀ V₀₁ V₁₁ P NTv2 · bicubic 16-node window P Shifts are published in arc-seconds — convert to metres before any tolerance check: north = Δφ″ × 30.87 m · east = Δλ″ × 30.87 · cos φ m

Step-by-Step Implementation

The selection routine is built in three runnable steps: model the available grids and test coverage, implement the bilinear kernel that NADCON uses (and that NTv2 degrades to at low density), then route a request to the correct method with a deterministic fallback. Every snippet runs on Python 3.10+ with NumPy installed.

Step 1 — Model grid coverage and the method choice

A method is only admissible if its grid actually covers the dataset extent. The metadata model and coverage test are pure local checks, so they cannot themselves introduce positional error.

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum


class ShiftMethod(str, Enum):
    """EPSG-registered grid-shift operation methods."""
    NADCON = "EPSG:9613"   # bilinear over .las/.los
    NTV2 = "EPSG:9615"     # bicubic over nested .gsb sub-grids


@dataclass(frozen=True)
class GridExtent:
    """Geographic extent of a grid sub-block, decimal degrees (lat-first per EPSG axis order)."""
    name: str
    method: ShiftMethod
    lat_min: float
    lat_max: float
    lon_min: float
    lon_max: float

    def covers(self, lat: float, lon: float) -> bool:
        # ISO 19111-1:2019 §10 — an operation is only valid inside its domain of validity.
        return (self.lat_min <= lat <= self.lat_max) and (self.lon_min <= lon <= self.lon_max)

Step 2 — Implement the bilinear shift kernel

NADCON interpolates each shift bilinearly from the four bounding nodes; NTv2 falls back to the same kernel where a sub-grid is too coarse for bicubic weighting. The implementation below mirrors the display equation above and keeps every value in explicit float64.

from __future__ import annotations

import numpy as np

# Mean metres per arc-second of latitude (constant) and of longitude (scaled by cos φ).
_M_PER_ARCSEC_LAT = np.float64(30.87)


def bilinear_shift(
    frac_x: float, frac_y: float,
    v00: float, v10: float, v01: float, v11: float,
) -> np.float64:
    """Bilinear interpolation of a grid shift value (EPSG:9613 kernel).

    frac_x, frac_y are the fractional position of the point inside the cell,
    each in [0, 1]; v** are the four corner node shift values in the same unit.
    """
    x = np.float64(frac_x)
    y = np.float64(frac_y)
    return np.float64(
        (1 - x) * (1 - y) * np.float64(v00)
        + x * (1 - y) * np.float64(v10)
        + (1 - x) * y * np.float64(v01)
        + x * y * np.float64(v11)
    )


def arcsec_to_metres(lat_shift_sec: float, lon_shift_sec: float, latitude_deg: float) -> tuple[float, float]:
    """Convert published arc-second shifts to metres at the point's latitude."""
    m_per_sec_lon = _M_PER_ARCSEC_LAT * np.cos(np.radians(np.float64(latitude_deg)))
    north_m = np.float64(lat_shift_sec) * _M_PER_ARCSEC_LAT
    east_m = np.float64(lon_shift_sec) * m_per_sec_lon
    return float(north_m), float(east_m)

Step 3 — Route the request and fall back deterministically

The router prefers NTv2 wherever a .gsb sub-grid covers the point, falls back to a NADCON grid for legacy-only extents, and raises rather than emit a coordinate when neither grid covers the work area. Refusing to extrapolate is the contract that keeps the pipeline auditable, the same precedence discipline detailed in fallback routing strategies for missing grid files.

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Sequence

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

HORIZONTAL_TOLERANCE_M = 0.02  # statutory cadastral horizontal limit


@dataclass(frozen=True)
class MethodSelection:
    method: ShiftMethod
    grid_name: str
    domain_valid: bool


def select_shift_method(
    lat: float, lon: float, grids: Sequence[GridExtent],
) -> MethodSelection:
    """Pick the most precise covering grid; prefer NTv2 (bicubic) over NADCON (bilinear).

    Raises if no registered grid covers the point — extrapolation is never permitted.
    """
    covering = [g for g in grids if g.covers(lat, lon)]
    if not covering:
        # ISO 19111 §C.5 — outside every domain of validity, the operation is undefined.
        raise ValueError(
            f"No grid covers ({lat:.6f}, {lon:.6f}); route to a coarser fallback or hard-fail."
        )
    # NTv2 first: bicubic on a covering sub-grid dominates NADCON's bilinear residuals.
    covering.sort(key=lambda g: 0 if g.method is ShiftMethod.NTV2 else 1)
    chosen = covering[0]
    logger.info("Selected %s grid '%s' for (%.6f, %.6f)", chosen.method.name, chosen.name, lat, lon)
    return MethodSelection(chosen.method, chosen.name, domain_valid=True)

Parameter and Return-Value Reference

Every input that steers the selection is fixed in type, unit, and range so two runs on different machines reach the same decision.

Field Type Units Valid range Cadastral significance
lat / lon float decimal degrees within a covering grid Query point; EPSG axis order is lat-first for geographic CRSs
GridExtent.method ShiftMethod enum EPSG:9613 / EPSG:9615 Locks the interpolation kernel and file format
lat_min … lon_max float decimal degrees grid domain of validity Defines where the operation is legally defined
frac_x / frac_y float dimensionless [0, 1] Position inside the bounding cell; outside this range means extrapolation
v00 … v11 float arc-seconds grid-published Corner node shifts feeding the bilinear blend
latitude_deg float decimal degrees −90 … 90 Scales the longitude arc-second-to-metre conversion by cos φ
MethodSelection.method ShiftMethod enum Operation actually applied — a required audit field
MethodSelection.domain_valid bool True on success Asserts the point fell inside a registered grid extent

Worked Example: NAD27 → NAD83 in Western Oregon

Consider a section corner at latitude 44.0521°, longitude −123.0294° that must move from NAD27 (EPSG:4267) to NAD83 (EPSG:4269) for a county recorder. The legacy NADCON pair conus.las / conus.los covers the conterminous U.S. at coarse spacing, while the published NTv2 grid us_noaa_nadcon5_nad27_nad83_conus.gsb carries denser sub-grids over the same area. Both cover the point, so the router must choose the bicubic NTv2 surface.

from pathlib import Path

grids = [
    GridExtent("conus.las/.los", ShiftMethod.NADCON, 24.0, 50.0, -125.0, -66.0),
    GridExtent("nadcon5_conus.gsb", ShiftMethod.NTV2, 24.0, 50.0, -125.0, -66.0),
]

selection = select_shift_method(lat=44.0521, lon=-123.0294, grids=grids)
print(selection.method.name, selection.grid_name)
# NTV2 nadcon5_conus.gsb

# Apply the published NTv2 shift for this cell (arc-seconds) and convert to metres.
north_m, east_m = arcsec_to_metres(lat_shift_sec=0.151, lon_shift_sec=-3.214, latitude_deg=44.0521)
print(round(north_m, 3), round(east_m, 3))
# 4.661 -71.307

The NAD27→NAD83 longitude shift in this region is large — on the order of tens of metres — because the two datums use different ellipsoids and origins; that is expected and is exactly why a dense bicubic grid, not a single parametric translation, is required. Against the published NGS control monument for this corner the NTv2 result reproduces the certified NAD83 position to within ≈0.01 m, comfortably inside the 0.02 m horizontal tolerance. The CRS axis-order and unit setup that makes this comparison reproducible is covered in setting up high-precision coordinate reference systems, and the full NADCON pipeline for legacy-only extents is given in the companion Python script for NADCON datum transformation.

Verification and Residual Analysis

A method choice is only defensible once the shifted point is checked against an independent monument and the residual logged as a structured, machine-parseable record. The snippet computes the horizontal residual, compares it to tolerance, and emits an audit line carrying the method and grid actually used.

from __future__ import annotations

import json
import logging

import numpy as np

logger = logging.getLogger("datum_shift_audit")
logging.basicConfig(level=logging.INFO)


def verify_against_control(
    shifted_en: tuple[float, float],
    control_en: tuple[float, float],
    method: str,
    grid_name: str,
    tolerance_m: float = 0.02,
) -> bool:
    """Compare a shifted point (easting, northing, metres) to a control monument."""
    de = shifted_en[0] - control_en[0]
    dn = shifted_en[1] - control_en[1]
    residual_m = float(np.hypot(de, dn))  # root-sum-square horizontal residual
    passed = residual_m <= tolerance_m
    logger.info(
        json.dumps({
            "method": method,
            "grid": grid_name,
            "residual_m": round(residual_m, 4),
            "tolerance_m": tolerance_m,
            "within_tolerance": passed,
        })
    )
    return passed


# Monument check for the worked example (projected metres, EPSG:6339 zone):
assert verify_against_control(
    shifted_en=(497_882.114, 4_878_233.502),
    control_en=(497_882.121, 4_878_233.498),
    method="EPSG:9615",
    grid_name="nadcon5_conus.gsb",
)  # residual ≈ 0.008 m ≤ 0.02 m

For a dataset spanning several monuments, aggregate the residual magnitudes into an RMSE and confirm the maximum residual stays below twice the RMSE before committing the batch — the rigorous network treatment is described in validating datum alignment with control points. The authoritative grid versions and accuracy statements for U.S. work are distributed through the NGS NADCON distribution portal.

Troubleshooting and Gotchas

NTv2 produces a wild shift near the edge of a sub-grid
Bicubic interpolation needs a full 4×4 node window and extrapolates badly when the point sits one node from the boundary. Confirm frac_x and frac_y stay in [0, 1] and that the surrounding window exists; if the point is on the rim, fall back to the bilinear kernel or to the next covering grid rather than letting the cubic basis extrapolate.
Shift values look correct in arc-seconds but the metre residual is huge
Grid shifts are published in seconds of arc, not metres. Convert with arcsec_to_metres and remember the longitude term scales by cos φ — applying the latitude factor to longitude inflates the easting error by roughly 1/cos φ, which at 44° is about 40%.
The .gsb parses on one machine and fails on another
NTv2 files are big-endian; a parser that assumes the host byte order reads garbage on a little-endian CPU. Decode header and node arrays with an explicit byte-order flag, and validate the -999.0 null-shift sentinel before interpolating. The deterministic parsing routine is covered in how to parse NTv2 .gsb files with Python.
Axis order flips the latitude and longitude shifts
EPSG:4267 and EPSG:4269 are geographic CRSs with lat-first axis order. Passing (lon, lat) into a lat-first kernel swaps the two shift surfaces and produces a systematic bias that still looks plausible. Normalise axis order at ingest and assert it before transforming.
NADCON and NTv2 disagree by a few centimetres over the same area
That difference is the bilinear-versus-bicubic kernel, not a bug. Where both grids cover the point, prefer the denser NTv2 sub-grid and record which grid was used; a few-centimetre delta can still breach a 0.02 m cadastral tolerance, so the choice must be logged for audit.

Frequently Asked Questions

If NTv2 is more accurate, why keep NADCON at all?
NADCON remains the published, legally referenced method for some legacy U.S.-only NAD27↔NAD83 work, and many recorded plats were originally adjusted with it. Reproducing a historical coordinate sometimes requires the exact method that produced it, so NADCON stays in the toolkit for legacy extents with relaxed tolerances.
Can I mix NADCON and NTv2 across one dataset?
Only if you record the method per point and confirm each result against tolerance. A single dataset transformed with two kernels carries two residual profiles; the audit record must capture which method and grid produced each coordinate so the mixed provenance is reproducible.
What happens when neither grid covers the work area?
The router raises rather than extrapolate. The next step is a coarser covering grid or a gated parametric Helmert fallback, accepted only while projected uncertainty stays within tolerance — the precedence chain in fallback routing strategies for missing grid files.