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/schedule.py
ADDED
|
@@ -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
|