saw-livetrack 0.1.0__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.
- saw_livetrack-0.1.0/LICENSE +21 -0
- saw_livetrack-0.1.0/PKG-INFO +71 -0
- saw_livetrack-0.1.0/README.md +53 -0
- saw_livetrack-0.1.0/pyproject.toml +30 -0
- saw_livetrack-0.1.0/setup.cfg +4 -0
- saw_livetrack-0.1.0/src/saw_livetrack/__init__.py +53 -0
- saw_livetrack-0.1.0/src/saw_livetrack/amtraker.py +367 -0
- saw_livetrack-0.1.0/src/saw_livetrack/track.py +227 -0
- saw_livetrack-0.1.0/src/saw_livetrack.egg-info/PKG-INFO +71 -0
- saw_livetrack-0.1.0/src/saw_livetrack.egg-info/SOURCES.txt +13 -0
- saw_livetrack-0.1.0/src/saw_livetrack.egg-info/dependency_links.txt +1 -0
- saw_livetrack-0.1.0/src/saw_livetrack.egg-info/top_level.txt +1 -0
- saw_livetrack-0.1.0/tests/test_amtraker.py +263 -0
- saw_livetrack-0.1.0/tests/test_shared_coordinator.py +96 -0
- saw_livetrack-0.1.0/tests/test_track.py +183 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StrausbergAutomationWorks
|
|
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,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: saw-livetrack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared code for the Live Track Home Assistant integrations: source-agnostic fix tracking, plus an Amtraker API client
|
|
5
|
+
Author: StrausbergAutomationWorks
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/StrausbergAutomationWorks/LiveTrackCore
|
|
8
|
+
Project-URL: Issues, https://github.com/StrausbergAutomationWorks/LiveTrackCore/issues
|
|
9
|
+
Keywords: home-assistant,train-tracking,geo-location,amtrak,via-rail,brightline,amtraker
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# saw-amtraker-client
|
|
20
|
+
|
|
21
|
+
Shared Amtraker API client for the **Live Track** passenger-rail integrations
|
|
22
|
+
for Home Assistant: Live Track Amtrak, Live Track VIA Rail and Live Track
|
|
23
|
+
Brightline.
|
|
24
|
+
|
|
25
|
+
Pure Python, no Home Assistant imports. Each integration declares it in
|
|
26
|
+
`manifest.json` `requirements`.
|
|
27
|
+
|
|
28
|
+
## Why this is a separate package
|
|
29
|
+
|
|
30
|
+
HACS permits only **one integration per repository**, so three integrations
|
|
31
|
+
cannot share a folder. Vendoring the same client into three repositories is how
|
|
32
|
+
three subtly different clients get written, so it ships as a dependency instead.
|
|
33
|
+
|
|
34
|
+
## What it refuses to do, and why
|
|
35
|
+
|
|
36
|
+
Each refusal comes from a measurement against the live feed, not from caution.
|
|
37
|
+
|
|
38
|
+
| Behaviour | Reason |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `course_deg()` always returns `None` | the feed's direction is an eight-value octant string; converting `"SW"` to `225.0` invents precision the feed never carried |
|
|
41
|
+
| `speed_kmh()` returns `None` for Brightline, even when the field is non-zero | measured 0.0 on every instance while trains covered 76-133 km at 44-77 mph. The field is unpopulated, not stationary |
|
|
42
|
+
| `observed_at()` returns `None` for Brightline | its `lastValTS` advances every 30 s while the position changes every 60 s. It is a feed refresh clock, not an observation time |
|
|
43
|
+
| `observed_at()` returns `None` for `Predeparture` trains | that field then carries a scheduled *future* departure |
|
|
44
|
+
| a finished train is handled by type | the API returns a bare `[]` list, not the documented keyed object |
|
|
45
|
+
| HTTP 429 raises `RateLimited` | a non-200 must never be recorded as "no data" |
|
|
46
|
+
| an empty `User-Agent` raises at construction | the server blocks such requests, so failing here is clearer than an empty result later |
|
|
47
|
+
|
|
48
|
+
A terminal snap - the feed resetting a finished train's position to its
|
|
49
|
+
terminus - is detected by **implied speed**, not by a fixed distance. A flat
|
|
50
|
+
distance threshold discards genuine movement: at 125 mph over the observed
|
|
51
|
+
180 s maximum interval a train legitimately covers 10 km.
|
|
52
|
+
|
|
53
|
+
## Caching
|
|
54
|
+
|
|
55
|
+
`/v3/trains` takes no parameters and returns every provider, so N consumers
|
|
56
|
+
would otherwise make N identical system-wide requests. One client instance
|
|
57
|
+
serves all three providers from a single cached fetch.
|
|
58
|
+
|
|
59
|
+
## Data attribution
|
|
60
|
+
|
|
61
|
+
Train data is provided by **[Amtraker](https://amtraker.com)** and is licensed
|
|
62
|
+
under the [Open Data Commons Attribution License (ODC-By) v1.0](https://opendatacommons.org/licenses/by/1-0/).
|
|
63
|
+
|
|
64
|
+
## Disclaimer
|
|
65
|
+
|
|
66
|
+
Independent project. Not affiliated with, endorsed by or connected to Amtrak,
|
|
67
|
+
VIA Rail Canada or Brightline.
|
|
68
|
+
|
|
69
|
+
## Licence
|
|
70
|
+
|
|
71
|
+
MIT.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# saw-amtraker-client
|
|
2
|
+
|
|
3
|
+
Shared Amtraker API client for the **Live Track** passenger-rail integrations
|
|
4
|
+
for Home Assistant: Live Track Amtrak, Live Track VIA Rail and Live Track
|
|
5
|
+
Brightline.
|
|
6
|
+
|
|
7
|
+
Pure Python, no Home Assistant imports. Each integration declares it in
|
|
8
|
+
`manifest.json` `requirements`.
|
|
9
|
+
|
|
10
|
+
## Why this is a separate package
|
|
11
|
+
|
|
12
|
+
HACS permits only **one integration per repository**, so three integrations
|
|
13
|
+
cannot share a folder. Vendoring the same client into three repositories is how
|
|
14
|
+
three subtly different clients get written, so it ships as a dependency instead.
|
|
15
|
+
|
|
16
|
+
## What it refuses to do, and why
|
|
17
|
+
|
|
18
|
+
Each refusal comes from a measurement against the live feed, not from caution.
|
|
19
|
+
|
|
20
|
+
| Behaviour | Reason |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `course_deg()` always returns `None` | the feed's direction is an eight-value octant string; converting `"SW"` to `225.0` invents precision the feed never carried |
|
|
23
|
+
| `speed_kmh()` returns `None` for Brightline, even when the field is non-zero | measured 0.0 on every instance while trains covered 76-133 km at 44-77 mph. The field is unpopulated, not stationary |
|
|
24
|
+
| `observed_at()` returns `None` for Brightline | its `lastValTS` advances every 30 s while the position changes every 60 s. It is a feed refresh clock, not an observation time |
|
|
25
|
+
| `observed_at()` returns `None` for `Predeparture` trains | that field then carries a scheduled *future* departure |
|
|
26
|
+
| a finished train is handled by type | the API returns a bare `[]` list, not the documented keyed object |
|
|
27
|
+
| HTTP 429 raises `RateLimited` | a non-200 must never be recorded as "no data" |
|
|
28
|
+
| an empty `User-Agent` raises at construction | the server blocks such requests, so failing here is clearer than an empty result later |
|
|
29
|
+
|
|
30
|
+
A terminal snap - the feed resetting a finished train's position to its
|
|
31
|
+
terminus - is detected by **implied speed**, not by a fixed distance. A flat
|
|
32
|
+
distance threshold discards genuine movement: at 125 mph over the observed
|
|
33
|
+
180 s maximum interval a train legitimately covers 10 km.
|
|
34
|
+
|
|
35
|
+
## Caching
|
|
36
|
+
|
|
37
|
+
`/v3/trains` takes no parameters and returns every provider, so N consumers
|
|
38
|
+
would otherwise make N identical system-wide requests. One client instance
|
|
39
|
+
serves all three providers from a single cached fetch.
|
|
40
|
+
|
|
41
|
+
## Data attribution
|
|
42
|
+
|
|
43
|
+
Train data is provided by **[Amtraker](https://amtraker.com)** and is licensed
|
|
44
|
+
under the [Open Data Commons Attribution License (ODC-By) v1.0](https://opendatacommons.org/licenses/by/1-0/).
|
|
45
|
+
|
|
46
|
+
## Disclaimer
|
|
47
|
+
|
|
48
|
+
Independent project. Not affiliated with, endorsed by or connected to Amtrak,
|
|
49
|
+
VIA Rail Canada or Brightline.
|
|
50
|
+
|
|
51
|
+
## Licence
|
|
52
|
+
|
|
53
|
+
MIT.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "saw-livetrack"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared code for the Live Track Home Assistant integrations: source-agnostic fix tracking, plus an Amtraker API client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "StrausbergAutomationWorks" }]
|
|
13
|
+
keywords = ["home-assistant", "train-tracking", "geo-location", "amtrak", "via-rail", "brightline", "amtraker"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/StrausbergAutomationWorks/LiveTrackCore"
|
|
24
|
+
Issues = "https://github.com/StrausbergAutomationWorks/LiveTrackCore/issues"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.package-dir]
|
|
30
|
+
"" = "src"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""SAW LiveTrack - shared code for the Live Track Home Assistant integrations.
|
|
2
|
+
|
|
3
|
+
Two submodules, deliberately separate:
|
|
4
|
+
|
|
5
|
+
saw_livetrack.track source-agnostic. Holds the last two DISTINCT fixes
|
|
6
|
+
per object and emits the `previous_*` segment fields
|
|
7
|
+
so a consumer can animate between two OBSERVED
|
|
8
|
+
positions. The aliasing bug it prevents is not
|
|
9
|
+
specific to any feed: ANY integration whose poll
|
|
10
|
+
interval is near its source's publish interval sees
|
|
11
|
+
duplicate fixes, and storing one as the previous fix
|
|
12
|
+
collapses the segment to zero length.
|
|
13
|
+
|
|
14
|
+
saw_livetrack.amtraker the Amtraker API client, for Amtrak, VIA Rail and
|
|
15
|
+
Brightline. Data from Amtraker (amtraker.com),
|
|
16
|
+
ODC-By v1.0.
|
|
17
|
+
|
|
18
|
+
`amtraker` imports from `track`. Never the reverse.
|
|
19
|
+
|
|
20
|
+
Not affiliated with Amtrak, VIA Rail Canada or Brightline.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .track import ( # noqa: F401
|
|
24
|
+
COURSE_MIN_M,
|
|
25
|
+
JITTER_M,
|
|
26
|
+
MAX_PLAUSIBLE_KMH,
|
|
27
|
+
SNAP_KM,
|
|
28
|
+
FixTracker,
|
|
29
|
+
bearing_deg,
|
|
30
|
+
haversine_km,
|
|
31
|
+
)
|
|
32
|
+
from .amtraker import ( # noqa: F401
|
|
33
|
+
BATCH_STAMPED,
|
|
34
|
+
CONTRACT_VERSION,
|
|
35
|
+
FEED_TICK_S,
|
|
36
|
+
POSITION_INTERVAL_S,
|
|
37
|
+
PROVIDERS,
|
|
38
|
+
SHARED_KEY,
|
|
39
|
+
VELOCITY_UNPOPULATED,
|
|
40
|
+
AmtrakerClient,
|
|
41
|
+
AmtrakerError,
|
|
42
|
+
FeedUnavailable,
|
|
43
|
+
RateLimited,
|
|
44
|
+
classify_movement,
|
|
45
|
+
course_deg,
|
|
46
|
+
derived_speed_kmh,
|
|
47
|
+
heading_octant,
|
|
48
|
+
observed_at,
|
|
49
|
+
parse_ts,
|
|
50
|
+
speed_kmh,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Shared Amtraker API client for the Live Track family.
|
|
2
|
+
|
|
3
|
+
Consumed by Live Track Amtrak, Live Track VIA Rail and Live Track Brightline.
|
|
4
|
+
Pure Python: NO homeassistant imports, so it can ship as a PyPI package that
|
|
5
|
+
each integration declares in manifest.json requirements (05_SHARED_LESSONS.md
|
|
6
|
+
section B8.1a).
|
|
7
|
+
|
|
8
|
+
Data: Amtraker (https://amtraker.com), ODC-By v1.0.
|
|
9
|
+
|
|
10
|
+
Every rule enforced here was MEASURED. See 05_SHARED_LESSONS.md section B8.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime as _dt
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
# Contract version stamped into the shared hass.data key. Bump ONLY when the
|
|
22
|
+
# shape a coordinator publishes changes. B8.1a: a consumer finding a
|
|
23
|
+
# coordinator at a version it does not understand must build its own rather
|
|
24
|
+
# than misuse a foreign object.
|
|
25
|
+
CONTRACT_VERSION = 1
|
|
26
|
+
SHARED_KEY = "saw_amtraker_feed_v%d" % CONTRACT_VERSION
|
|
27
|
+
|
|
28
|
+
BASE = "https://api-v3.amtraker.com/v3"
|
|
29
|
+
|
|
30
|
+
# B8: a User-Agent is MANDATORY. Requests without one are blocked server-side.
|
|
31
|
+
DEFAULT_UA = "SAW-LiveTrack/0.1 (+https://github.com/StrausbergAutomationWorks)"
|
|
32
|
+
|
|
33
|
+
PROVIDERS = ("Amtrak", "Via", "Brightline")
|
|
34
|
+
|
|
35
|
+
# --- Measured artefact thresholds. B8, Brightline SSOT section 4.3 ----------
|
|
36
|
+
|
|
37
|
+
# TERMINAL SNAP: on completing a run the feed resets a train's position to the
|
|
38
|
+
# terminus before dropping it. Measured at 313 km in one 30 s tick, an implied
|
|
39
|
+
# 11,670 mph. trainState stayed "Active" and eventCode stayed unchanged, so
|
|
40
|
+
# NEITHER FIELD FLAGS IT. Displacement is the only available signal.
|
|
41
|
+
#
|
|
42
|
+
# A snap is an impossible SPEED, not a fixed distance. CAUGHT BY A TEST
|
|
43
|
+
# 2026-09-06: a flat 5 km threshold would have discarded GENUINE movement,
|
|
44
|
+
# because at 125 mph over the observed 180 s maximum interval a train
|
|
45
|
+
# legitimately covers 10 km. Fastest scheduled service on this feed is Acela
|
|
46
|
+
# at 150 mph (241 km/h); 322 km/h leaves headroom while still rejecting a
|
|
47
|
+
# 37,560 km/h reset by two orders of magnitude.
|
|
48
|
+
|
|
49
|
+
# Distance fallback for when the two fixes cannot be dated -- which is exactly
|
|
50
|
+
# the BATCH_STAMPED case where the snap was observed. 25 km is ~2.5x the
|
|
51
|
+
# largest legitimate displacement seen and still 12x below the measured 313 km.
|
|
52
|
+
|
|
53
|
+
# Two clocks, measured with ZERO variance (n=225 and n=74). B8.3.
|
|
54
|
+
FEED_TICK_S = 30.0
|
|
55
|
+
POSITION_INTERVAL_S = 60.0
|
|
56
|
+
|
|
57
|
+
# Providers whose `velocity` is never populated. B8.4: measured 0 of 9 for
|
|
58
|
+
# Brightline while trains covered 76-133 km at highway speed.
|
|
59
|
+
VELOCITY_UNPOPULATED = frozenset({"Brightline"})
|
|
60
|
+
|
|
61
|
+
# Providers whose lastValTS is a fleet-wide feed refresh clock rather than a
|
|
62
|
+
# per-train observation time. B8.3 - cannot serve as `last_seen`.
|
|
63
|
+
BATCH_STAMPED = frozenset({"Brightline"})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AmtrakerError(Exception):
|
|
67
|
+
"""Base error for this client."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RateLimited(AmtrakerError):
|
|
71
|
+
"""HTTP 429. NOT 'no data' and NOT a hard negative -- back off and retry.
|
|
72
|
+
|
|
73
|
+
01_ENVIRONMENT.md: letting a non-200 count as absence once deleted two
|
|
74
|
+
working sensor sites from a build plan.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FeedUnavailable(AmtrakerError):
|
|
79
|
+
"""Transport or decode failure. Distinct from 'looked and found nothing',
|
|
80
|
+
which is an empty result rather than an exception (C2)."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --------------------------------------------------------------------------
|
|
84
|
+
# Timestamps
|
|
85
|
+
# --------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def parse_ts(value):
|
|
88
|
+
"""Parse a feed timestamp to an aware datetime, or None.
|
|
89
|
+
|
|
90
|
+
B8.4: THREE formats occur in this one field, and VIA is inconsistent with
|
|
91
|
+
itself. Measured over 381 instances:
|
|
92
|
+
Amtrak offset, no millis '2026-09-06T20:41:41-07:00'
|
|
93
|
+
Brightline Z + millis '2026-09-07T02:00:08.539Z'
|
|
94
|
+
VIA Z (x48) AND Z+millis (x76)
|
|
95
|
+
Never assume a provider is uniform.
|
|
96
|
+
"""
|
|
97
|
+
if not value or not isinstance(value, str):
|
|
98
|
+
return None
|
|
99
|
+
text = value.strip()
|
|
100
|
+
if not text:
|
|
101
|
+
return None
|
|
102
|
+
if text.endswith("Z"):
|
|
103
|
+
text = text[:-1] + "+00:00"
|
|
104
|
+
try:
|
|
105
|
+
parsed = _dt.datetime.fromisoformat(text)
|
|
106
|
+
except ValueError:
|
|
107
|
+
return None
|
|
108
|
+
if parsed.tzinfo is None:
|
|
109
|
+
return None
|
|
110
|
+
return parsed
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def observed_at(train):
|
|
114
|
+
"""When this train's position was true, or None if unknowable.
|
|
115
|
+
|
|
116
|
+
B8.3: for BATCH_STAMPED providers lastValTS is a FEED REFRESH CLOCK -- it
|
|
117
|
+
advances every 30 s while the position sits still, so it cannot date the
|
|
118
|
+
position. D0 then applies: omit rather than approximate.
|
|
119
|
+
|
|
120
|
+
B8: for Amtrak, lastValTS on a `Predeparture` train carries a SCHEDULED
|
|
121
|
+
FUTURE DEPARTURE, not an observation. Measured negative ages of -1,774 s,
|
|
122
|
+
-274 s and -94 s. Branch on trainState before trusting it.
|
|
123
|
+
"""
|
|
124
|
+
if train.get("provider") in BATCH_STAMPED:
|
|
125
|
+
return None
|
|
126
|
+
if train.get("trainState") == "Predeparture":
|
|
127
|
+
return None
|
|
128
|
+
return parse_ts(train.get("lastValTS"))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --------------------------------------------------------------------------
|
|
132
|
+
# Geometry
|
|
133
|
+
# --------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
# haversine_km, JITTER_M, SNAP_KM and MAX_PLAUSIBLE_KMH now live in
|
|
136
|
+
# track.py, which knows nothing about any particular source.
|
|
137
|
+
from .track import ( # noqa: E402,F401
|
|
138
|
+
JITTER_M,
|
|
139
|
+
MAX_PLAUSIBLE_KMH,
|
|
140
|
+
SNAP_KM,
|
|
141
|
+
haversine_km,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def classify_movement(prev, curr):
|
|
146
|
+
"""Classify displacement between two fixes of the SAME train.
|
|
147
|
+
|
|
148
|
+
Returns one of "snap", "still", "moved", or "unknown".
|
|
149
|
+
|
|
150
|
+
B8 / Brightline SSOT 4.3: both artefacts are invisible to trainState and
|
|
151
|
+
eventCode, so a consumer that trusts those fields ships both bugs.
|
|
152
|
+
|
|
153
|
+
A snap is judged by IMPLIED SPEED where the fixes can be dated, and only
|
|
154
|
+
falls back to raw distance where they cannot. A flat distance test throws
|
|
155
|
+
away real movement -- see MAX_PLAUSIBLE_KMH.
|
|
156
|
+
"""
|
|
157
|
+
for rec in (prev, curr):
|
|
158
|
+
if not isinstance(rec.get("lat"), (int, float)):
|
|
159
|
+
return "unknown"
|
|
160
|
+
if not isinstance(rec.get("lon"), (int, float)):
|
|
161
|
+
return "unknown"
|
|
162
|
+
km = haversine_km(prev["lat"], prev["lon"], curr["lat"], curr["lon"])
|
|
163
|
+
|
|
164
|
+
t0, t1 = observed_at(prev), observed_at(curr)
|
|
165
|
+
if t0 is not None and t1 is not None:
|
|
166
|
+
dt = (t1 - t0).total_seconds()
|
|
167
|
+
if dt > 0:
|
|
168
|
+
if km / (dt / 3600.0) > MAX_PLAUSIBLE_KMH:
|
|
169
|
+
return "snap"
|
|
170
|
+
elif km * 1000.0 >= JITTER_M:
|
|
171
|
+
# Movement with no forward time cannot be real.
|
|
172
|
+
return "snap"
|
|
173
|
+
elif km > SNAP_KM:
|
|
174
|
+
return "snap"
|
|
175
|
+
|
|
176
|
+
if km * 1000.0 < JITTER_M:
|
|
177
|
+
return "still"
|
|
178
|
+
return "moved"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def speed_kmh(train):
|
|
182
|
+
"""Reported speed in km/h, or None when the field is not real.
|
|
183
|
+
|
|
184
|
+
B8.4: `velocity` is documented in MPH and is genuinely populated for
|
|
185
|
+
Amtrak (220 of 248) and VIA (44 of 124). For Brightline it read 0.0 on
|
|
186
|
+
every one of 9 instances while four trains covered 76-133 km at 44-77 mph
|
|
187
|
+
-- the field is UNPOPULATED, not "stationary" (D3b-ii-a).
|
|
188
|
+
|
|
189
|
+
D0: omit rather than approximate. Returning None means the caller omits
|
|
190
|
+
the key; returning 0.0 would assert the train is stopped.
|
|
191
|
+
"""
|
|
192
|
+
if train.get("provider") in VELOCITY_UNPOPULATED:
|
|
193
|
+
return None
|
|
194
|
+
value = train.get("velocity")
|
|
195
|
+
if not isinstance(value, (int, float)):
|
|
196
|
+
return None
|
|
197
|
+
if value < 0:
|
|
198
|
+
return None
|
|
199
|
+
return value * 1.609344
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def derived_speed_kmh(prev, curr):
|
|
203
|
+
"""Speed from two position fixes, or None.
|
|
204
|
+
|
|
205
|
+
WARNING -- READ BEFORE USING. This is an ESTIMATE and it is a 60-second
|
|
206
|
+
AVERAGE, not an instantaneous speed. It is intended as a DISPLAYED value,
|
|
207
|
+
labelled as derived.
|
|
208
|
+
|
|
209
|
+
It must NEVER feed `max_extrapolation_s` or any dead reckoning: D3b-i
|
|
210
|
+
forbids deriving speed from consecutive positions and then extrapolating
|
|
211
|
+
on it, however good the number looks.
|
|
212
|
+
|
|
213
|
+
Returns None for snaps, for stationary jitter, and for missing timestamps,
|
|
214
|
+
because each of those produces a fictional speed.
|
|
215
|
+
"""
|
|
216
|
+
kind = classify_movement(prev, curr)
|
|
217
|
+
if kind in ("snap", "unknown"):
|
|
218
|
+
return None
|
|
219
|
+
t0, t1 = observed_at(prev), observed_at(curr)
|
|
220
|
+
if t0 is None or t1 is None:
|
|
221
|
+
return None
|
|
222
|
+
dt = (t1 - t0).total_seconds()
|
|
223
|
+
if dt <= 0:
|
|
224
|
+
return None
|
|
225
|
+
if kind == "still":
|
|
226
|
+
return 0.0
|
|
227
|
+
km = haversine_km(prev["lat"], prev["lon"], curr["lat"], curr["lon"])
|
|
228
|
+
return km / (dt / 3600.0)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def course_deg(train):
|
|
232
|
+
"""Always None. Present so nobody adds it later thinking it was missed.
|
|
233
|
+
|
|
234
|
+
B8.2: the feed's `heading` is one of eight OCTANT STRINGS. Converting "SW"
|
|
235
|
+
to 225.0 invents 22.5 degrees of precision the feed never carried, and D0
|
|
236
|
+
says omit rather than approximate. Use heading_octant() instead.
|
|
237
|
+
"""
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def heading_octant(train):
|
|
242
|
+
"""The raw octant string, or None.
|
|
243
|
+
|
|
244
|
+
D4 display helper. All eight values occur (measured n=198), so it is real
|
|
245
|
+
data rather than a constant -- but it is the DISPLAY form, which inverts
|
|
246
|
+
D5's usual 'integrations emit physics, consumers derive display'.
|
|
247
|
+
"""
|
|
248
|
+
value = train.get("heading")
|
|
249
|
+
if not isinstance(value, str):
|
|
250
|
+
return None
|
|
251
|
+
value = value.strip().upper()
|
|
252
|
+
if value in ("N", "NE", "E", "SE", "S", "SW", "W", "NW"):
|
|
253
|
+
return value
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# --------------------------------------------------------------------------
|
|
258
|
+
# Transport
|
|
259
|
+
# --------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _request(path, user_agent, timeout, opener=None):
|
|
262
|
+
url = "%s/%s" % (BASE, path)
|
|
263
|
+
req = urllib.request.Request(url, headers={"User-Agent": user_agent})
|
|
264
|
+
try:
|
|
265
|
+
open_fn = opener or urllib.request.urlopen
|
|
266
|
+
with open_fn(req, timeout=timeout) as response:
|
|
267
|
+
raw = response.read()
|
|
268
|
+
except urllib.error.HTTPError as err:
|
|
269
|
+
if err.code == 429:
|
|
270
|
+
raise RateLimited("HTTP 429 from %s" % path) from err
|
|
271
|
+
raise FeedUnavailable("HTTP %s from %s" % (err.code, path)) from err
|
|
272
|
+
except Exception as err: # noqa: BLE001 - transport of any kind
|
|
273
|
+
raise FeedUnavailable("%s from %s" % (type(err).__name__, path)) from err
|
|
274
|
+
try:
|
|
275
|
+
return json.loads(raw.decode("utf-8"))
|
|
276
|
+
except Exception as err: # noqa: BLE001
|
|
277
|
+
raise FeedUnavailable("undecodable body from %s" % path) from err
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _flatten(payload):
|
|
281
|
+
"""Normalise a trains payload to a list.
|
|
282
|
+
|
|
283
|
+
B8: a bare number or a finished train returns `[]` -- a LIST, not the
|
|
284
|
+
documented keyed object. Branch on TYPE, not on emptiness.
|
|
285
|
+
"""
|
|
286
|
+
if isinstance(payload, list):
|
|
287
|
+
return []
|
|
288
|
+
if not isinstance(payload, dict):
|
|
289
|
+
return []
|
|
290
|
+
out = []
|
|
291
|
+
for value in payload.values():
|
|
292
|
+
if isinstance(value, list):
|
|
293
|
+
out.extend(x for x in value if isinstance(x, dict))
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class AmtrakerClient:
|
|
298
|
+
"""Fetches the Amtraker feed once and serves every provider from it.
|
|
299
|
+
|
|
300
|
+
B8.1: /v3/trains takes NO parameters and returns every provider, so N
|
|
301
|
+
consumers make N identical system-wide requests unless the FEED is cached.
|
|
302
|
+
Same shape as 05 section B7 and 03_BACKLOG.md item 104. Documentation does
|
|
303
|
+
not fix it; a shared cache does.
|
|
304
|
+
|
|
305
|
+
Measured payload sizes:
|
|
306
|
+
/v3/trains ~1,150,000 bytes whole fleet
|
|
307
|
+
/v3/stale ~6,500 bytes per-train timeSince
|
|
308
|
+
/v3/trains/<id> ~2,500 bytes one train
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
def __init__(self, user_agent=DEFAULT_UA, ttl_s=FEED_TICK_S, timeout=30,
|
|
312
|
+
opener=None, clock=None):
|
|
313
|
+
if not user_agent or not str(user_agent).strip():
|
|
314
|
+
# B8: the server blocks requests with no User-Agent. Fail loudly
|
|
315
|
+
# here rather than puzzling over empty results later.
|
|
316
|
+
raise ValueError("a non-empty User-Agent is required")
|
|
317
|
+
self.user_agent = user_agent
|
|
318
|
+
self.ttl_s = float(ttl_s)
|
|
319
|
+
self.timeout = timeout
|
|
320
|
+
self._opener = opener
|
|
321
|
+
self._clock = clock or (lambda: _dt.datetime.now(_dt.timezone.utc))
|
|
322
|
+
self._cached = None
|
|
323
|
+
self._cached_at = None
|
|
324
|
+
self.fetch_count = 0
|
|
325
|
+
self.serve_count = 0
|
|
326
|
+
|
|
327
|
+
def _fresh(self):
|
|
328
|
+
if self._cached_at is None:
|
|
329
|
+
return False
|
|
330
|
+
return (self._clock() - self._cached_at).total_seconds() < self.ttl_s
|
|
331
|
+
|
|
332
|
+
def fetch_fleet(self, force=False):
|
|
333
|
+
"""All trains, cached for ttl_s. Default TTL is the measured 30 s feed
|
|
334
|
+
tick, so polling faster than the feed changes costs nothing upstream."""
|
|
335
|
+
self.serve_count += 1
|
|
336
|
+
if not force and self._fresh():
|
|
337
|
+
return self._cached
|
|
338
|
+
payload = _request("trains", self.user_agent, self.timeout, self._opener)
|
|
339
|
+
self._cached = _flatten(payload)
|
|
340
|
+
self._cached_at = self._clock()
|
|
341
|
+
self.fetch_count += 1
|
|
342
|
+
return self._cached
|
|
343
|
+
|
|
344
|
+
def trains(self, provider=None, force=False):
|
|
345
|
+
"""Trains, optionally filtered to one provider.
|
|
346
|
+
|
|
347
|
+
B8.4: the providers do NOT share a data contract -- velocity is real
|
|
348
|
+
for two and always-zero for the third, last_seen has a source for two
|
|
349
|
+
and none for the third. Filtering is where three integrations diverge.
|
|
350
|
+
"""
|
|
351
|
+
if provider is not None and provider not in PROVIDERS:
|
|
352
|
+
raise ValueError("unknown provider %r" % (provider,))
|
|
353
|
+
rows = self.fetch_fleet(force=force)
|
|
354
|
+
if provider is None:
|
|
355
|
+
return list(rows)
|
|
356
|
+
return [t for t in rows if t.get("provider") == provider]
|
|
357
|
+
|
|
358
|
+
def stale(self):
|
|
359
|
+
"""/v3/stale: per-train timeSince for every active train, ~180x
|
|
360
|
+
cheaper than the fleet feed. Use it to DETECT update events."""
|
|
361
|
+
return _request("stale", self.user_agent, self.timeout, self._opener)
|
|
362
|
+
|
|
363
|
+
def train(self, train_id):
|
|
364
|
+
"""One train, ~2,500 bytes. Accepts 'b5756' or 'b5756-6'."""
|
|
365
|
+
return _flatten(
|
|
366
|
+
_request("trains/%s" % train_id, self.user_agent, self.timeout,
|
|
367
|
+
self._opener))
|