caltrain-api 1.0.1__tar.gz
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-1.0.1/.gitignore +32 -0
- caltrain_api-1.0.1/.python-version +1 -0
- caltrain_api-1.0.1/LICENSE +21 -0
- caltrain_api-1.0.1/PKG-INFO +116 -0
- caltrain_api-1.0.1/README.md +81 -0
- caltrain_api-1.0.1/docs/data-findings.md +95 -0
- caltrain_api-1.0.1/pyproject.toml +50 -0
- caltrain_api-1.0.1/src/caltrain_api/__init__.py +68 -0
- caltrain_api-1.0.1/src/caltrain_api/_feedcache.py +164 -0
- caltrain_api-1.0.1/src/caltrain_api/api.py +519 -0
- caltrain_api-1.0.1/src/caltrain_api/corridor.py +224 -0
- caltrain_api-1.0.1/src/caltrain_api/describe.py +126 -0
- caltrain_api-1.0.1/src/caltrain_api/errors.py +37 -0
- caltrain_api-1.0.1/src/caltrain_api/models.py +323 -0
- caltrain_api-1.0.1/src/caltrain_api/py.typed +0 -0
- caltrain_api-1.0.1/src/caltrain_api/realtime.py +315 -0
- caltrain_api-1.0.1/src/caltrain_api/schedule.py +746 -0
- caltrain_api-1.0.1/tests/__init__.py +0 -0
- caltrain_api-1.0.1/tests/conftest.py +29 -0
- caltrain_api-1.0.1/tests/fixtures/caltrain-gtfs-20261113.zip +0 -0
- caltrain_api-1.0.1/tests/fixtures/servicealerts-20261113-1145.pb +0 -0
- caltrain_api-1.0.1/tests/fixtures/siri-vm-20261113-1145.json +1 -0
- caltrain_api-1.0.1/tests/integration/__init__.py +0 -0
- caltrain_api-1.0.1/tests/integration/test_live.py +39 -0
- caltrain_api-1.0.1/tests/unit/__init__.py +0 -0
- caltrain_api-1.0.1/tests/unit/test_api.py +251 -0
- caltrain_api-1.0.1/tests/unit/test_corridor.py +88 -0
- caltrain_api-1.0.1/tests/unit/test_describe.py +71 -0
- caltrain_api-1.0.1/tests/unit/test_journeys.py +67 -0
- caltrain_api-1.0.1/tests/unit/test_realtime.py +139 -0
- caltrain_api-1.0.1/tests/unit/test_schedule.py +196 -0
- caltrain_api-1.0.1/tests/unit/test_schedule_load.py +130 -0
- caltrain_api-1.0.1/tools/one-off-explore-realtime.py +211 -0
- caltrain_api-1.0.1/tools/one-off-explore-static-gtfs.py +182 -0
- caltrain_api-1.0.1/tools/one-off-measure-report-lag.py +94 -0
- caltrain_api-1.0.1/uv.lock +650 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Python bytecode
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
|
|
5
|
+
# Virtual environments (both names, so a stray one can't be committed)
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
|
|
9
|
+
# Build artifacts
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
*.egg-info/
|
|
13
|
+
|
|
14
|
+
# IDE / OS
|
|
15
|
+
.idea/
|
|
16
|
+
.vscode/
|
|
17
|
+
.DS_Store
|
|
18
|
+
Thumbs.db
|
|
19
|
+
|
|
20
|
+
# Secrets
|
|
21
|
+
.env
|
|
22
|
+
*.key
|
|
23
|
+
*.pem
|
|
24
|
+
|
|
25
|
+
# Downloaded feed data used by tools/one-off-* exploration scripts
|
|
26
|
+
.explore/
|
|
27
|
+
|
|
28
|
+
# Editor workspace files
|
|
29
|
+
*.code-workspace
|
|
30
|
+
|
|
31
|
+
# Personal project notes for Claude Code (not part of the published project)
|
|
32
|
+
CLAUDE.md
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joe Sinnott
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: caltrain-api
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Caltrain schedules and train positions (lat/lon, milepost, and plain-English) from GTFS and 511 real-time data.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jsinnott/caltrain-api
|
|
6
|
+
Project-URL: Repository, https://github.com/jsinnott/caltrain-api
|
|
7
|
+
Project-URL: Issues, https://github.com/jsinnott/caltrain-api/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/jsinnott/caltrain-api#changelog
|
|
9
|
+
Author-email: Joe Sinnott <joe@jsinnott.net>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: 511,bay area,caltrain,gtfs,gtfs-realtime,train,transit
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Requires-Dist: gtfs-realtime-bindings>=1.0
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: platformdirs>=4
|
|
29
|
+
Requires-Dist: pydantic>=2.6
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
33
|
+
Requires-Dist: types-protobuf; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# caltrain-api
|
|
37
|
+
|
|
38
|
+
Caltrain schedules and train positions -- latitude/longitude, milepost from SF 4th & King, and plain-English descriptions ("approaching Redwood City") -- from Caltrain's static GTFS and 511.org real-time data.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install caltrain-api
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Python 3.11+. Real-time data needs a free [511.org API key](https://511.org/open-data/token); without one, everything works from the published timetable.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import os
|
|
52
|
+
from datetime import date
|
|
53
|
+
from caltrain_api import CaltrainAPI, Direction
|
|
54
|
+
|
|
55
|
+
api = CaltrainAPI(api_key=os.environ.get("CALTRAIN_511_API_KEY")) # None -> schedule only
|
|
56
|
+
|
|
57
|
+
for train in api.get_active_trains():
|
|
58
|
+
p = train.position # best estimate
|
|
59
|
+
print(train.number, train.service_type.value, f"mp {p.milepost:.1f}",
|
|
60
|
+
p.description, train.delay) # e.g. 124 local mp 30.0 at Palo Alto 0:01:58
|
|
61
|
+
|
|
62
|
+
train = api.get_train(124)
|
|
63
|
+
train.position # ESTIMATED: from real-time predicted times
|
|
64
|
+
train.reported_position # REPORTED: raw feed lat/lon (can be minutes stale)
|
|
65
|
+
train.scheduled_position # SCHEDULED: where the timetable says it should be
|
|
66
|
+
train.next_stop, train.stops, train.alerts
|
|
67
|
+
|
|
68
|
+
for d in api.get_departures("Palo Alto", direction=Direction.NORTHBOUND, limit=5):
|
|
69
|
+
print(d.train.number, d.stop.departure, d.stop.delay)
|
|
70
|
+
|
|
71
|
+
for j in api.get_schedule("Palo Alto", "San Francisco",
|
|
72
|
+
api.schedule.typical_service_date("weekday", date.today())):
|
|
73
|
+
print(j.train.number, j.origin_stop.scheduled_departure, j.scheduled_duration)
|
|
74
|
+
|
|
75
|
+
api.get_stations() # in milepost order, SF first
|
|
76
|
+
api.get_alerts()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Every method that takes `at=` answers for any moment (timezone-aware datetimes); real-time data applies only within 5 minutes of now.
|
|
80
|
+
|
|
81
|
+
Positions offer three expressions: `lat`/`lon`, `milepost` (miles along the track from SF 4th & King = 0.0, ~76.8 at Gilroy; computed from GTFS geometry, so close to but not identical with Caltrain's official mileposts), and `description` plus a structured `relation` (`AT`, `PASSING`, `APPROACHING`, `DEPARTED`, `BETWEEN`, `WAITING`, `ARRIVED`). Thresholds (0.2 mi "at", 0.5 mi "approaching/departed") are configurable via `DescriptionPolicy`.
|
|
82
|
+
|
|
83
|
+
### Data handling
|
|
84
|
+
|
|
85
|
+
- The static GTFS is downloaded on first use, cached in the user cache dir (`~/Library/Caches/caltrain-api` on macOS), and re-checked daily or when the feed expires. `api.refresh_schedule(force=True)` forces it. Offline, the cached copy is used.
|
|
86
|
+
- Real-time data is fetched lazily, at most every 120 s (511 allows 60 requests/hour; positions only change every few minutes). Empty feeds and failures keep the last good data for up to 10 minutes, then fall back to the schedule. Rate limiting backs off automatically.
|
|
87
|
+
- `CaltrainAPI` is thread-safe; share one instance.
|
|
88
|
+
|
|
89
|
+
See [docs/data-findings.md](https://github.com/jsinnott/caltrain-api/blob/master/docs/data-findings.md) for what the data sources actually provide and how that shaped these choices.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
From a clone of https://github.com/jsinnott/caltrain-api:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
uv sync --extra dev
|
|
101
|
+
.venv/bin/pytest tests/unit # offline, pinned fixtures
|
|
102
|
+
CALTRAIN_511_API_KEY=... .venv/bin/pytest -s tests/integration # live; ~2 511 requests
|
|
103
|
+
.venv/bin/mypy --strict src
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Unit tests use a pinned GTFS feed and a captured 511 response (2026-11-13 11:45 PT) in `tests/fixtures/`. `tools/one-off-*.py` are survey/measurement scripts for the live data sources.
|
|
107
|
+
|
|
108
|
+
## Changelog
|
|
109
|
+
|
|
110
|
+
- 1.0.1 -- Packaging for PyPI: LICENSE file, project URLs, classifiers, `py.typed` marker (type checkers now use the library's annotations), README links that work on PyPI. No code changes.
|
|
111
|
+
- 1.0.0 -- Stable release: the public API (everything exported from `caltrain_api`) is now covered by semantic versioning. No code changes from 0.2.1.
|
|
112
|
+
- 0.2.1 -- Fix: a train waiting at its origin could be placed miles down the line, because 511 reports its equipment (often finishing a previous run) before departure. Reports no longer constrain estimates before departure, and WAITING/ARRIVED apply only near the origin/destination.
|
|
113
|
+
- 0.2.0 -- `CaltrainAPI.get_schedule(origin, destination, service_date)` returning `Journey` objects (point-to-point timetable); `Schedule.typical_service_date("weekday"|"weekend", on_or_after)` picks an ordinary non-holiday date.
|
|
114
|
+
- 0.1.0 -- First working release. `Schedule.load`/`refresh` (download, disk cache, conditional revalidation, offline fallback); `SiriRealtimeSource` (511 SIRI VehicleMonitoring + ServiceAlerts); `CaltrainAPI` facade with lazy polling, empty-feed/failure tolerance, rate-limit backoff, estimated/reported/scheduled positions, delays, alerts, `get_departures`.
|
|
115
|
+
- 0.0.1 -- Offline timetable: `Corridor`, `DescriptionPolicy`, `Schedule.from_zip`. `Train.trip_id` added; holiday "M"-prefixed trains parsed to plain numbers.
|
|
116
|
+
- 0.0.0 -- API stubs for review.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# caltrain-api
|
|
2
|
+
|
|
3
|
+
Caltrain schedules and train positions -- latitude/longitude, milepost from SF 4th & King, and plain-English descriptions ("approaching Redwood City") -- from Caltrain's static GTFS and 511.org real-time data.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install caltrain-api
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python 3.11+. Real-time data needs a free [511.org API key](https://511.org/open-data/token); without one, everything works from the published timetable.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
from datetime import date
|
|
18
|
+
from caltrain_api import CaltrainAPI, Direction
|
|
19
|
+
|
|
20
|
+
api = CaltrainAPI(api_key=os.environ.get("CALTRAIN_511_API_KEY")) # None -> schedule only
|
|
21
|
+
|
|
22
|
+
for train in api.get_active_trains():
|
|
23
|
+
p = train.position # best estimate
|
|
24
|
+
print(train.number, train.service_type.value, f"mp {p.milepost:.1f}",
|
|
25
|
+
p.description, train.delay) # e.g. 124 local mp 30.0 at Palo Alto 0:01:58
|
|
26
|
+
|
|
27
|
+
train = api.get_train(124)
|
|
28
|
+
train.position # ESTIMATED: from real-time predicted times
|
|
29
|
+
train.reported_position # REPORTED: raw feed lat/lon (can be minutes stale)
|
|
30
|
+
train.scheduled_position # SCHEDULED: where the timetable says it should be
|
|
31
|
+
train.next_stop, train.stops, train.alerts
|
|
32
|
+
|
|
33
|
+
for d in api.get_departures("Palo Alto", direction=Direction.NORTHBOUND, limit=5):
|
|
34
|
+
print(d.train.number, d.stop.departure, d.stop.delay)
|
|
35
|
+
|
|
36
|
+
for j in api.get_schedule("Palo Alto", "San Francisco",
|
|
37
|
+
api.schedule.typical_service_date("weekday", date.today())):
|
|
38
|
+
print(j.train.number, j.origin_stop.scheduled_departure, j.scheduled_duration)
|
|
39
|
+
|
|
40
|
+
api.get_stations() # in milepost order, SF first
|
|
41
|
+
api.get_alerts()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Every method that takes `at=` answers for any moment (timezone-aware datetimes); real-time data applies only within 5 minutes of now.
|
|
45
|
+
|
|
46
|
+
Positions offer three expressions: `lat`/`lon`, `milepost` (miles along the track from SF 4th & King = 0.0, ~76.8 at Gilroy; computed from GTFS geometry, so close to but not identical with Caltrain's official mileposts), and `description` plus a structured `relation` (`AT`, `PASSING`, `APPROACHING`, `DEPARTED`, `BETWEEN`, `WAITING`, `ARRIVED`). Thresholds (0.2 mi "at", 0.5 mi "approaching/departed") are configurable via `DescriptionPolicy`.
|
|
47
|
+
|
|
48
|
+
### Data handling
|
|
49
|
+
|
|
50
|
+
- The static GTFS is downloaded on first use, cached in the user cache dir (`~/Library/Caches/caltrain-api` on macOS), and re-checked daily or when the feed expires. `api.refresh_schedule(force=True)` forces it. Offline, the cached copy is used.
|
|
51
|
+
- Real-time data is fetched lazily, at most every 120 s (511 allows 60 requests/hour; positions only change every few minutes). Empty feeds and failures keep the last good data for up to 10 minutes, then fall back to the schedule. Rate limiting backs off automatically.
|
|
52
|
+
- `CaltrainAPI` is thread-safe; share one instance.
|
|
53
|
+
|
|
54
|
+
See [docs/data-findings.md](https://github.com/jsinnott/caltrain-api/blob/master/docs/data-findings.md) for what the data sources actually provide and how that shaped these choices.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
From a clone of https://github.com/jsinnott/caltrain-api:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv sync --extra dev
|
|
66
|
+
.venv/bin/pytest tests/unit # offline, pinned fixtures
|
|
67
|
+
CALTRAIN_511_API_KEY=... .venv/bin/pytest -s tests/integration # live; ~2 511 requests
|
|
68
|
+
.venv/bin/mypy --strict src
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Unit tests use a pinned GTFS feed and a captured 511 response (2026-11-13 11:45 PT) in `tests/fixtures/`. `tools/one-off-*.py` are survey/measurement scripts for the live data sources.
|
|
72
|
+
|
|
73
|
+
## Changelog
|
|
74
|
+
|
|
75
|
+
- 1.0.1 -- Packaging for PyPI: LICENSE file, project URLs, classifiers, `py.typed` marker (type checkers now use the library's annotations), README links that work on PyPI. No code changes.
|
|
76
|
+
- 1.0.0 -- Stable release: the public API (everything exported from `caltrain_api`) is now covered by semantic versioning. No code changes from 0.2.1.
|
|
77
|
+
- 0.2.1 -- Fix: a train waiting at its origin could be placed miles down the line, because 511 reports its equipment (often finishing a previous run) before departure. Reports no longer constrain estimates before departure, and WAITING/ARRIVED apply only near the origin/destination.
|
|
78
|
+
- 0.2.0 -- `CaltrainAPI.get_schedule(origin, destination, service_date)` returning `Journey` objects (point-to-point timetable); `Schedule.typical_service_date("weekday"|"weekend", on_or_after)` picks an ordinary non-holiday date.
|
|
79
|
+
- 0.1.0 -- First working release. `Schedule.load`/`refresh` (download, disk cache, conditional revalidation, offline fallback); `SiriRealtimeSource` (511 SIRI VehicleMonitoring + ServiceAlerts); `CaltrainAPI` facade with lazy polling, empty-feed/failure tolerance, rate-limit backoff, estimated/reported/scheduled positions, delays, alerts, `get_departures`.
|
|
80
|
+
- 0.0.1 -- Offline timetable: `Corridor`, `DescriptionPolicy`, `Schedule.from_zip`. `Train.trip_id` added; holiday "M"-prefixed trains parsed to plain numbers.
|
|
81
|
+
- 0.0.0 -- API stubs for review.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Data source findings
|
|
2
|
+
|
|
3
|
+
Survey of Caltrain's static GTFS and 511's real-time feeds, taken 2026-11-13
|
|
4
|
+
(a Friday, ~11:45 PT) with `tools/one-off-explore-static-gtfs.py` and
|
|
5
|
+
`tools/one-off-explore-realtime.py`. Re-run those scripts to re-check; feed
|
|
6
|
+
behavior can change without notice.
|
|
7
|
+
|
|
8
|
+
## Static GTFS
|
|
9
|
+
|
|
10
|
+
- Source: `https://data.trilliumtransit.com/gtfs/caltrain-ca-us/caltrain-ca-us.zip`
|
|
11
|
+
(linked from caltrain.com/developer-resources). ~170 KB, no key. Feed is
|
|
12
|
+
republished frequently; `feed_info.txt` carries `feed_start_date` /
|
|
13
|
+
`feed_end_date` (this one: 2026-11-13 .. 2027-01-31).
|
|
14
|
+
- **Stations**: 31 parent stations (`location_type=1`), each with NB and SB
|
|
15
|
+
platform children (`70011`/`70012` etc.; Stanford's are `2537740`/`2537744`),
|
|
16
|
+
plus elevator entrances (`location_type=2`). `stop_times` references
|
|
17
|
+
platforms only. Stanford exists but no trip stops there.
|
|
18
|
+
- **Trains**: `trip_id == trip_short_name` for all trips. On regular service
|
|
19
|
+
that is the plain train number ("123"); the holiday-only ("modified")
|
|
20
|
+
service's 75 trips are prefixed: "M101".."M176". The library parses the
|
|
21
|
+
digits as `Train.number` and keeps the raw id as `Train.trip_id`. (The
|
|
22
|
+
survey script's parity check silently skipped the M trips -- 182 of 257.)
|
|
23
|
+
`direction_id` 0 = North (odd numbers), 1 = South (even numbers), confirmed
|
|
24
|
+
by `directions.txt`.
|
|
25
|
+
- **Mileposts** (computed, SF = 0.0): San Jose Diridon 46.84 (official 46.9),
|
|
26
|
+
Gilroy 76.77 (official 77.4).
|
|
27
|
+
- **Service type**: `routes.txt` short names are "Local Weekday", "Local
|
|
28
|
+
Weekend", "Limited", "Express", "South County". `realtime_routes.txt`
|
|
29
|
+
collapses them to clean names (Local / Limited / Express / South County).
|
|
30
|
+
- **Skip-stop**: express/limited trips simply omit stations they don't serve
|
|
31
|
+
(7-24 stops per trip); `pickup_type`/`drop_off_type` are always 0. Knowing
|
|
32
|
+
what a train *passes* requires station mileposts independent of the trip.
|
|
33
|
+
- **Shapes**: 17 directional, per-pattern shapes. Corridor is covered in two
|
|
34
|
+
pieces: SF <-> San Jose Diridon/Tamien (~46.9 / 48.7 mi) and Diridon <->
|
|
35
|
+
Gilroy (~30.0 mi). `shape_dist_traveled` is present on every point and every
|
|
36
|
+
stop_time, **in meters**. A single SF->Gilroy milepost axis must be stitched
|
|
37
|
+
from these.
|
|
38
|
+
- **Calendar**: weekday and weekend services in `calendar.txt`; a third,
|
|
39
|
+
holiday-only service (`c_71743...`, Local + South County, 79 trips) appears
|
|
40
|
+
only in `calendar_dates.txt`, which also has `holiday_name`. Times run past
|
|
41
|
+
midnight (max `25:29:00`, 161 rows >= 24:00).
|
|
42
|
+
- **Data-quality note**: `calendar_dates.txt` labels 2027-01-27 (a Wednesday)
|
|
43
|
+
as Martin Luther King Jr. Day; MLK Day 2027 is Monday 2027-01-18. The library
|
|
44
|
+
should follow the feed, but this is a reminder the feed can be wrong.
|
|
45
|
+
- `linked_datasets.txt` points at a **keyless** Trillium service-alerts
|
|
46
|
+
GTFS-RT feed.
|
|
47
|
+
|
|
48
|
+
## 511 real-time (agency=CT)
|
|
49
|
+
|
|
50
|
+
- **Rate limit**: 60/hour per key, reported in `ratelimit-limit` /
|
|
51
|
+
`ratelimit-remaining` headers. `remaining` was non-monotonic across calls
|
|
52
|
+
(59,58,57,56,55,58,54,58), suggesting several backend counters -- don't rely
|
|
53
|
+
on it for precise budgeting.
|
|
54
|
+
- **VehiclePositions** (GTFS-RT): real lat/lon (not station-snapped -- 44 of
|
|
55
|
+
69 observations were mid-segment), but only `trip`, `vehicle`, `position.
|
|
56
|
+
{lat,lon}`, `timestamp` are populated -- no bearing, speed, stop status.
|
|
57
|
+
**Positions refresh only every ~3-4 minutes** (11:45 -> 11:48 -> 11:52) while
|
|
58
|
+
the per-vehicle `timestamp` advances every minute, so the timestamp is not
|
|
59
|
+
the fix time. At line speed a fix can be several miles stale.
|
|
60
|
+
- **Feed dropouts**: 2 of 10 VehiclePositions samples came back empty (15
|
|
61
|
+
bytes, 0 entities) mid-service. Empty must mean "no data", not "no trains".
|
|
62
|
+
- Trains staged at their origin before departure (SF, Tamien, Diridon) appear
|
|
63
|
+
in the feed.
|
|
64
|
+
- **TripUpdates**: absolute predicted `arrival.time`/`departure.time` per
|
|
65
|
+
remaining stop; `delay` fields are all 0 and `stop_sequence` is 0 -- delay
|
|
66
|
+
must be computed against the static schedule. Example: train 123 predicted
|
|
67
|
+
at Mountain View 11:44:45 vs scheduled 10:46 (~59 min late) while showing
|
|
68
|
+
`delay=0`. Cancellation (per alerts) was *not* reflected in
|
|
69
|
+
`schedule_relationship` (all SCHEDULED).
|
|
70
|
+
- **ServiceAlerts**: useful structured `informed_entity` (trip ids, stop ids)
|
|
71
|
+
and effects (SIGNIFICANT_DELAYS, MODIFIED_SERVICE), plus free text.
|
|
72
|
+
Sometimes contradictory (train 123 both "Cancelled" and "38 minutes late").
|
|
73
|
+
- **SIRI VehicleMonitoring** (`format=json`): one call returns, per train,
|
|
74
|
+
the same position as VehiclePositions *plus* Aimed (scheduled) and Expected
|
|
75
|
+
times for the monitored call and all onward calls. One request ~= VP + TU.
|
|
76
|
+
Times are UTC ISO-8601; JSON has a UTF-8 BOM.
|
|
77
|
+
`FramedVehicleJourneyRef.DataFrameRef` is the trip's service date, so a
|
|
78
|
+
late train is matched to the right day's run exactly. `StopPointRef` is the
|
|
79
|
+
GTFS platform stop_id.
|
|
80
|
+
- **Report lag** (measured 2026-11-13 12:50-12:58 with
|
|
81
|
+
`tools/one-off-measure-report-lag.py`, 31 observations): reported positions
|
|
82
|
+
normally trail the predicted timeline by ~1 minute (median +0.9 min).
|
|
83
|
+
But at 12:50 *every* train's report lagged ~15 minutes at once -- 511
|
|
84
|
+
briefly served a frozen batch of positions -- and two minutes later all
|
|
85
|
+
were fresh again and agreed with the predictions. Conclusion: predicted
|
|
86
|
+
times are the more reliable source; reports are used only to stop an
|
|
87
|
+
estimate falling behind them (a train held longer than predicted).
|
|
88
|
+
- **Pre-departure reports are the equipment, not the trip**: a train waiting
|
|
89
|
+
to start (e.g. 140 at SF) can be reported miles away -- its trainset still
|
|
90
|
+
finishing the previous run (seen 2026-11-13 ~13:50, 140 reported at mp 10).
|
|
91
|
+
The library ignores reports for reconciliation until departure.
|
|
92
|
+
- **Trillium's keyless alerts feed** (from `linked_datasets.txt`) returned
|
|
93
|
+
HTTP 500 on 2026-11-13; the library uses 511's ServiceAlerts instead.
|
|
94
|
+
- Real-time trip ids match static trip ids. Real-time `route_id` is the route
|
|
95
|
+
*short name* ("Local Weekday"), not the static numeric `route_id`.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "caltrain-api"
|
|
7
|
+
version = "1.0.1"
|
|
8
|
+
description = "Caltrain schedules and train positions (lat/lon, milepost, and plain-English) from GTFS and 511 real-time data."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Joe Sinnott", email = "joe@jsinnott.net" }]
|
|
14
|
+
keywords = ["caltrain", "gtfs", "gtfs-realtime", "511", "transit", "train", "bay area"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"httpx>=0.27",
|
|
31
|
+
"pydantic>=2.6",
|
|
32
|
+
"gtfs-realtime-bindings>=1.0",
|
|
33
|
+
"platformdirs>=4",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/jsinnott/caltrain-api"
|
|
38
|
+
Repository = "https://github.com/jsinnott/caltrain-api"
|
|
39
|
+
Issues = "https://github.com/jsinnott/caltrain-api/issues"
|
|
40
|
+
Changelog = "https://github.com/jsinnott/caltrain-api#changelog"
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=8",
|
|
45
|
+
"mypy>=1.10",
|
|
46
|
+
"types-protobuf",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/caltrain_api"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Caltrain schedules and train positions.
|
|
2
|
+
|
|
3
|
+
Public API -- everything importable from ``caltrain_api`` directly is covered
|
|
4
|
+
by semantic versioning; submodule internals are not.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.1"
|
|
8
|
+
|
|
9
|
+
from .api import CaltrainAPI
|
|
10
|
+
from .corridor import Corridor
|
|
11
|
+
from .describe import DescriptionPolicy
|
|
12
|
+
from .errors import (
|
|
13
|
+
CaltrainError,
|
|
14
|
+
OffCorridorError,
|
|
15
|
+
RateLimitedError,
|
|
16
|
+
RealtimeUnavailableError,
|
|
17
|
+
ScheduleUnavailableError,
|
|
18
|
+
UnknownStationError,
|
|
19
|
+
UnknownTrainError,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
Alert,
|
|
23
|
+
Departure,
|
|
24
|
+
Journey,
|
|
25
|
+
Direction,
|
|
26
|
+
PositionSource,
|
|
27
|
+
Relation,
|
|
28
|
+
ServiceType,
|
|
29
|
+
Station,
|
|
30
|
+
Stop,
|
|
31
|
+
TrackLocation,
|
|
32
|
+
Train,
|
|
33
|
+
TrainPosition,
|
|
34
|
+
TrainStatus,
|
|
35
|
+
)
|
|
36
|
+
from .realtime import RealtimeSnapshot, RealtimeSource, SiriRealtimeSource, TrainObservation
|
|
37
|
+
from .schedule import Schedule
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Alert",
|
|
41
|
+
"CaltrainAPI",
|
|
42
|
+
"CaltrainError",
|
|
43
|
+
"Corridor",
|
|
44
|
+
"Departure",
|
|
45
|
+
"Journey",
|
|
46
|
+
"DescriptionPolicy",
|
|
47
|
+
"Direction",
|
|
48
|
+
"OffCorridorError",
|
|
49
|
+
"PositionSource",
|
|
50
|
+
"RateLimitedError",
|
|
51
|
+
"RealtimeSnapshot",
|
|
52
|
+
"RealtimeSource",
|
|
53
|
+
"RealtimeUnavailableError",
|
|
54
|
+
"Relation",
|
|
55
|
+
"Schedule",
|
|
56
|
+
"ScheduleUnavailableError",
|
|
57
|
+
"ServiceType",
|
|
58
|
+
"SiriRealtimeSource",
|
|
59
|
+
"Station",
|
|
60
|
+
"Stop",
|
|
61
|
+
"TrackLocation",
|
|
62
|
+
"Train",
|
|
63
|
+
"TrainObservation",
|
|
64
|
+
"TrainPosition",
|
|
65
|
+
"TrainStatus",
|
|
66
|
+
"UnknownStationError",
|
|
67
|
+
"UnknownTrainError",
|
|
68
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""On-disk cache for the static GTFS zip (internal).
|
|
2
|
+
|
|
3
|
+
Layout under the cache dir:
|
|
4
|
+
caltrain-gtfs.zip the feed, replaced atomically
|
|
5
|
+
caltrain-gtfs.json metadata: url, downloaded_at, checked_at, etag, last_modified
|
|
6
|
+
|
|
7
|
+
Downloads are conditional (If-None-Match / If-Modified-Since), and a new zip
|
|
8
|
+
replaces the cached one only after it parses successfully, so a bad download
|
|
9
|
+
never clobbers a good cache.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import date, datetime, timedelta
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TypeVar
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from .errors import ScheduleUnavailableError
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
ZIP_NAME = "caltrain-gtfs.zip"
|
|
32
|
+
META_NAME = "caltrain-gtfs.json"
|
|
33
|
+
USER_AGENT = f"caltrain-api/{__version__}"
|
|
34
|
+
|
|
35
|
+
T = TypeVar("T")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class FeedMeta:
|
|
40
|
+
url: str
|
|
41
|
+
downloaded_at: datetime
|
|
42
|
+
checked_at: datetime
|
|
43
|
+
etag: str | None = None
|
|
44
|
+
last_modified: str | None = None
|
|
45
|
+
|
|
46
|
+
def to_json(self) -> str:
|
|
47
|
+
return json.dumps({
|
|
48
|
+
"url": self.url,
|
|
49
|
+
"downloaded_at": self.downloaded_at.isoformat(),
|
|
50
|
+
"checked_at": self.checked_at.isoformat(),
|
|
51
|
+
"etag": self.etag,
|
|
52
|
+
"last_modified": self.last_modified,
|
|
53
|
+
}, indent=2)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_json(cls, text: str) -> FeedMeta:
|
|
57
|
+
d = json.loads(text)
|
|
58
|
+
return cls(url=d["url"], downloaded_at=datetime.fromisoformat(d["downloaded_at"]),
|
|
59
|
+
checked_at=datetime.fromisoformat(d["checked_at"]),
|
|
60
|
+
etag=d.get("etag"), last_modified=d.get("last_modified"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FeedCache:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
cache_dir: Path,
|
|
67
|
+
url: str,
|
|
68
|
+
max_age: timedelta,
|
|
69
|
+
http_client: httpx.Client | None,
|
|
70
|
+
clock: Callable[[], datetime],
|
|
71
|
+
):
|
|
72
|
+
self.cache_dir = cache_dir
|
|
73
|
+
self.url = url
|
|
74
|
+
self.max_age = max_age
|
|
75
|
+
self.http_client = http_client
|
|
76
|
+
self.clock = clock
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def zip_path(self) -> Path:
|
|
80
|
+
return self.cache_dir / ZIP_NAME
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def meta_path(self) -> Path:
|
|
84
|
+
return self.cache_dir / META_NAME
|
|
85
|
+
|
|
86
|
+
def read_meta(self) -> FeedMeta | None:
|
|
87
|
+
try:
|
|
88
|
+
return FeedMeta.from_json(self.meta_path.read_text())
|
|
89
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
90
|
+
if self.meta_path.exists():
|
|
91
|
+
log.warning("Ignoring unreadable GTFS cache metadata %s: %s", self.meta_path, exc)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def is_stale(self, valid_until: date | None) -> bool:
|
|
95
|
+
"""True if the cache should be re-checked against the server."""
|
|
96
|
+
meta = self.read_meta()
|
|
97
|
+
now = self.clock()
|
|
98
|
+
if meta is None or not self.zip_path.exists():
|
|
99
|
+
return True
|
|
100
|
+
if meta.url != self.url:
|
|
101
|
+
log.info("GTFS URL changed; cache is stale")
|
|
102
|
+
return True
|
|
103
|
+
if now - meta.checked_at > self.max_age:
|
|
104
|
+
return True
|
|
105
|
+
return valid_until is not None and now.date() > valid_until
|
|
106
|
+
|
|
107
|
+
def _write_atomic(self, path: Path, data: bytes) -> None:
|
|
108
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
fd, tmp = tempfile.mkstemp(dir=self.cache_dir, prefix=path.name, suffix=".tmp")
|
|
110
|
+
try:
|
|
111
|
+
with os.fdopen(fd, "wb") as f:
|
|
112
|
+
f.write(data)
|
|
113
|
+
os.replace(tmp, path)
|
|
114
|
+
except BaseException:
|
|
115
|
+
Path(tmp).unlink(missing_ok=True)
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
def download(self, parse: Callable[[bytes], T], *, conditional: bool) -> T | None:
|
|
119
|
+
"""Fetch the feed. Returns ``parse(data)`` for new content, or None if
|
|
120
|
+
the server says the cached copy is current (HTTP 304).
|
|
121
|
+
|
|
122
|
+
The data is parsed *before* it replaces the cache.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ScheduleUnavailableError: network/HTTP failure or unparseable feed.
|
|
126
|
+
"""
|
|
127
|
+
meta = self.read_meta()
|
|
128
|
+
headers = {"User-Agent": USER_AGENT}
|
|
129
|
+
if conditional and meta is not None and self.zip_path.exists() and meta.url == self.url:
|
|
130
|
+
if meta.etag:
|
|
131
|
+
headers["If-None-Match"] = meta.etag
|
|
132
|
+
if meta.last_modified:
|
|
133
|
+
headers["If-Modified-Since"] = meta.last_modified
|
|
134
|
+
|
|
135
|
+
log.info("Downloading GTFS from %s", self.url)
|
|
136
|
+
client = self.http_client or httpx.Client(timeout=60, follow_redirects=True)
|
|
137
|
+
try:
|
|
138
|
+
resp = client.get(self.url, headers=headers)
|
|
139
|
+
except httpx.HTTPError as exc:
|
|
140
|
+
raise ScheduleUnavailableError(f"GTFS download failed: {type(exc).__name__}: {exc}") from exc
|
|
141
|
+
finally:
|
|
142
|
+
if self.http_client is None:
|
|
143
|
+
client.close()
|
|
144
|
+
|
|
145
|
+
now = self.clock()
|
|
146
|
+
if resp.status_code == 304 and meta is not None:
|
|
147
|
+
log.info("GTFS unchanged on server (304)")
|
|
148
|
+
meta.checked_at = now
|
|
149
|
+
self._write_atomic(self.meta_path, meta.to_json().encode())
|
|
150
|
+
return None
|
|
151
|
+
if resp.status_code != 200:
|
|
152
|
+
raise ScheduleUnavailableError(f"GTFS download failed: HTTP {resp.status_code}")
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
parsed = parse(resp.content)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
raise ScheduleUnavailableError(f"downloaded GTFS is unusable: {exc}") from exc
|
|
158
|
+
|
|
159
|
+
self._write_atomic(self.zip_path, resp.content)
|
|
160
|
+
new_meta = FeedMeta(url=self.url, downloaded_at=now, checked_at=now,
|
|
161
|
+
etag=resp.headers.get("ETag"), last_modified=resp.headers.get("Last-Modified"))
|
|
162
|
+
self._write_atomic(self.meta_path, new_meta.to_json().encode())
|
|
163
|
+
log.info("Cached GTFS (%d bytes) at %s", len(resp.content), self.zip_path)
|
|
164
|
+
return parsed
|