Resolving a Vertical Datum Mismatch in GCP Heights
The horizontal residuals are two centimetres and the vertical residuals are all about −43 metres. Or all about −2.1 metres. In either case every control point agrees with every other, the plan position is excellent, and the surface sits at the wrong height by a constant.
That constant is the signature. A vertical datum mismatch is the only error in a survey that is genuinely uniform, and recognising it from that uniformity is faster than any amount of inspection of individual points.
Three vertical references, two ways to mix them
Ellipsoidal height is what a GNSS receiver computes directly: distance from the reference ellipsoid along its normal. It is purely geometric and has no relationship to where water flows.
Orthometric height is measured from the geoid — the equipotential surface mean sea level approximates — and is what every survey deliverable is expressed in. The difference between the two is the geoid undulation, and it ranges from about −105 m to +85 m worldwide.
A local vertical datum is a national realisation tied to specific tide gauges and benchmarks. It differs from a global geoid model by a smaller amount, typically a few decimetres to a couple of metres, and that difference is the second mixing failure.
Those two produce distinguishable magnitudes. A tens-of-metres offset is ellipsoidal against orthometric. A decimetre-to-two-metre offset is one orthometric system against another.
Figure 1 — Two possible mixings with two distinguishable magnitudes. The measured offset is a diagnosis, not just a correction.
Minimal reproducible solution
Confirm uniformity first — a non-uniform vertical error is not a datum problem and applying a datum fix to it makes things worse.
import numpy as np
def classify_vertical_error(dz: np.ndarray, planimetric_extent_m: float) -> str:
"""Uniform, tilted, or neither, from the residuals alone."""
dz = np.asarray(dz, dtype=float)
if dz.size < 4:
return "too few points to classify"
mean, spread = float(dz.mean()), float(dz.std())
if spread > abs(mean) * 0.25 and spread > 0.05:
return (f"not uniform: mean {mean:.3f} m, spread {spread:.3f} m — "
"this is not a datum offset")
magnitude = abs(mean)
if magnitude > 8.0:
return (f"uniform {mean:+.2f} m — ellipsoidal height used where "
"orthometric was expected, or vice versa")
if magnitude > 0.08:
return (f"uniform {mean:+.3f} m — two orthometric systems, or a "
"national datum against a global geoid model")
return f"uniform {mean:+.3f} m — within normal survey noise"
The spread test comes before the magnitude test on purpose. A block with a systematic tilt has a non-zero mean and a large spread, and treating it as an offset removes the average while leaving the tilt — producing a result that is worse at both ends and correct in the middle. That case belongs in diagnosing systematic tilt in GCP residuals.
With uniformity established, the fix is a transformation, not a subtraction. The undulation varies across a survey — by centimetres over a small site and by decimetres over a corridor — so a single constant is right at one point and wrong everywhere else.
import numpy as np
from pyproj import CRS, Transformer
def to_orthometric(lon: np.ndarray, lat: np.ndarray, h_ellipsoidal: np.ndarray,
vertical_crs: str = "EPSG:5773") -> np.ndarray:
"""Ellipsoidal to orthometric heights via a geoid grid.
Compound source (horizontal + ellipsoidal) to compound target (horizontal +
the named vertical datum). PROJ applies the grid; without it installed the
transform silently returns the heights unchanged, so the result is checked.
"""
src = CRS.from_user_input("EPSG:4979") # WGS84 3D, ellipsoidal
tgt = CRS.from_user_input(f"EPSG:4326+{vertical_crs.split(':')[1]}")
tr = Transformer.from_crs(src, tgt, always_xy=True)
_, _, h_ortho = tr.transform(lon, lat, h_ellipsoidal)
h_ortho = np.asarray(h_ortho, dtype=float)
if np.allclose(h_ortho, h_ellipsoidal, atol=1e-6):
raise RuntimeError(
f"heights unchanged — the geoid grid for {vertical_crs} is not "
"installed. Set PROJ_NETWORK=ON or ship the grid; PROJ falls back "
"to a horizontal-only transform without raising.")
if not np.all(np.isfinite(h_ortho)):
raise RuntimeError("some points fell outside the grid's area of use")
return h_ortho
The unchanged-heights check is the guard that matters most, and it is easy to omit. A missing geoid grid does not raise; PROJ resolves a lower-accuracy path that leaves height alone, so the code runs, the numbers come back, and the correction was never applied. Asserting that the values moved is the only reliable detection.
Edge-case matrix
| Situation | Signature | Correct action |
|---|---|---|
| Ellipsoidal used as orthometric | Uniform, tens of metres | Transform with a geoid grid |
| National datum vs global geoid | Uniform, 0.1–2 m | Transform between the two vertical CRS |
| Geoid grid missing | Heights unchanged after transform | Install the grid or enable network |
| Point outside the grid extent | Height returns non-finite | Different grid, or the point is misplaced |
| Antenna height not subtracted | Uniform, 1.5–2.5 m | Not a datum issue — instrument setup |
| Uniform offset plus a tilt | Non-zero mean, large spread | Fix the tilt; the offset may be a symptom |
| Two surveys, two datums | Step between two groups | Classify per group, not per project |
| Offset changes between epochs | Small, growing | Vertical plate motion; needs epochs |
The antenna-height row is the one that costs the most time, because it mimics a small datum difference exactly. A rover pole height not entered, or entered and applied twice, produces a uniform offset of one to two and a half metres — the same range as a national datum difference — and the two are distinguished only by checking the field notes.
Verification snippet
After the transform, confirm that the applied correction matches the undulation the model predicts, and that the residual spread did not grow.
import numpy as np
def assert_correction_is_the_undulation(h_before: np.ndarray, h_after: np.ndarray,
expected_n: np.ndarray,
tol_m: float = 0.05) -> None:
"""The height change must equal the geoid undulation, point by point."""
applied = h_before - h_after
departure = np.abs(applied - expected_n)
worst = float(departure.max())
assert worst <= tol_m, (
f"applied correction departs from the modelled undulation by up to "
f"{worst:.3f} m — a different grid was used than the one expected")
# The correction must not have added scatter: it is a smooth field.
assert float(applied.std()) < float(expected_n.std()) * 1.5 + 0.01, (
"the correction is noisier than the undulation field — check that "
"points were not transformed individually with different operations")
The second assertion catches an unusual but real failure: transforming points one at a time, where PROJ may select a different operation per point if some fall outside a grid’s extent. The result is a correction field with steps in it, which is invisible in a mean and obvious in a standard deviation.
Figure 2 — Why the fix is a transformation. A constant is the correct answer at exactly one point on the site and drifts away from it in both directions.
When to escalate
- The offset persists after a correct transformation. The declared source datum is wrong rather than the transformation. Check what the receiver was actually configured to output — a base station entered at the wrong height produces a uniform offset that no vertical transformation will remove, because the input was already wrong.
- Two groups of control disagree by a constant. Two surveys or two instruments with different setups. Classify each group separately; a single mean across both describes neither.
- The offset is uniform and small and matches the antenna height. Not a datum problem. Confirm against the field notes before transforming anything, because applying a geoid correction on top of an instrument-setup error leaves the survey wrong by their difference.
Related
- Troubleshooting GCP and coordinate errors
- Diagnosing systematic tilt in GCP residuals
- Managing coordinate reference systems in GDAL
← Troubleshooting GCP and Coordinate Errors
Figure 3 — The magnitude as a first-pass diagnosis. Three of the four regions identify the cause outright; the fourth narrows it to two candidates that a field note settles.