How the punctuality forecasts work
One snapshot of trip updates yields thousands of delay-versus-horizon observations. That is enough for an empirical forecast — and not enough for several things you might want one for.
The punctuality endpoints answer a question the raw feed does not: given where this trip is now, how late is it likely to be in five stops' time? They are the only part of this API that produces a number the agency did not publish, so this page explains where those numbers come from and — more usefully — what they cannot tell you.
Where the data comes from
A single TripUpdate carries a predicted delay for every remaining call of
its trip, not just the next one. So one snapshot is not one observation per trip — it is a whole
delay-versus-horizon curve per trip. Across 933 forecastable trips that yields
15,451 drift observations: pairs of the form “delay was d at this
stop and d′ N stops later”.
That is the model. There is no regression and no training — the “forecast” is the empirical distribution of those drifts, shifted by the trip's current delay. The quantiles you get back are order statistics over real observations, which is why they can be asymmetric and lumpy in ways a fitted curve would smooth away.
Anchored and prior
Every forecast reports a method, and the two are answering genuinely different
questions:
method | When | What it means |
|---|---|---|
anchored | A current delay is known | This trip's delay, shifted by how far delay typically drifts over that horizon. Specific to the trip. |
prior | No current delay | The punctuality of this route or mode in general, knowing nothing about the specific trip. Necessarily much wider. |
The difference is large, and it is the whole value of knowing where a vehicle is:
# prior — buses in general
GET /api/punctuality/predict?mode=bus
p10=0s p50=60s p90=281s
# anchored — a bus currently 180s late, five stops out
GET /api/punctuality/predict?mode=bus¤tDelaySeconds=180&horizonStops=5
p10=74s p50=181s p90=274s The anchored p10–p90 spread is 200 seconds against the prior's 281, and it is centred on where the vehicle actually is rather than on the fleet average. Note also that the anchored median sits almost exactly at the current delay: over five stops a late bus tends to stay late rather than recover — which is itself a finding, and not one you would guess.
Backoff, and why you must read basis
Forecasts are sliced as finely as the data supports: route → mode → global. A lookup stops at the first slice holding at least 30 samples, so an answer is never computed from a handful of trips.
With 369 routes across ~930 live trips, most routes are far too thin for a route-level answer, so
basis: "mode" is the common case even when you pass a routeId. Busy
routes do reach basis: "route", and are likelier to at longer horizons, where the
pooled bands hold more samples.
This means you do not always get the slice you asked for, and the response says so rather than pretending. In this capture, ferries have only 19 trips — below the floor — so asking about ferries silently gets you the whole network:
| Requested | basis | sampleSize | Median delay |
|---|---|---|---|
?mode=bus | mode / bus | 791 | 60 s |
?mode=metro | mode / metro | 70 | 4 s |
?mode=tram | mode / tram | 53 | 33 s |
?mode=ferry | global | 933 | 49 s |
Horizon bands
Drift samples are pooled into bands of 1, 2–3, 4–6, 7–12 and 13+ stops ahead.
Per-stop buckets would be far too thin to give a stable distribution, and drift changes slowly
with distance, so pooling costs little. Your horizonStops is echoed back exactly as
you asked it; horizonBand tells you which pool actually answered.
The bands are where the uncertainty becomes visible. Anchoring a bus 120 seconds down and asking at increasing distance:
horizonStops=1 -> band "1" p10=86s p90=175s (89s wide)
horizonStops=3 -> band "2-3" p10=56s p90=210s (154s wide)
horizonStops=5 -> band "4-6" p10=14s p90=214s (200s wide)
horizonStops=9 -> band "7-12" p10=-42s p90=209s (251s wide)
horizonStops=20 -> band "13+" p10=-96s p90=206s (302s wide)
Note what widens and what does not. p90 barely moves — a late bus rarely gets much
later. It is p10 that collapses, from +86s to −96s: given enough stops, a bus that is
two minutes down might well recover completely. Twenty stops out you genuinely cannot say whether
it will be early or late, and the band is the only honest way to express that.
Departure or arrival
Every call carries two delays, and they routinely disagree. A vehicle running ahead of schedule waits at a timing point until its booked departure, so its arrival delay is strongly negative while its departure delay is about zero. That single choice moves the headline numbers a long way:
delayBasis | Early | On time | Late |
|---|---|---|---|
departure (default) | 2.6% | 52.6% | 44.8% |
arrival | 29.0% | 34.7% | 36.2% |
Counting arrivals makes nearly a third of the fleet look early when those vehicles are simply
sitting at a stop waiting for the clock. departure is the default and the
conventional punctuality measure. Ask for arrival only when your question is
genuinely about when the vehicle reaches the stop — a passenger alighting, or a trip's
final call.
Does it actually work? A held-out check
Trips are split in half by a hash of the trip id, the model is built on one half and evaluated on
the other. pnpm calibrate in the API repo reproduces this:
horizon n coverage p10-p90 width median |err| persistence skill
1 485 80.2% 87s 18s 19s 5.3%
2-3 954 80.1% 155s 31s 31s 1.6%
4-6 1352 79.0% 196s 40s 40s 0.0%
7-12 2194 79.6% 256s 49s 49s 0.0%
13+ 2862 76.0% 298s 62s 62s 0.0%
ALL 7847 78.3% 256s 45s 45s 0.0% There are two findings there, and they point in opposite directions.
The interval is well calibrated. The p10–p90 band contains the true value about 80% of the time, which is exactly what an 80% interval is supposed to do — and it holds at every horizon, not just on average. That is the model working.
The midpoint has no skill at all. “Persistence” is the free baseline
anyone can compute: assume the delay simply will not change. At every horizon past three stops,
predicting persistence is exactly as accurate as the model's p50. Skill
0.0%.
One more number from that run, worth holding onto: when the model says a call will be
on_time, the feed's own prediction agrees 66% of the time. The two disagree on a
third of calls — which is the gap this endpoint exists to expose, not a discrepancy to explain
away.
What the model cannot do
The model card returns these in a limits array
on every call, so they travel with the data rather than living only here:
- No time of day, day of week, or weather. Everything is built from one point-in-time capture. A Tuesday-lunchtime model applied to Friday rush hour is out of sample, and the API has no way to warn you.
- These are forecasts of forecasts. The downstream delays are what the operator predicted for those stops — not what was later observed to happen. If SL's own predictions are optimistic, the model inherits that optimism exactly. Nothing here is validated against outcomes, because the snapshot contains no outcomes.
- “Current delay” is a stand-in. No trip in this feed reports a trip-level delay, so the anchor is the delay at the trip's next call — the freshest thing available, but a prediction rather than a measurement.
- Route-level forecasts are mostly unavailable. With 933 trips spread over 369
routes, almost no route clears 30 samples on its own. In practice you are getting
mode-level answers even when you pass a
routeId. - Cancelled and skipped calls contribute nothing.
CANCELEDtrips andSKIPPEDcalls are excluded from the distributions, since their delays describe a service that is not running. The forecasts describe trips that are actually happening.
Reading a forecast
probability splits into early, onTime and
late at your toleranceSeconds (default 60, so ±1 minute). Widen the
tolerance and the on-time share grows — the underlying distribution has not changed, only where
you drew the lines.
delaySeconds gives quantiles, and they are more honest than the probability split
because they do not depend on a threshold. p90 is the number to build a schedule
against: it is the delay that is only exceeded one time in ten. p50 is the one to
be sceptical of, for the reason above.
For a specific question — “what are the odds this is more than five minutes late?” —
pass thresholdSeconds and read the exceedance array, rather than trying
to interpolate between quantiles.
curl "$API_BASE/api/punctuality/predict?mode=bus¤tDelaySeconds=180&horizonStops=5&thresholdSeconds=300,600" Where to start
- Model card — provenance, sample counts and limits. Read first.
- Baseline — how punctual the network is right now. Good for calibrating your expectations before forecasting anything.
- Forecast a live trip — the one to use when you have a trip id, and the only one that shows the operator's own prediction alongside the model's.
- Predict — for trips not in the snapshot, or for exploring the model's behaviour directly.