A sampled elevation profile is a vector; an edge weight needs a scalar. The function that reduces one to the other looks trivial and is where a surprising amount of ETA accuracy is won or lost, because different reductions describe genuinely different physical quantities. This page covers that reduction as the aggregation stage of elevation and terrain data integration, sitting inside the OSM Graph Architecture & Network Modeling pipeline and feeding the kinematics in speed profile calibration for heavy vehicles.

The profiles arrive from sampling SRTM and Copernicus DEM along edges as arrays with possible NaN gaps. What leaves this stage is a signed percentage per direction plus a confidence flag.

When to use this approach

Use climb-weighted aggregation rather than net rise when:

  • The vehicle loses energy on every climb. Any internal-combustion profile, and any electric profile whose regeneration efficiency is below about 90 %, which is all of them.
  • Edges are long relative to terrain variation. A 600 metre edge across rolling ground can have a net rise of zero and three separate 6 % pitches. Net rise reports flat.
  • The cost model has a grade cutoff. A cutoff applied to net rise never fires on rolling terrain, so the constraint it encodes — a loaded artic that cannot sustain more than 10 % — is silently unenforced.

There is a third option worth knowing about and rarely worth using: the maximum pitch anywhere in the profile. It answers a genuine question — can this vehicle physically climb this edge at all — but as a cost input it is far too pessimistic, because a single 12 % ramp of twenty metres would price a two-kilometre edge as though the whole thing were steep. Where a maximum-pitch constraint genuinely matters, such as an articulated vehicle with a known gradeability limit, store it as a separate attribute alongside the aggregated grade rather than replacing it. The cost function then multiplies by the aggregate and hard-filters on the maximum, which is the behaviour the physics actually implies.

Net rise remains correct for elevation-difference questions: what altitude does this route end at, how much potential energy does the vehicle gain. Those are real questions, just not the one an edge weight answers.

Two aggregations of the same profile A 600 metre edge rises and falls three times, ending at its starting elevation. Net-rise aggregation reports zero percent grade. Climb-weighted aggregation reports 4.2 percent, matching the effort a vehicle actually expends. 600 m of rolling terrain that ends where it started 0 m 600 m elevation net rise: 0 m total climb: 150 m net rise → 0.0 % reads as flat, costs nothing climb-weighted → 4.2 % matches the fuel actually burned A grade cutoff applied to the net figure never fires here, so a vehicle that cannot sustain 6 % is routed over three of them. The sign still comes from the net direction — an edge that ends lower than it began is a descent, however much climbing it contains. Shorter edges make the two converge, which is why the choice matters most on rural extracts with long ways.

Implementation

# requires: numpy (pip install numpy)
import numpy as np
from dataclasses import dataclass


@dataclass(frozen=True)
class GradeResult:
    grade_pct: float
    inferred: bool     # profile needed void bridging
    clamped: bool      # value hit the plausibility bound


def aggregate_grade(
    elev: np.ndarray,
    length_m: float,
    *,
    mode: str = "climb_weighted",
    clamp_pct: float = 25.0,
) -> GradeResult:
    """Reduce an elevation profile to a signed grade with provenance flags."""
    if length_m <= 0 or elev.size < 2:
        return GradeResult(0.0, inferred=True, clamped=False)

    good = ~np.isnan(elev)
    if good.sum() < 2:
        return GradeResult(0.0, inferred=True, clamped=False)

    inferred = bool((~good).any())
    if inferred:
        idx = np.arange(elev.size)
        elev = np.interp(idx, idx[good], elev[good])

    net = float(elev[-1] - elev[0])
    if mode == "net":
        raw = 100.0 * net / length_m
    else:
        # Sign from net direction, magnitude from total vertical movement
        sign = 1.0 if net >= 0 else -1.0
        raw = sign * 100.0 * float(np.abs(np.diff(elev)).sum()) / length_m

    clamped = abs(raw) > clamp_pct
    value = float(np.clip(raw, -clamp_pct, clamp_pct))
    return GradeResult(round(value, 3), inferred=inferred, clamped=clamped)

Two design choices in there are worth naming. The result carries flags rather than only a number, so a consumer can distinguish a directly measured 8 % from an 8 % that was interpolated across a void or clipped down from 400 %. And the clamp is symmetric and generous — 25 % is well above any public road — so it only ever catches artefacts, never real terrain.

Deriving the reverse direction is one line, and it should be exactly one line:

def directed_grades(fwd: GradeResult) -> tuple[GradeResult, GradeResult]:
    """Forward and reverse grade for the same physical segment."""
    rev = GradeResult(-fwd.grade_pct, fwd.inferred, fwd.clamped)
    return fwd, rev

Recomputing the reverse from a reversed profile is the alternative and it is worse, because floating-point summation is order-dependent and the two values can differ in the last digits. That difference is harmless numerically and poisonous operationally: it means the invariant grade_fwd + grade_rev == 0 no longer holds exactly, so the assertion that would have caught a genuine sign bug has to be loosened to a tolerance, and a real defect can hide inside it.

The sign invariant across a directed edge pair One physical segment becomes two directed edges. The forward edge climbs at 6.4 percent and the reverse descends at the same rate, so the two values sum to exactly zero — an invariant strong enough to assert on. One segment, two directed edges, one invariant u · 84 m v · 116 m grade_fwd = +6.4 % grade_rev = −6.4 % sum is exactly 0.0 assert without a tolerance Deriving the reverse by negation keeps this exact. Recomputing it from a reversed profile introduces float drift and forces a tolerance. Any tolerance in that assertion is a place a genuine sign inversion can hide, which is why the exactness is worth protecting.

Key parameters and tuning

Parameter Recommended value Notes
mode climb_weighted Use net only for potential-energy questions, not for cost
clamp_pct 25.0 Above any real public road; catches artefacts without touching terrain
Void bridging linear over the gap Only sound for isolated cells; a mostly-void profile should return zero and flag
Minimum valid samples 2 Below this there is no gradient to compute, only a guess
Rounding 3 decimal places Below the DEM’s own vertical accuracy; more digits imply precision that is not there
Reverse derivation negation only Never recompute; the exact-zero invariant is worth more than the symmetry
Flag propagation carry to the edge attribute Lets a cost model discount inferred grades rather than trusting them equally

Integration points

Into the cost function. Grade enters the composite weight described in configuring edge weights for freight logistics as both a multiplicative penalty and, above a cutoff, a hard filter. The inferred flag is worth consulting at the cutoff specifically — excluding a road from a heavy-vehicle profile on the strength of an interpolated grade is a harsher decision than the data supports.

Into EV energy models. The climb-weighted magnitude and the net rise are both needed for electric profiles: total climb drives consumption, net rise drives what regeneration can recover. Storing both costs one extra float per edge and saves recomputing the profile later, as the model in modeling battery-range constraints for EV fleets needs the two separately.

Into structure correction. Edges flagged bridge or tunnel should have their profile replaced before reaching this function, per handling DEM voids and bridge-tunnel artefacts. Aggregating first and correcting afterwards does not work, because the correction operates on the profile shape rather than on the scalar.

The same number, three different levels of trust Three edges report a grade of eight percent. One was measured from a complete profile, one interpolated across a DEM void, and one clamped down from an implausible raw value. The cost model should apply the penalty confidently, cautiously, and not at all respectively. Three edges reporting 8.0 % — the flags are what distinguishes them measured complete profile inferred = False clamped = False apply the full penalty interpolated void bridged linearly inferred = True clamped = False penalise, but do not hard-filter clamped raw value was 340 % inferred = True clamped = True treat as unknown, not as steep Without the flags all three are 8 % and the third quietly excludes a perfectly ordinary road from every heavy-vehicle route. Count clamped edges per build — a rising count means the DEM coverage is degrading, not that the terrain changed.

Validation checklist

  • Forward and reverse sum to exactly zero. Not approximately — exactly. Any tolerance in this assertion is a place a sign bug can live.
  • A synthetic ramp returns its known grade. Feed a linearly increasing profile of known rise and length; the result must match to the rounding precision in both modes.
  • A symmetric rolling profile returns zero under net. And a non-zero value under climb_weighted. If both return zero, the mode switch is not wired up.
  • Clamped count is small and stable. A few per hundred thousand edges is normal at borders and coastlines. A rising trend means DEM coverage is degrading.
  • Flags reach the edge attributes. Assert that inferred and clamped are persisted alongside the grade. A flag computed and dropped is worse than never computing it, because the code implies a safety that is not there.