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