Jun 25, 2026

How TTC Pulse works: from delay logs to route scores

1.1 million TTC delay events, one score per route, and the small-sample fault I found in it.

Data EngineeringMLOpsResearch Notes

TTC Pulse scores how reliable TTC buses, subways and streetcars have been, by route, station, hour and cause. It joins 1,111,831 delay events from January 2014 to January 2026 with the TTC schedule (GTFS) and with TTC service alerts. The pipeline uses DuckDB and Parquet. The dashboard on this site shows the results, and its Live view adds the current TTC alerts and vehicles.

What the data shows:

  • Delays are concentrated. 29 of 284 bus route keys (the top 10%) have 50.1% of all bus delay events.
  • Rush hours carry a large share. 42.8% of bus delay events occur on weekdays from 06:00 to 10:00 and from 15:00 to 19:00.
  • Mechanical faults are the top bus cause. They cause 234,358 of 723,518 bus delay events (32.4%).
  • The first reliability score has a small-sample fault. A route with 10 delay events ranked above the route with the most delay events. I explain the fault and the fix below.

TTC Pulse was a team project at York University (Data Analytics and Visualization, Winter 2026). I wrote the first versions of 33 of the 36 pipeline modules, including the reliability score. When I write "we", I mean the team.

Architecture

The system has two parts. The build part runs locally and makes the data. The request part runs on Cloudflare and serves the dashboard.

TTC Pulse architectureAt build time, three sources (TTC delay logs, the GTFS schedule and GTFS-Realtime alert snapshots) go through Python ingestion into DuckDB bronze tables, then into silver and gold Parquet tables. An export script reads the gold marts and writes 9 JSON files that are bundled into the Next.js page, plus 308 small files, one per route, station or line, that the drill-down loads on demand. At request time, a Cloudflare Worker serves the page to the browser. A live route on the same Worker fetches the TTC live alert API and the GTFS-Realtime vehicle positions feed, caches the result for 60 seconds, and sends it to the browser.Build time: runs locallyRequest time: no databaseDelay logsCity of Toronto1,111,831 eventsGTFS scheduleTTC routes, stops229 routesGTFS-RT alerts234 snapshotsIngestionPythonSHA-256 registryBronzeDuckDB tablesrow hash per rowSilverParquet factsand dimensionsGold8 Parquet marts22.0 MBExport scriptPython, DuckDBreads gold onlyJSON files9 bundled, 130 KB308 loaded on demandTTC live alertsalerts.ttc.caTTC vehiclesGTFS-RT positionsbustime.ttc.caLive route/api/ttc-pulse/liveCloudflare Worker, 60 s cacheBrowserDashboard viewsLive viewNext.js page/demos/ttc-pulseCloudflare Workerbundled at build
TTC Pulse architectureAt build time, three sources (TTC delay logs, the GTFS schedule and GTFS-Realtime alert snapshots) go through Python ingestion into DuckDB bronze tables, then into silver and gold Parquet tables. An export script reads the gold marts and writes 9 JSON files that are bundled into the Next.js page, plus 308 small files, one per route, station or line, that the drill-down loads on demand. At request time, a Cloudflare Worker serves the page to the browser. A live route on the same Worker fetches the TTC live alert API and the GTFS-Realtime vehicle positions feed, caches the result for 60 seconds, and sends it to the browser.Build time: runs locallyRequest time: no databaseSourcesDelay logs: 1,111,831 eventsGTFS schedule: 229 routesGTFS-RT alerts: 234 snapshotsIngestionPythonSHA-256 registryBronzeDuckDB tablesrow hash per rowSilverParquet factsand dimensionsGold8 Parquet marts22.0 MBExport scriptPython, DuckDBreads gold onlyJSON files9 bundled, 130 KB308 loaded on demandTTC live feedsAlerts APIGTFS-RT vehiclesLive routeCloudflare Worker60 s cacheBrowserDashboard viewsLive viewNext.js page/demos/ttc-pulseCloudflare Workerbundled at build
  • Source data
  • Python code
  • Stored data
  • Runs on Cloudflare
  • Live TTC feed
  • Browser
TTC Pulse architecture. The top band runs locally. The bottom band runs on each request, with no database.
  • Build time. Python code ingests the three sources and writes a registry with a SHA-256 checksum for each file. DuckDB loads the rows into bronze tables. Then the pipeline writes the silver and gold layers as Parquet files with ZSTD compression.
  • Export. The script scripts/export_site_data.py reads only the gold marts. It writes 9 JSON files (129,777 bytes in total) that ship with the site. It also writes one file for each of 308 routes, stations and lines (2.6 MB in total, at most about 13 KB each). The drill-down loads one of these only when a visitor opens that route.
  • Request time. A Cloudflare Worker serves the Next.js page. The historical views read the bundled JSON, so the site needs no Streamlit server and no database server. A second route on the same Worker serves the live data. The live layer section below describes it.

Data flow: bronze, silver, gold

Each layer has one job. Bronze keeps the source rows. Silver cleans them and links them to the schedule. Gold aggregates them for the dashboard.

TTC Pulse data flow from bronze to goldBronze has DuckDB tables for bus, subway and streetcar delays, GTFS routes, stops, trips and stop times, and GTFS-Realtime alerts. Silver has per-mode event tables (bus 723,518 rows, subway 235,397, streetcar 152,916), the fact table fact_delay_events_norm with 1,111,831 rows, the alert fact table with 5,984 rows, and seven dimension and bridge tables. Gold has 8 marts, 22.0 MB in total, the largest being gold_delay_events_core with 1,086,378 rows. The export script writes 9 bundled JSON files and 308 per-route files for the site.Bronze: DuckDBOne row per source rowDelay logsbronze_busbronze_subwaybronze_streetcarSchedule and alertsbronze_gtfs_routesbronze_gtfs_stopsbronze_gtfs_tripsbronze_gtfs_stop_timesbronze_gtfsrt_alertsSilver: eventsParquet, rowsPer modesilver_bus_events723,518silver_subway_events235,397silver_streetcar_events152,916Factsfact_delay_events_norm1,111,831fact_gtfsrt_alerts_norm5,984Silver: dimensionsParquet, rowsdim_route_gtfs229dim_stop_gtfs9,417dim_service_gtfs8dim_route_alias865dim_station_alias13,860dim_incident_code267bridge_route_direction_stop17,824Gold: 8 marts, 22.0 MBParquet, rowsgold_delay_events_core1,086,378gold_route_time_metrics893,537gold_station_time_metrics223,480gold_alert_validation5,984gold_linkage_quality752gold_time_reliability504gold_top_offender_ranking283gold_spatial_hotspot71Site data: JSON filesFrom export_site_data.py, bytesentities.json44,871drill-index.json44,214scores.json12,803sample.json12,641stations.json5,834overview.json3,904causes.json2,411time.json2,245quality.json854entity/*.json, 3082,600,020
TTC Pulse data flow from bronze to goldBronze has DuckDB tables for bus, subway and streetcar delays, GTFS routes, stops, trips and stop times, and GTFS-Realtime alerts. Silver has per-mode event tables (bus 723,518 rows, subway 235,397, streetcar 152,916), the fact table fact_delay_events_norm with 1,111,831 rows, the alert fact table with 5,984 rows, and seven dimension and bridge tables. Gold has 8 marts, 22.0 MB in total, the largest being gold_delay_events_core with 1,086,378 rows. The export script writes 9 bundled JSON files and 308 per-route files for the site.Bronze: DuckDBOne row per source rowDelay logsbronze_busbronze_subwaybronze_streetcarSchedule and alertsbronze_gtfs_routesbronze_gtfs_stopsbronze_gtfs_tripsbronze_gtfs_stop_timesbronze_gtfsrt_alertsSilver: ParquetRows per tablePer modesilver_bus_events723,518silver_subway_events235,397silver_streetcar_events152,916Factsfact_delay_events_norm1,111,831fact_gtfsrt_alerts_norm5,984Dimensionsdim_route_gtfs229dim_stop_gtfs9,417dim_service_gtfs8dim_route_alias865dim_station_alias13,860dim_incident_code267bridge_route_direction_stop17,824Gold: 8 marts, 22.0 MBParquet, rowsgold_delay_events_core1,086,378gold_route_time_metrics893,537gold_station_time_metrics223,480gold_alert_validation5,984gold_linkage_quality752gold_time_reliability504gold_top_offender_ranking283gold_spatial_hotspot71Site data: JSON filesFrom export_site_data.py, bytesentities.json44,871drill-index.json44,214scores.json12,803sample.json12,641stations.json5,834overview.json3,904causes.json2,411time.json2,245quality.json854entity/*.json, 3082,600,020
Tables and row counts in each layer, from the committed Parquet files. The bronze tables are in a DuckDB file that is not committed, so they have no counts here.
  • Bronze keeps each source row as it arrived. It adds three lineage fields: source_file, source_row_id and row_hash. All 1,111,831 delay events keep these fields in silver, so each event traces back to its file and row.
  • Silver normalizes dates, routes, stations and causes. The bus route labels are free text, so the normalizer puts each label into one of three tiers. Exact means the full label is a route number. Token means the label starts with a route number or contains LINE n. Fallback means the label has no route number. Then it joins the number to the GTFS route list. 95.8% of delay events (1,065,527) match by the exact tier and 4.2% (46,304) by the token tier. The fact table has no fallback rows.
  • Gold aggregates silver into 8 marts for the dashboard. The marts do not keep the row IDs.

The logs hold delays only, not the trips that ran on time. So the dashboard shows counts, not rates.

The reliability score

I wrote the first version of the score in src/ttc_pulse/marts/scoring.py. It combines four inputs into one number:

score = 0.35 z(frequency) + 0.30 z(severity_p90) + 0.20 z(regularity_p90) + 0.15 cause_mix
  • Frequency is the number of delay events.
  • Severity is the 90th percentile of delay minutes.
  • Regularity is the 90th percentile of gap minutes between vehicles.
  • Cause mix is 1 minus the sum of squared cause shares. It is a diversity index, not a z-score.

In the ranking mart, each z-score compares one route or station with the others of the same mode and entity type. The window is the 90 days to 31 January 2026. A check script recomputes the stored score from its four inputs for all 893,537 route rows. The largest difference is 2.2e-16.

The small-sample fault

For bus routes, route 28 Bayview South ranks 1st with only 10 delay events. Route 32 Eglinton West has the most delay events (447) and ranks 9th.

Delay events in the 90-day window, raw top 10 bus routes
1. 28 Bayview South10 events
2. 162 Lawrence-Donway34 events
3. 87 Cosburn26 events
4. 55 Warren Park33 events
5. 127 Davenport53 events
6. 115 Silver Hills9 events
7. 33 Forest Hill21 events
8. 184 Ancaster Park2 events
9. 32 Eglinton West447 events
10. 52 Lawrence West388 events

Source: gold_top_offender_ranking, bus routes, ranking date 31 January 2026.

8 of the top 10 routes have fewer than 60 delay events. Their severity_p90 is 287 to 471 minutes. With few events, one long delay sets the 90th percentile. The pipeline sets bus delays of 999 minutes or more to null, but values below 999 stay. So a quiet route with one bad day can rank above a route that is late every day.

The fix: weight the score by sample size

On the site, I added an adjusted score:

adjusted = score × n / (n + k)

n is the number of delay events of the route or station in the 90-day window. k is the median n for that mode and entity type. When n is small, the weight is near 0 and the score moves toward 0, which is the mean of a z-score. When n is large, the weight is near 1 and the score stays almost the same. This is a simple credibility weight.

Mode Entity Entities ranked k
Bus Route 201 54
Streetcar Route 10 274
Subway Station 70 73

For an even count of entities, the export takes the upper of the two middle values as the median. With the adjusted score, route 32 moves from 9th to 1st. Route 28 moves from 1st to 18th. The limit: k is a rule of thumb. I did not fit it to the data.

Two queries and their results

Both queries run with DuckDB on the committed Parquet files, from the root of the TTC Pulse repository. The results come from TTC Pulse commit d5d2a02.

Query 1: the adjusted bus ranking

sql
WITH r AS (
  SELECT entity_id, frequency AS n, severity_p90, composite_score, rank_position
  FROM read_parquet('gold/gold_top_offender_ranking.parquet')
  WHERE mode = 'bus' AND entity_type = 'route'
),
k AS (SELECT median(n) AS k FROM r)
SELECT r.entity_id AS route, d.route_long_name AS name, r.n,
       round(r.severity_p90, 1) AS severity_p90,
       round(r.composite_score, 2) AS score, r.rank_position AS raw_rank,
       round(r.composite_score * r.n / (r.n + k.k), 2) AS adjusted,
       rank() OVER (ORDER BY r.composite_score * r.n / (r.n + k.k) DESC) AS adjusted_rank
FROM r CROSS JOIN k
LEFT JOIN read_parquet('dimensions/dim_route_gtfs.parquet') d ON d.route_id = r.entity_id
ORDER BY adjusted DESC
LIMIT 5;
Route Name n severity_p90 Score Raw rank Adjusted Adjusted rank
32 Eglinton West 447 22.6 1.35 9 1.20 1
52 Lawrence West 388 27.0 1.15 10 1.01 2
127 Davenport 53 300.0 1.68 5 0.83 3
102 Markham Rd 330 30.0 0.94 12 0.81 4
162 Lawrence-Donway 34 367.1 2.06 2 0.80 5

The two routes with the most delay events move to the top. Two routes with a high severity_p90 (127 and 162) stay in the top 5, because their raw score is high enough to survive the weight.

Query 2: how many delay events match a GTFS route

sql
SELECT mode, link_status, sum(row_count) AS events,
       round(100.0 * sum(row_count) / sum(sum(row_count)) OVER (PARTITION BY mode), 1) AS pct
FROM read_parquet('gold/gold_linkage_quality.parquet')
GROUP BY mode, link_status
ORDER BY mode, link_status;
Mode Link status Events Percent
bus matched 706,725 97.7
bus unmatched_review 16,793 2.3
streetcar matched 152,916 100.0
subway matched 235,397 100.0

Only bus has unmatched events, because only the bus logs use free-text route labels.

The live layer

The Live view on the dashboard calls a server route on the Cloudflare Worker at /api/ttc-pulse/live. The route fetches two TTC feeds:

  • the TTC live alert API, https://alerts.ttc.ca/api/alerts/live-alerts
  • the GTFS-Realtime vehicle positions feed, https://bustime.ttc.ca/gtfsrt/vehicles

The route caches the result for 60 seconds. The TTC feeds give no delay value for each trip. So the Live view does not compute a live score. It shows the current alerts (significant delays, detours and closures) and the live vehicles. Then it joins each route to its historical rank from the gold marts.

Three details keep the route small and safe:

  • A small decoder. The vehicle feed is protobuf. The route reads only the route ID and the position of each vehicle. It does not load a full GTFS-Realtime library. On one feed of 1,166 vehicles, its output matches the official bindings exactly, and it decodes in about 1 to 4 ms.
  • A last good copy. The route keeps a second copy for one day. If a TTC feed fails, the page shows that copy with its feed time and a "Feed unavailable" label. The panel is never empty.
  • One map for all views. The maps draw the TTC network from the GTFS route shapes, simplified to 42 KB. Vehicles and stations sit on the same projection.

The historical data does not change between exports, so it ships as static JSON. Only the live data needs a server route. We first built the dashboard in Streamlit. For a public link, I compared two options:

Streamlit server Static JSON and one live route
Server to keep running Yes No, the Worker runs on each request
Link works when The server is up The site is up
Historical data Live query on the marts Fixed at the last export
Live data Polled every 30 s by the app Fetched by the route, cached 60 s

I chose static JSON and one live route. The gold marts stop at 31 January 2026, so a live query on them adds no new data. If a TTC feed fails, the historical views still work, because they do not use the live route.

Data quality findings

  • Unmatched bus routes. 16,793 bus delay events (2.3%) do not match a GTFS route. The most common raw labels are 56 (2,479 events), 81 (2,441) and 190 (2,341). These events stay in silver with unmatched_review. The route marts have 0 rows with a NULL route.
  • Raw cause codes. From 2025, all bus and streetcar cause values are raw codes, for example EFO and MFWEA. These codes have no description in the incident code dimension. The cause charts for 2025 and 2026 therefore show codes, not names.
  • One station at one hour. Yonge Station has 2,343 of its 8,229 delay events at 22:00. 2,085 of those have the cause "Miscellaneous Other". This looks like a logging pattern, not a real peak. It needs a check before anyone uses the station ranking.
  • Snapshot checksums. 60 of the 234 alert snapshot files do not match the SHA-256 value in the registry. All 60 are smaller than the recorded size.
  • Alert loads. The alert parser is not idempotent. A second run appends the same alert rows again. The alert table also has one build time per row (16 April 2026), so it cannot show when an alert started.

Limits and next steps

  • The raw files and the DuckDB database are not in the repository. The full pipeline does not run from a clone. The silver and gold Parquet files do, so the queries above run from a clone.
  • The repository has no tests and no CI. I would add the number check script to CI first, because it already exits 0 on a correct build.
  • The AWS kit (S3, Glue Data Catalog, Athena) is written but has not run on AWS.
  • To turn counts into rates, I would join the GTFS trip counts per route and hour.
  • I would fit k in the adjusted score from the data, and not use the median.
  • I would give the alert parser a unique key on snapshot time, alert and entity, so a second run changes nothing.

Where this lives in the repo

The code is in the TTC Pulse repository:

  • Bronze load and lineage fields: src/ttc_pulse/bronze/build_bronze_tables.py
  • Bus route matching: src/ttc_pulse/normalization/normalize_bus.py
  • Score weights and z-scores: src/ttc_pulse/marts/scoring.py
  • Ranking mart and 90-day window: src/ttc_pulse/marts/build_gold_rankings.py
  • JSON export for this site: scripts/export_site_data.py
  • Number checks: scripts/check_readme_numbers.py
  • AWS kit: aws/
Back to blog