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.

Ellipsoid height, orthometric height and the separation between them A cross-section showing the smooth ellipsoid surface, the undulating geoid above and below it, and the terrain above both. A point on the terrain has an ellipsoid height measured from the ellipsoid and an orthometric height measured from the geoid; the difference is the geoid separation N, drawn as a vertical offset that varies along the section from about minus 30 metres to plus 12 metres. The receiver reports the ellipsoid height and, in most NMEA sentences, an N value it interpolated from a coarse internal model. The receiver measures to the smooth surface; everything else is written against the wobbly one ellipsoid — a definition geoid — mean sea level terrain orthometric h ellipsoid H N h = H − N. The receiver gives you H and, in most NMEA sentences, an N it interpolated from a coarse internal model.
Nothing here is an error. Both heights are correct answers to different questions, and the failure is only ever in not saying which one a number is.

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)
Three conversion options against accuracy, storage and operating range Trusting the receiver's internal model costs nothing in storage, is accurate to one to three metres and works anywhere. A clipped regional grid at five arc minutes costs a few hundred kilobytes, is accurate to a few centimetres and works across the clipped region. A single fitted constant costs four bytes, is accurate to a few centimetres within about thirty kilometres of the fit point and is wrong outside it. A fourth row marks the failure: assuming a separation of zero, which is accurate only where the geoid happens to cross the ellipsoid and is silently wrong elsewhere by up to a hundred metres. Three honest options, and the one that is not storageaccuracyvalid where receiver's own model 01–3 manywhere clipped regional grid 200–800 KB2–5 cmthe clipped region one fitted constant 4 bytes3–8 cm≤30 km from the fit assume N = 0 0up to 105 ma few narrow bands
The bottom row is not a simplification — it is a hundred-metre error in most of the world, arrived at by not thinking about the question.

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 altitude is a future incident. Call it height_ellipsoid_m or height_orthometric_m, or carry a vertical_datum field 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.0 when 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.
Three receiver behaviours that produce the same sentence field Three receivers at the same point in a region where the true separation is minus 42 metres. The first applies its internal model and reports separation minus 42, so its altitude field is orthometric and correct. The second applies no model and leaves the separation field empty, so its altitude field is really ellipsoidal and 42 metres high. The third applies no model but reports a separation of zero, which is indistinguishable in the sentence from a genuine zero and produces the same 42 metre error with no clue that anything is wrong. Same location, true separation −42 m, three receivers applies its model, reports it field 9 = 118.3 · field 11 = −42.0 orthometric, correct ellipsoid height recoverable by addition no model, empty field field 9 = 160.3 · field 11 = (empty) ellipsoidal — detectable the empty field is the warning no model, reports zero field 9 = 160.3 · field 11 = 0.0 42 m out, no signal indistinguishable from a genuine zero separation Catch the third case at provisioning by comparing the reported separation against the expected value for the region.
Only the middle row announces itself. The bottom row is the reason a provisioning-time sanity check on the separation is worth the ten minutes it takes.

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.