Speed percentile profiles for corridor benchmarking
A corridor’s speed distribution is bimodal — free-flowing traffic and queued traffic are two different populations — so its mean describes neither. The profile that works is a set of percentiles per corridor and time-of-day bin, with free-flow taken as the 85th percentile of the off-peak distribution and congestion expressed as the ratio of the current median to it. That ratio is comparable across corridors and across cities; a raw speed is not.
Why this happens
Averaging over a bimodal distribution produces a number no vehicle experienced. On an arterial where 60% of vehicles move at 45 km/h and 40% are queued at 8 km/h, the mean is 30 km/h — a speed that describes the corridor only in the sense that it lies between the two things happening on it. Worse, the mean moves with the mix rather than with either mode, so a corridor can show an improving mean purely because fewer vehicles used it.
Percentiles avoid this because they are order statistics: the 85th percentile is the speed exceeded by 15% of vehicles, which stays stable when the mix changes and moves only when the vehicles do. The same reasoning applies to the free-flow baseline that congestion thresholds are built on, which is why mapping congestion thresholds to real-time traffic windows expresses its threshold as a fraction rather than an absolute speed.
Core pipeline
- Assign each fix to a corridor and direction by map matching, because a corridor profile that mixes directions is meaningless.
- Bin by time of day and day type, keeping weekday and weekend separate.
- Compute percentiles per (corridor, direction, bin) with a minimum-sample floor.
- Express congestion as a ratio to the corridor’s own off-peak p85, never as an absolute speed.
Production-ready Python implementation
import numpy as np
import pandas as pd
def corridor_speed_profile(
df: pd.DataFrame,
corridor_col: str = "link_id",
dir_col: str = "direction",
time_col: str = "t",
speed_col: str = "speed_ms",
bin_minutes: int = 15,
min_obs: int = 30,
percentiles: tuple = (15, 50, 85),
) -> pd.DataFrame:
"""
Percentile speed profile per corridor, direction and time-of-day bin.
Parameters
----------
min_obs : int
Bins with fewer observations are emitted with NaN percentiles and a
flag rather than dropped. A missing bin and a quiet bin look identical
downstream unless the distinction is carried explicitly.
Returns
-------
pd.DataFrame
One row per (corridor, direction, bin) with the requested percentiles,
the observation count and a sufficient-sample flag.
Raises
------
ValueError
On missing columns, an empty frame, or non-positive speeds.
"""
required = {corridor_col, dir_col, time_col, speed_col}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
if df.empty:
raise ValueError("Input DataFrame is empty.")
if (df[speed_col] < 0).any():
raise ValueError("Negative speeds present — check the derivation.")
d = df.copy()
# Bin edges anchored to midnight LOCAL time: a corridor profile is a
# statement about the working day, and 08:00 has to mean 08:00 there.
minutes = d[time_col].dt.hour * 60 + d[time_col].dt.minute
d["tod_bin"] = (minutes // bin_minutes) * bin_minutes
d["day_type"] = np.where(d[time_col].dt.dayofweek < 5, "weekday", "weekend")
keys = [corridor_col, dir_col, "day_type", "tod_bin"]
g = d.groupby(keys, observed=True)[speed_col]
out = g.agg(n="size").reset_index()
for p in percentiles:
out[f"p{p}_ms"] = g.quantile(p / 100.0).to_numpy()
out["sufficient"] = out["n"] >= min_obs
out.loc[~out["sufficient"], [f"p{p}_ms" for p in percentiles]] = np.nan
return out
def add_congestion_ratio(
profile: pd.DataFrame,
corridor_col: str = "link_id",
dir_col: str = "direction",
offpeak_hours: tuple = (22, 5),
) -> pd.DataFrame:
"""
Express each bin's median as a fraction of the corridor's own free-flow.
Free-flow is the p85 of the OFF-PEAK distribution for that corridor and
direction, which is the standard definition and the only one that makes
two different roads comparable.
"""
lo, hi = offpeak_hours
h = profile["tod_bin"] // 60
off = profile[(h >= lo) | (h < hi)]
if off.empty:
raise ValueError(
"No off-peak bins — widen offpeak_hours or the free-flow baseline "
"will be taken from congested data and every ratio will look fine."
)
ff = (off.groupby([corridor_col, dir_col], observed=True)["p85_ms"]
.max().rename("freeflow_ms"))
out = profile.merge(ff, on=[corridor_col, dir_col], how="left")
out["congestion_ratio"] = out["p50_ms"] / out["freeflow_ms"]
return out
Validation block
def validate_profile(profile: pd.DataFrame, min_obs: int = 30) -> dict:
"""Four checks that catch a profile built on too little or the wrong data."""
# 1. Percentiles must be ordered. Out-of-order values mean the groupby
# keys and the quantile results were misaligned.
ok = profile.dropna(subset=["p15_ms", "p50_ms", "p85_ms"])
assert (ok["p15_ms"] <= ok["p50_ms"]).all() and (ok["p50_ms"] <= ok["p85_ms"]).all(), \
"percentiles out of order — the aggregation is misaligned"
# 2. Coverage. If most bins are under the sample floor, the corridor is
# too finely divided or the fleet is too small for this resolution.
cov = profile["sufficient"].mean()
assert cov > 0.6, (
f"only {cov:.0%} of bins have ≥{min_obs} observations — widen the bin "
"or aggregate corridors before believing this profile"
)
# 3. Congestion ratio in a sane range. Above 1.1 means the free-flow
# baseline came from congested data.
if "congestion_ratio" in profile:
r = profile["congestion_ratio"].dropna()
assert r.max() < 1.15, f"ratio up to {r.max():.2f} — free-flow baseline is too low"
assert r.min() > 0.02, "ratio near zero — stationary vehicles included in the median"
# 4. Directions must both be present, or the profile is one carriageway.
assert profile["direction"].nunique() >= 2, "only one direction in the profile"
return {"bins": len(profile), "coverage": float(cov)}
Common mistakes and gotchas
-
Using the mean. On a bimodal corridor it lands between the modes and moves with the traffic mix rather than the traffic speed.
-
Mixing directions. A dual carriageway with a peak in one direction produces a profile that shows moderate congestion all day in both.
-
Taking free-flow from the whole day. If the off-peak window overlaps the shoulder of the peak, the baseline is depressed and every congestion ratio looks healthy.
-
Dropping thin bins silently. A missing bin and a quiet bin are different facts. Emit the row with NaN and a flag.
-
Including stationary vehicles. Parked vehicles reporting on a link drag the lower percentiles to zero. Filter to moving fixes, or the p15 measures the car park.
-
Comparing absolute speeds across cities. A 32 km/h corridor is congested in one city and free-flowing in another. The ratio to local free-flow is the comparable quantity.
FAQ
Why the 85th percentile for free-flow?
It is the long-standing traffic-engineering convention for the speed unimpeded drivers choose, and it is robust: high enough to exclude queued traffic, low enough to exclude the small number of vehicles travelling unusually fast. The exact choice matters less than using the same one everywhere, since the ratio is what gets compared.
What bin width should I use?
15 minutes is the usual compromise: fine enough to resolve the shape of a peak, wide enough that most corridors clear the sample floor. Check the coverage assertion — if fewer than 60% of bins have enough observations, the corridor definition is too fine before the bin is.
How do I handle corridors with almost no data?
Aggregate upward rather than reporting noise: combine adjacent links into a route, or widen the bin to an hour, and label the result at the coarser level. A profile that silently mixes resolutions is worse than one that admits a corridor cannot be measured.
Related
- Speed & Acceleration Profiling — the parent reference and the derivation of the speed column.
- Detecting Harsh Braking and Acceleration Events — the event rates these profiles provide context for.
- Mapping Congestion Thresholds to Real-Time Traffic Windows — the live detector built on this baseline.
- Map-Matching to Road Networks — how fixes get a corridor and a direction in the first place.
- Dynamic Time-Binning Strategies — choosing the bin width these percentiles are computed over.