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/api.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""CaltrainAPI: the main entry point, combining the timetable with real-time data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from datetime import UTC, date, datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .describe import DescriptionPolicy
|
|
14
|
+
from .errors import (
|
|
15
|
+
CaltrainError,
|
|
16
|
+
OffCorridorError,
|
|
17
|
+
RateLimitedError,
|
|
18
|
+
RealtimeUnavailableError,
|
|
19
|
+
UnknownStationError,
|
|
20
|
+
UnknownTrainError,
|
|
21
|
+
)
|
|
22
|
+
from .models import (
|
|
23
|
+
Alert,
|
|
24
|
+
Departure,
|
|
25
|
+
Journey,
|
|
26
|
+
Direction,
|
|
27
|
+
PositionSource,
|
|
28
|
+
Station,
|
|
29
|
+
Stop,
|
|
30
|
+
Train,
|
|
31
|
+
TrainPosition,
|
|
32
|
+
TrainStatus,
|
|
33
|
+
)
|
|
34
|
+
from .realtime import RealtimeSnapshot, RealtimeSource, SiriRealtimeSource, TrainObservation
|
|
35
|
+
from .schedule import TZ, Schedule, _interpolate
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
#: Minimum time between real-time fetches. 511 allows 60 requests/hour per key
|
|
40
|
+
#: and refreshes Caltrain positions only every ~3-4 minutes, so polling faster
|
|
41
|
+
#: than this buys nothing.
|
|
42
|
+
DEFAULT_POLL_INTERVAL = timedelta(seconds=120)
|
|
43
|
+
|
|
44
|
+
#: How long a real-time snapshot stays usable when fetches fail or come back
|
|
45
|
+
#: empty. Older than this, trains fall back to schedule-only data.
|
|
46
|
+
DEFAULT_MAX_STALENESS = timedelta(minutes=10)
|
|
47
|
+
|
|
48
|
+
#: Real-time data is applied only when ``at`` is within this window of now;
|
|
49
|
+
#: questions about other times are answered from the timetable alone.
|
|
50
|
+
REALTIME_WINDOW = timedelta(minutes=5)
|
|
51
|
+
|
|
52
|
+
#: How often the facade checks whether the static GTFS needs refreshing.
|
|
53
|
+
SCHEDULE_CHECK_INTERVAL = timedelta(hours=1)
|
|
54
|
+
|
|
55
|
+
#: get_departures looks this far back for scheduled departures, to catch late trains.
|
|
56
|
+
LATE_TRAIN_LOOKBACK = timedelta(hours=2)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _shift(dt: datetime, delta: timedelta) -> datetime:
|
|
60
|
+
"""Absolute-time shift (zoneinfo datetimes otherwise do wall-clock arithmetic)."""
|
|
61
|
+
return (dt.astimezone(UTC) + delta).astimezone(TZ)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CaltrainAPI:
|
|
65
|
+
"""Caltrain schedules and train positions.
|
|
66
|
+
|
|
67
|
+
Without an API key, everything works from the published timetable
|
|
68
|
+
(positions are SCHEDULED). With a 511 key, trains also carry reported
|
|
69
|
+
positions, predicted times, delays, and best-estimate positions.
|
|
70
|
+
|
|
71
|
+
Thread-safe: one instance can serve a long-running, concurrent application.
|
|
72
|
+
Real-time data is fetched lazily -- when a method needs it and the last
|
|
73
|
+
fetch is older than ``poll_interval`` -- so call as often as you like. The
|
|
74
|
+
static timetable is re-checked at most hourly and re-downloaded when stale
|
|
75
|
+
(by default, daily); that download happens inside whichever call notices.
|
|
76
|
+
|
|
77
|
+
Example::
|
|
78
|
+
|
|
79
|
+
api = CaltrainAPI(api_key=os.environ["CALTRAIN_511_API_KEY"])
|
|
80
|
+
for train in api.get_active_trains():
|
|
81
|
+
p = train.position
|
|
82
|
+
print(f"{train.number} {train.service_type.value:8} mp {p.milepost:5.1f} "
|
|
83
|
+
f"{p.description} ({train.delay or 'on time'})")
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
api_key: 511.org API key. None means schedule-only.
|
|
87
|
+
schedule: A preloaded Schedule (default: ``Schedule.load(cache_dir=...)``).
|
|
88
|
+
realtime: A custom real-time source (default: SiriRealtimeSource when
|
|
89
|
+
``api_key`` is given). Overrides ``api_key``.
|
|
90
|
+
cache_dir: Static GTFS cache location (see :meth:`Schedule.load`).
|
|
91
|
+
poll_interval: Minimum time between real-time fetches.
|
|
92
|
+
max_staleness: How long to keep using the last good snapshot when fetches fail.
|
|
93
|
+
description_policy: Thresholds for position descriptions (used when
|
|
94
|
+
this facade loads the schedule itself).
|
|
95
|
+
clock: Returns "now" as an aware datetime; injected for tests.
|
|
96
|
+
http_client: Shared HTTP client for all network access.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ScheduleUnavailableError: no schedule given, none cached, and the download failed.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
api_key: str | None = None,
|
|
105
|
+
*,
|
|
106
|
+
schedule: Schedule | None = None,
|
|
107
|
+
realtime: RealtimeSource | None = None,
|
|
108
|
+
cache_dir: Path | None = None,
|
|
109
|
+
poll_interval: timedelta = DEFAULT_POLL_INTERVAL,
|
|
110
|
+
max_staleness: timedelta = DEFAULT_MAX_STALENESS,
|
|
111
|
+
description_policy: DescriptionPolicy | None = None,
|
|
112
|
+
clock: Callable[[], datetime] | None = None,
|
|
113
|
+
http_client: httpx.Client | None = None,
|
|
114
|
+
):
|
|
115
|
+
self._clock = clock or (lambda: datetime.now(TZ))
|
|
116
|
+
self._schedule = schedule or Schedule.load(
|
|
117
|
+
cache_dir=cache_dir, http_client=http_client,
|
|
118
|
+
description_policy=description_policy, clock=self._clock)
|
|
119
|
+
if realtime is None and api_key:
|
|
120
|
+
realtime = SiriRealtimeSource(api_key, http_client=http_client, clock=self._clock)
|
|
121
|
+
self._realtime = realtime
|
|
122
|
+
self._poll_interval = poll_interval
|
|
123
|
+
self._max_staleness = max_staleness
|
|
124
|
+
|
|
125
|
+
self._lock = threading.RLock()
|
|
126
|
+
self._schedule_checked_at = self._now()
|
|
127
|
+
self._last_attempt: datetime | None = None
|
|
128
|
+
self._backoff_until: datetime | None = None
|
|
129
|
+
self._last_good: RealtimeSnapshot | None = None
|
|
130
|
+
self._alerts: list[Alert] = []
|
|
131
|
+
# train number -> ((lat, lon), when this library first saw it there)
|
|
132
|
+
self._first_seen: dict[int, tuple[tuple[float, float], datetime]] = {}
|
|
133
|
+
log.info("CaltrainAPI ready (real-time: %s)", type(realtime).__name__ if realtime else "none")
|
|
134
|
+
|
|
135
|
+
def _now(self) -> datetime:
|
|
136
|
+
now = self._clock()
|
|
137
|
+
if now.tzinfo is None:
|
|
138
|
+
raise ValueError("clock must return timezone-aware datetimes")
|
|
139
|
+
return now
|
|
140
|
+
|
|
141
|
+
def _resolve_at(self, at: datetime | None, now: datetime) -> datetime:
|
|
142
|
+
if at is None:
|
|
143
|
+
return now
|
|
144
|
+
if at.tzinfo is None or at.utcoffset() is None:
|
|
145
|
+
raise ValueError("datetimes passed to caltrain_api must be timezone-aware")
|
|
146
|
+
return at
|
|
147
|
+
|
|
148
|
+
# -- schedule pass-throughs ---------------------------------------------
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def schedule(self) -> Schedule:
|
|
152
|
+
"""The current timetable (replaced, not mutated, by :meth:`refresh_schedule`)."""
|
|
153
|
+
return self._schedule
|
|
154
|
+
|
|
155
|
+
def refresh_schedule(self, *, force: bool = False) -> bool:
|
|
156
|
+
"""Re-download the static GTFS if stale (or ``force``). Returns True if it changed.
|
|
157
|
+
|
|
158
|
+
Also happens automatically: the facade checks staleness at most hourly
|
|
159
|
+
when answering queries.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
ScheduleUnavailableError: only with ``force``, if the download fails.
|
|
163
|
+
"""
|
|
164
|
+
with self._lock:
|
|
165
|
+
old = self._schedule
|
|
166
|
+
self._schedule = old.refresh(force=force)
|
|
167
|
+
self._schedule_checked_at = self._now()
|
|
168
|
+
return self._schedule is not old
|
|
169
|
+
|
|
170
|
+
def _maybe_refresh_schedule(self, now: datetime) -> Schedule:
|
|
171
|
+
if now - self._schedule_checked_at >= SCHEDULE_CHECK_INTERVAL:
|
|
172
|
+
with self._lock:
|
|
173
|
+
if now - self._schedule_checked_at >= SCHEDULE_CHECK_INTERVAL:
|
|
174
|
+
self._schedule_checked_at = now
|
|
175
|
+
try:
|
|
176
|
+
self._schedule = self._schedule.refresh()
|
|
177
|
+
except CaltrainError as exc: # refresh() already degrades; belt and braces
|
|
178
|
+
log.warning("Schedule refresh failed: %s", exc)
|
|
179
|
+
return self._schedule
|
|
180
|
+
|
|
181
|
+
def get_stations(self) -> list[Station]:
|
|
182
|
+
"""Served stations in milepost order (SF first)."""
|
|
183
|
+
return self._maybe_refresh_schedule(self._now()).stations()
|
|
184
|
+
|
|
185
|
+
def get_station(self, key: str) -> Station:
|
|
186
|
+
"""Station by id, platform stop_id, or name. See :meth:`Schedule.station`."""
|
|
187
|
+
return self._maybe_refresh_schedule(self._now()).station(key)
|
|
188
|
+
|
|
189
|
+
# -- real-time state ----------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _snapshot_for(self, at: datetime, now: datetime) -> RealtimeSnapshot | None:
|
|
192
|
+
"""The real-time snapshot to apply at ``at``, fetching if due; None if not applicable."""
|
|
193
|
+
if self._realtime is None or abs(at - now) > REALTIME_WINDOW:
|
|
194
|
+
return None
|
|
195
|
+
with self._lock:
|
|
196
|
+
due = self._last_attempt is None or now - self._last_attempt >= self._poll_interval
|
|
197
|
+
backing_off = self._backoff_until is not None and now < self._backoff_until
|
|
198
|
+
if due and not backing_off:
|
|
199
|
+
self._fetch(now)
|
|
200
|
+
snap = self._last_good
|
|
201
|
+
if snap is not None and now - snap.fetched_at > self._max_staleness:
|
|
202
|
+
log.info("Real-time data is %s old; using schedule only", now - snap.fetched_at)
|
|
203
|
+
return None
|
|
204
|
+
return snap
|
|
205
|
+
|
|
206
|
+
def _fetch(self, now: datetime) -> None:
|
|
207
|
+
assert self._realtime is not None
|
|
208
|
+
self._last_attempt = now
|
|
209
|
+
try:
|
|
210
|
+
snap = self._realtime.fetch()
|
|
211
|
+
except RateLimitedError as exc:
|
|
212
|
+
wait = timedelta(seconds=exc.retry_after) if exc.retry_after else self._poll_interval * 5
|
|
213
|
+
self._backoff_until = now + wait
|
|
214
|
+
log.warning("511 rate limit hit; pausing real-time fetches for %s", wait)
|
|
215
|
+
return
|
|
216
|
+
except RealtimeUnavailableError as exc:
|
|
217
|
+
log.warning("Real-time fetch failed: %s", exc)
|
|
218
|
+
return
|
|
219
|
+
self._backoff_until = None
|
|
220
|
+
|
|
221
|
+
if snap.alerts is not None:
|
|
222
|
+
self._alerts = [self._normalize_alert(a) for a in snap.alerts]
|
|
223
|
+
if snap.is_empty:
|
|
224
|
+
# 511 drops to empty feeds mid-service; keep the last good trains.
|
|
225
|
+
log.info("Real-time feed returned no trains; keeping previous snapshot")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
seen: dict[int, tuple[tuple[float, float], datetime]] = {}
|
|
229
|
+
for number, obs in snap.trains.items():
|
|
230
|
+
if obs.lat is None or obs.lon is None:
|
|
231
|
+
continue
|
|
232
|
+
key = (round(obs.lat, 5), round(obs.lon, 5))
|
|
233
|
+
prev = self._first_seen.get(number)
|
|
234
|
+
seen[number] = prev if prev is not None and prev[0] == key else (key, snap.fetched_at)
|
|
235
|
+
self._first_seen = seen
|
|
236
|
+
self._last_good = snap
|
|
237
|
+
|
|
238
|
+
def _normalize_alert(self, alert: Alert) -> Alert:
|
|
239
|
+
ids: list[str] = []
|
|
240
|
+
for raw in alert.station_ids:
|
|
241
|
+
try:
|
|
242
|
+
sid = self._schedule.station(raw).id
|
|
243
|
+
except UnknownStationError:
|
|
244
|
+
log.debug("Alert %s cites unknown stop %s", alert.id, raw)
|
|
245
|
+
continue
|
|
246
|
+
if sid not in ids:
|
|
247
|
+
ids.append(sid)
|
|
248
|
+
return alert.model_copy(update={"station_ids": ids})
|
|
249
|
+
|
|
250
|
+
# -- combining schedule + real-time --------------------------------------
|
|
251
|
+
|
|
252
|
+
def _enrich(self, train: Train, snap: RealtimeSnapshot | None, at: datetime) -> Train:
|
|
253
|
+
"""Apply real-time data and alerts to a schedule-only snapshot."""
|
|
254
|
+
alerts = [a for a in self._alerts if train.number in a.train_numbers] if snap else []
|
|
255
|
+
obs = snap.trains.get(train.number) if snap else None
|
|
256
|
+
if obs is not None and obs.service_date is not None and obs.service_date != train.service_date:
|
|
257
|
+
obs = None # the feed is describing a different day's run of this number
|
|
258
|
+
if obs is None:
|
|
259
|
+
return train.model_copy(update={"alerts": alerts}) if alerts else train
|
|
260
|
+
return self._apply_observation(train, obs, at, alerts)
|
|
261
|
+
|
|
262
|
+
def _apply_observation(self, train: Train, obs: TrainObservation, at: datetime,
|
|
263
|
+
alerts: list[Alert]) -> Train:
|
|
264
|
+
schedule = self._schedule
|
|
265
|
+
by_stop = {c.platform_stop_id: c for c in obs.expected_calls}
|
|
266
|
+
|
|
267
|
+
# 1. Expected times onto the stops the feed still lists.
|
|
268
|
+
stops: list[Stop] = []
|
|
269
|
+
for s in train.stops:
|
|
270
|
+
pid = s.station.platform_stop_ids.get(train.direction)
|
|
271
|
+
call = by_stop.get(pid) if pid else None
|
|
272
|
+
if call is None:
|
|
273
|
+
stops.append(s)
|
|
274
|
+
continue
|
|
275
|
+
arr = call.expected_arrival or call.expected_departure
|
|
276
|
+
dep = call.expected_departure or call.expected_arrival
|
|
277
|
+
if arr is not None and dep is not None and dep < arr:
|
|
278
|
+
dep = arr
|
|
279
|
+
stops.append(s.model_copy(update={"expected_arrival": arr, "expected_departure": dep}))
|
|
280
|
+
|
|
281
|
+
# 2. Delay = lateness at the first stop with a prediction.
|
|
282
|
+
first = next((s for s in stops if s.expected_arrival is not None), None)
|
|
283
|
+
delay = (first.expected_arrival - first.scheduled_arrival
|
|
284
|
+
if first is not None and first.expected_arrival is not None else None)
|
|
285
|
+
|
|
286
|
+
# 3. For estimation only: stops without predictions (mostly ones already
|
|
287
|
+
# passed) get scheduled + delay, so interpolation between the last
|
|
288
|
+
# stop and the next one uses a consistent timeline.
|
|
289
|
+
if delay is not None:
|
|
290
|
+
est_stops = [s if s.expected_arrival is not None else s.model_copy(update={
|
|
291
|
+
"expected_arrival": _shift(s.scheduled_arrival, delay),
|
|
292
|
+
"expected_departure": _shift(s.scheduled_departure, delay)}) for s in stops]
|
|
293
|
+
else:
|
|
294
|
+
est_stops = stops
|
|
295
|
+
|
|
296
|
+
if obs.cancelled:
|
|
297
|
+
status = TrainStatus.CANCELLED
|
|
298
|
+
elif at < est_stops[0].departure:
|
|
299
|
+
status = TrainStatus.SCHEDULED
|
|
300
|
+
elif at >= est_stops[-1].arrival:
|
|
301
|
+
status = TrainStatus.COMPLETED
|
|
302
|
+
else:
|
|
303
|
+
status = TrainStatus.EN_ROUTE
|
|
304
|
+
|
|
305
|
+
# 4. Reported position: raw lat/lon, milepost by projection.
|
|
306
|
+
reported: TrainPosition | None = None
|
|
307
|
+
if obs.lat is not None and obs.lon is not None:
|
|
308
|
+
seen = self._first_seen.get(train.number)
|
|
309
|
+
seen_at = seen[1] if seen else at
|
|
310
|
+
try:
|
|
311
|
+
loc = schedule.corridor.locate(obs.lat, obs.lon)
|
|
312
|
+
reported = schedule.position_at_milepost(
|
|
313
|
+
train.direction, est_stops, loc.milepost, seen_at,
|
|
314
|
+
source=PositionSource.REPORTED, lat_lon=(obs.lat, obs.lon))
|
|
315
|
+
except OffCorridorError as exc:
|
|
316
|
+
log.debug("Train %d report ignored: %s", train.number, exc)
|
|
317
|
+
|
|
318
|
+
# 5. Best estimate.
|
|
319
|
+
position: TrainPosition | None
|
|
320
|
+
if delay is None:
|
|
321
|
+
# No predicted times: the report is the best we have.
|
|
322
|
+
position = reported or train.scheduled_position
|
|
323
|
+
else:
|
|
324
|
+
mps = [s.station.location.milepost for s in est_stops]
|
|
325
|
+
times = [(s.arrival, s.departure) for s in est_stops]
|
|
326
|
+
est_mp = _interpolate(mps, times, at)
|
|
327
|
+
if est_mp is None and status is TrainStatus.SCHEDULED:
|
|
328
|
+
est_mp = mps[0] # waiting at origin
|
|
329
|
+
# Before departure, 511 reports where the train's *equipment* is --
|
|
330
|
+
# often still finishing its previous run miles away -- so the
|
|
331
|
+
# report says nothing about this trip yet.
|
|
332
|
+
if est_mp is not None and reported is not None and status is not TrainStatus.SCHEDULED:
|
|
333
|
+
est_mp = self._reconcile(train.direction, est_mp, reported)
|
|
334
|
+
position = (schedule.position_at_milepost(train.direction, est_stops, est_mp, at,
|
|
335
|
+
source=PositionSource.ESTIMATED)
|
|
336
|
+
if est_mp is not None else None)
|
|
337
|
+
|
|
338
|
+
return train.model_copy(update={
|
|
339
|
+
"stops": stops, "status": status, "delay": delay, "alerts": alerts,
|
|
340
|
+
"position": position, "reported_position": reported,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _reconcile(direction: Direction, est_mp: float, reported: TrainPosition) -> float:
|
|
345
|
+
"""Keep a time-based estimate consistent with the last reported position.
|
|
346
|
+
|
|
347
|
+
Trains don't reverse, so the estimate may not be behind the report
|
|
348
|
+
(this catches trains held at a station longer than predicted).
|
|
349
|
+
|
|
350
|
+
There is deliberately no limit on how far *ahead* of the report the
|
|
351
|
+
estimate may be: measured against live data (tools/one-off-measure-
|
|
352
|
+
report-lag.py), reports normally trail the predicted timeline by ~1
|
|
353
|
+
minute, but 511 occasionally serves a whole batch of positions ~15
|
|
354
|
+
minutes stale -- and then the predictions are right and the reports
|
|
355
|
+
are wrong.
|
|
356
|
+
"""
|
|
357
|
+
sign = 1.0 if direction is Direction.SOUTHBOUND else -1.0
|
|
358
|
+
if sign * (est_mp - reported.milepost) < 0:
|
|
359
|
+
return reported.milepost
|
|
360
|
+
return est_mp
|
|
361
|
+
|
|
362
|
+
# -- trains -------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
def get_active_trains(
|
|
365
|
+
self,
|
|
366
|
+
at: datetime | None = None,
|
|
367
|
+
*,
|
|
368
|
+
direction: Direction | None = None,
|
|
369
|
+
) -> list[Train]:
|
|
370
|
+
"""Trains running at ``at`` (default: now), ordered by milepost.
|
|
371
|
+
|
|
372
|
+
"Running" means either scheduled to be between origin and destination,
|
|
373
|
+
or present in the real-time feed (which covers late trains still
|
|
374
|
+
running after their scheduled arrival, and trains waiting at their
|
|
375
|
+
origin, whose position relation is WAITING). Trains the real-time data
|
|
376
|
+
shows as completed are omitted.
|
|
377
|
+
"""
|
|
378
|
+
now = self._now()
|
|
379
|
+
at = self._resolve_at(at, now)
|
|
380
|
+
schedule = self._maybe_refresh_schedule(now)
|
|
381
|
+
snap = self._snapshot_for(at, now)
|
|
382
|
+
|
|
383
|
+
trains = {t.number: t for t in schedule.active_trains(at)}
|
|
384
|
+
if snap is not None:
|
|
385
|
+
for number, obs in snap.trains.items():
|
|
386
|
+
if number in trains:
|
|
387
|
+
continue
|
|
388
|
+
try:
|
|
389
|
+
trains[number] = schedule.train(number, obs.service_date, at=at)
|
|
390
|
+
except UnknownTrainError:
|
|
391
|
+
log.debug("Feed reports train %d, which the schedule doesn't know", number)
|
|
392
|
+
|
|
393
|
+
out = [self._enrich(t, snap, at) for t in trains.values()]
|
|
394
|
+
out = [t for t in out if t.status is not TrainStatus.COMPLETED
|
|
395
|
+
and (direction is None or t.direction is direction)]
|
|
396
|
+
out.sort(key=lambda t: t.position.milepost if t.position else float("inf"))
|
|
397
|
+
return out
|
|
398
|
+
|
|
399
|
+
def get_train(self, number: int, at: datetime | None = None) -> Train:
|
|
400
|
+
"""One train as of ``at`` (default: now).
|
|
401
|
+
|
|
402
|
+
Uses the service date the real-time feed reports for ``number`` when
|
|
403
|
+
available; otherwise the run closest to ``at`` (so train 198 at
|
|
404
|
+
12:30 AM finds the previous evening's run).
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
UnknownTrainError
|
|
408
|
+
"""
|
|
409
|
+
now = self._now()
|
|
410
|
+
at = self._resolve_at(at, now)
|
|
411
|
+
schedule = self._maybe_refresh_schedule(now)
|
|
412
|
+
snap = self._snapshot_for(at, now)
|
|
413
|
+
obs = snap.trains.get(number) if snap else None
|
|
414
|
+
try:
|
|
415
|
+
train = schedule.train(number, obs.service_date if obs else None, at=at)
|
|
416
|
+
except UnknownTrainError:
|
|
417
|
+
if obs is None or obs.service_date is None:
|
|
418
|
+
raise
|
|
419
|
+
train = schedule.train(number, at=at)
|
|
420
|
+
return self._enrich(train, snap, at)
|
|
421
|
+
|
|
422
|
+
def get_trains(self, service_date: date | None = None, *, direction: Direction | None = None) -> list[Train]:
|
|
423
|
+
"""Every train on ``service_date`` (default: today), in departure order,
|
|
424
|
+
as of now, with real-time data applied to those currently running."""
|
|
425
|
+
now = self._now()
|
|
426
|
+
schedule = self._maybe_refresh_schedule(now)
|
|
427
|
+
sd = service_date or schedule.service_date_for(now)
|
|
428
|
+
snap = self._snapshot_for(now, now)
|
|
429
|
+
return [self._enrich(schedule.train(t.number, sd, at=now), snap, now)
|
|
430
|
+
for t in schedule.trains(sd, direction=direction)]
|
|
431
|
+
|
|
432
|
+
def get_departures(
|
|
433
|
+
self,
|
|
434
|
+
station: Station | str,
|
|
435
|
+
at: datetime | None = None,
|
|
436
|
+
*,
|
|
437
|
+
direction: Direction | None = None,
|
|
438
|
+
limit: int | None = 10,
|
|
439
|
+
window: timedelta = timedelta(hours=3),
|
|
440
|
+
) -> list[Departure]:
|
|
441
|
+
"""Upcoming departures from ``station`` after ``at``, by best-available
|
|
442
|
+
departure time (so a late train sorts by when it will actually leave).
|
|
443
|
+
|
|
444
|
+
Trains terminating at ``station`` are not departures and are excluded.
|
|
445
|
+
"""
|
|
446
|
+
now = self._now()
|
|
447
|
+
at = self._resolve_at(at, now)
|
|
448
|
+
schedule = self._maybe_refresh_schedule(now)
|
|
449
|
+
st = station if isinstance(station, Station) else schedule.station(station)
|
|
450
|
+
snap = self._snapshot_for(at, now)
|
|
451
|
+
end = at + window
|
|
452
|
+
|
|
453
|
+
today = schedule.service_date_for(at)
|
|
454
|
+
dates = sorted({today - timedelta(days=1), today, schedule.service_date_for(end)})
|
|
455
|
+
out: list[Departure] = []
|
|
456
|
+
for sd in dates:
|
|
457
|
+
for t0 in schedule.trains(sd, direction=direction):
|
|
458
|
+
s0 = t0.stop_at(st)
|
|
459
|
+
if s0 is None or s0 is t0.stops[-1]:
|
|
460
|
+
continue
|
|
461
|
+
if not (at - LATE_TRAIN_LOOKBACK <= s0.scheduled_departure <= end):
|
|
462
|
+
continue
|
|
463
|
+
train = self._enrich(schedule.train(t0.number, sd, at=at), snap, at)
|
|
464
|
+
stop = train.stop_at(st)
|
|
465
|
+
assert stop is not None
|
|
466
|
+
if train.delay is not None and stop.expected_departure is None:
|
|
467
|
+
continue # feed has predictions for later stops only: already left here
|
|
468
|
+
if at <= stop.departure <= end:
|
|
469
|
+
out.append(Departure(train=train, stop=stop))
|
|
470
|
+
out.sort(key=lambda d: d.stop.departure)
|
|
471
|
+
return out[:limit] if limit is not None else out
|
|
472
|
+
|
|
473
|
+
def get_schedule(
|
|
474
|
+
self,
|
|
475
|
+
origin: Station | str,
|
|
476
|
+
destination: Station | str,
|
|
477
|
+
service_date: date | None = None,
|
|
478
|
+
) -> list[Journey]:
|
|
479
|
+
"""Point-to-point timetable: every train on ``service_date`` (default:
|
|
480
|
+
today) that stops at ``origin`` and later at ``destination``, by
|
|
481
|
+
scheduled departure from ``origin``.
|
|
482
|
+
|
|
483
|
+
Direction follows from the station order. For today's trains, expected
|
|
484
|
+
times are applied where real-time data exists. Use
|
|
485
|
+
:meth:`Schedule.typical_service_date` to pick "a typical weekday".
|
|
486
|
+
|
|
487
|
+
Raises:
|
|
488
|
+
UnknownStationError: either station is unknown.
|
|
489
|
+
ValueError: origin and destination are the same station.
|
|
490
|
+
"""
|
|
491
|
+
now = self._now()
|
|
492
|
+
schedule = self._maybe_refresh_schedule(now)
|
|
493
|
+
o = origin if isinstance(origin, Station) else schedule.station(origin)
|
|
494
|
+
d = destination if isinstance(destination, Station) else schedule.station(destination)
|
|
495
|
+
if o.id == d.id:
|
|
496
|
+
raise ValueError("origin and destination are the same station")
|
|
497
|
+
|
|
498
|
+
out = []
|
|
499
|
+
for train in self.get_trains(service_date):
|
|
500
|
+
ids = [s.station.id for s in train.stops]
|
|
501
|
+
if o.id in ids and d.id in ids and ids.index(o.id) < ids.index(d.id):
|
|
502
|
+
out.append(Journey(train=train, origin_stop=train.stops[ids.index(o.id)],
|
|
503
|
+
destination_stop=train.stops[ids.index(d.id)]))
|
|
504
|
+
out.sort(key=lambda j: j.origin_stop.scheduled_departure)
|
|
505
|
+
return out
|
|
506
|
+
|
|
507
|
+
# -- alerts & raw data --------------------------------------------------
|
|
508
|
+
|
|
509
|
+
def get_alerts(self) -> list[Alert]:
|
|
510
|
+
"""Current service alerts (empty without real-time data)."""
|
|
511
|
+
now = self._now()
|
|
512
|
+
self._snapshot_for(now, now)
|
|
513
|
+
return list(self._alerts)
|
|
514
|
+
|
|
515
|
+
def get_realtime_snapshot(self) -> RealtimeSnapshot | None:
|
|
516
|
+
"""The most recent raw real-time snapshot (fetching if due), or None
|
|
517
|
+
without a real-time source. Mostly for debugging."""
|
|
518
|
+
now = self._now()
|
|
519
|
+
return self._snapshot_for(now, now)
|