Realtime/SL

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:

methodWhenWhat 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:

the same mode, with and without an anchor
# 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&currentDelaySeconds=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:

RequestedbasissampleSizeMedian delay
?mode=busmode / bus79160 s
?mode=metromode / metro704 s
?mode=trammode / tram5333 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:

?mode=bus&currentDelaySeconds=120
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:

delayBasisEarlyOn timeLate
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:

pnpm calibrate — 447 train / 487 test trips
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. CANCELED trips and SKIPPED calls 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.

terminal
curl "$API_BASE/api/punctuality/predict?mode=bus&currentDelaySeconds=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.