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,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
@@ -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."""