What comes out of BODS, and what it turns into
Status: field-level reference, written 2026-09-08. Two feeds go in — live positions and a timetable — and a handful of tables come out. This says what every field is, which ones are actually populated, and where each one ends up. Coverage figures are measured, and say what they were measured on.
The store section is generated rather than written: 07-schema.R prints the
real column list from the database itself, and what is below was pasted from
its output on 2026-09-08. The full column-by-column listing, every table, is
the store, table by table. A schema written from memory is wrong within a month
— this one already was, in two places, before it was first published.
Input 1: live positions (SIRI-VM)
One HTTP request every 20 seconds returns every vehicle currently reporting in the area. The XML is kept exactly as it arrived, gzipped, because the feed only ever serves now and a minute not captured cannot be asked for again.
Each VehicleActivity carries these. Coverage is out of 851 vehicles in one
region, measured 2026-09-07:
| Field | Coverage | What it is, and what it is good for |
|---|---|---|
RecordedAtTime |
100% | When the vehicle reported. Not when you fetched it — see the stale tail below. |
ValidUntilTime |
100% | Publisher's expiry. Not used. |
VehicleRef |
100% | The operator's own vehicle number. Collides between operators — 1,433 refs nationally are used by more than one — so it is never a key on its own. |
OperatorRef |
100% | A NOC (FSYO, SYRK). Not the GTFS agency_id, which is OP153-style. |
LineRef / PublishedLineName |
100% | The service number. Most operators publish it bare (X2); some publish a scheme-qualified reference, so both are read. |
DirectionRef |
100% | inbound / outbound. |
OriginRef |
100% | ATCO code of the first stop. Joins straight to GTFS stops.stop_id. |
DestinationRef |
100% | ATCO code of the last stop. |
OriginAimedDepartureTime |
95% | Scheduled departure from the origin. The third part of the journey key, and the field nine operators omit entirely. |
DestinationAimedArrivalTime |
not measured | Scheduled arrival. Not used. |
VehicleLocation/Latitude,/Longitude |
100% | WGS84. Everything measured here is derived from these. |
Bearing |
78% | Degrees. -1 is used as a "no bearing" sentinel by some operators. |
BlockRef |
68% | The operator's number for a day's work — the chain of journeys one bus runs. Not used yet, and the most promising thing in the feed. |
DatedVehicleJourneyRef |
most | Looks like a trip id and is not one. Values are 4, 0945, 1040: it equals the operator's own TicketMachine/JourneyCode. Joining it to GTFS trip_id gives 0%. |
DriverRef |
15% (national) | Published by some operators. Not used, and not something to put on a page. |
Occupancy |
0.2% | Coarse words, not counts. SIRI 2.0 cannot express a passenger count at all. |
Fields that do not exist in this feed at all, which is the part that determines what can be built:
Delay— nothing ever says a bus is late. Every punctuality figure here is derived by comparing positions against the timetable.MonitoredCall/OnwardCalls— the feed never names the next stop or gives a prediction. Departure predictions are ours to make.- Passenger counts — absent GB-wide, in both this feed and GTFS-RT.
- Accessibility — the fields exist in the standard and are unpopulated.
Two traps worth repeating, because both are silent:
- ~14% of the national feed is more than an hour stale, 7.3% more than
twelve hours. Records are not dropped when a vehicle stops reporting, so an
unfiltered feed reports buses that parked up this morning. Filter on
RecordedAtTime. - Key a vehicle on
OperatorRef+VehicleRef, never the ref alone.
Input 2: the timetable (GTFS)
A bulk export, refreshed weekly. Loaded all-varchar: ids look numeric, and
a stop_id of 0345 read as an integer silently drops every join it takes
part in. Every column of every file below survives into the store as text.
| File | Columns that matter here |
|---|---|
agency.txt |
agency_id (OP153-style), agency_name, agency_noc — the only field that joins the timetable to the live feed's OperatorRef. Populated 58/58. |
routes.txt |
route_id, route_short_name (the line as printed on the bus), agency_id. |
trips.txt |
trip_id (VJ + 40 hex), route_id, service_id, direction_id, shape_id. |
stop_times.txt |
trip_id, stop_id, stop_sequence, arrival_time, departure_time, timepoint. |
stops.txt |
stop_id (ATCO), stop_name, stop_lat, stop_lon. |
calendar.txt |
service_id, the seven weekday flags, start_date, end_date. |
calendar_dates.txt |
service_id, date, exception_type (1 adds a day, 2 removes one). |
shapes.txt |
shape_id, shape_pt_lat, shape_pt_lon, shape_pt_sequence — the road the bus drives. Present for 83.2% of trips. |
feed_info.txt |
feed_version and the validity dates. Empty in this export, which is why 06-stamp.R exists. |
Four things about this timetable that cost time to learn:
- Times are local service time, and the live feed is offset-stamped UTC. Compare them without converting and every summer journey is an hour out.
24:15:00is legal and means quarter past midnight on the following morning. So isH:MM:SS, unpadded.stop_sequenceis text, so ordering by it puts stop 10 before stop 2 and a distance chain doubles back on itself — 2.38x the true distance on a 14-stop test trip. Cast before ordering, every time.timepointis populated on 13.3% of stop times (552k of 4.16M). Those are the stops the timetable actually commits to; the rest are interpolated.
Input 3: fares (NeTEx), read separately
Parsed on demand rather than stored: 302 XML files in one region's zip give 40,393 fare pairs, £1.60–£3.00. The £3.00 that is both the maximum and the third quartile is the national fare cap showing up in the data.
What ends up in DuckDB
Generated by 07-schema.R on 2026-09-08, against a store holding two days of
capture and one timetable export. One DuckDB file, bods.duckdb, with the
parquet archive beside it. The database is in-process — no service, no port,
nothing to install.
The positions are not in the database
The poller writes one parquet file per poll into a dated folder, and every
query globs parquet\*\*.parquet. That keeps the single writer out of the
readers' way, and it is why 04-compact.R can fold a finished day into one
file without anything else changing. 1.07M rows over two days.
| column | type | note |
|---|---|---|
recorded |
TIMESTAMP | The vehicle's own RecordedAtTime |
pulled_at |
TIMESTAMP | When we fetched it. The pair is what makes the stale tail visible — a row where these are hours apart is a bus that stopped reporting, not a bus standing still |
operator, vehicle, vehicle_key |
VARCHAR | vehicle_key is the operator-plus-vehicle composite, because VehicleRef alone collides |
line_ref, line |
VARCHAR | As published, and as printed on the bus |
direction |
VARCHAR | |
journey_code |
VARCHAR | The operator's own code. Not a GTFS trip_id |
block_ref |
VARCHAR | The day's work this bus is on. Captured, not yet used |
origin_ref, origin_name, dest_ref, dest_name |
VARCHAR | ATCO codes and names |
origin_dep, origin_dep_local |
TIMESTAMP / VARCHAR | Both forms kept: the local string is what joins to the timetable |
dest_arr |
TIMESTAMP | |
lat, lon, bearing |
DOUBLE | bearing uses -1 as a no-value sentinel |
monitored |
VARCHAR | The feed's own claim that the vehicle is being tracked |
Occupancy is not captured. It is present on 0.2% of vehicles and coarse
where it exists, so nothing here would be able to use it; the raw XML still
has it if that ever changes.
The tables
| Table | Rows | One row per | Notes |
|---|---|---|---|
agency |
58 | operator | agency_noc is the only bridge to the feed's OperatorRef |
routes |
1,226 | route | route_short_name is the line as printed |
trips |
90,551 | trip | Carries block_id and vehicle_journey_code as well as shape_id — see below |
stop_times |
4,161,549 | trip × stop | timepoint, and a shape_dist_traveled column |
stops |
32,462 | stop | Includes wheelchair_boarding, which the export leaves empty |
calendar |
209 | service pattern | |
calendar_dates |
16,822 | service exception | |
shapes |
3,966,315 | shape point | Plus shape_dist_traveled |
sched |
90,551 | trip, not stop | The journey-level table: trip_id, service_id, direction_id, line, route_type, agency_id, agency_name, origin_ref, origin_dep_local. Those last three are the journey key the live feed is matched on. Stop-level work joins stop_times directly |
sched_today |
28,772 | trip today | sched filtered to one service day, with sched_today_services holding the 117 service_ids that ran |
hist_sched, hist_sched_services |
28,496 / 122 | trip on a scored day | The same, for the day being scored rather than today |
active_services |
117 | service_id | |
timetable_stamp |
1 | the loaded export | Label, source file, and the fingerprint that detects a reload |
hist_stop_visits |
22,752 | bus × stop × day | One observation per bus per stop, with delay_min, band, and the three counters |
hist_journeys |
12,594 | journey × day | Status, ran, measurable, miles, miles_basis |
hist_days |
1 | day | The headline figures, miles_basis, timetable, computed_at |
There is no feed_info table: the export does not provide one, which is what
06-stamp.R and timetable_stamp exist to replace.
Three leads, tested 2026-09-08
08-leads.R scored all three against journeys matched the way everything else
matches them. Two are dead and one is the lever.
trips.vehicle_journey_code is not the feed's journey code. Ruled out.
Populated on 100% of trips, present on both sides for 100% of matched
journeys, and matching on 0.0% of them — before and after stripping
leading zeros. The values explain why: the feed publishes 1018, 1042,
0935B — the operator's ticket-machine code — while the timetable holds
vj_2, VJ1519, VJ4343, which are TransXChange's own VehicleJourney
identifiers. Same column name, different namespaces, exactly like agency_id
against a NOC. There is still no shared journey id, and the three-column key
stays the only way across.
shape_dist_traveled is empty. Ruled out. 0.0% populated on both shapes
and stop_times. Distance has to be computed from the geometry, which is what
trip_miles already does.
Blocks work — through the timetable, not the feed. block_id is populated
on 74.7% of trips and block_ref on 71.9% of matched journeys, but they agree
on 0.0%: the feed sends the operator's block number (25123, 36210) and
the timetable holds a 40-hex hash. Another pair of namespaces, and another
join that cannot be made.
It does not need to be. 81.1% of journeys that have a block have an earlier journey on the same block, and that chain is entirely inside the timetable. The bus that will run the 14:20 is the bus running the 13:45 on the same block, so a departure 25 minutes out can inherit the delay of a vehicle that is live now — matched on its current journey by the ordinary key, with no block_ref involved at all. Roughly 60% of all trips have such a predecessor (74.7% × 81.1%), against the 27% of departures that get a live prediction today.
And what leaves it
| File | One row per | Read by |
|---|---|---|
positions.csv |
vehicle, now | The map |
observations.csv |
bus × stop today | Punctuality |
delivery.csv |
scheduled journey today | Did-it-run and lost mileage, with miles_basis on every row |
departures.csv |
scheduled departure in the next 30 min | The departure board |
history_days.csv, history_operators.csv, history_periods.csv |
day / day × operator / reporting period | Trends, and anything asked about last month |
Every one of them is rewritten on a timer and none is worth editing or backing up: they are all derived from the raw capture, which is the only thing here that cannot be made again.