Skip to content

Commit 9d11f96

Browse files
committed
Initial commit
0 parents  commit 9d11f96

20 files changed

Lines changed: 1647 additions & 0 deletions

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Python-generated files
2+
__pycache__/
3+
*.py[oc]
4+
build/
5+
dist/
6+
wheels/
7+
*.egg-info
8+
9+
# Virtual environments
10+
.venv
11+
12+
# Data files
13+
data/

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# gobble-wrta
2+
3+
gobble-wrta adapts TransitMatters' [gobble](https://github.com/transitmatters/gobble) for the Worcester Regional Transit Authority bus network. WRTA doesn't expose a standard real-time GTFS feed, so this project reads their CAD/AVL API instead and republishes bus arrivals in formats that existing transit tooling already understands:
4+
5+
- CSV output matching the format used by the [TransitMatters Data Dashboard](https://github.com/transitmatters/t-performance-dash)
6+
- A GTFS Realtime stream
7+
8+
## How it works
9+
10+
WRTA does publish an official GTFS Realtime feed, but it only carries service alerts -- no trip updates or vehicle positions. gobble-wrta fills that gap by polling WRTA's CAD/AVL API for live vehicle positions and next-stop info, matching each vehicle against its GTFS route/trip/stop, and recording arrival and departure events from the result.
11+
12+
### GTFS Realtime coverage
13+
14+
Support for the spec is partial so far:
15+
16+
**Trip updates** -- trip info (trip/route/direction), and a single stop time update for the immediate next stop.
17+
18+
**Vehicle positions** -- vehicle ID, latitude/longitude, speed, current stop sequence, stop ID, timestamp, occupancy, and trip info.
19+
20+
Not yet supported: trip start time, schedule relationship, vehicle license plate, vehicle odometer, congestion level, and occupancy status.
21+
22+
## Requirements to develop locally
23+
24+
- [`uv`](https://docs.astral.sh/uv/) with Python 3.13
25+
- Ensure `uv` is using the correct Python version by running `uv venv --python 3.13`
26+
27+
## Development Instructions
28+
29+
1. In the root directory, run `uv sync` to install dependencies.
30+
2. Run `uv run src/gobble.py` to start.
31+
3. Output will be in `data/` in your current working directory.
32+
33+
## Support TransitMatters
34+
35+
If you've found this app helpful or interesting, please consider [donating](https://transitmatters.org/donate) to TransitMatters to help support our mission to provide data-driven advocacy for a more reliable, sustainable, and equitable transit system in Metropolitan Boston.

pyproject.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[project]
2+
name = "gobble-wrta"
3+
version = "0.1.0"
4+
description = "Process WRTA events into a format that can be consumed by the Data Dashboard and an unofficial GTFS Realtime feed"
5+
authors = ["TransitMatters Labs <labs@transitmatters.org>"]
6+
readme = "README.md"
7+
requires-python = ">=3.13"
8+
license = "MIT"
9+
dependencies = [
10+
"gtfs-realtime-bindings>=2.1.0",
11+
"pandas>=3.0.3",
12+
]
13+
14+
[dependency-groups]
15+
dev = [
16+
"pandas-stubs>=3.0.3.260530",
17+
]

src/disk.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import csv
2+
import pathlib
3+
from typing import Any
4+
5+
import pandas as pd
6+
7+
import util
8+
from logger import set_up_logging
9+
10+
logger = set_up_logging(__name__)
11+
12+
MAIN_DIR = pathlib.Path("./data/")
13+
14+
# column order matches the data dashboard's expected CSV schema
15+
CSV_FIELDS = [
16+
"service_date",
17+
"route_id",
18+
"trip_id",
19+
"direction_id",
20+
"stop_id",
21+
"stop_sequence",
22+
"vehicle_id",
23+
"vehicle_label",
24+
"event_type",
25+
"event_time",
26+
"scheduled_headway",
27+
"scheduled_tt",
28+
"vehicle_consist",
29+
"occupancy_status",
30+
"occupancy_percentage",
31+
]
32+
33+
34+
def write_events(events_df: pd.DataFrame) -> None:
35+
for row in events_df.itertuples():
36+
_write_row(row)
37+
38+
39+
def _write_row(row) -> None:
40+
dir_path = MAIN_DIR / util.output_dir_path(
41+
row.route_id, row.direction_id, row.stop_id, row.event_time
42+
)
43+
dir_path.mkdir(parents=True, exist_ok=True)
44+
file_path = dir_path / "events.csv"
45+
46+
write_header = not file_path.exists()
47+
with open(file_path, "a", newline="") as f:
48+
writer = csv.DictWriter(f, fieldnames=CSV_FIELDS)
49+
if write_header:
50+
writer.writeheader()
51+
writer.writerow(_row_to_csv_dict(row))
52+
53+
54+
def _row_to_csv_dict(row) -> dict[str, Any]:
55+
d: dict[str, Any] = {}
56+
for field in CSV_FIELDS:
57+
value = getattr(row, field)
58+
if field == "event_time" and isinstance(value, pd.Timestamp):
59+
value = value.isoformat()
60+
elif pd.isna(value):
61+
value = ""
62+
d[field] = value
63+
return d

src/event.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from datetime import datetime
2+
3+
import pandas as pd
4+
5+
import disk
6+
import gtfs
7+
import lookups
8+
import trip_state
9+
import util
10+
import vehicle_snapshot
11+
import vehicles
12+
from logger import set_up_logging
13+
from trip_state import VehicleState
14+
15+
logger = set_up_logging(__name__)
16+
17+
18+
def process_vehicle_update(
19+
vehicle: vehicles.Vehicle,
20+
current_lookups: lookups.Lookups,
21+
gtfs_data: gtfs.GTFS,
22+
) -> None:
23+
"""Advances one vehicle's tracked trip state by a single poll's worth of new data: detects
24+
whether it has passed its previously-seen next stop, emits an ARR/DEP event pair if so, and
25+
refreshes its live snapshot for the GTFS-RT feed either way."""
26+
route_id = current_lookups.route_id_by_ligne.get(vehicle.id_ligne)
27+
if route_id is None:
28+
return
29+
direction_id = lookups.direction_id_for_destination(route_id, vehicle.destination)
30+
31+
now = datetime.now(util.EASTERN_TIME)
32+
state = trip_state.get_state(vehicle.numero_equipement)
33+
34+
if (
35+
state is None
36+
or state.route_id != route_id
37+
or state.direction_id != direction_id
38+
or util.service_date(state.updated_at) != util.service_date(now)
39+
):
40+
state = VehicleState(
41+
trip_id=None,
42+
trip_id_is_synthesized=False,
43+
route_id=route_id,
44+
direction_id=direction_id,
45+
stop_sequence=0,
46+
prev_stop_name=None,
47+
updated_at=now,
48+
)
49+
50+
current_name = vehicle.arret_suiv_name
51+
if (
52+
state.prev_stop_name is not None
53+
and current_name is not None
54+
and state.prev_stop_name != current_name
55+
):
56+
stop_id = lookups.resolve_stop_id(
57+
current_lookups,
58+
route_id,
59+
direction_id,
60+
state.prev_stop_name,
61+
vehicle.lat,
62+
vehicle.lng,
63+
)
64+
if stop_id is not None:
65+
_emit_event(
66+
current_lookups, gtfs_data, state, route_id, direction_id, stop_id, vehicle, now
67+
)
68+
69+
if current_name is not None:
70+
state.prev_stop_name = current_name
71+
state.updated_at = now
72+
trip_state.set_state(vehicle.numero_equipement, state)
73+
74+
vehicle_snapshot.update(vehicle, route_id, direction_id, state, current_lookups, now)
75+
76+
77+
def _synthesize_trip_id(
78+
vehicle: vehicles.Vehicle, route_id: str, direction_id: int, event_time: datetime
79+
) -> str:
80+
return (
81+
f"{vehicle.numero_equipement}-{route_id}-{direction_id}-"
82+
f"{event_time:%Y%m%dT%H%M%S}"
83+
)
84+
85+
86+
def _emit_event(
87+
current_lookups: lookups.Lookups,
88+
gtfs_data: gtfs.GTFS,
89+
state: VehicleState,
90+
route_id: str,
91+
direction_id: int,
92+
stop_id: str,
93+
vehicle: vehicles.Vehicle,
94+
event_time: datetime,
95+
) -> None:
96+
if state.trip_id is None:
97+
matched_trip_id = lookups.match_trip_id(
98+
current_lookups, route_id, direction_id, stop_id, event_time
99+
)
100+
if matched_trip_id is not None:
101+
state.trip_id = matched_trip_id
102+
state.trip_id_is_synthesized = False
103+
else:
104+
state.trip_id = _synthesize_trip_id(vehicle, route_id, direction_id, event_time)
105+
state.trip_id_is_synthesized = True
106+
logger.warning(
107+
f"No GTFS trip match for vehicle={vehicle.numero_equipement} "
108+
f"route={route_id} direction={direction_id} stop={stop_id}; "
109+
f"using synthesized trip_id={state.trip_id}"
110+
)
111+
112+
if state.trip_id_is_synthesized:
113+
state.stop_sequence += 1
114+
else:
115+
resolved_sequence = lookups.stop_sequence_for(
116+
current_lookups, state.trip_id, stop_id, state.stop_sequence
117+
)
118+
state.stop_sequence = (
119+
resolved_sequence if resolved_sequence is not None else state.stop_sequence + 1
120+
)
121+
122+
base_row = {
123+
"service_date": util.service_date_iso8601(event_time),
124+
"route_id": route_id,
125+
"trip_id": state.trip_id,
126+
"direction_id": direction_id,
127+
"stop_id": stop_id,
128+
"stop_sequence": state.stop_sequence,
129+
"vehicle_id": "0",
130+
"vehicle_label": vehicle.numero_equipement,
131+
"event_time": event_time,
132+
"vehicle_consist": vehicle.numero_equipement,
133+
"occupancy_status": None,
134+
"occupancy_percentage": vehicle.taux_remplissage,
135+
}
136+
event_df = pd.DataFrame(
137+
[
138+
{**base_row, "event_type": "ARR"},
139+
{**base_row, "event_type": "DEP"},
140+
]
141+
)
142+
143+
enriched = lookups.add_gtfs_headways(event_df, gtfs_data.trips, gtfs_data.stop_times)
144+
disk.write_events(enriched)
145+
146+
logger.info(
147+
f"{event_time.isoformat()} ARR/DEP vehicle={vehicle.numero_equipement} "
148+
f"route={route_id} direction={direction_id} stop={stop_id} "
149+
f"trip_id={state.trip_id!r} stop_sequence={state.stop_sequence}"
150+
)

src/gobble.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from poll_loop import poll_vehicles_forever
2+
from gtfs import start_watching_gtfs
3+
from rt_server import start_server
4+
from topology import start_watching_topology
5+
6+
7+
def main():
8+
start_watching_gtfs()
9+
start_watching_topology()
10+
start_server()
11+
poll_vehicles_forever()
12+
13+
14+
if __name__ == "__main__":
15+
main()

0 commit comments

Comments
 (0)