Where time is lost, and how many buses it took
Status: built 2026-09-17. Two drill-downs from the network overview:
/overview/punctuality splits punctuality by where on the journey it was
measured and adds excess wait time; /overview/vehicles measures peak vehicle
requirement from the vehicles that actually reported.
Both take the same filters as /overview and read the same rollup, so a
figure on one page and a figure on another describe the same journeys.
Punctuality is three numbers, not one
A single on-time percentage hides which of three problems a route has. Leaving late is a depot problem. Losing time in the middle is a road problem. Arriving early at the end is usually neither.
South Yorkshire, weekdays, 1–16 September 2026:
| on time | early | late | measured | |
|---|---|---|---|---|
| Start point | 86.0% | 5.7% | 8.3% | 61,888 |
| Intermediate | 83.2% | 2.7% | 14.1% | 334,503 |
| End point | 51.9% | 34.7% | 13.4% | 38,020 |
The terminus figure is the point. Buses run early at the end of a journey
thirteen times more often than at intermediate timing points — 34.7%
against 2.7%. That is not bad driving: the last leg carries padding and an
interchange is where a bus is meant to wait. Counted in with the rest it drags
a route's figure down for doing something nobody minds, which is why
punctuality_rollup has always held it apart and why this page shows it apart.
Intermediate is the number to watch. It is the middle of the journey, where the road is the only thing acting on the bus.
On time is −60 to +359 seconds at timing points throughout, and the constants
are imported from punctuality_rollup rather than repeated, so the two cannot
drift apart.
Excess wait time, and why it barely applies here
EWT asks how much longer people waited than the timetable promised, without
pretending anybody consults a timetable for a bus every six minutes. Average
wait time is sum(h²) / (2·sum(h)) over headways; EWT is actual minus
scheduled.
headway_daily stores those components rather than the answer, because EWT
cannot be averaged but n, sum(h) and sum(h²) can all be added — so any
filter computes its own EWT correctly instead of averaging averages.
Measure the network first. Of 3.4 million measured gaps in that window, 2.4% were 12 minutes or less. On a single September weekday, exactly one route — 88, Stagecoach Yorkshire — was frequent on the usual definition, at 69% of its gaps with a median headway of 12.0 minutes.
So EWT is published per route, with its coverage beside it, and never as a network headline. A page printing one EWT for South Yorkshire would be describing about three per cent of it. The comparable products do print one; that is worth knowing when a number like "0.59 minutes" is quoted at you.
Worst in that window: route 218 (SYRK) at +7.65 minutes — scheduled wait 4.73, actual 12.38. Negative EWT is left unclamped, because buses running more evenly than the timetable asked for is a real outcome and hiding it would make the measure look one-directional.
The sums are filtered on the scheduled gap, not the actual one: which departures the measure applies to is a property of the timetable, and filtering on the actual gap would quietly drop the late buses that are the whole reason for measuring.
Peak vehicles, measured from vehicles
The comparable products derive PVR from the schedule's blocks. block_id is
present on 142,131 of 336,058 trips — 42% — so that route is closed to us.
It also turns out to be the worse route. Measured against the trips that do carry a block, for consecutive journeys on the same vehicle with a gap under ten minutes:
| pairs | |
|---|---|
| same block, same route | 4,142 |
| different block, different route | 915 |
| same block, different route | 569 |
| different block, same route | 50 |
Where the block changed, 95% of the time the route changed too. A block is largely a working within one service, so one bus running two routes in succession gets two of them — 609 of 2,844 blocks span more than one route. Where block and vehicle ought to agree, they disagree on 50 pairs in 5,666: 99.1% agreement.
So a vehicle's day is reconstructed from the vehicle. vehicle_spans holds
one row per observed journey with its vehicle and its real start and end;
journeys are joined into duties wherever the gap is under PVR_LAYOVER_MINUTES
(20 by default), and the peak is a sweep over those intervals.
The property the whole thing rests on: a vehicle is on exactly one journey at a time, so a per-route count of vehicles at an instant is additive across routes. The filtered set can be summed and the maximum taken, and the answer is a true peak rather than a sum of peaks that never happened at once.
Two numbers, both shown:
- In service counts a bus only while it is running a journey.
- Including layover counts it across the gaps too, which is what a vehicle requirement means — a bus standing at a terminus for eight minutes is not available for anything else.
The threshold moves the answer, so the page prints it. South Yorkshire, 15 September: 419 strict, 434 at ten minutes, 456 at twenty, 471 at thirty.
It is a floor, not the fleet. A vehicle that never reported never appears, so the true requirement is this or higher, never lower.
The headline is the median of the daily peaks, not the peak of the window: a peak across a fortnight is the busiest minute of the busiest day, which is true and useless.
Making the sweep fast
The first version ran two queries per day. Correct, and four seconds for a
month. Partitioning the sweep by service_date made it two queries whatever
the window — and slower, because finding the peak time meant joining the
sweep back to its own maximum. DISTINCT ON (service_date) ... ORDER BY
service_date, running DESC, t gets the peak and its moment from one ordered
pass.
Then the sorts were spilling to disk: 134,000 rows into
(service_date, vehicle_id, started_at) at 6.5 MB of external merge.
ix_vehicle_spans_sweep on (admin_area, service_date, vehicle_id,
started_at) removes that sort rather than speeding it up, and the sweep's own
sort gets SET LOCAL work_mem = '64MB' — LOCAL, so it lasts for the
transaction and does not become a setting every other query inherits.
Worst case now is the unfiltered 28-day network view at about 3.4 seconds; filtered to a route it is under half a second.
ORDER BY t, d matters. At an instant where one interval ends and another
begins, the -1 has to land before the +1, or a clean handover reads as two
vehicles for zero seconds and every terminus inflates the peak.
Files
| Path | What |
|---|---|
frontend/reliability_web.py |
both pages and their APIs |
frontend/overview_rollup.py |
fills overview_daily, vehicle_spans, headway_daily |
frontend/migrations/versions/d3a5c81e7f92_*.py |
the tables |
frontend/migrations/versions/e91c47b0da35_*.py |
the sweep index |
frontend/templates/punctuality_detail.html, vehicles.html, _overview_filters.html |
the pages |
frontend/static/js/punctuality_detail.js, vehicles.js |
the charts |
frontend/static/css/overview_detail.css |
layered over overview.css |