Planning the NAVD88 to NAPGD2022 Transition Jump to heading

A vertical datum modernization is not a code change. When a national agency replaces a levelling-based datum with a geoid-based one, every published height in the country moves — by a metre or more in places, with a spatial pattern that varies across a single state — and every flood elevation, drainage design, planning threshold and printed contour that was expressed in the old datum now describes a slightly different surface. NOAA’s replacement of NAVD88 with the North American-Pacific Geopotential Datum of 2022 is the worked example here; the plan applies equally to EVRF realizations in Europe or to any national vertical modernization. It extends vertical datum and geoid model handling inside CRS Normalization & Sync from a per-feature conversion to a programme of work.

The steps map to configure (Steps 1–2, impact assessment), execute (Step 3, dual publication), validate (Step 4, threshold migration), and log (Step 5, the published conversion path). None of it is technically difficult. All of it is easy to leave until a delivery is due.

Prerequisites checklist Jump to heading

Step 1: Quantify the shift across your own extent Jump to heading

National summaries — “typically between −0.5 m and +1.5 m” — are useless for planning because the number that matters is the one over your service area, and its variation across that area matters more than its magnitude.

python
# transition/impact.py — pyproj >=3.6, geopandas >=0.14 — Python 3.10+
import numpy as np
from pyproj import CRS
from pyproj.transformer import TransformerGroup

OLD = "EPSG:5703"      # NAVD88 height
NEW = "EPSG:20035"     # NAPGD2022 orthometric height (illustrative code)


def shift_grid(bounds, spacing_m=2000.0):
    """Sample the datum difference on a regular grid over the working extent."""
    group = TransformerGroup(CRS(OLD), CRS(NEW), always_xy=True)
    if not group.available_operations:
        raise RuntimeError("no transformation available — install the transition grids first")
    transformer = group.available_operations[0]

    minx, miny, maxx, maxy = bounds
    xs = np.arange(minx, maxx, spacing_m)
    ys = np.arange(miny, maxy, spacing_m)
    grid_x, grid_y = np.meshgrid(xs, ys)
    flat_x, flat_y = grid_x.ravel(), grid_y.ravel()
    h_in = np.zeros_like(flat_x)

    _x, _y, h_out = transformer.transform(flat_x, flat_y, h_in, errcheck=True)
    shift = h_out - h_in
    return {
        "min_m": float(np.nanmin(shift)),
        "max_m": float(np.nanmax(shift)),
        "mean_m": float(np.nanmean(shift)),
        "range_m": float(np.nanmax(shift) - np.nanmin(shift)),   # the number that matters
        "samples": int(shift.size),
    }

range_m is the planning number. A uniform 0.9 m shift across a county is a relabelling exercise: every threshold moves by the same amount and relative relationships are preserved. A shift that varies by 0.4 m across the same county means that two sites which were at the same elevation are no longer, and any model that compares them — drainage, flood extent, gravity flow — must be re-run rather than re-labelled.

Step 2: Inventory what depends on heights Jump to heading

The technical conversion is a day’s work. Finding everything it affects is the project. Each of these is a distinct piece of work with a distinct owner:

  • Regulatory thresholds. Base flood elevations, freeboard requirements, minimum finished-floor elevations. Expressed in the old datum in adopted ordinances, which means changing them is a legal process with its own calendar.
  • Model inputs and outputs. Hydraulic models, drainage designs and terrain derivatives that mix your data with someone else’s. A model fed one datum and calibrated against another produces confident nonsense.
  • Published contours and derived products. Anything printed or cached. These do not update themselves and often outlive the systems that made them.
  • Asset records. Invert levels, rim elevations, benchmark sheets. Frequently in a third datum — a local one established decades ago — which the transition is a good opportunity to discover and a bad time to discover accidentally.
  • Downstream consumers. Everyone harvesting your open data. They will not read a release note; they will read the vertical CRS field if you populate it.

Step 3: Dual-publish through a stated window Jump to heading

yaml
# publication.yaml — the transition release shape
dataset_id: "county-lidar-dtm"
release:
  version: "2027.1"
  vertical_transition:
    from_crs: "EPSG:5703"           # NAVD88
    to_crs: "EPSG:20035"            # NAPGD2022
    dual_publish_until: "2029-06-30"   # MANDATORY: an end date, decided now
    conversion_grid: "us_noaa_g2022_conus.tif"
    conversion_grid_sha256: "…"
distributions:
  - format: "GeoTIFF"
    path: "build/dtm_2027_1_navd88.tif"
    vertical_crs: "EPSG:5703"       # MANDATORY on every distribution during the window
    label: "NAVD88 (legacy — retires 2029-06-30)"
  - format: "GeoTIFF"
    path: "build/dtm_2027_1_napgd2022.tif"
    vertical_crs: "EPSG:20035"
    label: "NAPGD2022 (current)"

Two rules make dual publication work rather than double the confusion. The datum is in the filename and in the metadata, never only in the release notes — a file called dtm_2027_1.tif sitting on someone’s disk in 2030 must still be self-describing. And the window has an end date chosen at the start, published from the first transitional release. A dual-publication window with no end date does not end; it becomes a permanent obligation, and the legacy product quietly becomes the one everyone uses because their scripts already point at it.

Step 4: Migrate thresholds deliberately, not implicitly Jump to heading

This is where the transition stops being a data problem.

Threshold kind Wrong approach Correct approach
Adopted regulatory elevation Convert the number and keep using it Convert, then have the converted value formally re-adopted; until then, publish both and state which is in force
Engineering design level Re-run the model in the new datum and compare outputs Same, plus record which datum the design was approved against
Alert or monitoring trigger Leave the trigger and change the data feeding it Change both in the same deployment, or the alert silently re-baselines
Historical time series Convert the whole series to the new datum Convert and retain the original, with the datum recorded per observation

The monitoring row is the one that bites first and hardest. A gauge alert set at 4.20 m in the old datum, fed by a data pipeline that starts publishing in the new one, moves by the local shift — potentially most of a metre — without anyone changing a line of configuration. Alerts stop firing, or start firing constantly, and the cause is invisible from either system alone.

The Threshold That Moved Because the Data Did Two panels show the same physical water surface at a gauge. In the left panel, heights are published in the legacy datum and the alert threshold of 4.20 metres sits just above the water surface, so no alert fires. In the right panel the same physical water level is published in the new datum, where it reads 0.85 metres higher and now sits above the unchanged 4.20 metre threshold, so the alert fires continuously. Neither the gauge nor the alert configuration changed; only the datum of the feed did. The caption states that the trigger and the feed must change in the same deployment. Feed in the legacy datum alert 4.20 m water reads 3.95 m — below the trigger no alert Same water, new datum alert unchanged same water reads 4.80 m — above the trigger alert fires continuously

Step 5: Publish a reproducible conversion path Jump to heading

Consumers will need to convert their own data to match yours, and most of them will not have a geodesist. Publish the exact recipe rather than a description of it:

text
# Vertical transition, county-lidar-dtm release 2027.1
source vertical CRS : EPSG:5703  (NAVD88 height)
target vertical CRS : EPSG:20035 (NAPGD2022 orthometric height)
grid                : us_noaa_g2022_conus.tif  sha256 3f9a…c17b
PROJ version        : 9.4.1
pipeline            : +proj=pipeline +step +proj=vgridshift +grids=us_noaa_g2022_conus.tif +multiplier=1
shift over extent   : min +0.71 m  max +1.06 m  range 0.35 m  (2 km sample grid)
bash
# PROJ >= 9.3 — a consumer reproduces one point exactly
echo "-97.5 35.5 312.400" | cs2cs EPSG:5703 EPSG:20035

Publishing the grid hash and the PROJ version is what makes the conversion reproducible rather than merely described. A consumer who gets a different answer can then tell whether they used a different grid, a different PROJ, or a different pipeline — which is a five-minute diagnosis instead of a fortnight of correspondence. Carry the same fields into the ISO 19115 lineage statement so the record survives the release note.

Verification Jump to heading

python
# tests/test_transition.py — pytest >=7
def test_dual_distributions_differ_by_the_expected_shift(release):
    legacy = sample_heights(release["navd88"], POINTS)
    current = sample_heights(release["napgd2022"], POINTS)
    delta = current - legacy
    assert delta.min() > 0.6 and delta.max() < 1.2      # the assessed range for this extent


def test_every_distribution_declares_a_vertical_crs(release_manifest):
    assert all(d.get("vertical_crs") for d in release_manifest["distributions"])


def test_dual_publication_window_has_an_end_date(release_manifest):
    # Negative control: a transition with no end date must not be publishable.
    assert release_manifest["release"]["vertical_transition"]["dual_publish_until"]

Troubleshooting Jump to heading

Symptom Likely cause Fix
Shift computed as exactly zero everywhere The transformation fell back to a ballpark path with no grid Assert the selected operation carries a grid, as in the conversion procedure
Consumers report heights that disagree with yours by a constant They converted with a different grid version Publish the grid hash and PROJ version with every release
Legacy distribution downloads keep rising during the window Consumers’ scripts point at a fixed filename Label the legacy file with its retirement date and send a deprecation notice; do not silently swap its contents
Model outputs change more than the datum shift The model mixes your data with a source still in the old datum Audit every input’s vertical CRS before re-running; a mixed-datum model is worse than either
A local benchmark disagrees with both datums The asset record is in a third, historical local datum Record it as such; do not force it into either national datum without a survey
The transition never finishes No end date was set at the start Set one retroactively, publish it, and hold it — the window ends by decision, never by attrition