Atlas API — the complete data reference
Every dataset and every field the Atlas API serves: what it means, which upstream source it comes from, how it is fetched, cleaned, derived and validated, how often the upstream changes, and how often we refresh it. This page is generated from the same pipeline the data ships from — if a field exists in the API, it is documented here.
Overview & conventions
The API has three groups, discoverable from the index:
| Group | Path | What it serves | Backing |
|---|---|---|---|
| current | /api/v1/<dataset> | Today's snapshot of every dataset — the same files the Atlas apps render | Committed store files, edge-cached 5–10 min |
| live | /api/v1/live/<feed> | Real-time bus + road feeds proxied from TfL, plus live bus GPS | TfL Unified API / BODS SIRI-VM, edge-cached 10–120 s |
| history | /api/v1/history/<dataset> | Time-series that accrues in the warehouse — daily reliability, CDC snapshots, sightings, collision history | Self-hosted Postgres via PostgREST (server-side key, never exposed) |
The discovery index — GET /api/v1
Machine-readable self-description; everything on this page is enumerable from it.
| Field | Type | Meaning |
|---|---|---|
| service / version / description / attribution | string | Identity, API version, upstream attribution |
| livePositions | object | { path, note } — pointer to the separate keyed live-GPS endpoint /api/live/vehicles |
| groups | object | { current, history, live }, each { path, url?, note } |
| endpoints | array | One { name, path, url, description } per current-group dataset (a retired live feed additionally carries retired:true in the live group's own index at /api/v1/live) |
Response & error conventions
- Versioned: breaking changes only ever arrive under a new version prefix.
/api/v1shapes are stable; fields are added, not repurposed. - Errors are JSON:
404unknown dataset (body listsavailablenames),405non-GET,410permanently retired feeds (e.g.live/national-highways),502upstream failure,503history store unconfigured. Live-feed parameter errors return400. - Caching: current group
max-age=300, s-maxage=600; live feeds 10–120 s matched to each upstream's real cadence; error responses inherit these TTLs, so a transient upstream 404/502 self-heals within minutes. - Freshness is explicit: every dataset carries
generatedAt(or equivalent), andmanifestis the ledger of when each dataset last refreshed, from which source, with how many rows.
Keys & limits
Callers never need a key. Every endpoint on this page is open. Keys exist only behind the API, server-side, and never reach the browser or the responses:
| Key (name only) | Lives | Powers |
|---|---|---|
| BODS_API_KEY | Cloudflare Pages secret | The BODS SIRI-VM pull behind live/vehicles — all callers share its one 10 s-cached snapshot |
| TFL_APP_KEY | Cloudflare secret + pipeline env | Optional — TfL works keyless; the key only raises our upstream rate allowance |
| WAREHOUSE_URL / WAREHOUSE_ANON_KEY | Cloudflare secrets | The history group's PostgREST connection (anon = read-only role behind row-level security) |
| DVLA_API_KEY | pipeline env only | Vehicle enrichment at build time — never used at request time |
| Limit | Value | Notes |
|---|---|---|
| Per-caller rate limit | none enforced today | Edge caching absorbs load (a whole-network flood collapses to one upstream pull per TTL). A Cloudflare rate-limit rule (~100 req/min/IP baseline) is planned; poll politely — respect the cache TTLs, there is nothing fresher behind them |
| History page size | limit ≤ 1000 · default 200 | Server-capped twice (the API clamps, and PostgREST enforces max 1000 rows); paginate with order + range filters (from/to), there is no offset param |
| Live feed freshness floor | 10–120 s per feed | Matches each upstream's real cadence (BODS asks ≥10 s; TfL caches arrivals 30 s upstream) — faster polling returns the same cached bytes |
| Current-group freshness | 5–10 min edge cache | Data itself changes at most daily (03:17 UTC build) — one fetch per session is usually right; check manifest for staleness |
| Payload sizes | ≤ ~8 MB | Largest: routes-overview (~3 MB), accidents (~2 MB), route-stops (~2.5 MB), crowding-profile (~1.5 MB) — all gzip-compressed on the wire; fetch once and cache client-side |
Every endpoint — the complete inventory
Every URL the API answers. No other paths exist; anything else returns a JSON 404 listing what's available.
| Pattern | Group | Params |
|---|---|---|
| GET /api/v1 | index | — (discovery: all groups + datasets) |
| GET /api/v1/{dataset} | current | — · dataset ∈ the 18 names in the sidebar |
| GET /api/v1/live | index | — (live-feed discovery, incl. retired flags) |
| GET /api/v1/live/status | live | route (optional, comma-separable) |
| GET /api/v1/live/disruptions | live | — |
| GET /api/v1/live/arrivals | live | stop (NaPTAN) or route — one required |
| GET /api/v1/live/road-disruptions | live | — |
| GET /api/v1/live/vehicles | live | line or route (optional, comma-separable) — filters by publishedLine |
| GET /api/v1/live/national-highways | live | — (always 410 Gone — retired) |
| GET /api/live/vehicles | legacy | line — same data as live/vehicles, envelope {live, cached, capturedAt, vehicles[]}; kept for compatibility |
| GET /api/v1/history | index | — (history discovery) |
| GET /api/v1/history/{dataset} | history | limit (≤1000) · order=col.asc|col.desc · plus per-dataset filters (exact whitelist in the history table; filter grammar: route/severity/borough/… are equality, from/to are ≥/≤ on the dataset's date column). Unknown params are ignored, never errors |
| GET /docs | — | this page · GET /llms.txt — the machine-readable summary for AI agents |
How everything joins — the key map
The single most important thing to know: three route-key conventions exist, verbatim from the upstreams. Getting these wrong is the #1 integration bug.
| Key | Example | Used by |
|---|---|---|
| route id lowercase | w12 | routes[].id · route-stops.routes keys · routes-overview properties.routeId · route-classifications keys · route-diversions routes[*].id + upcomingFreeze[] · TfL live URLs (/Line/w12/…) |
| route name as published | W12 | routes[].name · keys of route-meta.routes, fleet.byRoute, tenders.byRoute, crowding.routes, crowding-profile.routes, route-diversions.routes · live publishedLine · history route_id columns (yes — the warehouse's route_id holds the name) |
| route name UPPERCASED | W12 / N136 | route-performance.routes keys only. For numeric routes all three conventions coincide ("25"); for lettered routes always normalise — safest: match case-insensitively |
| NaPTAN stop id | 490011979N | route-stops stop id ↔ live arrivals naptanId ↔ diversion missedStops/addedStops id ↔ history schedule qsi_point_stop_ids / timing_point_stop_id. Nationally unique — also joins to any NaPTAN/BODS dataset |
| registration | LK67ENF | fleet regs[] ↔ vehicles.byReg keys ↔ live GPS reg ↔ live arrivals vehicleId ↔ history vehicle-sightings registration |
| garage code | AW | route-meta garage ↔ garages code ↔ history garage_code |
| direction | "1" / "2" | "1" = outbound, "2" = inbound — in routes-overview properties.direction, live vehicle direction, crowding direction. The word forms outbound/inbound key route-stops and the diversion stop/segment maps |
| borough | E09000001 vs "Barking & Dagenham" | Inconsistent upstreams, preserved honestly: accidents.borough is the ONS district code; bridges.borough and garage-snapshots carry names. Map codes↔names with any ONS lookup if you need to join them |
| tender id | btID "2179" | tenders.byId keys ↔ every award object's btID |
Vocabularies & formats — every closed value set
| Field(s) | Complete value set |
|---|---|
| type / routeType | regular · night · twentyfour · school. (The apps additionally display a "prefix" class for lettered routes — that's derived client-side from the name, not a value in this field) |
| severity (accidents) | fatal · serious · slight |
| statusSeverity (live/status) | 0–20 TfL scale; bus values in practice: 10 Good Service · 0 Special Service (diversions) · 3 Part Suspended · 4 Suspended · 5 Part Closure · 6 Severe Delays · 9 Minor Delays. Treat ≠10 as "not normal", read statusSeverityDescription for the label |
| band / crowding bands | comfortable (<0.5) · moderate (<0.65) · busy (<0.8) · crowded (≥0.8) — thresholds also shipped in the payload's bands |
| geometryStatus | published · unpublished |
| baselineSource | store · ibus:YYYYMMDD |
| serviceClass / service_class | high-frequency · low-frequency (occasionally null when TfL's PDF omits it) |
| propulsion | electric · hybrid · hydrogen · diesel (route-meta/fleet buckets); vehicles[].propulsion may additionally yield gas or null from raw DVLA fuel strings |
| structure_type | bridge · tunnel · barrier |
| postcodeSource | garage · override · company · null |
| day / timeBand (accidents) | Mon…Sun · AM peak/Inter-peak/PM peak/Evening/Night |
| dayType (crowding) | Weekday · Saturday · Sunday |
| manifest status | ok · failed |
| jb.isJoint / was_joint_bid basis | vehicle.basis: new · existing · null; vehicle.deck: double · single · null |
| Format rule | Detail |
|---|---|
| Timestamps | ISO 8601 UTC everywhere — except the verbatim-preserved upstream strings: route-meta.contractDate (DD/MM/YY), tenders awardDate (16 January 2024), crowding time (HH:MM:SS) and timeOfDay t (HH:MM) |
| Coordinates | WGS84. GeoJSON and diversion segments are [lng,lat] (GeoJSON order); stop/vehicle/bridge/accident objects use named lat/lng fields (except history garage-snapshots: lon). Live road-disruptions point is a JSON-encoded string "[lng,lat]" |
| Units | distance km (bridges: metres, 2 dp) · wait times minutes · reliability % · crowding V/C ratio (0–2) · money £ (bids annual, costPerMile per live mile) · speedLimit is a display string ("30 mph") |
| Casing | current group = camelCase · history group = snake_case · GeoJSON = GeoJSON |
| Null semantics | null always means "not known / not published / not parseable" — never zero. Numeric zero is a real measurement |
Worked examples
# discover everything curl -s https://atlas.farhan.app/api/v1 | jq ".endpoints[].name" # everything about one route (note the key conventions: name for meta/perf/crowding, id for stops/geometry) curl -s https://atlas.farhan.app/api/v1/route-meta | jq ".routes.W12" curl -s https://atlas.farhan.app/api/v1/route-performance | jq ".routes.W12" curl -s https://atlas.farhan.app/api/v1/route-stops | jq ".routes.w12.outbound[:3]" curl -s https://atlas.farhan.app/api/v1/tenders | jq ".byRoute.W12[0]" # which routes are diverted right now, and where does W12 actually go? curl -s https://atlas.farhan.app/api/v1/route-diversions | jq ".count, (.routes | keys[:10])" curl -s https://atlas.farhan.app/api/v1/route-diversions | jq ".routes.W12 | {status, until, geometryStatus, missed: [.missedStops[][].name]}" # live: buses on a route · arrivals at a stop curl -s "https://atlas.farhan.app/api/v1/live/vehicles?line=W12" | jq ".count, .data[0]" curl -s "https://atlas.farhan.app/api/v1/live/arrivals?stop=490011979N" | jq ".data[0] | {lineName, destinationName, timeToStation}" # history: reliability trend · what changed on a route over time curl -s "https://atlas.farhan.app/api/v1/history/reliability-daily?route=W12&from=2026-08-01" | jq ".rows[] | {day, ewt_minutes, sample_count}" curl -s "https://atlas.farhan.app/api/v1/history/route-snapshots?route=W12&limit=30" | jq "[.rows[] | {snapshot_date, operator, propulsion, pvr}] | unique_by(.operator, .propulsion, .pvr)"
For AI agents
A condensed, machine-digestible version of this page is served at GET /llms.txt (llms.txt convention). Deterministic rules an agent should hold:
- Enumerate, don't guess:
GET /api/v1,/api/v1/live,/api/v1/historylist every valid name; a 404 body listsavailablenames. - Key normalisation: to look a route up across datasets, carry both
id(lowercase) andnamefromroutes, and matchroute-performancekeys case-insensitively (see the key map). - Freshness: read
manifestonce; trustfetchedAt+status. Data changes at most daily except the live group. - Never poll faster than the cache TTLs (they match upstream reality); batch questions against one fetched payload instead of re-fetching.
- Nulls are honest — absent knowledge, not zero. Don't impute.
- Shape stability: fields are added, never repurposed, under
/api/v1; a permanently removed feed answers410with analternative. - Provenance: every dataset documents its upstream here; when citing figures, TfL's QSI (
route-performance) is official — Atlas's own reliability estimate (history/reliability-daily) is experimental and must be labelled as such.
How data flows — Fetch → Clean → Validate → Store → Serve
Nothing is served straight from a scrape or a live pull. Every dataset moves through the same five-stage pipeline, run unattended every night:
03:17 UTC atlas-refresh (VPS scheduled task) clones main and runs the pipeline: for each dataset: fetch upstream → clean/normalise → validate (hard gate) → write store JSON diversions builder runs FIRST and freezes the canonical baselines of diverted routes validate-atlas.js (50 checks) gates the commit — a failing dataset keeps its last-good file bot commits data/*.json → push → Cloudflare Pages redeploys the API + apps automatically 08:37 UTC daily fleet sample (warehouse) ingest pipeline, decoupled — can never block the API 09:23 UTC warehouse refresh: CDC snapshots per route/garage + reference mirror (incl. diversions history) every 30m live arrival/headway sampler (service hours) → our own reliability estimates
- Last-good retention: a failed or implausible fetch never overwrites a good file. Failure is recorded in the
manifest(status:"failed",lastError) while the previous data keeps serving. - Validation is a hard gate, not a warning: row-count bands, required-field checks, coordinate bounds, totals reconciliation and dataset-specific sanity checks all run before a byte lands (see Integrity).
- Degraded-feed gates: TfL's bulk status feed intermittently reports the entire network as Good Service while per-line endpoints still carry disruptions. Status-consuming builders detect the signature (zero disruptions after a run that saw many), retry once, then keep last-good.
- Idempotent + atomic: re-runs produce the same state; files are written via temp-file + rename so a killed run never leaves a torn file.
- Decoupled warehouse: the history warehouse ingests on its own schedule and writes only to Postgres — a slow or failed ingest can never block or corrupt the static API.
Refresh cadences at a glance
Polling matches each upstream's real publication rhythm — a quarterly PDF is not fetched hourly, and a 30-second feed is not cached for a day.
| Dataset | Upstream publishes | We refresh | Skip logic (politeness) |
|---|---|---|---|
| routes · route-stops · routes-overview · route-classifications | network revisions land within a day | daily | conditional requests; per-route failures don't abort the run |
| route-diversions | live status ~30 s · sequences on change | every run (daily) | sequence diffs only for flagged routes; iBus fetched only when a recovery is needed |
| route-meta · garages | daily (londonbusroutes.net) | daily | geocodes cached; overrides pinned |
| fleet · vehicles | continuous (live arrivals) · DVLA static | daily sample | DVLA per-run cap + 429 backoff; hit/miss cache persists |
| tenders | event-driven awards | daily incremental | append-only by tender id — only NEW ids fetched |
| route-performance | ~4-weekly (QSI) · quarterly (MPS) | daily check | Last-Modified skip makes the daily check ~free |
| accidents (STATS19) | annual | monthly re-check | bulk CSV, conditional |
| bridges (EPOWR) | annual | monthly re-check | single xlsx |
| crowding · crowding-profile (BUSTO) | annual | monthly re-check | ~98 MB CSV streamed, skipped when year unchanged |
| localities (OSM) | rarely | monthly | single Overpass query |
| line-status | ~30 s (TfL cache) | every run | stored snapshot is the offline fallback tier; apps read TfL live |
routes reference daily
The route catalogue — every bus line TfL currently registers. Non-public placeholder lines (the ZZ* test/rail-replacement designations) are filtered out so counts reconcile across datasets. Sorted numerically then lexically (2 before 10, letters after).
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| id | string | TfL line id — lowercase, e.g. w12. The join key for route-stops, routes-overview and live TfL calls. | TfL verbatim |
| name | string | Public route number, e.g. W12. The join key for route-meta, fleet, tenders, crowding, route-diversions. | TfL verbatim |
| type | string | regular · night · twentyfour · school | Derived: N-prefix → night; 6xx/9xx → school. 24-hour isn't in any free feed — set from a curated overrides file. Lettered/express classification is refined app-side (prefix). |
Validation: 400–2000 rows; every row must carry id, name, type. Failure keeps last-good.
route-meta reference daily
Operational metadata per route — who runs it, from which garage, with how many buses, on what vehicle type and contract. Sourced from the long-running londonbusroutes.net reference (the community authority on garage allocations), scraped in the pipeline only — never at runtime — and joined against the TfL route list.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| type | string | Route type (same vocabulary as routes.type) | as routes |
| operator | string | Operating brand, e.g. Stagecoach London | Scraped operator string canonicalised via the operator-alias table (legal suffixes stripped, subsidiary variants rolled up to the parent brand) |
| company | string | The legal operating company, e.g. East London Bus & Coach Co. Ltd. | Scraped verbatim (the raw counterpart to operator) |
| propulsion | string|null | electric · hybrid · hydrogen · diesel | Parsed from the vehicle-type string; then reconciled against the DVLA-enriched live fleet: a fleet sample ≥4 vehicles that is ≥75% zero-emission upgrades a stale diesel/null (DVLA reliably identifies zero-emission but mis-reports hybrids as diesel, so hybrid-vs-diesel stays this source's call). Guarded by a regression check in the validator. |
| garage | string|null | Garage code(s), e.g. AW | garages.csv allocation |
| garageName | string|null | Garage display name, e.g. Walthamstow Avenue | joined from the garages dataset |
| pvr | number|null | Peak Vehicle Requirement — buses needed to run the peak timetable | Scraped; the operationally-current figure (tender awards freeze PVR at award time — see tenders) |
| fleet | string|null | Rostered vehicle spec, e.g. E100EV/Enviro100EV 8.5m | Scraped verbatim |
| lengthKm | number|null | Route length, km | Scraped; cross-checkable against routes-overview.lengthKm (haversine) |
| contractDate | string|null | Contract spec date, DD/MM/YY | Parsed from details.htm — the last date on the route's row; a lone date on a night-route row that defers to its day route (See …) is a timetable date and deliberately not read as a contract date |
| contractStart / contractEnd | string|null | Contract window, YYYY-MM | Currently a curated map covering 8 flagship routes only — an interim measure until the Find-a-Tender OCDS ingester lands; null elsewhere |
| source | string | Provenance marker (londonbusroutes.net) | constant |
Coverage: every current route (676/676 at last audit). TfL-sourced values are never overwritten by scraped ones — scrape fills only what TfL doesn't publish.
route-classifications reference daily
A convenience projection of the route list keyed by lowercase line id — the lookup the map filter layer uses. Note the shape: unlike most keyed datasets there is no { routes: … } envelope — the response is the map. Fields name and type are identical to routes; same derivation, same validation, written by the same builder in the same run (they can never drift apart).
route-stops reference daily diversion-frozen
The ordered stop sequence for every route, both directions. Stops are de-duplicated by NaPTAN id across branch variants while preserving order. Canonical-baseline rule: when a route is on (or within 14 days of) an active diversion, its entry is frozen at the last pre-diversion state — TfL temporarily rewrites the sequence during diversions, and absorbing that would silently redefine the route. The temporary state is served separately in route-diversions; the freeze self-heals when the episode ends.
| Field (per stop) | Type | Meaning | Source & processing |
|---|---|---|---|
| id | string | NaPTAN stop-point id, e.g. 490011525W — nationally unique, joinable to any NaPTAN dataset and to live arrivals | TfL verbatim |
| name | string | Public stop name | TfL verbatim |
| lat / lng | number | WGS84 position | TfL verbatim |
| lines | array[string] | Every route serving this stop (by name) | TfL stop-point line list |
Validation: a full run must yield stop sequences for ≥400 routes or the write is refused (a wholesale sequence failure can't blank the file).
routes-overview reference daily diversion-frozen
Route line geometry for the whole network — one LineString feature per route per direction. Geometry is simplified (Ramer–Douglas–Peucker, tolerance 0.0005° ≈ ≤55 m) and coordinate-rounded to 4 dp (~11 m) so the entire network renders instantly; the un-simplified length is computed before simplification. The same diversion freeze as route-stops applies. An independent cross-check against TfL's iBus scheduling geometry is part of the source health sweep.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| metadata.generatedAt | ISO datetime | Build timestamp | pipeline |
| metadata.routeCount / featureCount | number | Routes attempted / features written | pipeline; a run where >30% of routes lose geometry is refused as an upstream outage |
| metadata.partial | boolean | true only for capped dev builds — always false in production | pipeline |
| metadata.simplificationTolerance / coordinatePrecision | number | The exact simplification parameters (0.0005° / 4 dp) so consumers can reason about positional error | pipeline constants |
| properties.routeId / name | string | Line id (lowercase) / public route number | TfL |
| properties.direction | string | "1" outbound · "2" inbound | TfL direction encoding, validated to exactly {1,2} |
| properties.routeType | string | Route type | as routes.type |
| properties.lengthKm | number | Haversine length of the un-simplified line, 0.1 km precision | computed pre-simplification (unit-tested against an independent implementation) |
| properties.stops | number | Stop count for this direction | same sequence call |
| geometry.coordinates | [lng,lat][] | The simplified line | RDP + rounding as above |
line-status snapshot every run
The most recent stored network-status snapshot. The apps read live status from TfL directly (or via /api/v1/live/status); this file is the graceful-degrade tier when live is unreachable, and the historical record's source. Two important interpretations are applied:
- Validity windows are checked by date, not by TfL's
isNowflag — TfL routinely publishes in-progress works withisNow:false, which would misreport active diversions as Good Service. A status counts only if its window brackets the current time (or it has no window). - Degraded-feed gate — a snapshot reporting zero disrupted routes after one that saw many is a known TfL bulk-feed failure mode, and is rejected (retry once, keep last-good).
ZZ*placeholder lines are dropped; rows sort worst-severity first.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| capturedAt | ISO datetime | Snapshot time | pipeline |
| summary.total / good / disrupted | number | Network tallies (validated: parts sum to total) | computed |
| rows[].route | string | Route name | TfL |
| rows[].status | string | TfL severity description active now, e.g. Good Service, Special Service, Severe Delays | window-filtered as above |
| rows[].reason | string | TfL's prose explanation (empty for Good Service) — names roads, dates and skipped stops | TfL verbatim |
| rows[].severity | number | TfL statusSeverity (10 = Good Service; 0 = Special Service; lower = worse) | TfL verbatim |
route-diversions derived every run
Active diversion episodes with real diverted geometry. TfL publishes no structured diversion data (its affectedStops/affectedRoutes arrays are empty), but for planned diversions it redraws the route's sequence — so the truth is recovered by diffing TfL's current Route/Sequence against our frozen canonical baseline. Detection uses date-window checking (TfL's isNow is unreliable) plus a diversion-language text signal; both the degraded-feed gate and per-route last-good retention apply. Three baseline tiers:
- store — the frozen canonical baseline (the normal case; the freeze also engages up to 14 days before a window opens, because TfL redraws ~10 days in advance).
- ibus:<version> — recovery tier: if a baseline was already polluted (missed stops fire but no geometry diff), the pipeline walks TfL's dated fortnightly iBus schedule drops and diffs against the first version whose line passes all the episode's missed stops — a self-validating criterion.
- unpublished — TfL hasn't redrawn (typically short-notice/emergency works): the episode still carries status, reason, window and any stop diffs; the map app supplements with a live-GPS estimated path, clearly labelled.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| generatedAt / count | ISO / number | Build time · active episode count (validated: count = routes, ≤400) | pipeline |
| upcomingFreeze | array[string] | Line ids frozen ahead of a window opening within 14 days (not yet active episodes) | derived from validity windows + diversion text |
| routes[name].id | string | Lowercase line id | TfL |
| routes[name].status / severity | string / number | Worst active TfL status for the route | TfL, window-filtered |
| routes[name].disruptions[] | array | Each active status: reason (TfL prose), category, since/until (earliest/latest window bounds, ISO, nullable) | TfL validity periods, date-parsed |
| routes[name].since / until | ISO|null | Overall episode window (min/max across disruptions) | derived |
| routes[name].detectedAt | ISO datetime | First pipeline run that saw this episode — stable across the episode's life; the warehouse history key | carried over run-to-run |
| routes[name].geometryStatus | string | published (segments present) · unpublished | derived; validated vocabulary |
| routes[name].baselineSource | string | store or ibus:<YYYYMMDD> — which baseline the diff ran against | see tiers above |
| routes[name].missedStops.{outbound,inbound}[] | array | Stops on the canonical route not currently served — id, name, lat, lng | baseline stop-set minus current stop-set, by NaPTAN id |
| routes[name].addedStops.{…}[] | array | Temporary stops served only during the diversion (same shape, in sequence order — they trace the diversion path) | current minus baseline |
| routes[name].diversionSegments.{…} | [[lng,lat][]] | The diverted path — segments of the current line deviating >75 m from the baseline, extended one point each side to rejoin visually. Noise-filtered: leave→rejoin separation ≥150 m of bypassed roadway (or ≥400 m travelled for loops); ≤12 segments/direction; 5 dp coords; every coordinate validated inside Greater London | geometric diff (threshold sits above the ≤55 m simplification noise, verified empirically) |
| routes[name].bypassedSegments.{…} | [[lng,lat][]] | Baseline sections not currently served (the closed roadway) — same filtering | reverse diff |
Guarantee: a temporary diversion never redefines the canonical route in route-stops/routes-overview — the freeze holds the last-good baseline for flagged routes and releases it automatically when the episode ends. Ended episodes leave this dataset but remain forever in the warehouse's route_diversions table (one row per route_id, detected_at), which accrues into a permanent diversion history.
garages reference daily
Every London bus garage with position, operator, allocation and utilisation. Tramlink depots are excluded (their lines 1–4 would collide with bus routes 1–4). A stale-duplicate rule drops a routeless garage sitting <150 m from an active one (e.g. a depot listed under both a former and current operator's code); garages that fail to geocode are kept without coordinates so route→garage joins still work.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| code | string | LBR garage code, e.g. AW | garages.csv |
| name | string | Garage name | garages.csv |
| operator / company | string | Operating brand / legal company | garages.csv, operator normalised to the parent brand |
| postcode | string|null | Postcode used for geocoding | Regex-extracted from the garage address |
| postcodeSource | string|null | garage · override · company — which address won | Precedence: garage address → curated per-code override → company/HQ address |
| lat / lng | number|null | WGS84 position | postcodes.io bulk geocoder (one batched pass per run); validated not-all-null so a failed geocode run can't blank coordinates |
| pvr | number|null | Total PVR allocated to this garage | garages.csv |
| capacity | number|null | Approximate bus capacity | curated per-garage file (community-sourced) |
| utilisation | number|null | pvr ÷ capacity, 3 dp | computed only when both known |
| routes | array[string] | Routes allocated here | garages.csv; first listing wins as a route's primary garage |
fleet sampled daily
The fleet actually running each route, observed live. Registrations come from TfL's arrival predictions (each prediction carries the vehicle id); DVLA's Vehicle Enquiry Service then enriches each registration with make, year and fuel. Important honesty note: this is a point-in-time sample of vehicles on the road at build time — a route's count is "seen this run", not its full roster (the warehouse accrues the roster over time). The DVLA lookup set includes every historically-rostered registration, not just today's, so new plates fill in regardless of timing.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| enriched | boolean | Whether DVLA enrichment ran this build (requires the server-side key) | pipeline |
| byRoute[r].regs | array[string] | Registrations observed on the route this run | TfL /Line/{id}/Arrivals vehicleIds, deduped, uppercased; concurrency-capped fetch |
| byRoute[r].count | number | Distinct vehicles observed | derived |
| byRoute[r].avgAgeYears | number|null | Mean vehicle age, 1 dp | current year − DVLA yearOfManufacture, years outside 1990–now discarded |
| byRoute[r].propulsion | object | {electric, hydrogen, hybrid, diesel} counts | DVLA fuelType bucketed (HEAVY OIL→diesel, FUEL CELL→hydrogen, any *HYBRID*→hybrid). DVLA mis-reports many hybrids as diesel — which is why route-meta.propulsion stays authoritative for hybrid-vs-diesel |
| byRoute[r].makes | array | Top-5 {make, n} chassis makers | DVLA make cleaned to display brands (ALL-CAPS chassis strings → e.g. Alexander Dennis, body-noise stripped) |
| byRoute[r].enriched | number | How many regs carried valid DVLA data | derived |
DVLA politeness: one lookup per registration ever (hits and misses both cached persistently), ~220 ms between calls, capped per run (currently 5,000), exponential backoff on 429 with a hard stop that resumes next run. Validation: ≥400 routes present; at least one route must have live vehicles or the write is refused.
Field presence: every route record carries route, regs, count; the enrichment fields (avgAgeYears, propulsion, makes, enriched) appear only on routes that had vehicles observed in the sample (currently ~179 of ~640 — night/school routes often sample empty outside their operating hours; the build runs at 03:17 UTC). The propulsion counts cover electric/hydrogen/hybrid/diesel only — gas-fuelled results are not counted (no gas buses operate TfL routes), so the four buckets may sum to slightly less than count when regs lack DVLA data.
vehicles derived daily
The vehicle register: fleet turned inside-out, keyed by registration. Purely derived — zero network calls. A registration appearing on more than one route in the same snapshot is a cross-route working; day-to-day fleet moves are recovered from the warehouse's daily snapshots, not from this file.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| reg | string | Vehicle registration mark | fleet |
| routes | array[string] | Routes this vehicle was observed on (deduped, sorted) | fleet inversion |
| operator | string|null | Operator inherited from its route(s) | route-meta join |
| make / year / fuel / propulsion | varies | Chassis maker (cleaned) · build year · raw DVLA fuel string · derived bucket | DVLA cache; present only for enriched regs |
Validation: ≥100 registrations. If the fleet dataset is missing the builder skips without writing — it can never blank the register.
tenders reference daily · incremental
The complete TfL route-tendering award history. Awards are immutable once published, so the store is append-only and incremental — a daily run fetches only award ids it hasn't seen. Multi-route awards (1/N1) are split so each route carries the award. byRoute lists are sorted newest-first; the raw byId cache is never mutated.
| Field (per award) | Type | Meaning | Source & processing |
|---|---|---|---|
| btID | string | TfL's award id — the incremental-fetch key | TfL |
| route | string | Route(s) as published, e.g. W12 or 1/N1 | TfL verbatim |
| operator | string | Winning operator, canonicalised to the parent brand | operatorRaw preserves the published subsidiary name |
| numberOfTenderers | number|null | How many bids were received | Accepts TfL's English number-words ("three") as well as digits |
| acceptedBid / lowestBid / highestBid | number|null | The bid spread, £/year | Parsed from the award table |
| costPerMile | number|null | £ per live mile | Two comma conventions in the same TfL column are disambiguated (4,205,196 thousands vs 6,25 European decimal); values outside £0–200 are nulled — TfL sometimes pastes the annual bid into this cell |
| jointBid / jb | string|null / object|null | Raw joint-bid text · derived {isJoint, partners[], total, raw} | Partner routes extracted from the prose (token-validated as route numbers, own variants excluded); total = the first £ amount |
| notes / vehicle | string|null / object|null | Award notes · derived {basis, propulsion, deck, lowFloor, euro, summary} | Vehicle spec parsed from the prose: basis new/existing, propulsion with precedence hydrogen→hybrid→electric→diesel (diesel only when neither "electric" nor "hybrid" appears), deck, Euro standard. Everything degrades to null, never a guess |
| tranche | number|null | LBSL tendering tranche | First tranche N found in joint-bid text, then notes |
| awardDate | string|null | Award date, e.g. 16 January 2024 | First long-form date on the award page |
Validation: the award count may never shrink (append-only guarantee) — a shrunken result refuses to overwrite last-good. The derived fields (jb/vehicle/tranche) are computed identically in the warehouse ingester, kept in deliberate lockstep.
route-performance official stats daily check · ~4-weekly data
TfL's published reliability figures — the authoritative Quality of Service Indicators — extracted from TfL's own PDFs (there is no API for these). Position-aware PDF text extraction reconstructs the real table rows; a sticky table-shape detector tells high-frequency pages (EWT/SWT) from low-frequency ones (on-time %). Numbers are sanity-capped (SWT/AWT ≤ 60 min, EWT ≤ 30, percentages ≤ 100) and anything unparseable becomes null, never a guess. Covers ~738 route ids — more than the current network, because TfL publishes for some night/school variants too.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| periodLabel | string | QSI period, e.g. Q4 25/26 | parsed from the quarter PDF |
| pdfModifiedAt | ISO datetime | Upstream PDF's Last-Modified — the change-detection key | HTTP HEAD; an unchanged PDF skips the whole download |
| routes[R].serviceClass | string | high-frequency (measured by EWT) · low-frequency (measured by on-time %) | table-shape detection; falls back to the route's MPS PDF class |
| routes[R].ewtMinutes / swtMinutes / awtMinutes | number|null | Excess / Scheduled / Actual Wait Time (high-freq). EWT = AWT − SWT — the headline reliability metric | QSI PDF |
| routes[R].onTimePercent | number|null | % departures on time (low-freq) | QSI PDF |
| routes[R].mileagePercent | number|null | % scheduled mileage operated (latest period, from the 13-period MPS table) | MPS PDF; the 13-value row must parse completely, all values 50–100, or it is rejected |
| routes[R].ewtMps / otpMps / mileageMps | number|null | The contractual Minimum Performance Standard each figure is judged against | per-route MPS PDF, 28-day sticky cache (one TfL period), ≤1,000 PDFs per run rotating oldest-first; genuine 404s (e.g. school routes) cached so they aren't re-tried; parse errors retry next run |
Fallbacks: QSI and MPS halves fail independently, each keeping its last-good values. Validation: 400–2000 routes. Atlas's own higher-frequency reliability estimate is a separate, clearly-labelled dataset — see history · reliability-daily.
accidents official stats monthly check · annual data
Bus/coach-involved road collisions in Greater London from the national STATS19 casualty statistics. Two CSV tables per year are streamed (never buffered — 100k+ rows/year): the vehicle table first, collecting collision ids where a bus, coach or minibus was involved (vehicle types 10/11), then the collision table filtered to those ids, to finite coordinates inside the London bounding box, and to a decodable severity. All coded attributes are decoded to human labels; an unknown code becomes null, never a wrong label (e.g. the historic junction column is preferred because the newer one uses an incompatible code set).
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| sample | boolean | false = real STATS19 data. true only if the live pull and last-good both failed — a clearly-labelled deterministic sample keeps the UI honest instead of blank | three-tier fallback: live (≥200 rows) → last-good live file → labelled sample |
| period / count / bbox | varies | Year range of kept rows · row count · geographic extent | computed over kept rows |
| id | string | STATS19 collision index — stable national id | DfT verbatim |
| lat / lng | number | Collision location, 6 dp | DfT; bbox-validated |
| severity | string | fatal · serious · slight | decoded 1/2/3; validated vocabulary; severity split must sum to total |
| date / day / timeBand | string | ISO date · Mon…Sun · AM peak / Inter-peak / PM peak / Evening / Night | date reformatted from DD/MM/YYYY; day decoded (STATS19 counts Sunday=1); time bucketed <6/<10/<16/<19/else |
| borough | string|null | ONS district code, e.g. E09000001 | DfT local_authority_ons_district; -1 → null |
| vehicles / casualties | number|null | Vehicles involved · people injured | kept only when finite and >0; casualties validated positive-integer |
| roadType / speedLimit / junction / light / weather / roadSurface | string|null | Decoded collision context — the "aggregate by" dimensions in the apps | each decoded from its STATS19 code table; speed limits accepted only 20–70 → "N mph"; context null-rates monitored by the validator |
bridges reference monthly check · annual data
Every height-restricted structure in London — the low-bridge layer and the double-deck diversion-risk input. The workbook is discovered through the Datastore's dataset API (the landing page is JS-rendered) with a pinned TfL fallback URL, and parsed with a dependency-free xlsx reader. The safety-critical rule: published heights are bands ("Between 4.6 and 5.1"); we always normalise to the band's lower bound — the guaranteed clearance, never an optimistic one. Imperial values (9'10") are converted exactly (ft + in/12 × 0.3048).
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| id | string | OS grid reference (or a synthetic id when absent) | EPOWR |
| lat / lng | number | WGS84 position | File's own lat/lng preferred; a full OSGB36→WGS84 Helmert transform runs only when they're missing; rows without usable coords are dropped |
| height_m | number | Guaranteed clearance in metres (band lower bound, 2 dp). <4.4 m = below double-deck height | see rule above |
| height_imperial | string|null | The published imperial band, verbatim | EPOWR |
| name / road | string|null | Structure / road name | EPOWR |
| structure_type | string | bridge · tunnel · barrier | classified from road + comments text |
| borough | string|null | Borough name | EPOWR |
Same three-tier fallback as accidents (live ≥50 → last-good live → labelled sample).
crowding official stats monthly check · annual data
How full each route gets, from TfL's BUSTO demand model. The newest year folder is auto-discovered on TfL's crowding bucket and the ~98 MB CSV is streamed line-by-line, reduced to one record per route: the peak V/C (load ÷ capacity) across every stop × timeband × direction, plus the per-day-type peaks. Stop names can contain commas, so the numeric columns are read right-anchored — a classic CSV trap handled explicitly. Band thresholds ship in the payload so consumers never hardcode them: comfortable <0.5 · moderate <0.65 · busy <0.8 · crowded ≥0.8.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| year / sourceFile | string | BUSTO year (e.g. 2025-2026) · the exact S3 object parsed | discovery |
| bands | array | {key, label, max} banding thresholds (max:null = unbounded) | pipeline constants, shared with the apps |
| routes[r].peakVC | number | Peak volume/capacity at the max-demand hour, 4 dp — the headline number | max over all cells; validated ∈ (0,2] and ≈ load÷capacity |
| routes[r].band | string | Band for peakVC | validated against the thresholds |
| routes[r].load / capacity / seats / boardings | number | Passengers · total capacity · seats · boardings/hr at the peak cell | BUSTO, 1 dp |
| routes[r].dayType / time / timeband / direction | varies | When and which way the peak occurs | BUSTO |
| routes[r].stopcode / stopname / stopSeq | varies | The busiest stop | BUSTO |
| routes[r].maxLoad / maxCapacity | number | Network-normalisation extremes for the route | computed |
| routes[r].byDay.{Weekday,Saturday,Sunday} | object | Per-day-type peak: {vc, load, capacity, time, stopname} | reduced per day type |
School (6xx/9xx) routes are absent from BUSTO — coverage ~606 of ~640 routes is expected, not a defect. Fallback: last-good only (no synthetic sample for crowding).
crowding-profile official stats monthly check · annual data
The per-route detail deliberately split out of crowding so the network layer stays light — apps lazy-load this only when a route is selected. Same source, same build, same validation run.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| routes[r].profileDir | string | Direction the load profile describes (the busiest one) | direction of the route's peak cell |
| routes[r].loadProfile | array | {seq, name, vc} per stop in sequence — V/C along the route; powers the corridor gradient. vc:0 = empty terminus | per-stop maxima in the peak direction, sequence-sorted, 3 dp; validated ∈ [0,2] |
| routes[r].timeOfDay | object | { [dayType]: [{t:"HH:MM", vc}] } — the demand curve through the day | per-timeband maxima, sorted by clock time (BUSTO's timeband index is not time-ordered — validated chronological) |
localities reference monthly
Town and suburb labels for the map's "Place names" layer — one bounded Overpass query for place=town|suburb nodes across Greater London. Nameless nodes and names >60 chars dropped; duplicates collapsed by name within a ~1 km cell; coordinates rounded to 5 dp; alphabetically sorted.
| Field | Type | Meaning | Source & processing |
|---|---|---|---|
| name | string | Place name | OSM name tag |
| lat / lng | number | Label anchor | OSM node, 5 dp, bbox-clamped |
| kind | string | town (labels from z10) · suburb (joins at z12) | OSM place tag |
manifest meta every run
The pipeline's own ledger — the machine-readable answer to "how fresh is this?". One entry per dataset. A failed refresh is recorded here while the previous data keeps serving, so consumers can detect staleness honestly.
| Field | Type | Meaning |
|---|---|---|
| datasets[d].source | string | Human-readable upstream description |
| datasets[d].fetchedAt | ISO datetime | When this dataset last refreshed successfully |
| datasets[d].status | string | ok · failed (last-good still serving) |
| datasets[d].lastError / lastAttempt | string | What broke, when. Note: these persist after recovery (entries merge run-over-run), so a record can carry status:"ok" alongside an old lastError — always judge health by status + fetchedAt, not by the presence of lastError |
| datasets[d].rows / files / cadence / note | varies | Row count · files written · refresh cadence · builder notes (e.g. enrichment counts) |
The live group — /api/v1/live real-time
Live feeds proxied through our API so external callers get the same real-time view the apps do — CORS-open, keyless, edge-cached so a flood of callers collapses to a trickle of upstream pulls. Every response wraps the payload as { feed, capturedAt, data }.
| Endpoint | Cache | Params | Returns · source |
|---|---|---|---|
| GET /live/status | 30 s | ?route=25 or 25,86; omit = whole network | TfL line status + disruption reason + validity windows, verbatim from /Line/…/Status. Note: interpret windows by date, not TfL's isNow flag (see line-status) |
| GET /live/disruptions | 60 s | — | Active bus-line disruptions (description + affected stops), /Line/Mode/bus/Disruption |
| GET /live/arrivals | 30 s | ?stop=<naptanId> or ?route=<id> (one required → else 400) | Live arrival predictions; TfL caches this feed 30 s upstream — polling faster gains nothing |
| GET /live/road-disruptions | 60 s | — | London road incidents/closures from TfL's traffic control centre (~5 min upstream cadence), /Road/all/Disruption |
| GET /live/national-highways | — | — | Retired — always 410 Gone. National Highways withdrew the keyless RSS this proxied; use road-disruptions |
| GET /live/vehicles | 10 s | ?line=25 / ?line=25,86; omit = whole network | Live bus GPS (BODS SIRI-VM, Greater London bounding box). All callers share ONE 10-second-cached upstream snapshot — the BODS key stays server-side. Also at the legacy /api/live/vehicles |
What's inside the TfL passthrough payloads
The status, disruptions, arrivals and road-disruptions feeds carry TfL's payload verbatim inside data (every object also carries a TfL $type marker — ignore it). The fields consumers actually need:
| Feed | Key fields per record |
|---|---|
| status | Per line: id, name, modeName, lineStatuses[]. Per status: statusSeverity (10 = Good Service, 0 = Special Service, lower = worse), statusSeverityDescription, reason (prose — names roads, dates, skipped stops), validityPeriods[] { fromDate, toDate, isNow }, disruption { category, categoryDescription, description, created, affectedRoutes, affectedStops }. Caveats we verified: judge windows by their dates, not isNow (unreliable for in-progress works); affectedRoutes/affectedStops are empty in practice — use route-diversions for structured stop/geometry impact; the whole-network call intermittently degrades to all-Good-Service while per-route calls stay correct |
| disruptions | category, categoryDescription (PlannedWork / RealTime…), description, created, lastUpdate, affectedRoutes[], affectedStops[] (the latter two empty in practice) |
| arrivals | id (prediction id), vehicleId (registration — joins to vehicles), naptanId + stationName (the stop), lineId/lineName, destinationName, towards, direction, bearing, currentLocation (prose), timeToStation (seconds), expectedArrival (ISO), timeToLive (prediction expiry), timestamp. TfL caches 30 s upstream |
| road-disruptions | id (TIMS ref), point — a JSON-encoded string "[lng,lat]", parse before use — severity (Serious/Moderate/Minimal), category (Collisions/Works/Hazards…) + subCategory, location, comments + currentUpdate (prose), startDateTime/endDateTime/lastModifiedTime, status (Active…), hasClosures, isProvisional, plus geometry in geography/roadDisruptionLines/roadDisruptionImpactAreas when TfL publishes it |
Live vehicle fields (SIRI-VM, parsed)
| Field | Type | Meaning |
|---|---|---|
| reg | string | Vehicle registration (VehicleRef) — joins to vehicles |
| line / publishedLine | string | Operator-internal line ref / the public route number — filter on publishedLine; LineRef is not the public number |
| direction | string | 1 outbound · 2 inbound |
| lat / lng / bearing | number | Position + heading (bearing may be null) |
| destination / origin | string | Journey endpoints as published |
| operatorRef / recordedAt | string | Operator code · when the position was recorded (positions update every ~10–30 s) |
The history group — /api/v1/history time-series
The static datasets are "today's values"; this group serves the time-series that accrues in our self-hosted warehouse (Postgres + PostgREST — the server-side key never reaches the browser; each endpoint is a strict whitelist of table + filters). Common params: limit (max 1000, default 200), order=col.asc|desc, plus the per-endpoint filters below. Responses: { dataset, table, count, limit, rows }. Returns 503 (never a fake 200) if the store is unconfigured; accidents and crowding transparently fall back to filtering the static snapshot.
| Endpoint | Filters | What accrues |
|---|---|---|
| /history/reliability-daily | route, from, to | Atlas's own daily reliability estimate — AWT/SWT/EWT (high-freq), OTD (low-freq), scheduled vs operated km. Methodology: EWT = AWT − SWT where each = Σh²/2Σh over observed/scheduled headways; observed side sampled from live arrivals every ~30 min in service hours. EXPERIMENTAL — biased high (sparse sampling under-observes short headways); never comparable to TfL's QSI |
| /history/performance-history | route | TfL's quarterly QSI per route, every period we've captured |
| /history/schedule | route, from, to | Scheduled service over time — service class, SWT, trips/km, representative headway (from TfL Timetable) |
| /history/tender-programme | route, year | TfL's forward LBSL tendering programme — issue/return/award/start dates, vehicle type |
| /history/route-snapshots | route, from, to, operator, propulsion, garage | Daily change-data-capture per route — PVR, propulsion, deck, operator, garage, fleet size/age, MPS. The record behind fleet-move / electrification / PVR-change analysis |
| /history/garage-snapshots | garage, operator, from, to | Per-garage over time — total PVR, route count, allocation |
| /history/vehicle-sightings | route, reg, from, to | Vehicle-on-route observations (reg ↔ route ↔ timestamp), months deep |
| /history/accidents | from, to, severity, borough, road_type, speed_limit, day, time_band | STATS19 collisions, snake_case row shape; the temporal source behind the snapshot |
| /history/crowding | route, band, year, day_type | BUSTO crowding per route per year — trend across annual releases |
History row columns — every field, per endpoint
All history rows are snake_case (the warehouse's convention — note it differs from the current group's camelCase). extracted_at on any row is the ingest write time.
| Endpoint | Row columns · semantics |
|---|---|
| reliability-daily | route_id, day, service_class, awt_minutes/swt_minutes/ewt_minutes (high-frequency routes; EWT = AWT − SWT, each Σh²/2Σh over observed/scheduled headways — null when the day's samples are insufficient), otd_percent (low-frequency: % departures 2 min early–5 min late), scheduled_km/operated_km/lost_km/mileage_operated_percent (treat with caution — operated km reflects sampling coverage, not service operated; the apps deliberately don't show it), sample_count (how many arrival sweeps informed the day — confidence grows with it) |
| performance-history | route_id, period_label (e.g. Q4 25/26), period_start/period_end, service_class, ewt_minutes/swt_minutes/awt_minutes, on_time_percent, early_percent/late_percent/non_arrival_percent (the low-frequency breakdown — not in the current-group snapshot at all), scheduled_mileage_operated_percent, source_url, pdf_modified_at, extracted_at |
| schedule | route_id, snapshot_date, service_class, swt_minutes (scheduled wait computed from the timetable), scheduled_trips, scheduled_km, headway_min (representative), scheduled_departures — an object {weekday, saturday, sunday} of departure times in minutes-since-midnight at the timing point — qsi_point_stop_ids[], timing_point_stop_id (the stop SWT/OTD are measured at), source |
| tender-programme | id, programme_year (e.g. 2019-2020), tranche, route_id, tender_issue_date/tender_return_date, award_estimated, contract_start_date, route_description (termini), vehicle_type, propulsion_type, two_year_extension, previous_operator, source_url/pdf_modified_at/data_as_of/extracted_at — parsed from TfL's annual LBSL tendering-programme PDFs, all years retained |
| route-snapshots | The richest table — one row per route per day, ~60 columns in five families: identity route_id, snapshot_date, type, is_prefix, length_band, stop_count, frequency; vehicle & fleet deck, vehicle_type, propulsion, make, vehicle_age_years, fleet_size; operation operator, garage_name/garage_code, pvr; performance service_class, ewt_minutes, on_time_percent, perf_period, ewt_mps_minutes/otp_mps_percent/mileage_mps_percent; tender & contract context previous_operator, last_award_date/last_awarded_operator/last_cost_per_mile, tender_award_count, number_of_tenderers, was_joint_bid, contract_term_years, awarded_propulsion/awarded_deck (+ prev_* equivalents), the current-contract family current_contract_award_date, current_contract_cost_per_mile, current_contract_accepted_bid, current_contracted_annual_miles, current_contract_number_of_tenderers, current_contract_was_joint_bid, current_contract_awarded_propulsion/current_contract_awarded_deck/current_contract_awarded_operator; the previous-award family previous_award_date, previous_cost_per_mile, previous_accepted_bid, previous_contracted_annual_miles, previous_contract_term_years, previous_number_of_tenderers, previous_was_joint_bid; and the forward view next_tender_start/next_tender_year/next_tender_tranche/next_award_propulsion/next_award_deck/extension_eligible. This is the change-data-capture record — diff rows across dates to detect fleet moves, electrification, PVR changes, operator handovers |
| garage-snapshots | garage_code, snapshot_date, garage_name, operator, address, postcode, lat, lon (note: lon here, lng in the current group), total_pvr, route_count, routes[], night_routes[], school_routes[], extracted_at |
| vehicle-sightings | Deliberately minimal — route_id, registration, observed_at. One row per vehicle-on-route observation; months deep; the raw material for fleet-movement analysis |
| accidents | collision_id, lat/lng, severity, collision_date, borough, vehicles, casualties, road_type, speed_limit, junction, light, weather, road_surface, day, time_band, extracted_at — the same decoded values as the snapshot, snake_cased |
| crowding | route_id, busto_year, peak_vc, band, load/capacity/seats/boardings, day_type/peak_time/timeband/direction, stopcode/stopname/stop_sequence, max_load/max_capacity, and the flattened per-day peaks weekday_vc/saturday_vc/sunday_vc, extracted_at — one row per route per BUSTO year, so crowding trends across annual releases |
The warehouse also mirrors reference data (stops, geometry, bridges, crowding profiles, localities) and accrues route_diversions — one row per episode keyed (route_id, detected_at), never deleted: a permanent diversion history. These are storage/history tables; their current values are served by the datasets above. Sidecar note: the store also ships two internal cache files (fleet-dvla-cache.json, route-mps-cache.json) and serves raw files under /data/*.json — implementation details, not part of the API contract; consume via /api/v1.
Integrity & validation — how we know the data is right
- Per-builder hard gates (documented per dataset above): row-count bands, not-all-null columns, coordinate bounds, vocabulary checks, append-only counts, totals reconciliation. Any failure throws — the dataset keeps last-good and the manifest records the error.
validate-atlas— 50 dataset-level checks gate every nightly commit: severity splits sum to totals, V/C ≈ load÷capacity, band thresholds honoured, time curves chronological, geometry direction encoding exact, diversion coords inside London, profile peaks reconcile with summaries, and a propulsion-reconciliation regression guard.test-functions— 43 unit checks validate the custom maths against independent reference implementations: haversine distances (±0.5%), the diversion deviation thresholds, validity-window logic (including the real-world TfLisNowfailure case), zip reading, DVLA/operator canonicalisation rules.check-sources— a reproducible health sweep of every upstream (TfL Unified, iBus, BODS, DVLA, londonbusroutes, postcodes.io, EPOWR, Datastore, STATS19, BUSTO, Overpass, QSI host), including a deep mode that cross-checks our stored geometry against TfL's independent iBus scheduling geometry.- Rendered ↔ source cross-checks — headless-browser verification drives the map apps, reads back what they render, and asserts it equals what the API serves (the front-end pages are kept precisely because they make every dataset visually inspectable).
- Politeness & resilience: 30 s timeouts and up to 4 retries with jittered exponential backoff honouring
Retry-After; conditional requests (ETag/Last-Modified) wherever upstreams support them; bounded concurrency everywhere; per-source rate caps (e.g. 4 req/s on TfL's PDF host, ~4.5 req/s on DVLA with a hard-stop-and-resume).
Licensing & attribution
| Source | Feeds | Licence / terms |
|---|---|---|
| TfL Unified API, iBus static drops, BUSTO, EPOWR, QSI/MPS reports | routes, stops, geometry, status, diversions, crowding, bridges, performance, live feeds | TfL Transport Data Service terms (free with attribution) — Powered by TfL Open Data |
| DfT — Bus Open Data Service (SIRI-VM), STATS19 | live bus GPS, collisions | Open Government Licence v3.0 |
| DVLA Vehicle Enquiry Service | vehicle make/year/fuel | OGL; keyed server-side, one lookup per vehicle ever |
| OpenStreetMap (Overpass) | localities | ODbL — © OpenStreetMap contributors |
| postcodes.io | garage geocoding | Open (ONS/OS open data) |
| londonbusroutes.net | operators, garages, PVR, vehicle types | Community reference — scraped politely in-pipeline only, credited; TfL-sourced values are never overwritten by it |
Respect the upstream licences when reusing this API. Data is provided as-is; the manifest tells you exactly how fresh each dataset is.