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.
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.
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
maxspeedusually 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.
Related
- Speed profile calibration for heavy vehicles — the kinematic model these observations correct
- Map matching GPS traces to OSM networks — producing the edge-attributed traversals this aggregation consumes
- Filtering GPS noise before map matching — where the dwell column that removes the dominant bias comes from
- Time-dependent speed profiles in Python — evaluating the bucketed output at query time