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.
@@ -0,0 +1,746 @@
1
+ """The static timetable: stations, trips, service calendar, scheduled positions.
2
+
3
+ Needs no API key. After the first download it works offline from a disk cache.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import csv
9
+ import io
10
+ import logging
11
+ import re
12
+ import zipfile
13
+ from collections import defaultdict
14
+ from collections.abc import Callable, Sequence
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, date, datetime, time, timedelta
17
+ from pathlib import Path
18
+ from typing import Literal
19
+ from zoneinfo import ZoneInfo
20
+
21
+ import httpx
22
+ import platformdirs
23
+
24
+ from ._feedcache import FeedCache
25
+ from .corridor import Corridor, LatLon
26
+ from .describe import DescriptionPolicy
27
+ from .errors import ScheduleUnavailableError, UnknownStationError, UnknownTrainError
28
+ from .models import (
29
+ Direction,
30
+ PositionSource,
31
+ Relation,
32
+ ServiceType,
33
+ Station,
34
+ Stop,
35
+ TrackLocation,
36
+ Train,
37
+ TrainPosition,
38
+ TrainStatus,
39
+ )
40
+
41
+ log = logging.getLogger(__name__)
42
+
43
+ #: Caltrain's static GTFS, as linked from https://www.caltrain.com/developer-resources
44
+ DEFAULT_GTFS_URL = "https://data.trilliumtransit.com/gtfs/caltrain-ca-us/caltrain-ca-us.zip"
45
+
46
+ #: How old the cached feed may get before :meth:`Schedule.load` re-downloads it.
47
+ DEFAULT_MAX_AGE = timedelta(days=1)
48
+
49
+ #: Caltrain's agency timezone; every datetime the library produces is in it.
50
+ TZ = ZoneInfo("America/Los_Angeles")
51
+
52
+ # realtime_routes.txt / route_short_name prefixes -> ServiceType
53
+ _SERVICE_TYPES = {
54
+ "local": ServiceType.LOCAL,
55
+ "limited": ServiceType.LIMITED,
56
+ "express": ServiceType.EXPRESS,
57
+ "south county": ServiceType.SOUTH_COUNTY,
58
+ }
59
+
60
+ #: Argument to :meth:`Schedule.typical_service_date`.
61
+ DayType = Literal["weekday", "weekend"]
62
+
63
+ _TRAIN_NUMBER = re.compile(r"^[A-Za-z]*(\d+)$")
64
+ _NAME_SUFFIX = re.compile(r"\s+(caltrain\s+)?station$", re.IGNORECASE)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Time helpers
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def parse_gtfs_time(value: str) -> int:
73
+ """'25:10:00' -> seconds after the service day's reference time (may exceed 24h)."""
74
+ h, m, s = (int(x) for x in value.strip().split(":"))
75
+ return h * 3600 + m * 60 + s
76
+
77
+
78
+ def gtfs_time(service_date: date, seconds: int) -> datetime:
79
+ """Absolute time of a GTFS stop time on ``service_date``.
80
+
81
+ Per the GTFS spec, times are measured from "noon minus 12 hours" local time,
82
+ which differs from midnight on daylight-saving transition days. Arithmetic
83
+ is done in UTC: adding a timedelta to a zoneinfo-aware datetime does
84
+ wall-clock arithmetic, which is wrong across a DST change.
85
+ """
86
+ noon = datetime.combine(service_date, time(12), tzinfo=TZ)
87
+ base = noon.astimezone(UTC) - timedelta(hours=12)
88
+ return (base + timedelta(seconds=seconds)).astimezone(TZ)
89
+
90
+
91
+ def parse_train_number(value: str) -> int | None:
92
+ """'123' -> 123; holiday-schedule 'M147' -> 147; anything else -> None."""
93
+ m = _TRAIN_NUMBER.match(value.strip())
94
+ return int(m.group(1)) if m else None
95
+
96
+
97
+ def _require_aware(at: datetime) -> datetime:
98
+ if at.tzinfo is None or at.utcoffset() is None:
99
+ raise ValueError("datetimes passed to caltrain_api must be timezone-aware")
100
+ return at
101
+
102
+
103
+ def _now() -> datetime:
104
+ return datetime.now(TZ)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Internal parsed-feed records
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class _Call:
114
+ station: Station
115
+ arrival: int # GTFS seconds
116
+ departure: int
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class _Trip:
121
+ number: int
122
+ trip_id: str
123
+ direction: Direction
124
+ service_type: ServiceType
125
+ service_id: str
126
+ calls: tuple[_Call, ...]
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class _CalendarRule:
131
+ weekdays: tuple[bool, ...] # Monday..Sunday
132
+ start: date
133
+ end: date
134
+
135
+
136
+ def _gtfs_date(value: str) -> date:
137
+ return datetime.strptime(value.strip(), "%Y%m%d").date()
138
+
139
+
140
+ def _clean_name(name: str) -> str:
141
+ return _NAME_SUFFIX.sub("", name.strip())
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Schedule
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ class Schedule:
150
+ """Caltrain's published timetable, parsed from static GTFS.
151
+
152
+ Immutable once loaded; :meth:`refresh` returns a *new* Schedule rather than
153
+ mutating this one, so a Schedule can be shared between threads safely.
154
+
155
+ Example::
156
+
157
+ schedule = Schedule.load()
158
+ for train in schedule.active_trains():
159
+ print(train.number, train.position.description)
160
+ """
161
+
162
+ # -- construction -------------------------------------------------------
163
+
164
+ @classmethod
165
+ def load(
166
+ cls,
167
+ *,
168
+ cache_dir: Path | None = None,
169
+ max_age: timedelta = DEFAULT_MAX_AGE,
170
+ url: str = DEFAULT_GTFS_URL,
171
+ http_client: httpx.Client | None = None,
172
+ description_policy: DescriptionPolicy | None = None,
173
+ clock: Callable[[], datetime] | None = None,
174
+ ) -> Schedule:
175
+ """Load the timetable, downloading it if the cache is missing or stale.
176
+
177
+ The cache is considered stale when older than ``max_age`` or when today
178
+ is past the feed's ``feed_end_date``. If a download fails but a cached
179
+ copy exists, the cached copy is used and a warning is logged.
180
+
181
+ Args:
182
+ cache_dir: Where to keep the GTFS zip. Default: the platform's user
183
+ cache dir (e.g. ``~/Library/Caches/caltrain-api`` on macOS).
184
+ max_age: Re-download when the cached file is older than this.
185
+ url: GTFS zip URL.
186
+ http_client: Injected for testing or connection reuse.
187
+ description_policy: Thresholds for position descriptions.
188
+ clock: Returns "now" (aware); for tests. Drives staleness checks.
189
+
190
+ Raises:
191
+ ScheduleUnavailableError: no cached copy and the download failed.
192
+ """
193
+ policy = description_policy or DescriptionPolicy()
194
+ cache = FeedCache(
195
+ cache_dir=cache_dir or Path(platformdirs.user_cache_dir("caltrain-api")),
196
+ url=url, max_age=max_age, http_client=http_client, clock=clock or _now,
197
+ )
198
+ return cls._load_from_cache(cache, policy, force=False)
199
+
200
+ @classmethod
201
+ def _load_from_cache(cls, cache: FeedCache, policy: DescriptionPolicy, *, force: bool) -> Schedule:
202
+ cached: Schedule | None = None
203
+ if cache.zip_path.exists():
204
+ try:
205
+ cached = cls._from_bytes(cache.zip_path.read_bytes(), policy)
206
+ except ScheduleUnavailableError as exc:
207
+ log.warning("Cached GTFS unusable, will re-download: %s", exc)
208
+
209
+ result = cached
210
+ if force or cached is None or cache.is_stale(cached.valid_until):
211
+ try:
212
+ fresh = cache.download(lambda data: cls._from_bytes(data, policy),
213
+ conditional=cached is not None)
214
+ if fresh is not None:
215
+ result = fresh
216
+ except ScheduleUnavailableError as exc:
217
+ if cached is None:
218
+ raise
219
+ log.warning("GTFS refresh failed; using cached copy: %s", exc)
220
+
221
+ assert result is not None # download() returns None only when a cache exists
222
+ meta = cache.read_meta()
223
+ result._cache = cache
224
+ result._downloaded_at = meta.downloaded_at if meta else None
225
+ return result
226
+
227
+ @classmethod
228
+ def from_zip(cls, path: Path, *, description_policy: DescriptionPolicy | None = None) -> Schedule:
229
+ """Load from a local GTFS zip with no network access (tests, pinned feeds).
230
+
231
+ Raises:
232
+ ScheduleUnavailableError: the file is missing or not a usable GTFS feed.
233
+ """
234
+ log.info("Loading GTFS from %s", path)
235
+ try:
236
+ data = Path(path).read_bytes()
237
+ except OSError as exc:
238
+ raise ScheduleUnavailableError(f"cannot read GTFS from {path}: {exc}") from exc
239
+ return cls._from_bytes(data, description_policy or DescriptionPolicy())
240
+
241
+ @classmethod
242
+ def _from_bytes(cls, data: bytes, policy: DescriptionPolicy) -> Schedule:
243
+ try:
244
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
245
+ return cls(zf, description_policy=policy)
246
+ except (zipfile.BadZipFile, KeyError, ValueError) as exc:
247
+ raise ScheduleUnavailableError(f"not a usable GTFS feed: {exc}") from exc
248
+
249
+ def __init__(self, zf: zipfile.ZipFile, *, description_policy: DescriptionPolicy):
250
+ """Parse an open GTFS zip. Use :meth:`load` or :meth:`from_zip` instead."""
251
+ self._policy = description_policy
252
+ self._downloaded_at: datetime | None = None
253
+ self._cache: FeedCache | None = None
254
+
255
+ def table(name: str, required: bool = True) -> list[dict[str, str]]:
256
+ if name not in zf.namelist():
257
+ if required:
258
+ raise KeyError(f"{name} missing from GTFS feed")
259
+ return []
260
+ with zf.open(name) as f:
261
+ return list(csv.DictReader(io.TextIOWrapper(f, encoding="utf-8-sig")))
262
+
263
+ stops = table("stops.txt")
264
+ routes = table("routes.txt")
265
+ rt_routes = {r["route_id"]: r for r in table("realtime_routes.txt", required=False)}
266
+ trips = table("trips.txt")
267
+ stop_times = table("stop_times.txt")
268
+ shapes = table("shapes.txt")
269
+ feed_info = table("feed_info.txt", required=False)
270
+
271
+ self._parse_calendar(table("calendar.txt", required=False),
272
+ table("calendar_dates.txt", required=False))
273
+
274
+ # --- corridor: stitched from the shapes southbound trips use --------
275
+ sb_shape_ids = {t["shape_id"] for t in trips if t.get("direction_id") == "1" and t.get("shape_id")}
276
+ pts_by_shape: dict[str, list[tuple[int, LatLon]]] = defaultdict(list)
277
+ for row in shapes:
278
+ if row["shape_id"] in sb_shape_ids:
279
+ pts_by_shape[row["shape_id"]].append(
280
+ (int(row["shape_pt_sequence"]), (float(row["shape_pt_lat"]), float(row["shape_pt_lon"]))))
281
+ parents = [s for s in stops if s.get("location_type") == "1"]
282
+ if not parents or not pts_by_shape:
283
+ raise ValueError("feed has no parent stations or no southbound shapes")
284
+ # SF 4th & King is the northernmost station; it defines milepost 0.
285
+ sf = max(parents, key=lambda s: float(s["stop_lat"]))
286
+ self._corridor = Corridor.from_shapes(
287
+ [[p for _, p in sorted(v)] for v in pts_by_shape.values()],
288
+ origin=(float(sf["stop_lat"]), float(sf["stop_lon"])),
289
+ )
290
+
291
+ # --- stations -------------------------------------------------------
292
+ trip_dir = {t["trip_id"]: t.get("direction_id") for t in trips}
293
+ platform_dir: dict[str, str] = {}
294
+ used_platforms: set[str] = set()
295
+ for st in stop_times:
296
+ used_platforms.add(st["stop_id"])
297
+ d = trip_dir.get(st["trip_id"])
298
+ if d in ("0", "1"):
299
+ platform_dir[st["stop_id"]] = d
300
+
301
+ platforms_by_parent: dict[str, dict[Direction, str]] = defaultdict(dict)
302
+ served_parents: set[str] = set()
303
+ parent_of: dict[str, str] = {}
304
+ for s in stops:
305
+ if not s.get("parent_station"):
306
+ continue
307
+ # Every child (platforms, and elevators/entrances that alerts cite)
308
+ # resolves to its station in lookups; only platforms carry service.
309
+ parent_of[s["stop_id"]] = s["parent_station"]
310
+ if (s.get("location_type") or "0") != "0":
311
+ continue
312
+ d = platform_dir.get(s["stop_id"])
313
+ if d is None: # unserved platform: fall back to its name
314
+ name = s["stop_name"].lower()
315
+ d = "0" if "northbound" in name else "1" if "southbound" in name else None
316
+ if d is not None:
317
+ direction = Direction.NORTHBOUND if d == "0" else Direction.SOUTHBOUND
318
+ platforms_by_parent[s["parent_station"]][direction] = s["stop_id"]
319
+ if s["stop_id"] in used_platforms:
320
+ served_parents.add(s["parent_station"])
321
+
322
+ stations = []
323
+ for p in parents:
324
+ lat, lon = float(p["stop_lat"]), float(p["stop_lon"])
325
+ mp = self._corridor.locate(lat, lon).milepost
326
+ stations.append(Station(
327
+ id=p["stop_id"],
328
+ name=_clean_name(p["stop_name"]),
329
+ location=TrackLocation(lat=lat, lon=lon, milepost=mp),
330
+ platform_stop_ids=platforms_by_parent.get(p["stop_id"], {}),
331
+ has_scheduled_service=p["stop_id"] in served_parents,
332
+ ))
333
+ stations.sort(key=lambda s: s.location.milepost)
334
+ self._all_stations = stations
335
+ self._served_stations = [s for s in stations if s.has_scheduled_service]
336
+ self._station_by_id = {s.id: s for s in stations}
337
+ self._station_by_platform = {
338
+ pid: self._station_by_id[parent] for pid, parent in parent_of.items() if parent in self._station_by_id
339
+ }
340
+ self._station_by_name = {s.name.lower(): s for s in stations}
341
+ log.info("Loaded %d stations (%d served)", len(stations), len(self._served_stations))
342
+
343
+ # --- trips ----------------------------------------------------------
344
+ route_type: dict[str, ServiceType] = {}
345
+ for r in routes:
346
+ label = (rt_routes.get(r["route_id"], {}).get("realtime_routename")
347
+ or r.get("route_short_name") or r.get("route_long_name") or "").lower()
348
+ st_type = next((v for k, v in _SERVICE_TYPES.items() if label.startswith(k)), None)
349
+ if st_type is None:
350
+ log.warning("Unrecognized route %s (%r); treating as LOCAL", r["route_id"], label)
351
+ st_type = ServiceType.LOCAL
352
+ route_type[r["route_id"]] = st_type
353
+
354
+ calls_by_trip: dict[str, list[tuple[int, _Call]]] = defaultdict(list)
355
+ for st in stop_times:
356
+ station = self._station_by_platform.get(st["stop_id"])
357
+ if station is None:
358
+ log.warning("stop_time references unknown stop %s", st["stop_id"])
359
+ continue
360
+ calls_by_trip[st["trip_id"]].append((int(st["stop_sequence"]), _Call(
361
+ station=station,
362
+ arrival=parse_gtfs_time(st["arrival_time"] or st["departure_time"]),
363
+ departure=parse_gtfs_time(st["departure_time"] or st["arrival_time"]),
364
+ )))
365
+
366
+ self._trips_by_service: dict[str, list[_Trip]] = defaultdict(list)
367
+ self._trips_by_number: dict[int, list[_Trip]] = defaultdict(list)
368
+ for t in trips:
369
+ # Holiday ("modified") service numbers trains like "M147"; the train
370
+ # number is the digits. The raw id is kept as Train.trip_id.
371
+ raw_number = (t.get("trip_short_name") or t["trip_id"]).strip()
372
+ parsed = parse_train_number(raw_number)
373
+ if parsed is None:
374
+ log.warning("Skipping trip %s: unparseable train number %r", t["trip_id"], raw_number)
375
+ continue
376
+ number = parsed
377
+ calls = tuple(c for _, c in sorted(calls_by_trip.get(t["trip_id"], [])))
378
+ if len(calls) < 2:
379
+ log.warning("Skipping trip %s: fewer than two stops", t["trip_id"])
380
+ continue
381
+ d = t.get("direction_id")
382
+ direction = (Direction.NORTHBOUND if d == "0" else Direction.SOUTHBOUND if d == "1"
383
+ else Direction.NORTHBOUND if number % 2 else Direction.SOUTHBOUND)
384
+ trip = _Trip(number=number, trip_id=t["trip_id"], direction=direction,
385
+ service_type=route_type.get(t["route_id"], ServiceType.LOCAL),
386
+ service_id=t["service_id"], calls=calls)
387
+ self._trips_by_service[trip.service_id].append(trip)
388
+ self._trips_by_number[number].append(trip)
389
+ for lst in self._trips_by_service.values():
390
+ lst.sort(key=lambda tr: tr.calls[0].departure)
391
+ log.info("Loaded %d trips across %d services",
392
+ sum(len(v) for v in self._trips_by_service.values()), len(self._trips_by_service))
393
+
394
+ # --- feed metadata --------------------------------------------------
395
+ fi = feed_info[0] if feed_info else {}
396
+ self._feed_version = fi.get("feed_version") or "unknown"
397
+ starts = [r.start for r in self._calendar.values()] + list(self._exceptions)
398
+ ends = [r.end for r in self._calendar.values()] + list(self._exceptions)
399
+ self._valid_from = _gtfs_date(fi["feed_start_date"]) if fi.get("feed_start_date") else min(starts)
400
+ self._valid_until = _gtfs_date(fi["feed_end_date"]) if fi.get("feed_end_date") else max(ends)
401
+
402
+ def _parse_calendar(self, calendar: list[dict[str, str]], calendar_dates: list[dict[str, str]]) -> None:
403
+ days = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
404
+ self._calendar: dict[str, _CalendarRule] = {
405
+ r["service_id"]: _CalendarRule(
406
+ weekdays=tuple(r[d] == "1" for d in days),
407
+ start=_gtfs_date(r["start_date"]),
408
+ end=_gtfs_date(r["end_date"]),
409
+ )
410
+ for r in calendar
411
+ }
412
+ # date -> list of (service_id, added?) ; date -> holiday name
413
+ self._exceptions: dict[date, list[tuple[str, bool]]] = defaultdict(list)
414
+ self._holidays: dict[date, str] = {}
415
+ for r in calendar_dates:
416
+ d = _gtfs_date(r["date"])
417
+ self._exceptions[d].append((r["service_id"], r["exception_type"].strip() == "1"))
418
+ if r.get("holiday_name"):
419
+ self._holidays[d] = r["holiday_name"].strip()
420
+
421
+ def refresh(self, *, force: bool = False) -> Schedule:
422
+ """Return an up-to-date Schedule, downloading only if stale (or ``force``).
423
+
424
+ Returns ``self`` when nothing changed, including for schedules made
425
+ with :meth:`from_zip`, which have no download source.
426
+
427
+ Raises:
428
+ ScheduleUnavailableError: only when ``force`` is set and the download fails.
429
+ """
430
+ if self._cache is None:
431
+ return self
432
+ if not force and not self._cache.is_stale(self.valid_until):
433
+ return self
434
+ try:
435
+ fresh = self._cache.download(lambda data: Schedule._from_bytes(data, self._policy),
436
+ conditional=not force)
437
+ except ScheduleUnavailableError as exc:
438
+ if force:
439
+ raise
440
+ log.warning("GTFS refresh failed; keeping current schedule: %s", exc)
441
+ return self
442
+ if fresh is None:
443
+ return self
444
+ meta = self._cache.read_meta()
445
+ fresh._cache = self._cache
446
+ fresh._downloaded_at = meta.downloaded_at if meta else None
447
+ log.info("Schedule refreshed: %s -> %s", self.feed_version, fresh.feed_version)
448
+ return fresh
449
+
450
+ # -- feed metadata ------------------------------------------------------
451
+
452
+ @property
453
+ def feed_version(self) -> str:
454
+ return self._feed_version
455
+
456
+ @property
457
+ def valid_from(self) -> date:
458
+ return self._valid_from
459
+
460
+ @property
461
+ def valid_until(self) -> date:
462
+ return self._valid_until
463
+
464
+ @property
465
+ def downloaded_at(self) -> datetime | None:
466
+ """When the underlying zip was fetched (None for :meth:`from_zip`)."""
467
+ return self._downloaded_at
468
+
469
+ @property
470
+ def corridor(self) -> Corridor:
471
+ return self._corridor
472
+
473
+ @property
474
+ def description_policy(self) -> DescriptionPolicy:
475
+ return self._policy
476
+
477
+ # -- stations -----------------------------------------------------------
478
+
479
+ def stations(self, *, include_unserved: bool = False) -> list[Station]:
480
+ """All stations in milepost order (SF first).
481
+
482
+ Args:
483
+ include_unserved: include stations no trip stops at (e.g. Stanford).
484
+ """
485
+ return list(self._all_stations if include_unserved else self._served_stations)
486
+
487
+ def station(self, key: str) -> Station:
488
+ """Look up a station by GTFS id ('palo_alto'), platform stop_id ('70171'),
489
+ or name ('Palo Alto', 'palo alto', 'San Jose Diridon Station'; case- and
490
+ 'Station'-suffix-insensitive).
491
+
492
+ Raises:
493
+ UnknownStationError
494
+ """
495
+ k = key.strip()
496
+ found = (self._station_by_id.get(k)
497
+ or self._station_by_platform.get(k)
498
+ or self._station_by_name.get(_clean_name(k).lower())
499
+ or self._station_by_id.get(k.lower().replace(" ", "_")))
500
+ if found is None:
501
+ raise UnknownStationError(key)
502
+ return found
503
+
504
+ # -- calendar -----------------------------------------------------------
505
+
506
+ def service_ids(self, service_date: date) -> frozenset[str]:
507
+ """GTFS service_ids running on ``service_date`` (calendar + calendar_dates exceptions)."""
508
+ active = {
509
+ sid for sid, rule in self._calendar.items()
510
+ if rule.start <= service_date <= rule.end and rule.weekdays[service_date.weekday()]
511
+ }
512
+ for sid, added in self._exceptions.get(service_date, []):
513
+ if added:
514
+ active.add(sid)
515
+ else:
516
+ active.discard(sid)
517
+ return frozenset(active)
518
+
519
+ def holiday(self, service_date: date) -> str | None:
520
+ """Holiday name from the feed if ``service_date`` has modified service, e.g. 'Thanksgiving Day'."""
521
+ return self._holidays.get(service_date)
522
+
523
+ def typical_service_date(self, day_type: DayType, on_or_after: date) -> date:
524
+ """The first date on or after ``on_or_after`` that is an ordinary
525
+ weekday (Mon-Fri) or weekend day (Sat/Sun) with no holiday service
526
+ changes -- e.g. for showing "the weekday timetable".
527
+
528
+ Raises:
529
+ ValueError: no such date within the feed's validity period.
530
+ """
531
+ d = max(on_or_after, self.valid_from)
532
+ while d <= self.valid_until:
533
+ is_weekend = d.weekday() >= 5
534
+ if is_weekend == (day_type == "weekend") and self.holiday(d) is None \
535
+ and d not in self._exceptions:
536
+ return d
537
+ d += timedelta(days=1)
538
+ raise ValueError(f"no ordinary {day_type} on or after {on_or_after} "
539
+ f"within the feed (valid until {self.valid_until})")
540
+
541
+ def service_date_for(self, at: datetime) -> date:
542
+ """The service date a moment *primarily* belongs to (the calendar date in Pacific time).
543
+
544
+ Note a moment can be covered by two service days: trips from the previous
545
+ day run past midnight (GTFS times like 25:10:00). Methods that ask
546
+ "what's running at T" consider both.
547
+ """
548
+ return _require_aware(at).astimezone(TZ).date()
549
+
550
+ # -- trains -------------------------------------------------------------
551
+
552
+ def _trips_on(self, service_date: date) -> list[_Trip]:
553
+ out: list[_Trip] = []
554
+ for sid in sorted(self.service_ids(service_date)):
555
+ out.extend(self._trips_by_service.get(sid, []))
556
+ out.sort(key=lambda tr: tr.calls[0].departure)
557
+ return out
558
+
559
+ def _snapshot(self, trip: _Trip, service_date: date, at: datetime) -> Train:
560
+ stops = [
561
+ Stop(station=c.station,
562
+ scheduled_arrival=gtfs_time(service_date, c.arrival),
563
+ scheduled_departure=gtfs_time(service_date, c.departure))
564
+ for c in trip.calls
565
+ ]
566
+ if at < stops[0].scheduled_departure:
567
+ status = TrainStatus.SCHEDULED
568
+ elif at >= stops[-1].scheduled_arrival:
569
+ status = TrainStatus.COMPLETED
570
+ else:
571
+ status = TrainStatus.EN_ROUTE
572
+ pos = self.position_from_stops(trip.direction, stops, at, source=PositionSource.SCHEDULED)
573
+ return Train(
574
+ number=trip.number, trip_id=trip.trip_id, direction=trip.direction, service_type=trip.service_type,
575
+ service_date=service_date, as_of=at.astimezone(TZ), status=status, stops=stops,
576
+ position=pos, scheduled_position=pos,
577
+ )
578
+
579
+ def trains(
580
+ self,
581
+ service_date: date | None = None,
582
+ *,
583
+ direction: Direction | None = None,
584
+ ) -> list[Train]:
585
+ """All trains scheduled on ``service_date`` (default: today), in departure order.
586
+
587
+ Snapshots are taken at the start of that service day, so ``position`` is None.
588
+ """
589
+ sd = service_date or self.service_date_for(_now())
590
+ as_of = gtfs_time(sd, 0)
591
+ return [self._snapshot(t, sd, as_of) for t in self._trips_on(sd)
592
+ if direction is None or t.direction is direction]
593
+
594
+ def train(self, number: int, service_date: date | None = None, *, at: datetime | None = None) -> Train:
595
+ """One scheduled train, as a snapshot at ``at`` (default: now).
596
+
597
+ Without ``service_date``, picks the run of ``number`` (from the service
598
+ days of ``at`` and the day before) whose span contains ``at``, else the
599
+ one closest to it -- so train 198 asked about at 12:30 AM finds the
600
+ previous evening's run.
601
+
602
+ Raises:
603
+ UnknownTrainError: ``number`` doesn't run on the candidate service date(s).
604
+ """
605
+ at = _require_aware(at) if at else _now()
606
+ if service_date is not None:
607
+ dates = [service_date]
608
+ else:
609
+ today = self.service_date_for(at)
610
+ dates = [today - timedelta(days=1), today]
611
+
612
+ best: tuple[timedelta, _Trip, date] | None = None
613
+ for sd in dates:
614
+ sids = self.service_ids(sd)
615
+ for trip in self._trips_by_number.get(number, []):
616
+ if trip.service_id not in sids:
617
+ continue
618
+ start = gtfs_time(sd, trip.calls[0].departure)
619
+ end = gtfs_time(sd, trip.calls[-1].arrival)
620
+ gap = start - at if at < start else at - end if at > end else timedelta(0)
621
+ if best is None or gap < best[0]:
622
+ best = (gap, trip, sd)
623
+ if best is None:
624
+ raise UnknownTrainError(f"train {number} does not run on {', '.join(map(str, dates))}")
625
+ return self._snapshot(best[1], best[2], at)
626
+
627
+ def active_trains(self, at: datetime | None = None, *, direction: Direction | None = None) -> list[Train]:
628
+ """Trains that, per the timetable, are between origin departure and
629
+ destination arrival at ``at`` (default: now), across both service days
630
+ that can cover that moment. Ordered by milepost.
631
+ """
632
+ at = _require_aware(at) if at else _now()
633
+ today = self.service_date_for(at)
634
+ out = []
635
+ for sd in (today - timedelta(days=1), today):
636
+ for trip in self._trips_on(sd):
637
+ if direction is not None and trip.direction is not direction:
638
+ continue
639
+ if gtfs_time(sd, trip.calls[0].departure) <= at <= gtfs_time(sd, trip.calls[-1].arrival):
640
+ out.append(self._snapshot(trip, sd, at))
641
+ out.sort(key=lambda tr: tr.position.milepost if tr.position else 0.0)
642
+ return out
643
+
644
+ def scheduled_position(self, train: Train, at: datetime) -> TrainPosition | None:
645
+ """Where ``train`` should be at ``at`` per the timetable (None outside its trip span).
646
+
647
+ Interpolates linearly in milepost between consecutive scheduled stops and
648
+ holds position at a station between its arrival and departure times.
649
+ """
650
+ return self.position_from_stops(train.direction, train.stops, _require_aware(at),
651
+ source=PositionSource.SCHEDULED)
652
+
653
+ def position_from_stops(
654
+ self,
655
+ direction: Direction,
656
+ stops: Sequence[Stop],
657
+ at: datetime,
658
+ *,
659
+ source: PositionSource,
660
+ ) -> TrainPosition | None:
661
+ """Interpolate a train's position at ``at`` from its stop times.
662
+
663
+ With ``source=SCHEDULED`` uses timetable times; otherwise uses each
664
+ stop's best-available (expected, else scheduled) times. Returns None
665
+ before the origin departure or after the destination arrival.
666
+ """
667
+ if source is PositionSource.SCHEDULED:
668
+ times = [(s.scheduled_arrival, s.scheduled_departure) for s in stops]
669
+ else:
670
+ times = [(s.arrival, s.departure) for s in stops]
671
+ mps = [s.station.location.milepost for s in stops]
672
+
673
+ milepost = _interpolate(mps, times, at)
674
+ if milepost is None:
675
+ return None
676
+ return self.position_at_milepost(direction, stops, milepost, at, source=source)
677
+
678
+ def position_at_milepost(
679
+ self,
680
+ direction: Direction,
681
+ stops: Sequence[Stop],
682
+ milepost: float,
683
+ at: datetime,
684
+ *,
685
+ source: PositionSource,
686
+ lat_lon: tuple[float, float] | None = None,
687
+ ) -> TrainPosition:
688
+ """Build a :class:`TrainPosition` for a train known to be at ``milepost``.
689
+
690
+ The relation is WAITING before the origin departure (by best-available
691
+ times) and ARRIVED from the destination arrival on -- in both cases
692
+ only within ``near_radius`` of that station -- and otherwise
693
+ classified by :attr:`description_policy`.
694
+
695
+ Args:
696
+ stops: The train's stops (determine AT vs PASSING and WAITING/ARRIVED).
697
+ lat_lon: Report this lat/lon instead of the track point at
698
+ ``milepost`` (used for raw REPORTED positions).
699
+ """
700
+ times = [(s.arrival, s.departure) for s in stops]
701
+ if source is PositionSource.SCHEDULED:
702
+ times = [(s.scheduled_arrival, s.scheduled_departure) for s in stops]
703
+ served = {s.station.id for s in stops}
704
+ relation, station, prev, nxt = self._policy.classify(
705
+ milepost, direction, self._served_stations, served)
706
+ # WAITING/ARRIVED only when actually near the origin/destination: a
707
+ # pre-departure report can place the equipment far away.
708
+ near = self._policy.near_radius
709
+ if at < times[0][1] and abs(milepost - stops[0].station.location.milepost) <= near:
710
+ relation, station = Relation.WAITING, stops[0].station
711
+ elif at >= times[-1][0] and abs(milepost - stops[-1].station.location.milepost) <= near:
712
+ relation, station = Relation.ARRIVED, stops[-1].station
713
+ if lat_lon is None:
714
+ loc = self._corridor.at_milepost(
715
+ min(max(milepost, self._corridor.start), self._corridor.length))
716
+ lat_lon = (loc.lat, loc.lon)
717
+ return TrainPosition(
718
+ lat=lat_lon[0], lon=lat_lon[1], milepost=milepost,
719
+ source=source, as_of=at.astimezone(TZ), direction=direction,
720
+ relation=relation, station=station, previous_station=prev, next_station=nxt,
721
+ description=self._policy.phrase(
722
+ relation, station, prev, nxt,
723
+ stops_here=station is None or station.id in served),
724
+ )
725
+
726
+
727
+ def _interpolate(
728
+ mileposts: Sequence[float],
729
+ times: Sequence[tuple[datetime, datetime]],
730
+ at: datetime,
731
+ ) -> float | None:
732
+ """Milepost at ``at`` given per-stop mileposts and (arrival, departure) times.
733
+
734
+ Holds at a station during its dwell; moves linearly in time between
735
+ stations. None outside [first departure, last arrival].
736
+ """
737
+ if not mileposts or at < times[0][1] or at > times[-1][0]:
738
+ return None
739
+ for i, (arr, dep) in enumerate(times):
740
+ if arr <= at <= dep:
741
+ return mileposts[i]
742
+ if i + 1 < len(times) and dep < at < times[i + 1][0]:
743
+ next_arr = times[i + 1][0]
744
+ frac = (at - dep) / (next_arr - dep)
745
+ return mileposts[i] + frac * (mileposts[i + 1] - mileposts[i])
746
+ return mileposts[-1] # at == final arrival exactly, or out-of-order times