Geoid Models and Vertical Datum Automation

A GNSS receiver measures height above the reference ellipsoid — a smooth mathematical surface that is not sea level and is not what water flows downhill towards. A survey deliverable almost always wants orthometric height, measured from the geoid, which is the equipotential surface that mean sea level approximates.

The two differ by the geoid undulation: a few metres in some regions, over fifty in others, and varying by metres across a large site. Getting the conversion wrong produces a survey that is internally consistent and vertically offset by that amount — the single largest silent error available in drone photogrammetry.

This page covers automating the conversion in Python, keeping it reproducible when grid files are involved, and asserting the vertical datum on every product so the error cannot propagate. It is the vertical counterpart to the horizontal work in ground control point optimization and coordinate sync.

Audience and prerequisites. Python 3.10+, pyproj 3.6+, and a project whose deliverable specifies a vertical datum — which, if it does not, is the first thing to resolve.

Prerequisites

Library / tool Minimum version Install command Role
pyproj ≥ 3.6 pip install "pyproj>=3.6" Transformations and grid handling
PROJ data matching conda install proj-data Geoid grids
numpy ≥ 1.24 pip install numpy Bulk conversion
rasterio ≥ 1.3 pip install "rasterio>=1.3" Writing compound CRS into rasters

Conceptual architecture

Three heights are in play and conflating any two of them is the whole problem.

Ellipsoidal height — usually written h — is what GNSS reports. It has no physical meaning: a point with a larger h is not necessarily uphill.

Orthometric heightH — is measured from the geoid and is what a level instrument measures and what a drainage design needs. Water flows from larger H to smaller.

Geoid undulationN — is the separation between them, so that h = H + N to a good approximation. It is supplied by a geoid model, which is a grid of N values over a region.

The practical consequence is that a conversion needs a model, not a constant. The undulation varies across a site, and a survey of any size that subtracts a single value acquires a tilt.

Ellipsoidal height, orthometric height and the geoid undulation between them A vertical cross-section through three surfaces. A jagged terrain profile sits at the top. Below it an undulating geoid surface representing mean sea level. Below that a straight line representing the reference ellipsoid. At one station three spans are marked: h from the ellipsoid to the terrain, which GNSS reports; H from the geoid to the terrain, which a survey delivers; and N between the ellipsoid and the geoid, the undulation. A note gives the relationship h equals H plus N and records that N varies across a large site, so a single subtracted value introduces a tilt. terrain geoid ≈ mean sea level ellipsoid h H N h = H + N, and N varies across a site. Subtracting one value for a whole survey converts a vertical offset into a tilt.

Figure 1 — The three heights. Substituting one for another is the largest silent error in the discipline.

Step 1: Convert with a compound CRS, not a subtraction

import numpy as np
from pyproj import CRS, Transformer


def to_orthometric(lon: np.ndarray, lat: np.ndarray, ellipsoidal_h: np.ndarray,
                   *, horizontal_epsg: int, vertical_epsg: int) -> dict:
    """Convert ellipsoidal to orthometric height using a geoid model.

    A compound CRS lets PROJ apply the geoid grid at each point rather than a
    constant, which is what removes the tilt a single subtraction introduces
    across a large site.
    """
    source = CRS.from_epsg(4979)                               # WGS84 3D, ellipsoidal
    target = CRS.from_user_input(f"EPSG:{horizontal_epsg}+{vertical_epsg}")
    transformer = Transformer.from_crs(source, target, always_xy=True)

    x, y, z = transformer.transform(lon, lat, ellipsoidal_h)
    undulation = np.asarray(ellipsoidal_h) - np.asarray(z)

    return {"x": x, "y": y, "orthometric_h": z,
            "undulation_m": undulation,
            "undulation_range_m": float(np.ptp(undulation)),
            "target_crs": target.to_string()}

Reporting the undulation range is the diagnostic worth keeping. A range of a few centimetres over a small site is expected; a range of several metres means the site is large enough that the model is doing real work, and a range of zero means no grid was applied and the transformation silently fell back to an approximation.

Step 2: Confirm the grid was actually used

PROJ will complete a transformation without the best available grid, using a lower-accuracy fallback, and it reports this only if asked. On a survey claiming centimetres, a fallback that is accurate to a metre is a failure that looks like success.

from pyproj import CRS
from pyproj.transformer import TransformerGroup


def check_transformation_quality(horizontal_epsg: int, vertical_epsg: int) -> dict:
    """Is the best transformation available, and is it grid-based?

    TransformerGroup lists every candidate transformation and flags those
    whose grids are missing. A group whose best option is unavailable will
    silently use a worse one.
    """
    source = CRS.from_epsg(4979)
    target = CRS.from_user_input(f"EPSG:{horizontal_epsg}+{vertical_epsg}")
    group = TransformerGroup(source, target, always_xy=True)

    missing = [{"grid": g.short_name, "url": g.url, "available": g.available}
               for op in group.unavailable_operations for g in op.grids]

    best = group.transformers[0].description if group.transformers else None
    return {"best_available": best,
            "unavailable_operations": len(group.unavailable_operations),
            "missing_grids": [m for m in missing if not m["available"]],
            "best_is_available": group.best_available,
            "note": ("the best transformation is available" if group.best_available else
                     "the best transformation needs grids that are not installed — "
                     "results will use a lower-accuracy fallback")}

This check belongs in the pipeline rather than in a runbook, because the failure it detects is environmental: the same code on a machine with a different PROJ data installation produces different coordinates, which is the reproducibility problem described in pinning GDAL, PROJ and OpenCV versions reproducibly.

Step 3: Write the vertical datum into every product

A raster or point cloud with a horizontal CRS and no vertical one is a product whose heights mean whatever the reader assumes.

import rasterio
from pyproj import CRS


def write_with_compound_crs(src_path: str, dst_path: str,
                            horizontal_epsg: int, vertical_epsg: int) -> dict:
    """Re-write a raster with a compound CRS declaring both datums."""
    compound = CRS.from_user_input(f"EPSG:{horizontal_epsg}+{vertical_epsg}")
    with rasterio.open(src_path) as src:
        profile = src.profile
        profile.update(crs=compound)
        data = src.read()
        tags = src.tags()

    with rasterio.open(dst_path, "w", **profile) as dst:
        dst.write(data)
        dst.update_tags(**tags, VERTICAL_DATUM=f"EPSG:{vertical_epsg}",
                        HEIGHT_TYPE="orthometric")
    return {"crs": compound.to_string(), "path": dst_path}


def assert_vertical_datum(path: str, expected_vertical_epsg: int) -> None:
    """Refuse a product whose vertical datum is absent or unexpected."""
    with rasterio.open(path) as src:
        crs = src.crs
        tags = src.tags()
    if crs is None:
        raise ValueError(f"{path} has no CRS at all")
    parsed = CRS.from_user_input(crs)
    if not parsed.is_compound:
        raise ValueError(f"{path} declares a horizontal CRS only — its heights are "
                         "undefined")
    vertical = [sub for sub in parsed.sub_crs_list if sub.is_vertical]
    if not vertical or vertical[0].to_epsg() != expected_vertical_epsg:
        raise ValueError(f"{path} declares vertical {vertical[0].to_epsg() if vertical else None}, "
                         f"expected {expected_vertical_epsg}")

Step 4: Handle the GCP side consistently

The same conversion applies to the control points, and the most common failure in the whole area is applying it to one and not the other: control points delivered as orthometric heights, camera positions as ellipsoidal, and a reconstruction that splits the difference.

import numpy as np


def reconcile_heights(gcp_heights: np.ndarray, gcp_datum: str,
                      camera_heights: np.ndarray, camera_datum: str,
                      undulation_m: np.ndarray) -> dict:
    """Bring control and camera heights into one vertical frame.

    The undulation is per point, not a constant, because the control points
    and the camera positions are at different places and the model differs
    between them.
    """
    if gcp_datum == camera_datum:
        return {"converted": False, "note": "both are already in the same datum"}

    if gcp_datum == "orthometric" and camera_datum == "ellipsoidal":
        cameras = camera_heights - undulation_m
        return {"converted": True, "camera_heights": cameras,
                "note": "camera heights converted to orthometric"}
    if gcp_datum == "ellipsoidal" and camera_datum == "orthometric":
        gcps = gcp_heights - undulation_m
        return {"converted": True, "gcp_heights": gcps,
                "note": "control heights converted to orthometric"}
    raise ValueError(f"unhandled datum pair: {gcp_datum} and {camera_datum}")

Step 5: Handle dynamic datums and epochs

Modern global datums are dynamic: the reference frame is tied to the Earth’s crust, which moves. A coordinate in such a frame is only meaningful with an epoch attached, and in regions with fast plate motion the difference between epochs a decade apart is tens of centimetres horizontally.

Vertical motion is usually smaller and is not zero — subsidence, uplift and post-glacial rebound all move heights measurably over a survey programme’s lifetime. A monitoring series that ignores the epoch acquires a slow drift that looks like real ground movement.

from pyproj import CRS, Transformer


def transform_with_epoch(lon, lat, h, *, source_epsg: int, source_epoch: float,
                         target_epsg: int, target_epoch: float) -> dict:
    """Transform between dynamic datum realisations at stated epochs.

    Both epochs must be supplied. A transformation between dynamic frames
    without them uses a default that is unlikely to match either the survey
    or the deliverable, and the resulting shift is a systematic error nobody
    can attribute later.
    """
    source = CRS.from_epsg(source_epsg)
    target = CRS.from_epsg(target_epsg)
    transformer = Transformer.from_crs(source, target, always_xy=True)
    x, y, z = transformer.transform(lon, lat, h, source_epoch, target_epoch)
    return {"x": x, "y": y, "z": z,
            "source_epoch": source_epoch, "target_epoch": target_epoch,
            "note": "epochs recorded; a transformation without them uses a default"}


def epoch_warning(survey_epoch: float, control_epoch: float,
                  *, max_gap_years: float = 2.0) -> str | None:
    """Flag a survey whose control was measured at a materially different epoch."""
    gap = abs(survey_epoch - control_epoch)
    if gap > max_gap_years:
        return (f"control was measured {gap:.1f} years from the survey epoch — "
                "in a dynamic frame this is a real coordinate difference, "
                "not a measurement error")
    return None

The epoch warning is worth having even where plate motion is slow, because the failure it catches is not the motion itself but the assumption that coordinates are timeless. A control set measured in 2015 and a survey flown in 2026 are in different realisations of the same named datum, and treating them as identical introduces a shift the residuals will report as measurement error.

Step 6: Record the vertical frame in the deliverable, in words

A compound CRS in the file is necessary and not sufficient, because the person reading the report is not opening the file. Three sentences in the deliverable close the gap.

State which vertical datum the heights are in, by name and code. State which geoid model converted them, by name and version, because two models over the same region differ by centimetres and a client comparing against older data needs to know. And state the epoch if the horizontal frame is dynamic.

def vertical_statement(vertical_epsg: int, geoid_model: str,
                       epoch: float | None, benchmark_check: dict | None) -> str:
    """The sentence that belongs in every survey report."""
    from pyproj import CRS
    name = CRS.from_epsg(vertical_epsg).name
    parts = [f"Heights are orthometric, referenced to {name} (EPSG:{vertical_epsg}), "
             f"converted from ellipsoidal heights using the {geoid_model} geoid model."]
    if epoch is not None:
        parts.append(f"Horizontal coordinates are at epoch {epoch:.1f}.")
    if benchmark_check and benchmark_check.get("benchmarks"):
        parts.append(f"Verified against {benchmark_check['benchmarks']} published "
                     f"benchmarks with a mean difference of "
                     f"{benchmark_check['bias_m']:+.3f} m.")
    return " ".join(parts)

Generating that sentence from the values actually used, rather than writing it once into a template, is what keeps it true. A template sentence naming a geoid model is a claim; a generated one is a record, and the difference shows up the first time somebody changes the model and forgets the report.

Why this is the largest silent error available

It is worth being explicit about the scale, because the effort spent on vertical datums is often out of proportion to the effort spent on everything else in a pipeline — in the wrong direction.

A careful survey achieves two to three centimetres of vertical accuracy. A doming error might add ten. A wrong geoid undulation adds the local separation, which in Britain is around fifty metres, in parts of the United States around thirty, and in some regions over a hundred. The error is three orders of magnitude larger than everything else the pipeline works to control.

It is also uniquely silent. A domed survey looks slightly odd on close inspection; a survey offset by the geoid undulation looks entirely normal, because everything in it is offset by the same amount. Every internal consistency check passes. The reconstruction residuals are perfect. The only way to detect it is to compare against something external, which is why the benchmark check on this page is not an optional refinement.

The practical conclusion is that the vertical datum deserves a hard assertion at every stage — on ingest, on the control points, on every written product — rather than a note in a method statement. The assertions are three lines each and they close off the largest failure the discipline offers.

Parameter deep-dive

Parameter Typical Effect
Vertical EPSG region-specific Defines what “height” means in the product
Geoid grid from PROJ data Supplies the undulation; must be installed
PROJ_NETWORK OFF with grids baked in ON makes results depend on a download
Undulation range site-dependent Zero means no grid was applied
Compound CRS required Without it the heights are undefined
Transformation accuracy grid-dependent Fallbacks are metres, grids are centimetres
Epoch for dynamic datums Matters where plate motion is significant

Verification and output inspection

import numpy as np


def verify_against_benchmarks(measured_h: np.ndarray, published_h: np.ndarray,
                              *, tolerance_m: float = 0.05) -> dict:
    """Compare converted heights against published benchmark values.

    The bias is the number that matters: a systematic offset means the wrong
    datum or a missing grid, while scatter means ordinary measurement error.
    """
    d = np.asarray(measured_h) - np.asarray(published_h)
    d = d[np.isfinite(d)]
    if d.size < 2:
        return {"note": "need at least two benchmarks"}

    bias = float(np.mean(d))
    scatter = float(np.std(d, ddof=1))
    return {"benchmarks": int(d.size), "bias_m": bias, "scatter_m": scatter,
            "within_tolerance": abs(bias) < tolerance_m,
            "diagnosis": ("heights agree with the published datum" if abs(bias) < tolerance_m
                          else f"systematic {bias:+.3f} m — check the vertical datum, "
                               "the geoid grid and the antenna height")}
Three heights for the same point, and what each is for Three rows. An ellipsoidal height is what a satellite receiver measures directly, referenced to a mathematical surface, and it is what RTK and PPK produce before anything is applied. An orthometric height is referenced to the geoid, approximates height above mean sea level, and is what every engineering drawing, drainage design and benchmark in a national network uses. The geoid separation is the difference between them, varying smoothly across a country by tens of metres, and is what a grid model supplies. A note states that a survey delivering ellipsoidal heights labelled as elevation is the single most expensive metadata error in the field. ellipsoidal height what the receiver measures, against a mathematical surface orthometric height against the geoid — what drawings, drainage and benchmarks use geoid separation the difference, varying by tens of metres across a country Ellipsoidal heights delivered as elevation is the most expensive metadata error in the field.

Figure 3 — Three quantities, and only one of them is what a client means by height.

Where the vertical transformation belongs in a pipeline A four-stage placement. Stage one records the receiver's ellipsoidal heights unchanged, so the original observation is never lost. Stage two applies the geoid grid once, at a defined point in the pipeline, producing orthometric heights. Stage three labels the datum explicitly on every product derived from that point onward. Stage four validates against a benchmark of known orthometric height, which is the only check that distinguishes a correct transformation from a plausible one. A note states that applying the separation twice, or not at all, both produce heights that look entirely reasonable. 1. record ellipsoidal heights, unchanged 2. transform the geoid grid, applied exactly once 3. label the datum, on every derived product 4. validate against a benchmark of known height Applied twice or not at all, the resulting heights both look entirely reasonable.

Figure 4 — One transformation, applied once, labelled always, validated.

Troubleshooting

Every height is out by tens of metres. Ellipsoidal heights delivered as orthometric, or the reverse. The magnitude is the local geoid undulation, which identifies it immediately.

Heights are tilted across a large site. A single undulation value was subtracted instead of a model applied.

The same data converts differently on two machines. Different PROJ grid availability. Bake the grids in and check the transformation group.

The product has a horizontal CRS and no vertical one. Its heights are undefined. Write a compound CRS.

Control and camera heights disagree by a constant. One set was converted and the other was not, which is the most common instance of this whole class of failure and the easiest to confirm: the constant equals the local undulation.

Benchmarks agree and the survey still disagrees with a client’s data. Their datum may be a different realisation of the same named system, or their data may predate a geoid model revision. Compare the datum definitions and the model versions rather than the numbers, and expect a difference of a few centimetres to be legitimate on both sides.

Ground Control Point Optimization & Coordinate Sync