Live-departures dashboard for my local bus stops
  • Python 87.6%
  • HTML 10.3%
  • Dockerfile 1.2%
  • Nix 0.9%
Find a file
benmandrew 847d2571e2
Stop the test harness disagreeing with the code it tests
Three date-dependent faults found by auditing the suite after the learning
test turned out to fail every weekend. None of them fails today, which is
the point: each waits for a particular date.

conftest reimplemented `service_midnight` as local midnight. GTFS anchors
a service day at noon minus twelve hours instead, and the difference is
exactly what `matching.service_midnight`'s own docstring exists to
explain: on the two clock-change Sundays the local day is 23 or 25 hours
long, so the two disagree by an hour on 2026-03-29 and 2026-10-25 and
agree on the other 363 days. `vehicle_on_trip` builds every synthesised
vehicle's departure from it, so on those days `dep_delta` lands past
TIER3_TOL and some vehicles match the wrong run — the failure
TestClockChanges was written to catch, reintroduced through the harness
that tests for it. test_matching.py already imported the real function;
the two callers disagreeing was the whole bug. Re-export it instead.

The `live` marker deselected nothing. `addopts` set `--strict-markers`
but no `-m`, so TestAgainstTheRealFeed ran on every run despite its
docstring promising otherwise, putting a third-party HTTP call inside the
job that gates image publishing — it flaked there earlier today. A `-m`
on the command line replaces the one in `addopts`, so the documented
`pytest -m live` still selects them. Collection now reports 315 selected
and 2 deselected, against 317 and 0 before.

The fixture archive expires on 2035-12-31. `matching._runs_on` answers
False outside a service's range, so from 2036 `load_trips` returns
nothing and some twenty tests fail as a wrong trip count rather than as
an expired fixture. Rewrite the calendar's end date on the way out of the
`MINI_GTFS` constant, which removes the deadline rather than moving it,
and does so in the one place all two dozen copy sites across five modules
read from. The start date stays put: several tests pin real past dates
that have to remain inside the range.
2026-08-08 23:16:30 +01:00
.github/workflows Label the published image with the commit it was built from 2026-08-08 16:59:02 +01:00
ontime Push the board over SSE instead of polling it from every page 2026-08-05 16:18:03 +01:00
scripts Draw the route lines along the road 2026-07-30 19:48:57 +01:00
tests Stop the test harness disagreeing with the code it tests 2026-08-08 23:16:30 +01:00
.dockerignore Live departures dashboard for three Manchester bus stops 2026-07-27 00:34:25 +01:00
.env.example Set the journal mode only where it can be set 2026-07-28 01:03:02 +01:00
.envrc Profile the hot paths, cut dead indices and the scipy build 2026-07-27 00:59:43 +01:00
.gitignore Untrack .coverage and ignore tooling caches 2026-07-27 00:59:58 +01:00
CLAUDE.md Push the board over SSE instead of polling it from every page 2026-08-05 16:18:03 +01:00
docker-compose.yml Live departures dashboard for three Manchester bus stops 2026-07-27 00:34:25 +01:00
Dockerfile Treat the feed as hostile and stop telling clients what went wrong 2026-07-28 01:04:25 +01:00
flake.lock Add test suite, fix three bugs it exposed 2026-07-27 00:43:34 +01:00
flake.nix Timestamped logging, and 220MB of image down to 62.4MB 2026-07-27 01:20:33 +01:00
PLAN.md Push the board over SSE instead of polling it from every page 2026-08-05 16:18:03 +01:00
pyproject.toml Stop the test harness disagreeing with the code it tests 2026-08-08 23:16:30 +01:00
README.md Draw the route lines along the road 2026-07-30 19:48:57 +01:00

ontime

A live-departures dashboard for four bus stops in Manchester, built on the Department for Transport (DfT) Bus Open Data Service (BODS).

BODS publishes vehicle positions in the Standard Interface for Real-time Information Vehicle Monitoring profile (SIRI-VM). The standard permits an ExpectedArrivalTime per stop ahead; Greater Manchester publishers do not populate it. A sample of 417 vehicles contained no MonitoredCall, no OnwardCalls and no expected arrival of any kind, and the General Transit Feed Specification Realtime (GTFS-RT) mirror carries positions without trip updates.

The feed answers where is the bus and never when will it reach my stop. Every arrival time here is computed locally: each vehicle is matched to a timetabled trip, located in that trip's stop sequence, and the remaining segments are summed. Observed traversal times accumulate over time and replace the timetabled gaps once a segment has enough samples.

The map

Below the departure boards sits a map of the same data: a pin for each watched stop, and a dot for every vehicle the matcher has placed on a trip, rotated to the bearing the feed reports. Route lines follow the road.

They did not always. The Bus Open Data Service (BODS) publishes none: every one of the 1,261 watched trips carries an empty shape_id, as do 70,155 of the North West feed's 111,484, and the TransXChange schema's geometry fields are mostly left empty by operators. So each route was drawn as a chain of straight hops between its stops, 296m at the median — close along a straight road, and wrong at every bend.

OpenStreetMap carries the same services as type=route relations assembled from the roads themselves. scripts/build_route_shapes.py fetches them once, matches each to a cached route, and writes 56KB of polylines into the package; nothing at run time contacts the Overpass API. Across the thirteen relations kept, the median stop now sits 1321m from its route's line.

Relations are matched by proximity rather than by number, because numbers repeat. Asking for ref=41 inside the board's bounding box returns a First Manchester service around Ashton whose stops lie a median 8,598m from anything the watched 41 touches; it is rejected for covering 0.0% of them, where the two genuine relations cover 85.5% and 82.1%. One relation per direction is also what finally separates the 41's Oxford Road workings from its Swinton Grove ones, which share a number and little else.

Two routes keep the old straight lines. The 751 and the 797 run one trip each and have no relation inside the box, so web._route_lines falls back to the longest stop sequence their trips run. The same fallback covers a geometry file that is missing or malformed: the map loses its road detail and nothing else.

The basemap is the one part of this application that talks to a third party. Tiles load from the server named in ONTIME_MAP_TILE_URL, and the page's Content Security Policy permits that origin and no other. Leaflet 1.9.4 is vendored under ontime/static/vendor/ rather than loaded from a content delivery network, which costs 162KB in the image and removes a remote dependency from a dashboard that has to work when something else is down.

What the model knows

Learned traversal times are the part of this project that improves with age, and the part hardest to inspect. /segments reports the state of them: how many of the segments the timetable implies have been observed at all, how many carry the five samples eta.predict requires, and how wide the uncertainty on each median is. It rebuilds the sample vectors the learner was fitted to rather than reading the summary rows, so the page and the running model cannot drift apart.

That denominator is not a constant, and it moved when the Oxford Road stop was added: a weekday cache implied 6,736 segments at three stops and 9,199 at four. Coverage as a percentage therefore fell by about a third overnight without a single observation being lost. A drop across that boundary reads as a bigger timetable, not as a regression.

Intervals are distribution-free, taken from order statistics instead of an assumed shape, because traversal times are bounded below by the road and not bounded above by traffic. The widest interval n samples can offer covers the true median with probability 1 2/2ⁿ, which first clears 95% at n = 6 — one more than the gate requires. The page also counts the hops it could not measure because a stop between two detections went unseen, a gap in watching rather than in the timetable.

The page is deliberately unflattering. A dashboard reporting how much it has learned should make the shortfall the easiest thing on it to read.

Running it

The Nix flake pins the toolchain, so direnv allow is the only setup step. A free key takes a minute to register at data.bus-data.dft.gov.uk.

cp .env.example .env      # add the BODS key
chmod 600 .env
direnv allow              # or: nix develop

python -m ontime.ingest   # build the timetable cache, roughly 4 minutes
python -m ontime.web      # http://127.0.0.1:8000

Under Docker, the compose file runs the dashboard and a maintenance loop over one named volume, so refreshing the 89MB timetable archive never stalls the departure board:

docker compose up -d --build

The published port binds to 127.0.0.1. Exposing it is a separate step — tailscale serve --bg 8000 gives an HTTPS URL reachable only by devices on the same tailnet. Avoid tailscale funnel: it publishes to the open internet and this application has no authentication of its own.

Configuration

Settings are environment variables, listed with their defaults in .env.example. Only BODS_API_KEY is required; it stays in .env and never reaches the browser, because the page calls this application's own endpoints rather than BODS directly.

Changing the watched stops means editing STOPS in ontime/config.py and re-running the ingest.

Testing

pytest -q                     # unit and end-to-end
pytest -q -m live             # hits the real feed, needs a key
ruff check . && ruff format --check .
mypy ontime

Fixtures are cut from real published data rather than invented: tests/fixtures/mini_gtfs.zip is a 30-trip subset of the BODS North West archive and tests/fixtures/siri_sample.xml is a recorded response. scripts/build_fixtures.py regenerates both.

Layout

ontime/config.py       settings, stop definitions, redaction
ontime/logs.py         timestamped logging and the redaction filter
ontime/db.py           SQLite schema
ontime/ingest.py       GTFS download and cache build
ontime/siri.py         feed fetch and parse
ontime/matching.py     vehicle to trip matching
ontime/history.py      observation storage and segment learning
ontime/segments.py     what the learned model knows, and how firmly
ontime/eta.py          arrival prediction
ontime/web.py          poller and HTTP API
ontime/static/         the dashboard pages and vendored Leaflet
ontime/maintenance.py  periodic refresh loop

Accuracy on a straight corridor settles within a minute or two once a fortnight of history has accumulated, and degrades in the way anyone who has waited on Stockport Road would expect. A commercial feed with a real prediction engine would beat it, though not by as much as the price difference suggests.