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/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
@@ -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)