Geoid and Vertical Datum Transformations in Python
Every GNSS receiver reports a height above the reference ellipsoid, yet no cadastral plan, flood model, or levelling network is graded in ellipsoidal metres — they are graded in orthometric height above the geoid, the physical surface that water actually follows. Reconciling those two vertical worlds is the height dimension of Algorithmic Math & Geodetic Workflows, and it turns on a single quantity: the geoid undulation
Figure — the ellipsoid, geoid, and terrain at one point:
The Height Relation and Its Sign
The three heights are collinear along the ellipsoidal normal, so they combine by simple subtraction. The orthometric height is the ellipsoidal height minus the geoid undulation:
where
Strictly,
The Geoid Model Grid
A geoid model is a regular grid of undulation values sampled at a fixed angular spacing in latitude and longitude. The GTX format (used by NOAA’s geoid models) and the newer GGXF container both reduce to the same logical structure: a lower-left origin
Given a query point
with integer parts
This is the standard four-point bilinear kernel: linear in each axis, exact at the nodes, and continuous across cell boundaries. It is adequate for models whose spacing is one arc-minute or finer, where the geoid is smooth relative to the node interval; coarser grids or steep gravimetric features may warrant a bicubic kernel, but bilinear is the interoperable baseline that agencies expect and that matches how PROJ itself samples vertical grids.
Step-by-Step Implementation
The implementation uses only numpy, struct, dataclasses, logging, and the standard library so that every precision and sign decision stays explicit and auditable. Build it in four steps; each is a runnable, type-hinted unit.
Step 1 — Model the grid and parse a header
We represent the model as an immutable header plus a float64 undulation array. The reader below decodes a GTX-style binary header (lower-left latitude and longitude, latitude and longitude spacing, row and column counts) and then loads the big-endian float32 payload, promoting it to float64 before any interpolation touches it.
from __future__ import annotations
import logging
import struct
from dataclasses import dataclass
from pathlib import Path
import numpy as np
logger = logging.getLogger("geoid")
GTX_HEADER_STRUCT = ">dddii" # lat0, lon0, dlat, dlon (deg), nrows, ncols
GTX_HEADER_SIZE = struct.calcsize(GTX_HEADER_STRUCT) # 40 bytes
@dataclass(frozen=True)
class GeoidGrid:
"""A regular geoid-undulation grid, values in metres, promoted to float64.
Nodes are stored south-to-north by row and west-to-east by column, with
row 0 at the lower-left origin (lat0, lon0), matching the GTX convention.
"""
lat0: float # latitude of the lower-left node (deg)
lon0: float # longitude of the lower-left node (deg, positive east)
dlat: float # node spacing in latitude (deg, > 0)
dlon: float # node spacing in longitude (deg, > 0)
values: np.ndarray # shape (nrows, ncols), float64, undulation N in metres
@property
def nrows(self) -> int:
return int(self.values.shape[0])
@property
def ncols(self) -> int:
return int(self.values.shape[1])
@property
def lat_max(self) -> float:
return self.lat0 + (self.nrows - 1) * self.dlat
@property
def lon_max(self) -> float:
return self.lon0 + (self.ncols - 1) * self.dlon
def load_gtx(path: str | Path) -> GeoidGrid:
"""Read a GTX-style geoid grid into a GeoidGrid with float64 values."""
raw = Path(path).read_bytes()
lat0, lon0, dlat, dlon, nrows, ncols = struct.unpack_from(
GTX_HEADER_STRUCT, raw, 0
)
count = nrows * ncols
# GTX payload is big-endian float32; promote to float64 so the on-disk
# single precision never leaks into the height budget during interpolation.
payload = np.frombuffer(
raw, dtype=np.dtype(">f4"), count=count, offset=GTX_HEADER_SIZE
).astype(np.float64).reshape(nrows, ncols)
return GeoidGrid(lat0=lat0, lon0=lon0, dlat=dlat, dlon=dlon, values=payload)
Step 2 — Locate the enclosing cell and guard the extent
Before interpolating, the query must be inside the grid. A point outside the model’s footprint has no defensible undulation, and extrapolating one silently fabricates height. This step returns the lower-left node indices and the in-cell fractional offsets, raising if the point falls outside.
def locate_cell(grid: GeoidGrid, lat: float, lon: float) -> tuple[int, int, float, float]:
"""Return (row0, col0, ty, tx): the lower-left node of the enclosing cell
and the fractional offsets within it. Raises if the point is out of extent."""
if not (grid.lat0 <= lat <= grid.lat_max):
raise ValueError(f"latitude {lat} outside grid [{grid.lat0}, {grid.lat_max}]")
if not (grid.lon0 <= lon <= grid.lon_max):
raise ValueError(f"longitude {lon} outside grid [{grid.lon0}, {grid.lon_max}]")
fi = (lat - grid.lat0) / grid.dlat # fractional row index
fj = (lon - grid.lon0) / grid.dlon # fractional column index
row0 = min(int(np.floor(fi)), grid.nrows - 2) # clamp so row0+1 is valid
col0 = min(int(np.floor(fj)), grid.ncols - 2) # clamp so col0+1 is valid
ty = fi - row0 # 0..1 within the cell, north+
tx = fj - col0 # 0..1 within the cell, east+
return row0, col0, ty, tx
Step 3 — Bilinearly interpolate the undulation
With the cell located, blend its four corner undulations using the kernel derived above. The function checks for the model’s null sentinel so a query that lands on an unmodelled node is rejected rather than averaged into a fictitious value.
NULL_SENTINEL = -88.8888 # common GTX flag for an undefined node
def interpolate_undulation(grid: GeoidGrid, lat: float, lon: float) -> float:
"""Bilinearly interpolate the geoid undulation N (metres) at (lat, lon)."""
row0, col0, ty, tx = locate_cell(grid, lat, lon)
n00 = grid.values[row0, col0] # lower-left
n10 = grid.values[row0, col0 + 1] # lower-right
n01 = grid.values[row0 + 1, col0] # upper-left
n11 = grid.values[row0 + 1, col0 + 1] # upper-right
corners = np.array([n00, n10, n01, n11], dtype=np.float64)
if np.any(np.isclose(corners, NULL_SENTINEL, atol=1e-3)):
raise ValueError("query cell contains an undefined geoid node")
# Four-point bilinear kernel: exact at nodes, continuous across cell edges.
return float(
n00 * (1.0 - tx) * (1.0 - ty)
+ n10 * tx * (1.0 - ty)
+ n01 * (1.0 - tx) * ty
+ n11 * tx * ty
)
Step 4 — Apply the height relation and log the conversion
The final step applies
import json
from dataclasses import asdict
@dataclass(frozen=True)
class HeightConversion:
lat: float
lon: float
h_ellipsoidal_m: float
undulation_m: float
H_orthometric_m: float
geoid_model: str
def to_orthometric(
grid: GeoidGrid,
lat: float,
lon: float,
h_ellipsoidal_m: float,
geoid_model: str,
ndigits: int = 4,
) -> HeightConversion:
"""Convert an ellipsoidal height to orthometric via H = h - N and log it."""
n = interpolate_undulation(grid, lat, lon)
big_h = round(h_ellipsoidal_m - n, ndigits) # H = h - N (round-half-to-even)
result = HeightConversion(
lat=lat, lon=lon,
h_ellipsoidal_m=h_ellipsoidal_m,
undulation_m=round(n, ndigits),
H_orthometric_m=big_h,
geoid_model=geoid_model,
)
logger.info("geoid_convert %s", json.dumps(asdict(result)))
return result
Parameter and Return-Value Reference
| Name | Type | Units | Valid range | Cadastral significance |
|---|---|---|---|---|
lat / lon |
float |
degrees | inside grid extent | positive-east longitude; a point outside the footprint has no defensible |
dlat / dlon |
float |
degrees | > 0 | node spacing; drives interpolation error, must match the model header exactly |
h_ellipsoidal_m |
float |
metres | any finite | GNSS height above the ellipsoid — never a levelled height |
undulation_m (N) |
float |
metres | ≈ −110 to +90 globally | signed geoid–ellipsoid separation; its sign convention governs the whole result |
H_orthometric_m |
float |
metres | any finite | the certified height above the geoid, |
geoid_model |
str |
— | e.g. GEOID18 |
model identity must be logged; mixing models corrupts the vertical datum |
ty / tx |
float |
— | 0 ≤ t ≤ 1 | in-cell fractional position; outside [0,1] means a cell-location bug |
interpolate_undulation() → |
float |
metres | finite or raises | raises on out-of-extent or null-node queries, never extrapolates |
Worked Example: a North Sea GNSS Height
Consider a GNSS observation over the southern North Sea where the geoid lies well above the GRS80 ellipsoid, so
# Synthesise a small one-arc-minute grid around the point for a runnable demo.
lat0, lon0 = 53.9, 1.9
dlat = dlon = 1.0 / 60.0 # one arc-minute in degrees
rows, cols = 13, 13
base = np.full((rows, cols), 48.200, dtype=np.float64) # ~48.2 m undulation
grid = GeoidGrid(lat0=lat0, lon0=lon0, dlat=dlat, dlon=dlon, values=base)
conv = to_orthometric(grid, lat=54.0000, lon=2.0000,
h_ellipsoidal_m=142.500, geoid_model="DEMO_NSea")
print(conv.undulation_m, conv.H_orthometric_m)
# 48.2 94.3 -> H = 142.500 - 48.200 = 94.300 m
The orthometric height,
Verification and Residual Analysis
A vertical transformation is only trustworthy once it is pinned two ways: a round-trip identity that proves the interpolation and height algebra are self-consistent, and a comparison against a published benchmark whose orthometric height is independently known. The round-trip recovers
def verify_height(
conv: HeightConversion,
grid: GeoidGrid,
control_H_m: float,
tolerance_m: float = 0.030, # 30 mm typical vertical control gate
) -> dict[str, object]:
"""Round-trip the conversion and compare H against a control benchmark."""
# Round-trip: re-add the interpolated N and recover the ellipsoidal height.
n = interpolate_undulation(grid, conv.lat, conv.lon)
h_recovered = conv.H_orthometric_m + n # inverse of H = h - N
round_trip_err = abs(h_recovered - conv.h_ellipsoidal_m)
assert round_trip_err < 1e-6, "height algebra is not self-consistent"
residual = conv.H_orthometric_m - control_H_m # signed vertical residual
record = {
"geoid_model": conv.geoid_model,
"residual_m": round(residual, 4),
"tolerance_m": tolerance_m,
"round_trip_err_m": round(round_trip_err, 9),
"passed": abs(residual) <= tolerance_m,
}
logger.info("geoid_verify %s", json.dumps(record))
if not record["passed"]:
raise ValueError(f"vertical residual {residual:.4f} m exceeds {tolerance_m} m")
return record
rec = verify_height(conv, grid, control_H_m=94.312)
assert rec["passed"] # residual -0.012 m, inside the 30 mm gate
The signed residual matters: a systematic bias of one sign across many benchmarks usually means the wrong model or a tide-system offset (below), whereas scatter of both signs is ordinary interpolation and levelling noise. Emitting the model name, residual, tolerance, and round-trip error is the minimum audit payload a vertical deliverable needs. Propagating the interpolation uncertainty formally into that residual budget is treated in the dedicated routine on applying geoid undulation for orthometric heights.
Troubleshooting and Gotchas
Every orthometric height is off by roughly twice the undulation
N was inverted — you added it instead of subtracting, or vice versa. The relation is H = h - N. In CONUS N is negative (about −8 to −33 m), so subtracting a negative correctly raises H above h; in the North Atlantic N is positive and H falls below h. A blanket offset of 2N is the fingerprint of a flipped sign.Heights are consistently biased by a few centimetres to a decimetre
A valid-looking coastal point raises an out-of-extent error
-88.8888 null nodes over open ocean. If the query cell touches a null node or falls outside the header extent, reject it rather than extrapolate. Check the point against lat0, lon0, lat_max, and lon_max, and confirm longitude sign uses the model's positive-east convention.GNSS heights disagree with a published model by a constant offset
Frequently Asked Questions
Why not just let pyproj apply the vertical transformation?
Is bilinear interpolation accurate enough for survey-grade heights?
What is the difference between geoid undulation and height anomaly?
N pairs with orthometric height on the geoid; the height anomaly ζ pairs with normal height on the quasi-geoid. Regular geoid grids store N, quasi-geoid grids store ζ. The interpolation is identical, but the vertical datum you claim must match the quantity the grid was built to deliver.Related
- Applying geoid undulation for orthometric heights — the self-contained conversion function with sign discipline and uncertainty propagation.
- Understanding NTv2 grid shift files in Python — the horizontal analogue of reading a fixed-layout grid and bilinearly blending nodes.
- Geodetic conversion math: ellipsoid to Cartesian — the forward mapping that carries the same coordinates into a geocentric frame.
- Error distribution modeling in Python — propagating the vertical residual into a full covariance budget.
- Algorithmic Math & Geodetic Workflows — the parent reference covering the end-to-end geodetic computation pipeline.