A physics model predicts what a vehicle can do; telemetry records what it actually did. Reconciling the two is what turns plausible ETAs into accurate ones, and it is the empirical half of speed profile calibration for heavy vehicles within OSM Graph Architecture & Network Modeling. It depends entirely on traces having been matched to edges first, per map matching GPS traces to OSM networks.

The technique is simple aggregation. What makes it hard is that a fleet’s traces are a biased sample of traffic in a specific and predictable way, and correcting for that bias is most of the work.

When to use this approach

Derive profiles once the fleet produces enough matched traversals to be worth the pipeline — roughly when the busiest few thousand edges each see thirty traversals a week. Below that, a physics model with road-class priors is more accurate than a noisy empirical estimate, and pretending otherwise produces profiles that swing between rebuilds.

The place it pays most is exactly where physics is blindest: residential and unclassified roads, where parked vehicles, give-way behaviour and kerbside obstruction dominate and no tag captures any of it.

Dwell contamination is the dominant bias Observed speeds on a residential edge form a broad distribution centred near 24 kilometres per hour once traversals containing a dwell are excluded. Including them adds a large spike near zero that pulls the mean down to 9, on an edge where nothing about the road changed. Observed traversal speeds, one residential edge with a delivery on it 0 20 km/h 40 km/h traversals containing a dwell genuine moving traversals 24 km/h — excluding dwells 9 km/h — including them The bias is concentrated on delivery edges, which are precisely the edges a delivery ETA depends on — so it does not average out.

Implementation

Traversal times come from matched output, and the first job is discarding the ones that do not describe driving.

# requires: numpy, pandas (pip install numpy pandas)
import numpy as np
import pandas as pd


def traversal_speeds(matched: pd.DataFrame, *, max_dwell_s: float = 20.0,
                     min_len_m: float = 30.0) -> pd.DataFrame:
    """Per-traversal speed, excluding anything that contains a real stop."""
    df = matched[
        (matched["dwell_s"] <= max_dwell_s)     # a stop is not a slow drive
        & (matched["edge_length_m"] >= min_len_m)  # short edges are mostly noise
        & (matched["traversal_s"] > 0)
    ].copy()
    df["speed_kmh"] = df["edge_length_m"] / df["traversal_s"] * 3.6
    # Physically impossible values are matching errors, not observations
    return df[(df["speed_kmh"] > 1.0) & (df["speed_kmh"] < 130.0)]

The max_dwell_s threshold is the most consequential line on the page. Set it too high and delivery stops contaminate the estimate; too low and legitimate signal waits are discarded along with them, which biases upward instead. Twenty seconds separates a traffic-light wait from a parcel drop in most urban fleets.

The short-edge exclusion matters for a different reason: on a thirty-metre edge, one second of timestamp quantisation is a ten percent speed error, so short edges contribute noise rather than information.

# requires: numpy, pandas, scipy (pip install numpy pandas scipy)
import numpy as np
import pandas as pd
from scipy.stats import trim_mean


def aggregate(df: pd.DataFrame, *, trim: float = 0.1) -> pd.DataFrame:
    """Trimmed mean speed per edge and time bucket, with sample counts."""
    grouped = df.groupby(["edge_id", "time_bucket"])["speed_kmh"]
    out = grouped.agg(
        observed_kmh=lambda s: float(trim_mean(s, trim)) if len(s) >= 3 else float(s.mean()),
        samples="size",
        spread=lambda s: float(s.quantile(0.9) - s.quantile(0.1)),
    ).reset_index()
    return out

Retaining spread alongside the estimate is worth the column. A wide interdecile spread on a well-sampled edge means the edge is genuinely variable — a junction approach, say — and a downstream consumer may want to treat it differently from a tight one at the same mean.

Thin samples are then blended toward a prior rather than replaced by it:

# requires: numpy, pandas (pip install numpy pandas)
import numpy as np
import pandas as pd


def blend_with_prior(obs: pd.DataFrame, priors: dict[str, float],
                     road_class: pd.Series, *, k: float = 30.0) -> pd.Series:
    """Shrink sparse observations toward the road-class prior."""
    prior = road_class.map(priors).astype(float)
    n = obs["samples"].astype(float)
    # Weight rises smoothly with sample count; k is the half-confidence point
    w = n / (n + k)
    return (w * obs["observed_kmh"] + (1.0 - w) * prior).round(2)

Shrinkage rather than a threshold is what keeps adjacent edges consistent. With a hard cutoff, an edge with twenty-nine samples uses the class prior and its neighbour with thirty-one uses its own noisy estimate, producing a visible speed discontinuity that reflects fleet behaviour rather than the road.

One further bias is worth naming because it points the other way. A fleet that routes on its own derived profiles will preferentially drive the edges those profiles say are fast, and will therefore accumulate more samples on exactly those edges. Over successive refreshes the well-sampled set narrows toward the roads the router already likes, and the edges it avoids drift back toward their priors for want of evidence. The effect is slow and rarely large enough to matter within a season, but it is worth a periodic check: compare the sample-count distribution across road classes between refreshes, and if coverage is contracting, inject a small amount of deliberate route diversity or fall back to a longer observation window for the thinning classes.

Key parameters and tuning

Parameter Recommended value Notes
max_dwell_s 20 s Separates a signal wait from a delivery stop
min_len_m 30 m Below this, timestamp quantisation dominates
Speed sanity band 1–130 km/h Values outside are matching errors, not observations
Trim fraction 0.1 Removes outliers without discarding the distribution’s shape
Shrinkage k 30 samples The point at which an edge’s own estimate carries half the weight
Time buckets hourly, weekday/weekend split Finer buckets need proportionally more traces
Refresh cadence weekly Frequent enough to track seasonal change, rare enough to stay stable

Integration points

From matched traces. Input comes from the matcher, and the dwell_s column comes from the collapsing step in filtering GPS noise before map matching. Without that column the dwell exclusion is impossible and the whole exercise inherits the bias described above.

Into the kinematic model. Derived speeds do not replace the physics model; they correct it. The correction factor per edge — observed divided by predicted — is what speed profile calibration for heavy vehicles applies, and keeping it as a factor rather than an absolute speed means the model still generalises to edges with no traces at all.

Into time-dependent profiles. Bucketed output is exactly the shape consumed by time-dependent speed profiles in Python, which covers the query-time evaluation and the bucket-width trade-off.

Shrinking toward the prior instead of switching The weight given to an edge's own observation rises smoothly with sample count, reaching one half at thirty samples and approaching one beyond a hundred. A hard threshold at thirty would instead jump from zero to one, creating discontinuities between adjacent edges. Weight on the edge's own estimate, against sample count 1.0 0.5 0 0 30 100 300 samples shrinkage — smooth and continuous hard threshold — a cliff at 30 The cliff produces neighbouring edges with visibly different speeds purely because the fleet happened to drive one of them twice more often. Dispatchers notice that as inconsistency long before anyone checks the sample counts. Coverage growth, and where it stops The share of edges with enough traversals for a stable estimate rises quickly for the first three months and then plateaus near seventy percent, because the remaining edges are ones the fleet simply never drives. Edges reaching a stable estimate as traces accumulate after 1 week 11 % of edges above 30 samples after 4 weeks 34 % after 12 weeks 61 % after 26 weeks 68 % — plateau The plateau is not a data problem — a third of the network is genuinely outside the fleet’s operating pattern and always will be. Those edges keep their road-class prior, which is why the shrinkage blend matters more than pushing for coverage that will not come.

Validation checklist

  • Dwell exclusion moves delivery edges and nothing else. Compare estimates with and without the exclusion. Motorway edges should be unchanged; residential edges with stops should rise substantially.
  • Estimates are stable between refreshes. Rerun on two consecutive weeks and compare. Edges with adequate samples should move by a few percent; large swings mean the trim or the bucket width is wrong.
  • Sparse edges track their prior. Confirm that edges with fewer than ten samples sit close to the road-class prior rather than to a noisy observation.
  • No estimate exceeds the posted limit implausibly. A derived speed well above maxspeed usually means the matcher attributed a motorway traversal to a parallel service road.
  • Correction factors are centred near one. The distribution of observed over predicted should straddle one. A systematic offset means the physics model needs recalibrating, not the traces.