Checking Vertical Accuracy Against Benchmarks
Every internal check a photogrammetry pipeline performs is a consistency check: the residuals, the control fit, the overlap agreement. None of them can detect an error that shifted everything by the same amount, and a wrong vertical datum does exactly that.
The only test that catches it compares the survey against heights established independently — published benchmarks, a levelling run, or control measured by a method that does not share the survey’s assumptions. This page covers doing that comparison properly: which points to use, how to read the result, and how to state it in a way that survives a client’s own check. It completes geoid models and vertical datum automation.
What makes a comparison independent
A comparison is only as good as the independence of the reference, and three things can quietly destroy it.
The reference was used as control. A point that constrained the adjustment will agree with the survey by construction, whatever the datum. Reference points must be held out.
The reference came from the same GNSS session. A base station with a wrong height propagates the same error into both the control and the reference, and they agree perfectly while both are wrong.
The reference was converted with the same geoid model. If the published value was itself derived from an ellipsoidal measurement through the model under test, the comparison tests nothing about the model.
A genuinely independent reference is a levelled benchmark: a height established by spirit levelling from a national network, which never touches a satellite or a geoid model.
Figure 1 — Independence is a property of how the reference was measured, not of whether it was held out.
Minimal reproducible solution
import numpy as np
def benchmark_comparison(survey_h: np.ndarray, published_h: np.ndarray,
*, published_sigma_m: float = 0.01) -> dict:
"""Compare survey heights against published benchmark values.
Bias and scatter are reported separately because they mean different
things: a bias is a datum or systematic error and a scatter is ordinary
measurement noise, and a single RMSE conflates them.
"""
d = np.asarray(survey_h, dtype=float) - np.asarray(published_h, dtype=float)
d = d[np.isfinite(d)]
if d.size < 2:
return {"note": "at least two benchmarks are needed"}
bias = float(np.mean(d))
scatter = float(np.std(d, ddof=1))
standard_error = scatter / np.sqrt(d.size)
combined = float(np.hypot(standard_error, published_sigma_m))
return {"benchmarks": int(d.size),
"bias_m": bias, "scatter_m": scatter,
"bias_uncertainty_m": combined,
"bias_significant": abs(bias) > 2 * combined,
"rmse_m": float(np.sqrt(np.mean(d ** 2))),
"residuals_m": d.tolist()}
Folding the published values’ own uncertainty into the bias uncertainty is what keeps the conclusion honest. A measured bias of 2 cm against benchmarks known to 1 cm, from four points, is not significantly different from zero — and reporting it as a 2 cm datum error would send somebody looking for a fault that is not there.
Interpreting the result
The magnitude of the bias usually names its cause, which makes the diagnosis quick.
def diagnose_bias(bias_m: float, local_undulation_m: float,
antenna_height_m: float) -> str:
"""Name the likely cause from the size of the bias."""
candidates = {
"vertical datum (geoid undulation applied backwards)": 2 * local_undulation_m,
"vertical datum (geoid undulation not applied)": local_undulation_m,
"antenna height applied with the wrong sign": 2 * antenna_height_m,
"antenna height not applied": antenna_height_m,
}
for cause, expected in sorted(candidates.items(), key=lambda kv: -abs(kv[1])):
if abs(abs(bias_m) - abs(expected)) < max(0.1 * abs(expected), 0.02):
return f"{cause} — the bias matches {expected:+.3f} m"
if abs(bias_m) < 0.05:
return "no significant systematic offset"
return "systematic offset of unknown origin — investigate the control chain"
The doubled values are worth including because a sign error is at least as common as an omission, and it produces exactly twice the offset. A bias of 96 m in a region with a 48 m undulation is unmistakable once the doubling is in the candidate list and baffling when it is not.
Figure 3 — Three patterns, three quite different remedies.
Edge-case matrix
| Situation | Effect | Handling |
|---|---|---|
| Benchmark used as control | Agrees by construction | Hold it out |
| Benchmark from the same GNSS session | Shares the base station error | Use a levelled benchmark |
| Published value derived through the same geoid | Tests nothing about the model | Prefer levelled heights |
| Fewer than four benchmarks | Bias uncertain | Report the uncertainty with the bias |
| Benchmark disturbed or destroyed | One large residual | Robust statistics, and inspect it |
| Benchmarks clustered | Bias measured in one place | Distribute across the site |
| Benchmark network has its own tilt | Systematic pattern in residuals | Fit a trend before concluding |
| Survey older than a datum revision | Legitimate difference | Compare the datum definitions |
The clustered-benchmarks row deserves a check of its own, because a bias measured at one end of a site says nothing about the other:
import numpy as np
def benchmark_distribution(positions_xy: np.ndarray, site_bounds: tuple) -> dict:
"""Do the benchmarks cover the site, or one corner of it?"""
left, bottom, right, top = site_bounds
site_area = max((right - left) * (top - bottom), 1e-9)
xy = np.asarray(positions_xy, dtype=float)
hull_extent = (xy[:, 0].max() - xy[:, 0].min()) * (xy[:, 1].max() - xy[:, 1].min())
coverage = float(hull_extent / site_area)
return {"benchmarks": len(xy), "coverage_fraction": coverage,
"adequate": coverage > 0.3 and len(xy) >= 4,
"note": ("benchmarks span the site" if coverage > 0.3 else
"benchmarks are clustered — the bias is measured where they are "
"and says little about the rest of the survey")}
Verification snippet
import numpy as np
def check_for_trend(positions_xy: np.ndarray, residuals_m: np.ndarray) -> dict:
"""Is the difference a constant offset, or does it vary across the site?
A constant is a datum or antenna error. A trend is a tilt, which points
at the control distribution or a datum realisation difference rather than
at the geoid model.
"""
xy = np.asarray(positions_xy, dtype=float)
d = np.asarray(residuals_m, dtype=float)
if d.size < 4:
return {"note": "need at least four benchmarks to separate a trend"}
centred = xy - xy.mean(axis=0)
A = np.column_stack([centred, np.ones(len(d))])
coeffs, *_ = np.linalg.lstsq(A, d, rcond=None)
gradient = float(np.hypot(coeffs[0], coeffs[1])) * 1000 # mm per metre
residual = d - A @ coeffs
return {"offset_m": float(coeffs[2]),
"tilt_mm_per_100m": gradient * 100,
"scatter_after_trend_m": float(np.std(residual, ddof=1)),
"is_tilt": gradient * 100 > 5.0,
"note": ("a tilt is present — check control distribution and the datum "
"realisation" if gradient * 100 > 5 else
"the difference is a constant offset")}
Figure 2 — Four patterns, four causes, one comparison.
Stating the result
A vertical accuracy statement that survives a client’s own check has four parts: the number of benchmarks, the bias with its uncertainty, the scatter, and the datum and model the comparison was against.
The most common weakness is stating an RMSE alone. An RMSE of 5 cm made of a 4 cm bias and 3 cm scatter is a datum problem; the same RMSE made of no bias and 5 cm scatter is an ordinary survey. A client checking against their own data will find the first and not the second, and a statement that distinguished them would have avoided the conversation.
def vertical_accuracy_statement(comparison: dict, trend: dict,
vertical_datum: str, geoid_model: str) -> str:
"""The paragraph that belongs in the survey report."""
parts = [
f"Heights are referenced to {vertical_datum}, converted using the "
f"{geoid_model} geoid model.",
f"Compared against {comparison['benchmarks']} independent levelled benchmarks, "
f"the survey shows a mean difference of {comparison['bias_m']:+.3f} m "
f"(±{comparison['bias_uncertainty_m']:.3f} m) "
f"with a scatter of {comparison['scatter_m']:.3f} m.",
]
if trend.get("is_tilt"):
parts.append(f"A residual tilt of {trend['tilt_mm_per_100m']:.0f} mm per 100 m "
"is present across the site.")
return " ".join(parts)
When to escalate
- The bias is significant and matches no candidate cause. Work back through the control chain: base station height, antenna height, the control survey’s own datum, then the conversion.
- No independent benchmarks exist near the site. A levelling run from the nearest benchmark is the proper answer. Where that is impractical, state that the vertical datum is unverified rather than implying it was checked.
- The benchmark network itself is suspect. Older networks have their own distortions. Compare against several benchmarks and treat a consistent pattern as informative rather than as the survey’s fault.