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.
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
NTv2’s bicubic kernel replaces this with a convolution over a
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 |
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
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
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
-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
NADCON and NTv2 disagree by a few centimetres over the same area
Frequently Asked Questions
If NTv2 is more accurate, why keep NADCON at all?
Can I mix NADCON and NTv2 across one dataset?
What happens when neither grid covers the work area?
Related References
- Understanding NTv2 grid shift files in Python — the binary
.gsbstructure the NTv2 branch depends on. - Python script for NADCON datum transformation — the full bilinear pipeline for legacy-only extents.
- Fallback routing strategies for missing grid files — what to do when no grid covers the point.
- Validating datum alignment with control points — pinning the residual against independent monuments.
- Core Transformation Fundamentals & Standards — the parent reference on CRS resolution, grid selection, and ISO 19111 compliance.