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.
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.
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.
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_mand confirm the status isneeds_coarse_fillrather 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.
Related
- Elevation and terrain data integration — the pipeline these corrections sit inside
- Sampling SRTM and Copernicus DEM along edges — producing the raw profiles, including the nodata mapping these corrections depend on
- Computing edge grade from elevation profiles — the aggregation that must run after these corrections
- Speed profile calibration for heavy vehicles — where an uncorrected artefact turns into a wrong ETA