caltrain-api 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- caltrain_api/__init__.py +68 -0
- caltrain_api/_feedcache.py +164 -0
- caltrain_api/api.py +519 -0
- caltrain_api/corridor.py +224 -0
- caltrain_api/describe.py +126 -0
- caltrain_api/errors.py +37 -0
- caltrain_api/models.py +323 -0
- caltrain_api/py.typed +0 -0
- caltrain_api/realtime.py +315 -0
- caltrain_api/schedule.py +746 -0
- caltrain_api-1.0.1.dist-info/METADATA +116 -0
- caltrain_api-1.0.1.dist-info/RECORD +14 -0
- caltrain_api-1.0.1.dist-info/WHEEL +4 -0
- caltrain_api-1.0.1.dist-info/licenses/LICENSE +21 -0
caltrain_api/models.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Immutable data types returned by the caltrain_api library.
|
|
2
|
+
|
|
3
|
+
Everything here is a frozen pydantic model: a *snapshot* of what the library
|
|
4
|
+
knew at a moment in time. Nothing here performs I/O or changes after
|
|
5
|
+
construction; to see newer data, ask the API again.
|
|
6
|
+
|
|
7
|
+
All datetimes are timezone-aware. The library produces them in
|
|
8
|
+
America/Los_Angeles (Caltrain's agency timezone); convert with
|
|
9
|
+
``dt.astimezone(...)`` as needed.
|
|
10
|
+
|
|
11
|
+
Distances are in statute miles. A *milepost* is distance along the track from
|
|
12
|
+
San Francisco 4th & King (milepost 0.0), increasing southward toward Gilroy
|
|
13
|
+
(roughly 77). Mileposts are computed from the GTFS track geometry, so they
|
|
14
|
+
will not exactly match Caltrain's official chaining.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from datetime import date, datetime, timedelta
|
|
20
|
+
from enum import Enum
|
|
21
|
+
|
|
22
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Frozen(BaseModel):
|
|
26
|
+
model_config = ConfigDict(frozen=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Enumerations
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Direction(str, Enum):
|
|
35
|
+
"""Direction of travel. Northbound trains have odd numbers, southbound even."""
|
|
36
|
+
|
|
37
|
+
NORTHBOUND = "northbound"
|
|
38
|
+
SOUTHBOUND = "southbound"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ServiceType(str, Enum):
|
|
42
|
+
"""Caltrain's service patterns, from GTFS ``realtime_routes.txt``.
|
|
43
|
+
|
|
44
|
+
Weekday and weekend locals are both ``LOCAL``; whether a train runs on a
|
|
45
|
+
weekday or weekend schedule is a property of its service date, not its type.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
LOCAL = "local"
|
|
49
|
+
LIMITED = "limited"
|
|
50
|
+
EXPRESS = "express"
|
|
51
|
+
SOUTH_COUNTY = "south_county"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PositionSource(str, Enum):
|
|
55
|
+
"""How a :class:`TrainPosition` was obtained.
|
|
56
|
+
|
|
57
|
+
- ``REPORTED``: the raw location from the real-time feed. Real but stale --
|
|
58
|
+
511 refreshes Caltrain positions only every few minutes.
|
|
59
|
+
- ``ESTIMATED``: interpolated along the track using real-time *predicted*
|
|
60
|
+
arrival/departure times, cross-checked against the latest report. This is
|
|
61
|
+
the library's best guess and the default :attr:`Train.position`.
|
|
62
|
+
- ``SCHEDULED``: interpolated along the track from the published timetable
|
|
63
|
+
alone. This is where the train *should* be.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
REPORTED = "reported"
|
|
67
|
+
ESTIMATED = "estimated"
|
|
68
|
+
SCHEDULED = "scheduled"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Relation(str, Enum):
|
|
72
|
+
"""A train position's relationship to the nearest relevant station.
|
|
73
|
+
|
|
74
|
+
Used to build :attr:`TrainPosition.description`; exposed separately so UIs
|
|
75
|
+
can render their own wording or icons. Thresholds are configurable via
|
|
76
|
+
:class:`~caltrain_api.describe.DescriptionPolicy`.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
WAITING = "waiting" # at origin, before scheduled/expected departure
|
|
80
|
+
AT = "at" # at a station this train stops at
|
|
81
|
+
PASSING = "passing" # at a station this train does not stop at
|
|
82
|
+
APPROACHING = "approaching" # near the next station ahead
|
|
83
|
+
DEPARTED = "departed" # near the station just behind
|
|
84
|
+
BETWEEN = "between" # none of the above
|
|
85
|
+
ARRIVED = "arrived" # at destination, trip complete
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TrainStatus(str, Enum):
|
|
89
|
+
"""Where a train is in its trip's lifecycle, as of the snapshot."""
|
|
90
|
+
|
|
91
|
+
SCHEDULED = "scheduled" # not yet departed origin
|
|
92
|
+
EN_ROUTE = "en_route"
|
|
93
|
+
COMPLETED = "completed"
|
|
94
|
+
CANCELLED = "cancelled" # only when real-time data says so explicitly
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Locations
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TrackLocation(_Frozen):
|
|
103
|
+
"""A point on (or projected onto) the Caltrain corridor.
|
|
104
|
+
|
|
105
|
+
The common currency for anything with a place on the line: stations,
|
|
106
|
+
train positions, points along the track polyline.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
lat: float
|
|
110
|
+
lon: float
|
|
111
|
+
milepost: float = Field(
|
|
112
|
+
description="Miles along the track from SF 4th & King (0.0). May be slightly "
|
|
113
|
+
"negative for equipment north of the SF platform."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Station(_Frozen):
|
|
118
|
+
"""A physical Caltrain station.
|
|
119
|
+
|
|
120
|
+
GTFS models each station as a parent stop with separate northbound and
|
|
121
|
+
southbound platform stops; this class represents the station as a whole.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
id: str = Field(description="GTFS parent stop_id, e.g. 'palo_alto'. Stable across feeds.")
|
|
125
|
+
name: str = Field(description="Display name, e.g. 'Palo Alto' (trailing 'Station' removed).")
|
|
126
|
+
location: TrackLocation
|
|
127
|
+
platform_stop_ids: dict[Direction, str] = Field(
|
|
128
|
+
description="GTFS platform stop_ids by direction, for correlating with raw GTFS data."
|
|
129
|
+
)
|
|
130
|
+
has_scheduled_service: bool = Field(
|
|
131
|
+
description="False for stations present in GTFS but served by no trip in the "
|
|
132
|
+
"current feed (e.g. Stanford, which opens only for events)."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Trains
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Stop(_Frozen):
|
|
142
|
+
"""One scheduled call of a train at a station."""
|
|
143
|
+
|
|
144
|
+
station: Station
|
|
145
|
+
scheduled_arrival: AwareDatetime
|
|
146
|
+
scheduled_departure: AwareDatetime
|
|
147
|
+
expected_arrival: AwareDatetime | None = Field(
|
|
148
|
+
default=None, description="Real-time prediction; None if unavailable or already passed."
|
|
149
|
+
)
|
|
150
|
+
expected_departure: AwareDatetime | None = None
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def arrival(self) -> datetime:
|
|
154
|
+
"""Best available arrival time: expected if known, else scheduled."""
|
|
155
|
+
return self.expected_arrival or self.scheduled_arrival
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def departure(self) -> datetime:
|
|
159
|
+
"""Best available departure time: expected if known, else scheduled."""
|
|
160
|
+
return self.expected_departure or self.scheduled_departure
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def delay(self) -> timedelta | None:
|
|
164
|
+
"""Predicted departure lateness (negative = early); None without real-time data."""
|
|
165
|
+
if self.expected_departure is None:
|
|
166
|
+
return None
|
|
167
|
+
return self.expected_departure - self.scheduled_departure
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TrainPosition(TrackLocation):
|
|
171
|
+
"""Where a train is (or should be), with context.
|
|
172
|
+
|
|
173
|
+
A ``TrackLocation`` plus when, how we know, and how it relates to stations.
|
|
174
|
+
``station``/``previous_station``/``next_station`` refer to *physical*
|
|
175
|
+
neighbors along the track, including stations the train does not stop at;
|
|
176
|
+
use :attr:`Train.next_stop` for the next station it will serve.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
source: PositionSource
|
|
180
|
+
as_of: AwareDatetime = Field(
|
|
181
|
+
description="When this position applies. For REPORTED positions this is when the "
|
|
182
|
+
"library first saw the feed report this location -- 511's own timestamps do not "
|
|
183
|
+
"reflect fix time -- so the train may have been there somewhat earlier."
|
|
184
|
+
)
|
|
185
|
+
direction: Direction
|
|
186
|
+
relation: Relation
|
|
187
|
+
station: Station | None = Field(
|
|
188
|
+
default=None,
|
|
189
|
+
description="The station that ``relation`` refers to (None for BETWEEN).",
|
|
190
|
+
)
|
|
191
|
+
previous_station: Station | None = Field(
|
|
192
|
+
default=None, description="Nearest station behind the train (None before origin)."
|
|
193
|
+
)
|
|
194
|
+
next_station: Station | None = Field(
|
|
195
|
+
default=None, description="Nearest station ahead of the train (None past the end)."
|
|
196
|
+
)
|
|
197
|
+
description: str = Field(
|
|
198
|
+
description="Human-readable summary, e.g. 'approaching Redwood City', "
|
|
199
|
+
"'between California Avenue and San Antonio'."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Alert(_Frozen):
|
|
204
|
+
"""A Caltrain service alert (delay, cancellation, platform change, elevator outage...).
|
|
205
|
+
|
|
206
|
+
Alerts come from free-text operational messages and can be contradictory
|
|
207
|
+
(e.g. a train both 'cancelled' and 'running late'); treat them as
|
|
208
|
+
informational, not authoritative state.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
id: str
|
|
212
|
+
header: str
|
|
213
|
+
description: str | None = None
|
|
214
|
+
effect: str | None = Field(
|
|
215
|
+
default=None, description="GTFS-RT effect name, e.g. 'SIGNIFICANT_DELAYS'."
|
|
216
|
+
)
|
|
217
|
+
train_numbers: list[int] = Field(default_factory=list)
|
|
218
|
+
station_ids: list[str] = Field(default_factory=list)
|
|
219
|
+
active_from: AwareDatetime | None = None
|
|
220
|
+
active_until: AwareDatetime | None = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Train(_Frozen):
|
|
224
|
+
"""A snapshot of one Caltrain trip on one service day.
|
|
225
|
+
|
|
226
|
+
Created by :class:`~caltrain_api.CaltrainAPI` (schedule + real-time) or
|
|
227
|
+
:class:`~caltrain_api.Schedule` (schedule only, in which case real-time
|
|
228
|
+
fields are None and ``position`` equals ``scheduled_position``).
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
number: int = Field(description="Train number, e.g. 123. Odd = northbound.")
|
|
232
|
+
trip_id: str = Field(
|
|
233
|
+
description="Raw GTFS trip_id, e.g. '123', or 'M147' on holiday (modified) schedules."
|
|
234
|
+
)
|
|
235
|
+
direction: Direction
|
|
236
|
+
service_type: ServiceType
|
|
237
|
+
service_date: date = Field(
|
|
238
|
+
description="The GTFS service day this trip belongs to. A train running at 12:30 AM "
|
|
239
|
+
"Saturday on Friday's schedule has Friday's service_date."
|
|
240
|
+
)
|
|
241
|
+
as_of: AwareDatetime = Field(description="The moment this snapshot describes.")
|
|
242
|
+
status: TrainStatus
|
|
243
|
+
stops: list[Stop] = Field(description="Calls in travel order, origin first.")
|
|
244
|
+
|
|
245
|
+
position: TrainPosition | None = Field(
|
|
246
|
+
default=None,
|
|
247
|
+
description="Best estimate: ESTIMATED when real-time predicted times exist; REPORTED "
|
|
248
|
+
"when the feed gives only a location; else SCHEDULED. None when the train is not "
|
|
249
|
+
"running at ``as_of``.",
|
|
250
|
+
)
|
|
251
|
+
reported_position: TrainPosition | None = Field(
|
|
252
|
+
default=None, description="Raw real-time location, if the feed reports this train."
|
|
253
|
+
)
|
|
254
|
+
scheduled_position: TrainPosition | None = Field(
|
|
255
|
+
default=None, description="Timetable position at ``as_of``; None outside the trip's span."
|
|
256
|
+
)
|
|
257
|
+
delay: timedelta | None = Field(
|
|
258
|
+
default=None,
|
|
259
|
+
description="Current lateness (expected vs scheduled at the next stop); None without "
|
|
260
|
+
"real-time data.",
|
|
261
|
+
)
|
|
262
|
+
alerts: list[Alert] = Field(default_factory=list)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def origin(self) -> Station:
|
|
266
|
+
return self.stops[0].station
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def destination(self) -> Station:
|
|
270
|
+
return self.stops[-1].station
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def stations(self) -> list[Station]:
|
|
274
|
+
"""Stations this train stops at, in travel order."""
|
|
275
|
+
return [s.station for s in self.stops]
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def next_stop(self) -> Stop | None:
|
|
279
|
+
"""First stop whose best-available departure is after ``as_of``.
|
|
280
|
+
|
|
281
|
+
While the train dwells at a station, that station is still the next stop.
|
|
282
|
+
"""
|
|
283
|
+
return next((s for s in self.stops if s.departure > self.as_of), None)
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def previous_stop(self) -> Stop | None:
|
|
287
|
+
"""Last stop whose best-available departure is at or before ``as_of``."""
|
|
288
|
+
return next((s for s in reversed(self.stops) if s.departure <= self.as_of), None)
|
|
289
|
+
|
|
290
|
+
def stop_at(self, station: Station | str) -> Stop | None:
|
|
291
|
+
"""This train's stop at ``station`` (a Station, id, or name), or None if it doesn't stop there.
|
|
292
|
+
|
|
293
|
+
String matching is case-insensitive against the station id and name.
|
|
294
|
+
"""
|
|
295
|
+
key = station.id if isinstance(station, Station) else station.strip().lower()
|
|
296
|
+
for s in self.stops:
|
|
297
|
+
if key in (s.station.id, s.station.id.lower(), s.station.name.lower()):
|
|
298
|
+
return s
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class Departure(_Frozen):
|
|
303
|
+
"""A train's upcoming call at a particular station (one row of a departure board)."""
|
|
304
|
+
|
|
305
|
+
train: Train
|
|
306
|
+
stop: Stop
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class Journey(_Frozen):
|
|
310
|
+
"""A ride on one train from one station to another (one row of a point-to-point timetable)."""
|
|
311
|
+
|
|
312
|
+
train: Train
|
|
313
|
+
origin_stop: Stop
|
|
314
|
+
destination_stop: Stop
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def scheduled_duration(self) -> timedelta:
|
|
318
|
+
return self.destination_stop.scheduled_arrival - self.origin_stop.scheduled_departure
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def duration(self) -> timedelta:
|
|
322
|
+
"""Best-available duration (expected times where known)."""
|
|
323
|
+
return self.destination_stop.arrival - self.origin_stop.departure
|
caltrain_api/py.typed
ADDED
|
File without changes
|
caltrain_api/realtime.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Real-time data sources.
|
|
2
|
+
|
|
3
|
+
:class:`RealtimeSource` is the internal seam between the library and any
|
|
4
|
+
particular feed. The default implementation, :class:`SiriRealtimeSource`, uses
|
|
5
|
+
511's SIRI VehicleMonitoring endpoint, which returns positions plus scheduled
|
|
6
|
+
("aimed") and predicted ("expected") times for every remaining stop in a
|
|
7
|
+
single request -- half the rate-limit cost of 511's GTFS-RT VehiclePositions +
|
|
8
|
+
TripUpdates pair.
|
|
9
|
+
|
|
10
|
+
Sources return raw observations keyed by train number; turning those into
|
|
11
|
+
:class:`~caltrain_api.models.Train` snapshots (mileposts, estimates,
|
|
12
|
+
descriptions) is the facade's job.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from datetime import date, datetime
|
|
21
|
+
from typing import Any, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
from google.protobuf.message import DecodeError
|
|
25
|
+
from google.transit import gtfs_realtime_pb2
|
|
26
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
|
27
|
+
|
|
28
|
+
from ._feedcache import USER_AGENT
|
|
29
|
+
from .errors import RateLimitedError, RealtimeUnavailableError
|
|
30
|
+
from .models import Alert, Direction
|
|
31
|
+
from .schedule import TZ, parse_train_number
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ExpectedCall(BaseModel):
|
|
37
|
+
"""A predicted call at a station, as reported by the feed."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(frozen=True)
|
|
40
|
+
|
|
41
|
+
platform_stop_id: str
|
|
42
|
+
expected_arrival: AwareDatetime | None = None
|
|
43
|
+
expected_departure: AwareDatetime | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TrainObservation(BaseModel):
|
|
47
|
+
"""What the feed says about one train."""
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(frozen=True)
|
|
50
|
+
|
|
51
|
+
train_number: int
|
|
52
|
+
trip_id: str | None = Field(default=None, description="Feed's trip reference, e.g. '124' or 'M147'.")
|
|
53
|
+
service_date: date | None = Field(default=None, description="The trip's service day, if reported.")
|
|
54
|
+
direction: Direction | None = None
|
|
55
|
+
lat: float | None = None
|
|
56
|
+
lon: float | None = None
|
|
57
|
+
expected_calls: list[ExpectedCall] = Field(default_factory=list)
|
|
58
|
+
cancelled: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RealtimeSnapshot(BaseModel):
|
|
62
|
+
"""One fetch's worth of real-time data."""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(frozen=True)
|
|
65
|
+
|
|
66
|
+
fetched_at: AwareDatetime
|
|
67
|
+
trains: dict[int, TrainObservation] = Field(default_factory=dict)
|
|
68
|
+
alerts: list[Alert] | None = Field(
|
|
69
|
+
default=None, description="None if this fetch did not refresh alerts."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_empty(self) -> bool:
|
|
74
|
+
"""True when the feed returned no trains.
|
|
75
|
+
|
|
76
|
+
511 intermittently returns empty feeds mid-service; callers should treat
|
|
77
|
+
this as "no data", not "no trains running".
|
|
78
|
+
"""
|
|
79
|
+
return not self.trains
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@runtime_checkable
|
|
83
|
+
class RealtimeSource(Protocol):
|
|
84
|
+
"""Anything that can produce a :class:`RealtimeSnapshot`.
|
|
85
|
+
|
|
86
|
+
Implementations do no caching or rate limiting of their own beyond what
|
|
87
|
+
they document; the facade decides when to call :meth:`fetch`.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def fetch(self) -> RealtimeSnapshot:
|
|
91
|
+
"""Fetch current data.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
RateLimitedError: the provider refused for rate limiting.
|
|
95
|
+
RealtimeUnavailableError: any other fetch/parse failure.
|
|
96
|
+
"""
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Parsing helpers (module-level so tests can feed them saved responses)
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _as_list(value: Any) -> list[Any]:
|
|
106
|
+
"""SIRI-JSON renders one-element lists as bare objects and empty ones as null."""
|
|
107
|
+
if value is None or value == "":
|
|
108
|
+
return []
|
|
109
|
+
return value if isinstance(value, list) else [value]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _siri_time(value: Any) -> datetime | None:
|
|
113
|
+
if not value or not isinstance(value, str):
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
117
|
+
except ValueError:
|
|
118
|
+
return None
|
|
119
|
+
return dt.astimezone(TZ) if dt.tzinfo else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _float(value: Any) -> float | None:
|
|
123
|
+
try:
|
|
124
|
+
f = float(value)
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
return None
|
|
127
|
+
return f if f != 0.0 else None # 0.0 means "no fix", not the Gulf of Guinea
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_vehicle_monitoring(raw: bytes) -> dict[int, TrainObservation]:
|
|
131
|
+
"""Parse a 511 SIRI VehicleMonitoring JSON response into observations by train number.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ValueError: the response is not the expected JSON shape.
|
|
135
|
+
"""
|
|
136
|
+
doc = json.loads(raw.decode("utf-8-sig")) # 511 prefixes a BOM
|
|
137
|
+
try:
|
|
138
|
+
delivery = doc["Siri"]["ServiceDelivery"]["VehicleMonitoringDelivery"]
|
|
139
|
+
except (KeyError, TypeError) as exc:
|
|
140
|
+
raise ValueError(f"unexpected SIRI structure: missing {exc}") from exc
|
|
141
|
+
|
|
142
|
+
trains: dict[int, TrainObservation] = {}
|
|
143
|
+
for delivery_part in _as_list(delivery):
|
|
144
|
+
for activity in _as_list(delivery_part.get("VehicleActivity")):
|
|
145
|
+
journey = activity.get("MonitoredVehicleJourney") or {}
|
|
146
|
+
frame = journey.get("FramedVehicleJourneyRef") or {}
|
|
147
|
+
ref = str(frame.get("DatedVehicleJourneyRef") or journey.get("VehicleRef") or "")
|
|
148
|
+
number = parse_train_number(ref)
|
|
149
|
+
if number is None:
|
|
150
|
+
log.debug("Skipping SIRI activity with unparseable train ref %r", ref)
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
service_date = None
|
|
154
|
+
if frame.get("DataFrameRef"):
|
|
155
|
+
try:
|
|
156
|
+
service_date = date.fromisoformat(frame["DataFrameRef"])
|
|
157
|
+
except ValueError:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
direction = {"N": Direction.NORTHBOUND, "S": Direction.SOUTHBOUND}.get(
|
|
161
|
+
str(journey.get("DirectionRef") or "").upper()[:1])
|
|
162
|
+
|
|
163
|
+
loc = journey.get("VehicleLocation") or {}
|
|
164
|
+
lat, lon = _float(loc.get("Latitude")), _float(loc.get("Longitude"))
|
|
165
|
+
if lat is None or lon is None:
|
|
166
|
+
lat = lon = None
|
|
167
|
+
|
|
168
|
+
calls: list[ExpectedCall] = []
|
|
169
|
+
seen: set[str] = set()
|
|
170
|
+
onward = journey.get("OnwardCalls") or {}
|
|
171
|
+
for call in _as_list(journey.get("MonitoredCall")) + _as_list(onward.get("OnwardCall")):
|
|
172
|
+
stop_id = str(call.get("StopPointRef") or "")
|
|
173
|
+
if not stop_id or stop_id in seen:
|
|
174
|
+
continue
|
|
175
|
+
seen.add(stop_id)
|
|
176
|
+
calls.append(ExpectedCall(
|
|
177
|
+
platform_stop_id=stop_id,
|
|
178
|
+
expected_arrival=_siri_time(call.get("ExpectedArrivalTime")),
|
|
179
|
+
expected_departure=_siri_time(call.get("ExpectedDepartureTime")),
|
|
180
|
+
))
|
|
181
|
+
|
|
182
|
+
trains[number] = TrainObservation(
|
|
183
|
+
train_number=number, trip_id=ref, service_date=service_date, direction=direction,
|
|
184
|
+
lat=lat, lon=lon, expected_calls=calls,
|
|
185
|
+
)
|
|
186
|
+
return trains
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _translated(ts: Any) -> str:
|
|
190
|
+
"""Pick the English (or first) translation of a GTFS-RT TranslatedString."""
|
|
191
|
+
translations = list(ts.translation)
|
|
192
|
+
for t in translations:
|
|
193
|
+
if t.language.lower().startswith("en"):
|
|
194
|
+
return str(t.text)
|
|
195
|
+
return str(translations[0].text) if translations else ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_service_alerts(raw: bytes) -> list[Alert]:
|
|
199
|
+
"""Parse a GTFS-RT ServiceAlerts protobuf.
|
|
200
|
+
|
|
201
|
+
``station_ids`` holds the feed's raw stop ids (platforms, elevators...);
|
|
202
|
+
the facade normalizes them to station ids.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
ValueError: the bytes are not a GTFS-RT FeedMessage.
|
|
206
|
+
"""
|
|
207
|
+
feed = gtfs_realtime_pb2.FeedMessage()
|
|
208
|
+
try:
|
|
209
|
+
feed.ParseFromString(raw)
|
|
210
|
+
except DecodeError as exc:
|
|
211
|
+
raise ValueError(f"not a GTFS-RT feed: {exc}") from exc
|
|
212
|
+
|
|
213
|
+
alerts = []
|
|
214
|
+
for entity in feed.entity:
|
|
215
|
+
if not entity.HasField("alert"):
|
|
216
|
+
continue
|
|
217
|
+
a = entity.alert
|
|
218
|
+
numbers = sorted({n for ie in a.informed_entity if ie.HasField("trip")
|
|
219
|
+
for n in [parse_train_number(ie.trip.trip_id)] if n is not None})
|
|
220
|
+
stop_ids = sorted({ie.stop_id for ie in a.informed_entity if ie.stop_id})
|
|
221
|
+
period = a.active_period[0] if a.active_period else None
|
|
222
|
+
alerts.append(Alert(
|
|
223
|
+
id=entity.id,
|
|
224
|
+
header=_translated(a.header_text),
|
|
225
|
+
description=_translated(a.description_text) or None,
|
|
226
|
+
effect=gtfs_realtime_pb2.Alert.Effect.Name(a.effect) if a.HasField("effect") else None,
|
|
227
|
+
train_numbers=numbers,
|
|
228
|
+
station_ids=stop_ids,
|
|
229
|
+
active_from=datetime.fromtimestamp(period.start, TZ) if period and period.start else None,
|
|
230
|
+
active_until=datetime.fromtimestamp(period.end, TZ) if period and period.end else None,
|
|
231
|
+
))
|
|
232
|
+
return alerts
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# 511 source
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class SiriRealtimeSource:
|
|
241
|
+
"""511 SIRI VehicleMonitoring for Caltrain (operator ``CT``), plus service alerts.
|
|
242
|
+
|
|
243
|
+
Alerts come from 511's GTFS-RT ServiceAlerts endpoint. They change slowly,
|
|
244
|
+
so they are fetched at most every ``alerts_interval`` seconds and included
|
|
245
|
+
only in the snapshots where they were refreshed; an alerts failure is
|
|
246
|
+
logged and never fails the snapshot.
|
|
247
|
+
|
|
248
|
+
Request budget at the facade's default 120 s poll: ~30 VehicleMonitoring +
|
|
249
|
+
~6 ServiceAlerts calls per hour, against 511's default 60/hour.
|
|
250
|
+
|
|
251
|
+
The API key is sent as a query parameter (511's only option) and is never
|
|
252
|
+
included in exception messages or logs.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
VEHICLE_MONITORING_URL = "https://api.511.org/Transit/VehicleMonitoring"
|
|
256
|
+
SERVICE_ALERTS_URL = "https://api.511.org/Transit/ServiceAlerts"
|
|
257
|
+
OPERATOR = "CT"
|
|
258
|
+
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
api_key: str,
|
|
262
|
+
*,
|
|
263
|
+
http_client: httpx.Client | None = None,
|
|
264
|
+
alerts_interval: float = 600.0,
|
|
265
|
+
clock: Callable[[], datetime] | None = None,
|
|
266
|
+
):
|
|
267
|
+
if not api_key:
|
|
268
|
+
raise ValueError("api_key is required")
|
|
269
|
+
self._key = api_key
|
|
270
|
+
self._client = http_client or httpx.Client(timeout=30, headers={"User-Agent": USER_AGENT})
|
|
271
|
+
self._alerts_interval = alerts_interval
|
|
272
|
+
self._clock = clock or (lambda: datetime.now(TZ))
|
|
273
|
+
self._alerts_fetched_at: datetime | None = None
|
|
274
|
+
|
|
275
|
+
def _get(self, url: str, label: str, **params: str) -> bytes:
|
|
276
|
+
try:
|
|
277
|
+
resp = self._client.get(url, params={"api_key": self._key, "agency": self.OPERATOR, **params})
|
|
278
|
+
except httpx.HTTPError as exc:
|
|
279
|
+
# str(exc) can embed the request URL, which contains the key.
|
|
280
|
+
raise RealtimeUnavailableError(f"{label}: {type(exc).__name__}") from None
|
|
281
|
+
remaining = resp.headers.get("ratelimit-remaining")
|
|
282
|
+
log.debug("%s: HTTP %d, %d bytes, ratelimit-remaining=%s",
|
|
283
|
+
label, resp.status_code, len(resp.content), remaining)
|
|
284
|
+
if resp.status_code == 429:
|
|
285
|
+
retry = resp.headers.get("retry-after")
|
|
286
|
+
raise RateLimitedError(f"{label}: rate limited by 511",
|
|
287
|
+
retry_after=float(retry) if retry and retry.isdigit() else None)
|
|
288
|
+
if resp.status_code != 200:
|
|
289
|
+
raise RealtimeUnavailableError(f"{label}: HTTP {resp.status_code}")
|
|
290
|
+
return resp.content
|
|
291
|
+
|
|
292
|
+
def fetch(self) -> RealtimeSnapshot:
|
|
293
|
+
now = self._clock()
|
|
294
|
+
raw = self._get(self.VEHICLE_MONITORING_URL, "VehicleMonitoring", format="json")
|
|
295
|
+
try:
|
|
296
|
+
trains = parse_vehicle_monitoring(raw)
|
|
297
|
+
except ValueError as exc:
|
|
298
|
+
raise RealtimeUnavailableError(f"VehicleMonitoring: unparseable response: {exc}") from exc
|
|
299
|
+
log.info("Real-time: %d trains", len(trains))
|
|
300
|
+
|
|
301
|
+
alerts = None
|
|
302
|
+
due = (self._alerts_fetched_at is None
|
|
303
|
+
or (now - self._alerts_fetched_at).total_seconds() >= self._alerts_interval)
|
|
304
|
+
if due:
|
|
305
|
+
try:
|
|
306
|
+
alerts = parse_service_alerts(self._get(self.SERVICE_ALERTS_URL, "ServiceAlerts"))
|
|
307
|
+
self._alerts_fetched_at = now
|
|
308
|
+
log.info("Real-time: %d alerts", len(alerts))
|
|
309
|
+
except (RealtimeUnavailableError, ValueError) as exc:
|
|
310
|
+
# Includes RateLimitedError: keep the train data we already have,
|
|
311
|
+
# and wait a full interval before trying alerts again.
|
|
312
|
+
self._alerts_fetched_at = now
|
|
313
|
+
log.warning("Service alerts unavailable: %s", exc)
|
|
314
|
+
|
|
315
|
+
return RealtimeSnapshot(fetched_at=now, trains=trains, alerts=alerts)
|