Two artefacts account for nearly every implausible grade in a DEM-derived routing graph, and both produce numbers that look entirely reasonable to any downstream check. A missing DEM cell filled with a nodata sentinel becomes a cliff; a bridge sampled against the valley it crosses becomes a descent followed by a climb on a road that is level. This page covers detecting and correcting both, as the correction layer of elevation and terrain data integration inside the OSM Graph Architecture & Network Modeling pipeline. It runs between sampling SRTM and Copernicus DEM along edges and computing edge grade from elevation profiles, and it operates on the profile rather than the aggregate — which is why it cannot be applied after the fact.

When to use this approach

Both corrections should be permanent parts of any elevation pipeline, but their urgency differs by region:

  • Void handling is essential wherever SRTM is the source. Its unfilled voids cluster in steep terrain and at high latitudes, precisely where grade matters most. Copernicus GLO-30 is void-filled and reduces this to an edge case, but coastal and border cells still return nodata.
  • Structure handling is essential everywhere. Bridges and tunnels exist in every extract, and a single motorway viaduct can put a false 9 % grade on a corridor that carries most of a region’s freight.
  • Both matter more for heavy and electric profiles. A car’s cost function barely notices a spurious 6 %; a 44-tonne kinematic model or an EV energy model responds sharply, so the artefact propagates straight into an ETA or a charging decision.

The one case where you can skip structure correction is a pedestrian or cycling graph in flat terrain with no grade-separated crossings — which is rarer than it sounds, since footbridges and underpasses are exactly the places these networks connect.

The two artefacts, and what the road actually does On the left, a nodata cell left as its sentinel value produces a vertical drop of hundreds of metres on level ground. On the right, a tunnel profile follows the hill above the bore, reporting a steep climb and descent where the road runs level through the hillside. Both artefacts produce grades that are numerically valid and physically impossible nodata left as a sentinel one missing cell reported grade: −4 100 %, then +4 100 % tunnel sampled as terrain DEM: the hill above road: level through the bore reported grade: +11 %, then −11 % Neither artefact fails a range check, a NaN check, or a connectivity audit. The only defence is correcting the profile before it is aggregated. In both cases the endpoints are valid — the road meets the terrain there — which is what makes a ramp between them the right repair.

Implementation

Void handling first, because structure handling depends on having valid endpoints.

# requires: numpy (pip install numpy)
import numpy as np


def repair_voids(
    elev: np.ndarray,
    *,
    sample_interval_m: float,
    max_bridge_m: float = 300.0,
) -> tuple[np.ndarray, str]:
    """Bridge short voids by interpolation; report wide ones rather than guessing."""
    good = ~np.isnan(elev)
    if good.all():
        return elev, "none"
    if good.sum() < 2:
        return elev, "unusable"

    # Longest consecutive run of missing samples decides the strategy
    gaps, run = [], 0
    for ok in good:
        if ok:
            if run:
                gaps.append(run)
            run = 0
        else:
            run += 1
    if run:
        gaps.append(run)
    widest_m = max(gaps) * sample_interval_m

    if widest_m > max_bridge_m:
        # Too wide to interpolate honestly — caller should fall back to a coarser DEM
        return elev, "needs_coarse_fill"

    idx = np.arange(elev.size)
    return np.interp(idx, idx[good], elev[good]), "interpolated"

Returning a status string rather than silently repairing is the point. A profile with a 40-metre gap in gentle terrain is genuinely well estimated by interpolation; one with a two-kilometre gap is not, and the caller needs to know which it received so it can go and fetch a coarser but complete DEM for that span.

Structure correction is simpler but has one subtlety about which samples count as endpoints.

# requires: numpy (pip install numpy)
import numpy as np

STRUCTURE_KEYS = ("bridge", "tunnel", "covered")
NEGATIVE = frozenset({None, "no", "false"})


def is_structure(tags: dict) -> bool:
    """True when the road surface is not the surface a DEM samples."""
    return any(tags.get(k) not in NEGATIVE for k in STRUCTURE_KEYS)


def deck_ramp(elev: np.ndarray, *, edge_share: float = 0.1) -> np.ndarray:
    """Replace a structure's profile with a ramp between its terrain-meeting ends.

    The first and last edge_share of samples sit on approach ground and are
    treated as valid; everything between them is replaced.
    """
    n = elev.size
    if n < 4:
        return np.linspace(elev[0], elev[-1], n)

    k = max(int(round(n * edge_share)), 1)
    start = float(np.nanmedian(elev[:k]))
    end = float(np.nanmedian(elev[-k:]))
    if np.isnan(start) or np.isnan(end):
        return elev
    return np.linspace(start, end, n)

Taking a median over the first and last tenth of the profile rather than the single endpoint sample matters on long structures. The very first sample often lands on the abutment or a retaining wall, which the DEM records several metres above the carriageway; a median over a short run is robust to that without pulling in samples from the span itself.

The two corrections compose in a fixed order:

def correct_profile(elev, tags, *, sample_interval_m):
    """Void repair first, then structure replacement, with provenance."""
    elev, void_status = repair_voids(elev, sample_interval_m=sample_interval_m)
    structure = is_structure(tags)
    if structure and void_status != "unusable":
        elev = deck_ramp(elev)
    return elev, {"void": void_status, "structure": structure}

Void repair must come first because deck_ramp needs valid endpoint samples, and a void at the abutment would otherwise propagate a NaN through the ramp.

Void width decides the repair A void of two samples is bridged by interpolation. A void of forty samples is too wide to interpolate honestly and is filled from a coarser global DEM. A profile that is almost entirely void has no usable signal and is marked rather than repaired. Widest consecutive gap decides which repair is honest under 300 m interpolate across the gap terrain is smooth at this scale flag as inferred 300 m to a few km fill from a coarser global DEM less precise, correctly shaped flag as inferred most of the profile no usable signal to repair return zero grade flag as unusable, not inferred The distinction between inferred and unusable matters downstream: one is a weaker estimate, the other is an absence of data. Collapsing both into a single flag makes a coverage gap look like ordinary uncertainty.

Key parameters and tuning

Parameter Recommended value Notes
max_bridge_m 300 m Beyond the scale over which terrain is reliably smooth; fall back to a coarser DEM
edge_share 0.1 Median over the outer tenth resists abutment and retaining-wall samples
Structure keys bridge, tunnel, covered covered=yes catches arcades and galleries that behave like short tunnels
Void fill source coarser global DEM Less precise but correctly shaped; never a constant, and never zero
Correction order voids, then structures Reversed, a void at an abutment poisons the ramp
Provenance per-edge dict Consumers need to know which correction ran, not just that the number is finite
Embankments no correction Built-up ground is terrain and the DEM captures it correctly

Integration points

Before aggregation, always. The corrections reshape the profile, so they cannot be applied to the scalar grade that computing edge grade from elevation profiles produces. Ordering these two stages the wrong way round is the most common structural mistake in an elevation pipeline, and the symptom is that structure correction appears to have no effect.

Into the confidence flags. The provenance dict feeds the inferred flag on the aggregated grade, which lets a cost function distinguish measured terrain from repaired terrain. That distinction matters most at a hard cutoff, where excluding a road from freight routing on the strength of an interpolated value is a decision the data does not support.

Into the build audit. Count corrections per build. A jump in needs_coarse_fill means DEM coverage has changed; a jump in structure corrections means new bridges or tunnels were mapped, which is genuinely useful signal about the extract.

The corrections have a required order Running void repair before structure replacement produces a clean deck ramp. Running structure replacement first uses an endpoint that is still nodata, so the ramp is built from a NaN and the whole profile becomes unusable. A viaduct whose western abutment sits in a DEM void correct order repair voids build deck ramp level deck, 0.4 % grade reversed build deck ramp ramp end is NaN whole profile unusable The reversed order fails loudly here, which is fortunate. The dangerous variant is a void mid-span, where the ramp is built from valid ends and quietly discards the void — producing a correct answer for the wrong reason, and hiding a coverage gap that matters elsewhere. Assert the order in a test rather than relying on the call sequence staying put through future refactoring.

Validation checklist

  • A synthetic void produces the interpolated value, not the sentinel. Insert a nodata cell into a known ramp and confirm the repaired profile matches the original to within rounding.
  • A wide void reports rather than repairs. Insert a gap wider than max_bridge_m and confirm the status is needs_coarse_fill rather than a silently interpolated line.
  • Bridges report near-zero grade. Pick a known viaduct and confirm its corrected grade is within a percent of level. Before correction it will be several percent in each direction.
  • Correction counts are stable across builds. A sudden change means the DEM or the extract changed, and both are worth knowing about.
  • Order is enforced by a test. Assert that structure replacement on a profile with a NaN endpoint raises or returns unchanged, so a future refactor cannot silently reverse the two stages.