Geoid vs ellipsoid height on embedded receivers
Horizontal position gets all the attention and height is where field deployments quietly disagree with each other by tens of metres. A GNSS receiver measures height above the ellipsoid; almost every map, drawing and regulation is written against height above the geoid, and the difference between them varies from about −105 m to +85 m across the globe. This guide covers what the receiver is actually reporting, how to convert on hardware with no room for a geoid model, and how to make the choice explicit so two systems cannot silently disagree. It belongs to coordinate reference systems at the edge inside the Core Edge GIS Fundamentals guide.
What the two heights are
The ellipsoid height is the distance from a point to the WGS84 reference ellipsoid — a smooth mathematical surface fitted to the earth. It is what the receiver computes directly, because the satellite geometry gives a position in an earth-centred frame and the ellipsoid is a definition rather than a measurement.
The orthometric height is the distance to the geoid, the equipotential surface that approximates mean sea level. It is what a spirit level agrees with, what “50 m above sea level” on a drawing means, and what a flood model expects.
The two differ by the geoid separation — usually written N — which varies smoothly but substantially over distance. Across a single working area the separation is nearly constant; across a country it is not. Ignoring it is safe only for a deployment that never compares its heights against anything external, which in practice means almost none.
What NMEA actually gives you
A $GPGGA sentence carries both numbers, and the fields are routinely misread. Field 9 is the antenna altitude above the geoid — the orthometric height, already corrected. Field 11 is the geoid separation the receiver used to make that correction. Adding them recovers the ellipsoid height.
# nmea_height.py — extract both heights from a GGA sentence, honestly.
# No allocation beyond the split; safe to call per fix.
def parse_gga_height(sentence: str):
"""Returns (orthometric_m, separation_m, ellipsoid_m, source) or None.
GGA field 9 = altitude above geoid, in the units of field 10 ('M')
GGA field 11 = geoid separation, in the units of field 12 ('M')
Some receivers leave field 11 empty; some leave it as 0.0 while still
applying a separation internally. Both cases are indistinguishable from
a genuine zero separation, which is why `source` is returned."""
parts = sentence.split(",")
if len(parts) < 13 or not parts[0].endswith("GGA"):
return None
try:
ortho = float(parts[9])
except ValueError:
return None
if parts[10] != "M":
return None # feet, or a malformed sentence
raw_sep = parts[11].strip()
if raw_sep == "":
return ortho, None, None, "separation_absent"
try:
sep = float(raw_sep)
except ValueError:
return ortho, None, None, "separation_unparsable"
if parts[12] != "M":
return ortho, None, None, "separation_wrong_units"
return ortho, sep, ortho + sep, "receiver"
The source return value is the point of the function. A receiver that reports an empty separation has given you a height whose datum you cannot determine from the sentence alone — it may be orthometric using an internal model, or it may be ellipsoidal with the field left blank. Treating that as “orthometric, separation zero” is how a deployment ends up 40 m out in a region where N happens to be −40.
Converting without a geoid model
The full EGM2008 model is a 2.5-arc-minute grid — hundreds of megabytes — and is not going on an MCU. Three practical options exist, in descending order of accuracy and ascending order of viability on constrained hardware.
Trust the receiver’s model. Most modern chipsets carry a coarse geoid model, typically accurate to 1–3 m, and apply it to produce field 9 already. For asset tracking and most logistics work that is sufficient, and the correct action is to use the value and record that it came from the receiver.
Carry a regional grid. For a deployment bounded to a country or a state, a clipped geoid grid at 5-arc-minute spacing is a few hundred kilobytes and interpolates to centimetres. This is the right answer for a gateway with a filesystem and a fixed operating region.
Carry a single constant. Over a working area of a few tens of kilometres, N varies by well under a metre in most of the world. One constant, measured once against a survey mark, converts every height in that area to within a few centimetres — and it costs four bytes.
# geoid_local.py — constant-separation conversion for a bounded working area.
# Valid only inside the area the constant was fitted for; the guard enforces it.
from dataclasses import dataclass
import math
@dataclass(frozen=True)
class LocalGeoid:
"""A single separation value fitted at a reference point, with the radius
over which it is trusted. Beyond it, the conversion refuses rather than
guessing — a wrong height is worse than an absent one."""
ref_lat: float
ref_lon: float
separation_m: float
valid_radius_km: float = 30.0
fitted_utc: str = ""
def orthometric(self, ellipsoid_m: float, lat: float, lon: float):
if self._km_from_ref(lat, lon) > self.valid_radius_km:
return None, "outside fitted area"
return ellipsoid_m - self.separation_m, "local_constant"
def _km_from_ref(self, lat: float, lon: float) -> float:
# Equirectangular approximation: exact enough to police a 30 km radius.
mean_lat = math.radians((lat + self.ref_lat) * 0.5)
dx = math.radians(lon - self.ref_lon) * math.cos(mean_lat) * 6371.0
dy = math.radians(lat - self.ref_lat) * 6371.0
return math.hypot(dx, dy)
Constraint validation
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| Flash | A global geoid model is hundreds of megabytes | Constant or clipped grid; the constant is four bytes and needs no filesystem |
| RAM | Grid interpolation would need the grid resident | Constant conversion is one subtraction; the clipped grid is memory-mapped |
| CPU | Height conversion runs per fix | One subtraction plus a bounds check, under a microsecond |
| Correctness | A conversion applied outside its fitted area is silently wrong | Explicit radius guard returning None rather than a value |
| Auditability | Two systems can disagree without either being wrong | Every height carries the datum and the conversion source |
Gotchas and edge cases
- Never store a height without its datum. A field called
altitudeis a future incident. Call itheight_ellipsoid_morheight_orthometric_m, or carry avertical_datumfield alongside. This one convention prevents the majority of height disputes. - Receiver models differ between chipsets. Two devices from different vendors at the same point can report orthometric heights differing by a metre or two, purely from their internal geoid models. If a fleet mixes chipsets, either convert from ellipsoid height with your own model or accept that inter-device height comparisons carry that error.
- RTK changes the picture, not the problem. A corrected fix has centimetre-level ellipsoid height, which makes the geoid separation the dominant error unless the model is equally good. Precision in the horizontal does not confer precision in the vertical.
- Barometric fusion measures a third thing. Pressure altitude tracks a weather-dependent surface and is excellent for changes in height and poor for absolute height. Fuse it for vertical rate; do not let it silently replace a GNSS-derived height in a stored record.
- Field 11 of zero is ambiguous. Some receivers report
0.0when they mean “no model applied”. Detect it as a suspicious value at provisioning — compare against the expected separation for the deployment region — rather than trusting it.
Integrating with the pipeline
Convert once, at ingestion, alongside the horizontal transform, and carry both the value and its datum through every stage afterwards. Emit the conversion source as a field so a downstream analysis can filter on it: a dataset mixing receiver-model heights with locally fitted ones is usable if the mixture is visible and misleading if it is not.
Record the fitted constant, its reference point, its radius and the date it was fitted in the device manifest, the same way the coordinate transform parameters are recorded. A height that turns out to be wrong two years later is diagnosable only if the constant that produced it is recoverable, and a constant living in a source file that has since been edited is not.
Fitting the local constant
Fitting the constant takes an afternoon and a known point. Find a survey mark, a benchmark or any published control point inside the working area whose orthometric height is documented. Park the device over it, log ellipsoid heights for at least twenty minutes so short-period multipath averages out, and take the mean. The separation is that mean minus the published height.
Two refinements are worth the extra effort. Repeat at a second point on the far side of the working area: if the two separations differ by more than a few centimetres, the area is large enough that a constant is a poor fit and a grid is warranted. And repeat the whole fit annually — not because the geoid moves, but because it catches a changed antenna, a changed mount and a receiver whose firmware update altered its internal model, all of which present as a shifted constant.
Record the fit alongside the value: the control point’s identifier, its published height and datum, the date, the number of samples and the standard deviation. A separation with no provenance is indistinguishable from a guess, and the first person to question a height will question it.
Related
- Coordinate Reference Systems at the Edge — the horizontal transform this sits beside.
- Spatial Data Precision Standards — the precision contract a height field belongs to.
- Handling CRS transformations on ARM Cortex-M devices — the constrained-target arithmetic this conversion joins.