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/corridor.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Track geometry: converting between lat/lon and mileposts.
|
|
2
|
+
|
|
3
|
+
GTFS publishes 17 directional, per-service-pattern shapes covering the line in
|
|
4
|
+
two pieces (SF <-> San Jose/Tamien, San Jose <-> Gilroy). The Corridor stitches
|
|
5
|
+
them into one canonical SF -> Gilroy polyline and measures distance along it,
|
|
6
|
+
with San Francisco 4th & King defined as milepost 0.0.
|
|
7
|
+
|
|
8
|
+
Geometry notes: segment lengths use the haversine formula; projecting a point
|
|
9
|
+
onto a segment uses a local equirectangular approximation centered on the
|
|
10
|
+
point, which is accurate to well under a meter at segment scale.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import bisect
|
|
16
|
+
import logging
|
|
17
|
+
import math
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from .errors import OffCorridorError
|
|
22
|
+
from .models import TrackLocation
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
EARTH_RADIUS_MI = 3958.7613
|
|
27
|
+
_MI_PER_DEG = EARTH_RADIUS_MI * math.pi / 180.0
|
|
28
|
+
|
|
29
|
+
LatLon = tuple[float, float]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def haversine_mi(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
33
|
+
"""Great-circle distance in miles."""
|
|
34
|
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
35
|
+
dp, dl = p2 - p1, math.radians(lon2 - lon1)
|
|
36
|
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
37
|
+
return 2 * EARTH_RADIUS_MI * math.asin(math.sqrt(a))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class _Projection:
|
|
42
|
+
along: float # raw distance along the polyline (not origin-adjusted)
|
|
43
|
+
offset: float # perpendicular distance from the point to the track, miles
|
|
44
|
+
segment: int # index of the segment the point projects onto
|
|
45
|
+
lat: float # projected point
|
|
46
|
+
lon: float
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Polyline:
|
|
50
|
+
"""Measured polyline; the shared machinery behind Corridor and stitching."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, points: Sequence[LatLon]):
|
|
53
|
+
pts: list[LatLon] = []
|
|
54
|
+
for p in points:
|
|
55
|
+
if not pts or p != pts[-1]: # drop zero-length segments
|
|
56
|
+
pts.append(p)
|
|
57
|
+
if len(pts) < 2:
|
|
58
|
+
raise ValueError("a polyline needs at least two distinct points")
|
|
59
|
+
self.points = pts
|
|
60
|
+
self.cum = [0.0]
|
|
61
|
+
for (la1, lo1), (la2, lo2) in zip(pts, pts[1:]):
|
|
62
|
+
self.cum.append(self.cum[-1] + haversine_mi(la1, lo1, la2, lo2))
|
|
63
|
+
|
|
64
|
+
def project(self, lat: float, lon: float) -> _Projection:
|
|
65
|
+
kx = math.cos(math.radians(lat)) * _MI_PER_DEG
|
|
66
|
+
ky = _MI_PER_DEG
|
|
67
|
+
best: _Projection | None = None
|
|
68
|
+
best_d2 = math.inf
|
|
69
|
+
pts = self.points
|
|
70
|
+
for i in range(len(pts) - 1):
|
|
71
|
+
# Segment endpoints in a local plane with the query point at the origin.
|
|
72
|
+
ax, ay = (pts[i][1] - lon) * kx, (pts[i][0] - lat) * ky
|
|
73
|
+
bx, by = (pts[i + 1][1] - lon) * kx, (pts[i + 1][0] - lat) * ky
|
|
74
|
+
dx, dy = bx - ax, by - ay
|
|
75
|
+
seg2 = dx * dx + dy * dy
|
|
76
|
+
t = 0.0 if seg2 == 0 else max(0.0, min(1.0, -(ax * dx + ay * dy) / seg2))
|
|
77
|
+
px, py = ax + t * dx, ay + t * dy
|
|
78
|
+
d2 = px * px + py * py
|
|
79
|
+
if d2 < best_d2:
|
|
80
|
+
best_d2 = d2
|
|
81
|
+
best = _Projection(
|
|
82
|
+
along=self.cum[i] + t * (self.cum[i + 1] - self.cum[i]),
|
|
83
|
+
offset=math.sqrt(d2),
|
|
84
|
+
segment=i,
|
|
85
|
+
lat=pts[i][0] + t * (pts[i + 1][0] - pts[i][0]),
|
|
86
|
+
lon=pts[i][1] + t * (pts[i + 1][1] - pts[i][1]),
|
|
87
|
+
)
|
|
88
|
+
assert best is not None
|
|
89
|
+
return best
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Corridor:
|
|
93
|
+
"""The Caltrain line as a single measured polyline.
|
|
94
|
+
|
|
95
|
+
Obtain one from :attr:`Schedule.corridor`; constructing directly is for tests.
|
|
96
|
+
Immutable and safe to share between threads.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, points: Sequence[LatLon], origin: LatLon, *, max_offset: float = 0.25):
|
|
100
|
+
"""
|
|
101
|
+
Args:
|
|
102
|
+
points: (lat, lon) vertices ordered north to south.
|
|
103
|
+
origin: (lat, lon) of the point defined as milepost 0.0 (SF 4th & King).
|
|
104
|
+
It is projected onto the polyline; vertices north of it get
|
|
105
|
+
negative mileposts.
|
|
106
|
+
max_offset: :meth:`locate` rejects points farther than this many
|
|
107
|
+
miles from the track.
|
|
108
|
+
"""
|
|
109
|
+
self._line = _Polyline(points)
|
|
110
|
+
self.max_offset = max_offset
|
|
111
|
+
zero = self._line.project(*origin).along
|
|
112
|
+
self._mp = [c - zero for c in self._line.cum]
|
|
113
|
+
log.debug("Corridor: %d vertices, mileposts %.3f..%.3f",
|
|
114
|
+
len(self._mp), self._mp[0], self._mp[-1])
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_shapes(
|
|
118
|
+
cls,
|
|
119
|
+
shapes: Sequence[Sequence[LatLon]],
|
|
120
|
+
origin: LatLon,
|
|
121
|
+
*,
|
|
122
|
+
join_tolerance: float = 0.1,
|
|
123
|
+
max_offset: float = 0.25,
|
|
124
|
+
) -> Corridor:
|
|
125
|
+
"""Stitch several overlapping north-to-south shapes into one corridor.
|
|
126
|
+
|
|
127
|
+
Starts from the longest shape beginning nearest ``origin``, then
|
|
128
|
+
repeatedly splices on any shape that starts on the current line (within
|
|
129
|
+
``join_tolerance`` miles) and continues beyond its southern end -- e.g.
|
|
130
|
+
the San Jose -> Gilroy shape onto an SF -> Tamien shape. The splice
|
|
131
|
+
point is where the extension starts; the main line's overlapping tail
|
|
132
|
+
is dropped.
|
|
133
|
+
"""
|
|
134
|
+
if not shapes:
|
|
135
|
+
raise ValueError("no shapes")
|
|
136
|
+
lines = [_Polyline(s) for s in shapes]
|
|
137
|
+
|
|
138
|
+
def start_dist(line: _Polyline) -> float:
|
|
139
|
+
return haversine_mi(*line.points[0], *origin)
|
|
140
|
+
|
|
141
|
+
nearest = min(start_dist(line) for line in lines)
|
|
142
|
+
main = max((line for line in lines if start_dist(line) <= nearest + join_tolerance),
|
|
143
|
+
key=lambda line: line.cum[-1])
|
|
144
|
+
points = list(main.points)
|
|
145
|
+
|
|
146
|
+
while True:
|
|
147
|
+
current = _Polyline(points)
|
|
148
|
+
best: tuple[float, _Polyline, _Projection] | None = None
|
|
149
|
+
for line in lines:
|
|
150
|
+
start = current.project(*line.points[0])
|
|
151
|
+
end = current.project(*line.points[-1])
|
|
152
|
+
if start.offset > join_tolerance or end.offset <= join_tolerance:
|
|
153
|
+
continue # doesn't join the line, or doesn't extend past it
|
|
154
|
+
if best is None or end.offset > best[0]:
|
|
155
|
+
best = (end.offset, line, start)
|
|
156
|
+
if best is None:
|
|
157
|
+
break
|
|
158
|
+
_, ext, at = best
|
|
159
|
+
log.debug("Corridor: splicing %d-point shape at segment %d", len(ext.points), at.segment)
|
|
160
|
+
# Index into current.points (deduplicated), which at.segment refers to.
|
|
161
|
+
points = current.points[: at.segment + 1] + list(ext.points)
|
|
162
|
+
|
|
163
|
+
return cls(points, origin, max_offset=max_offset)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def length(self) -> float:
|
|
167
|
+
"""Milepost of the southern end of the line (Gilroy, ~77)."""
|
|
168
|
+
return self._mp[-1]
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def start(self) -> float:
|
|
172
|
+
"""Milepost of the northern end of the line (0.0 or slightly negative)."""
|
|
173
|
+
return self._mp[0]
|
|
174
|
+
|
|
175
|
+
def _locate(self, lat: float, lon: float) -> tuple[TrackLocation, float]:
|
|
176
|
+
p = self._line.project(lat, lon)
|
|
177
|
+
mp = self._mp[p.segment] + (p.along - self._line.cum[p.segment])
|
|
178
|
+
return TrackLocation(lat=p.lat, lon=p.lon, milepost=mp), p.offset
|
|
179
|
+
|
|
180
|
+
def locate(self, lat: float, lon: float) -> TrackLocation:
|
|
181
|
+
"""Project a lat/lon onto the track and return the projected point with its milepost.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
OffCorridorError: if the point is farther than ``max_offset`` miles
|
|
185
|
+
from the track -- e.g. a bad GPS fix.
|
|
186
|
+
"""
|
|
187
|
+
loc, offset = self._locate(lat, lon)
|
|
188
|
+
if offset > self.max_offset:
|
|
189
|
+
raise OffCorridorError(f"({lat}, {lon}) is {offset:.2f} mi from the track")
|
|
190
|
+
return loc
|
|
191
|
+
|
|
192
|
+
def offset(self, lat: float, lon: float) -> float:
|
|
193
|
+
"""Perpendicular distance in miles from a point to the track (no exception)."""
|
|
194
|
+
return self._locate(lat, lon)[1]
|
|
195
|
+
|
|
196
|
+
def at_milepost(self, milepost: float) -> TrackLocation:
|
|
197
|
+
"""The point on the track at ``milepost``.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
ValueError: if ``milepost`` is outside ``[start, length]``.
|
|
201
|
+
"""
|
|
202
|
+
mp = self._mp
|
|
203
|
+
eps = 1e-9
|
|
204
|
+
if not (mp[0] - eps <= milepost <= mp[-1] + eps):
|
|
205
|
+
raise ValueError(f"milepost {milepost} outside {mp[0]:.3f}..{mp[-1]:.3f}")
|
|
206
|
+
i = max(0, min(len(mp) - 2, bisect.bisect_right(mp, milepost) - 1))
|
|
207
|
+
span = mp[i + 1] - mp[i]
|
|
208
|
+
t = 0.0 if span == 0 else max(0.0, min(1.0, (milepost - mp[i]) / span))
|
|
209
|
+
(la1, lo1), (la2, lo2) = self._line.points[i], self._line.points[i + 1]
|
|
210
|
+
return TrackLocation(lat=la1 + t * (la2 - la1), lon=lo1 + t * (lo2 - lo1), milepost=milepost)
|
|
211
|
+
|
|
212
|
+
def polyline(self, *, start: float | None = None, end: float | None = None) -> list[TrackLocation]:
|
|
213
|
+
"""Track vertices between two mileposts (default: the whole line), for drawing maps."""
|
|
214
|
+
lo = self._mp[0] if start is None else start
|
|
215
|
+
hi = self._mp[-1] if end is None else end
|
|
216
|
+
if lo > hi:
|
|
217
|
+
raise ValueError("start must not exceed end")
|
|
218
|
+
out = [self.at_milepost(lo)]
|
|
219
|
+
for (lat, lon), mp in zip(self._line.points, self._mp):
|
|
220
|
+
if lo < mp < hi:
|
|
221
|
+
out.append(TrackLocation(lat=lat, lon=lon, milepost=mp))
|
|
222
|
+
if hi > lo:
|
|
223
|
+
out.append(self.at_milepost(hi))
|
|
224
|
+
return out
|
caltrain_api/describe.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Turning a milepost into a station relationship and a human-readable phrase."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Collection, Sequence
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from .models import Direction, Relation, Station
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DescriptionPolicy(BaseModel):
|
|
13
|
+
"""Thresholds and wording rules for :class:`~caltrain_api.models.Relation`.
|
|
14
|
+
|
|
15
|
+
Given a train's milepost and direction, the nearest stations ahead and behind
|
|
16
|
+
are classified as:
|
|
17
|
+
|
|
18
|
+
- within ``at_radius`` of a station -> ``AT`` (if the train stops there) or
|
|
19
|
+
``PASSING`` (if not);
|
|
20
|
+
- within ``near_radius`` of the station ahead -> ``APPROACHING``;
|
|
21
|
+
- within ``near_radius`` of the station behind -> ``DEPARTED``;
|
|
22
|
+
- otherwise ``BETWEEN`` the two.
|
|
23
|
+
|
|
24
|
+
Where stations are close enough for both APPROACHING and DEPARTED to apply,
|
|
25
|
+
APPROACHING wins. ``WAITING`` and ``ARRIVED`` apply at the origin before
|
|
26
|
+
departure and the destination after arrival, regardless of distance.
|
|
27
|
+
|
|
28
|
+
Neighbors are *physical* stations along the track, not the train's stops:
|
|
29
|
+
an express between Palo Alto and Mountain View is "between California
|
|
30
|
+
Avenue and San Antonio" when it is there.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(frozen=True)
|
|
34
|
+
|
|
35
|
+
at_radius: float = Field(default=0.2, gt=0, description="Miles.")
|
|
36
|
+
near_radius: float = Field(default=0.5, gt=0, description="Miles.")
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="after")
|
|
39
|
+
def _ordered(self) -> DescriptionPolicy:
|
|
40
|
+
if self.near_radius <= self.at_radius:
|
|
41
|
+
raise ValueError("near_radius must exceed at_radius")
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def classify(
|
|
45
|
+
self,
|
|
46
|
+
milepost: float,
|
|
47
|
+
direction: Direction,
|
|
48
|
+
stations: Sequence[Station],
|
|
49
|
+
served: Collection[str],
|
|
50
|
+
) -> tuple[Relation, Station | None, Station | None, Station | None]:
|
|
51
|
+
"""Classify a position.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
milepost: The train's milepost.
|
|
55
|
+
direction: Travel direction (determines which neighbor is "ahead").
|
|
56
|
+
stations: All stations, in milepost order.
|
|
57
|
+
served: ids of the stations this train stops at (AT vs PASSING).
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
``(relation, station, previous_station, next_station)`` where
|
|
61
|
+
``station`` is the one ``relation`` refers to (None for BETWEEN).
|
|
62
|
+
For AT/PASSING, previous/next are the at-station's own neighbors.
|
|
63
|
+
Does not produce WAITING/ARRIVED; the caller decides those from
|
|
64
|
+
trip timing.
|
|
65
|
+
"""
|
|
66
|
+
if not stations:
|
|
67
|
+
raise ValueError("no stations")
|
|
68
|
+
# Work in "travel order": southbound = increasing milepost.
|
|
69
|
+
ordered = list(stations) if direction is Direction.SOUTHBOUND else list(reversed(stations))
|
|
70
|
+
sign = 1.0 if direction is Direction.SOUTHBOUND else -1.0
|
|
71
|
+
|
|
72
|
+
def progress(s: Station) -> float:
|
|
73
|
+
"""How far past station ``s`` the train is, in miles (negative = not there yet)."""
|
|
74
|
+
return sign * (milepost - s.location.milepost)
|
|
75
|
+
|
|
76
|
+
nearest_i = min(range(len(ordered)), key=lambda i: abs(progress(ordered[i])))
|
|
77
|
+
nearest = ordered[nearest_i]
|
|
78
|
+
if abs(progress(nearest)) <= self.at_radius:
|
|
79
|
+
prev = ordered[nearest_i - 1] if nearest_i > 0 else None
|
|
80
|
+
nxt = ordered[nearest_i + 1] if nearest_i + 1 < len(ordered) else None
|
|
81
|
+
rel = Relation.AT if nearest.id in served else Relation.PASSING
|
|
82
|
+
return rel, nearest, prev, nxt
|
|
83
|
+
|
|
84
|
+
behind = next((s for s in reversed(ordered) if progress(s) > 0), None)
|
|
85
|
+
ahead = next((s for s in ordered if progress(s) < 0), None)
|
|
86
|
+
if ahead is not None and -progress(ahead) <= self.near_radius:
|
|
87
|
+
return Relation.APPROACHING, ahead, behind, ahead
|
|
88
|
+
if behind is not None and progress(behind) <= self.near_radius:
|
|
89
|
+
return Relation.DEPARTED, behind, behind, ahead
|
|
90
|
+
return Relation.BETWEEN, None, behind, ahead
|
|
91
|
+
|
|
92
|
+
def phrase(
|
|
93
|
+
self,
|
|
94
|
+
relation: Relation,
|
|
95
|
+
station: Station | None,
|
|
96
|
+
previous_station: Station | None,
|
|
97
|
+
next_station: Station | None,
|
|
98
|
+
*,
|
|
99
|
+
stops_here: bool = True,
|
|
100
|
+
) -> str:
|
|
101
|
+
"""English phrase, e.g. 'departed Palo Alto', 'between California Avenue and San Antonio',
|
|
102
|
+
'waiting to depart San Francisco', 'arrived at San Jose Diridon'.
|
|
103
|
+
|
|
104
|
+
``stops_here`` is whether the train stops at ``station``; DEPARTED from a
|
|
105
|
+
station it ran through reads 'passed X' rather than 'departed X'.
|
|
106
|
+
"""
|
|
107
|
+
name = station.name if station else "?"
|
|
108
|
+
match relation:
|
|
109
|
+
case Relation.WAITING:
|
|
110
|
+
return f"waiting to depart {name}"
|
|
111
|
+
case Relation.AT:
|
|
112
|
+
return f"at {name}"
|
|
113
|
+
case Relation.PASSING:
|
|
114
|
+
return f"passing {name}"
|
|
115
|
+
case Relation.APPROACHING:
|
|
116
|
+
return f"approaching {name}"
|
|
117
|
+
case Relation.DEPARTED:
|
|
118
|
+
return f"departed {name}" if stops_here else f"passed {name}"
|
|
119
|
+
case Relation.ARRIVED:
|
|
120
|
+
return f"arrived at {name}"
|
|
121
|
+
case Relation.BETWEEN:
|
|
122
|
+
if previous_station and next_station:
|
|
123
|
+
return f"between {previous_station.name} and {next_station.name}"
|
|
124
|
+
only = previous_station or next_station
|
|
125
|
+
return f"near {only.name}" if only else "on the line"
|
|
126
|
+
raise ValueError(relation)
|
caltrain_api/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exceptions raised by caltrain_api. All derive from :class:`CaltrainError`."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CaltrainError(Exception):
|
|
5
|
+
"""Base class for all library errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ScheduleUnavailableError(CaltrainError):
|
|
9
|
+
"""No usable static GTFS: no cached copy and the download failed, or the feed is unparseable."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RealtimeUnavailableError(CaltrainError):
|
|
13
|
+
"""The real-time source failed and no recent-enough cached snapshot exists.
|
|
14
|
+
|
|
15
|
+
The facade normally degrades to schedule-only data instead of raising; this
|
|
16
|
+
surfaces only from direct :class:`RealtimeSource` use.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RateLimitedError(RealtimeUnavailableError):
|
|
21
|
+
"""511 returned HTTP 429. ``retry_after`` is seconds to wait, if the server said."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, retry_after: float | None = None):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.retry_after = retry_after
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnknownStationError(CaltrainError, KeyError):
|
|
29
|
+
"""A station id or name matched no station."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnknownTrainError(CaltrainError, KeyError):
|
|
33
|
+
"""No train with that number runs on the requested service date."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OffCorridorError(CaltrainError, ValueError):
|
|
37
|
+
"""A coordinate is too far from the track to be assigned a milepost."""
|