Skip to content

Commit e418a3c

Browse files
NoamGaashclaude
andcommitted
Add algorithms docs for service-violations, bus-arrival-reliability, schedule-adherence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 35978cf commit e418a3c

3 files changed

Lines changed: 471 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Bus arrival reliability — segments, Marey, rush-hour heatmap
2+
3+
**Author:** noamf2001 — [github.com/noamf2001/PublicTransportHackathon](https://github.com/noamf2001/PublicTransportHackathon), branch `analyze-per-subsequent-stops`, vendored at [repos/PublicTransportHackathon](../repos/PublicTransportHackathon/) and pulled in as the `bus_times` dependency
4+
**Code:** [analyses/bus_arrival_reliability.py](../analyses/bus_arrival_reliability.py)
5+
**Cards:** `bus-segment-reliability`, `bus-marey-diagram`, `bus-hourly-heatmap`
6+
**Data:** Stride `/route_timetable/list` (planned + stop coordinates + Hebrew names) + `/siri_vehicle_locations/list` (GPS)
7+
8+
## What it answers
9+
10+
One question, three views:
11+
12+
> How long does a bus really take to get from each stop to the next, and how does
13+
> that compare to the published timetable?
14+
15+
- **Where is the timetable optimistic?** Median measured duration per stop-to-stop
16+
segment against the planned duration, with the interquartile spread as a whisker.
17+
- **Where does the bus lose time?** A Marey time-space diagram — one trajectory per
18+
sampled ride over the schedule. Steep = moving, flat = stuck, and the width of
19+
the fan is the route's unreliability.
20+
- **Which segments break down at rush hour?** Segment × departure hour, coloured by
21+
the actual/planned duration ratio.
22+
23+
## Algorithm
24+
25+
The analytical core lives in the upstream `bus_times` package; this module owns
26+
resolution, presentation and the caching.
27+
28+
1. **Resolve** `find_lines(short_name, …, agency_name, name_contains)` → candidate
29+
routes, filtered by direction if given.
30+
2. **Fetch** `load_line_data(line, date_from, date_to)``stop_events` (one row per
31+
ride × stop) and `ride_segments` (one row per ride × segment).
32+
3. **Derive arrival times.** *The API does not serve them.* An arrival is the moment
33+
of the vehicle's **closest approach to a stop's coordinates, interpolated between
34+
the two nearest GPS pings**.
35+
4. **Aggregate**`aggregate_segments(ride_segments, min_samples)` gives per-segment
36+
median / p25 / p75 actual, planned duration, and a `confidence` verdict;
37+
`segment_hour_matrix` gives the ratio and count matrices;
38+
`elapsed_profiles` / `stop_coverage` feed the Marey.
39+
5. **Render** client-side through `openbus_hack.contract` (the upstream matplotlib
40+
plotters are kept only for the optional "static draft" view).
41+
42+
### The confidence ladder (upstream `aggregate_segments`)
43+
44+
Under-sampled segments are **flagged, not dropped** — a segment silently missing
45+
from a chart is indistinguishable from a segment that does not exist, which is the
46+
most misleading failure available here.
47+
48+
| Verdict | Trigger |
49+
|---|---|
50+
| `implausible value` | Median actual/planned ratio outside 0.25–4.0 — almost always an artifact rather than traffic |
51+
| `few samples` | Fewer rides than `min_samples` |
52+
| `patchy coverage` | Under half the rides produced a usable value here |
53+
| `coarse GPS timing` | The pings bracketing the arrival were over 2 minutes apart |
54+
| `loose stop match` | Closest approach exceeded 150 m — which stop the bus was at is uncertain |
55+
56+
## Reasoning
57+
58+
**Why derive arrivals at all.** Probing established that on `/siri_ride_stops/list`
59+
every `gtfs_stop__*`, `gtfs_ride_stop__*` and `nearest_siri_vehicle_location__*`
60+
field is null for all available dates, and that `/stop_arrivals/list` and
61+
`/route_timetable/list` return planned times only. There is no served actual
62+
arrival time to use.
63+
64+
**Why GTFS is the single stop universe.** Planned times, stop coordinates and Hebrew
65+
names all come from `/route_timetable/list`, which sidesteps the fact that SIRI stop
66+
identities cannot be joined to GTFS ones.
67+
68+
**Why ambiguous line matches resolve rather than fail.** `bus_times.resolve_line()`
69+
raises when a description matches zero *or* several routes — right for a notebook,
70+
wrong for a dashboard, where picking line "1" with no operator would produce an
71+
error card. The port picks the first candidate deterministically and *says what it
72+
chose and what it ignored*.
73+
74+
**Why one shared fetch.** Resolving a line and pulling timetable + sampled pings
75+
costs ~1–2 minutes. The dashboard's global filter bar fires all three cards with
76+
identical parameters, so `_load` is disk-cached and single-flight: the first caller
77+
pays, the other two wait on its result.
78+
79+
## Findings
80+
81+
### 1. The Stride API serves no actual arrival times at all — **confidence: High**
82+
83+
Every candidate field is null across all available dates, on three separate
84+
endpoints. This is why every planned-vs-actual card in this repo derives arrivals
85+
from raw GPS. Directly probed, unambiguous, and independently re-hit by every other
86+
author here.
87+
88+
### 2. Derived arrival times are good to about ±30 s — **confidence: High**
89+
90+
Pings arrive roughly once a minute; interpolating closest approach between the two
91+
bracketing pings gives ~±30 s. This is a stated, propagated error bound rather than
92+
an unquantified caveat — and it is the reason the aggregate views are the point.
93+
94+
### 3. Single-ride short-segment durations are mostly noise — **confidence: High**
95+
96+
Consecutive city stops are often less than a minute apart, which is inside the
97+
error bar of the arrival estimate itself. Follows directly from finding 2 and is
98+
handled structurally: the charts always show spread and sample counts.
99+
100+
### 4. The first segment is systematically the least trustworthy — **confidence: High**
101+
102+
Buses idle at the terminal, so the origin stop resolves to *departure* rather than
103+
closest approach — a different quantity from every other stop on the route.
104+
Handled explicitly upstream rather than hidden.
105+
106+
### 5. Three named artifacts, each handled explicitly — **confidence: High**
107+
108+
Terminal dwell; coincident junction stops (fixed with a forward-constrained
109+
monotonic search, and segments the timetable allots zero seconds are dropped); and
110+
stops the bus never came within 300 m of (dropped, costing the two segments either
111+
side). Each is a mechanism, identified and mitigated, not a statistical guess.
112+
113+
### 6. On any given line, which segments are optimistically timetabled — **confidence: Medium**
114+
115+
The output the cards exist to produce. Medium because it rests on a ±30 s derived
116+
proxy over a ≤10-day window on one route variant. Directionally reliable —
117+
a segment that runs 1.5× its allotted time repeatedly is really slow — but the
118+
exact ratio should not be quoted.
119+
120+
## Criticism
121+
122+
**The dependency is a private-ish vendored package.** All the correctness risk —
123+
arrival derivation, segment aggregation, the confidence ladder — lives in
124+
`bus_times`, outside this repo, pulled from a branch (`analyze-per-subsequent-stops`)
125+
rather than a tag. The upstream has 56 network-free unit tests over its pure core,
126+
which is more than anything here has, but the coupling means this card's behaviour
127+
can change without a commit in this repo.
128+
129+
**Ambiguous-match resolution is deterministic, not correct.** Picking `df.iloc[0]`
130+
when the filters match 12 routes is defensible for a demo and is disclosed in the
131+
notes, but the reader has to actually read the note to know they are looking at one
132+
arbitrary variant of "line 1".
133+
134+
**The `name_contains` city list is hardcoded** to 11 Hebrew city names
135+
([bus_arrival_reliability.py:66](../analyses/bus_arrival_reliability.py#L66)). Any
136+
line outside those cities needs the free-text path, which the dropdown replaced.
137+
138+
**The static-PNG path is a genuine performance trap, now fixed but worth knowing.**
139+
matplotlib warns once per (glyph, call-site) for missing Hebrew glyphs — **8,465
140+
warnings in one session** — and the warning machinery, not the rendering, made the
141+
whole API unresponsive. A figure fully determined by its cache key was also being
142+
redrawn on every request, costing 67 s on a card whose data was already warm. Both
143+
are fixed here ([bus_arrival_reliability.py:86-124](../analyses/bus_arrival_reliability.py#L86-L124)),
144+
but any future matplotlib+Hebrew work in this codebase will hit the same wall.
145+
146+
**The 9-day window cap** ([bus_arrival_reliability.py:135](../analyses/bus_arrival_reliability.py#L135))
147+
is driven by fetch cost (~25 s/day for the timetable, ~0.7 s/ride for pings), not by
148+
what makes a statistically sound sample. Rush-hour heatmap cells on a low-frequency
149+
line can rest on very few rides — flagged by hatching, but the cap is what puts them
150+
there.

algorithms/schedule-adherence.md

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Schedule adherence — day-to-day variation, geographically, and per day
2+
3+
**Author:** yuvalko1 — [github.com/yuvalko1/talpiot-hackathon-public-transportation](https://github.com/yuvalko1/talpiot-hackathon-public-transportation), branch `main`, from `compare_gtfs_siri_average.ipynb`. Vendored at [repos/talpiot-hackathon-public-transportation](../repos/talpiot-hackathon-public-transportation/).
4+
**Code:** [analyses/schedule_adherence_average.py](../analyses/schedule_adherence_average.py)
5+
**Cards:** `schedule-adherence-average`, `schedule-adherence-map`, `schedule-adherence-by-day`
6+
**Data:** Stride `/route_timetable/list` + `/siri_vehicle_locations/list`
7+
8+
## What it answers
9+
10+
Take *one* departure — the same line, the same time of day — and watch it across
11+
many days.
12+
13+
- **Stringline** — each matched day as a faint trace, with the GTFS plan and the
14+
cross-day average bold over them. The fan between them is the day-to-day
15+
unreliability of that single departure.
16+
- **Map** — the same comparison geographically: the planned route dashed at its
17+
timetable coordinates, and the *measured* route solid, where each stop sits at a
18+
distance-weighted average of the real GPS pings that matched it.
19+
- **By day** — total journey time for each matched day against the schedule, so
20+
"which day ran worst" is answerable without reading a fan of traces.
21+
22+
## Algorithm
23+
24+
1. **Anchor on a real departure time.** Scan back up to 10 days for the first
25+
departure at or after the requested hour on a day that has any timetable for the
26+
line. That clock time becomes the fixed target for every subsequent day.
27+
2. **Per day, back `days_back` days** (default 21, the notebook used 90):
28+
- Pull the plan in a **1-minute window** around the target time.
29+
- Keep the largest `gtfs_ride_id` group.
30+
- Compute the **canonical stop signature** = `tuple(plan["name"])`.
31+
- **Skip the day if the signature differs from the reference day's.**
32+
- Pull GPS for the same 1-minute scheduled-start window; dedup; keep the largest
33+
`siri_ride__id` group.
34+
3. **Match pings to stops** — nearest planned stop by Euclidean distance in
35+
lon/lat, **masked to ±20 minutes** of that stop's planned elapsed time.
36+
4. **Per-stop elapsed** per day = mean elapsed of the pings assigned to that stop.
37+
5. **Average across days**, with two guards (below), and render.
38+
39+
### The two guards on the average
40+
41+
**Minimum days per stop.** Days cover very different subsets of the route (GPS
42+
drops out, trails start late). A naive per-stop mean can take stop 12 from a slow
43+
day and stop 13 from a fast one, producing an "average" trip that runs *backwards*
44+
along the route — a journey no bus ever made. Stops measured on fewer than
45+
`max(2, (n_days+1)//2)` days are excluded from the average and counted in the notes.
46+
47+
**Monotonic clamp.** Whatever survives should still climb: a bus cannot reach a
48+
later stop earlier. Residual dips are nearest-stop mis-assignment, not a reversing
49+
bus, so they are clamped forward and the count is reported.
50+
51+
### The map's weighting
52+
53+
Pings are pooled per stop across all matched days and averaged with weight
54+
`1 / (distance + 1e-6)`, so pings that passed closest to a stop dominate its
55+
position. **This is why the measured route can bow away from the planned one**
56+
it is rebuilt from where buses actually were, not the timetable's coordinates.
57+
58+
## Reasoning
59+
60+
**Why the canonical stop signature matters.** One `line_ref` serves several stop
61+
patterns. Averaging across them silently blends two different journeys into a
62+
number describing neither. The notebook's check is ported faithfully and skipped
63+
days are reported by category (`no_plan` / `route_mismatch` / `no_actual`) so
64+
"different journey" is visibly distinct from "missing data".
65+
66+
**Why nearest-stop matching is gated by time.** Distance alone mis-assigns pings on
67+
routes that loop back near themselves — a much-later ping matches an early stop.
68+
The ±20 min mask is ported unchanged.
69+
70+
**Why the by-day card compares to the plan *up to the same stop*.** A day whose GPS
71+
died halfway would otherwise be scored as an impossibly quick trip. Each day is
72+
measured to its **last resolved stop**, and the plan is truncated to match.
73+
74+
**Why the window shrank from 90 days to 21.** The notebook demanded 20 matched days
75+
before averaging; that is minutes of API calls. A dashboard card cannot make
76+
someone wait. The shape of the result is the same, the confidence is lower, and the
77+
notes say exactly that.
78+
79+
## Findings
80+
81+
### 1. One `line_ref` runs several distinct stop patterns, and they must not be averaged together — **confidence: High**
82+
83+
The signature check exists because it fires. Days are routinely skipped as
84+
`route_mismatch` — the same line number, the same departure minute, a different
85+
journey. Any planned-vs-actual analysis that groups by line number alone is mixing
86+
these. Mechanistically clear and visible in the per-run skip counts.
87+
88+
### 2. Nearest-stop matching produces route-order violations even after the time mask — **confidence: High**
89+
90+
The monotonic clamp fires on real data. Averaged trajectories dip backwards along
91+
the route, which is physically impossible and therefore definitely mis-assignment.
92+
The clamp count is reported per run.
93+
94+
### 3. GPS coverage of a single departure is patchy enough that a naive per-stop mean is invalid — **confidence: High**
95+
96+
The minimum-days-per-stop guard exists for the same reason and reports how many
97+
stops it excluded. Both guards are self-documenting: they print what they caught.
98+
99+
### 4. Duplicate pings materially bias a distance-weighted average — **confidence: Medium**
100+
101+
Unlike a `min()` or `max()` aggregation where duplicates are harmless, a duplicated
102+
ping carries its weight **twice** here, pulling the measured route toward whatever
103+
happened to be reported twice. Measured at ~10% of rows repo-wide. Medium because
104+
the mechanism is certain but the resulting positional bias was not quantified.
105+
106+
### 5. On a given line, where a departure is habitually late — **confidence: Medium**
107+
108+
The output the card exists for. Where the average sits right of the dashed plan,
109+
that stop is habitually reached late, and the spread of the faint lines is how
110+
consistent that is. Medium: typically well under the notebook's own 20-day
111+
threshold, one route variant, one departure time.
112+
113+
### 6. The measured route bows away from the planned route in places — **confidence: Low**
114+
115+
Visually striking on the map, and it *may* show real deviation. But the weighted
116+
average is over pings assigned by nearest-stop matching, which finding 2 shows
117+
mis-assigns. A bow could be a genuine detour or an assignment artifact, and this
118+
card cannot distinguish them. [route-divergence.md](route-divergence.md) measures
119+
deviation properly, with haversine distance and no assignment step.
120+
121+
## Criticism
122+
123+
**Distance is Euclidean in raw lon/lat degrees**
124+
([schedule_adherence_average.py:276](../analyses/schedule_adherence_average.py#L276)).
125+
At Israel's latitude a degree of longitude is ~15% shorter than a degree of
126+
latitude, so "nearest stop" is systematically biased toward east-west neighbours.
127+
[route_divergence.py](../analyses/route_divergence.py) uses haversine for exactly
128+
this reason and says so — the two cards disagree about how to measure the same
129+
thing, and this one is wrong. The bias is small relative to the ±20 min time mask
130+
doing most of the work, but it is free to fix.
131+
132+
**The monotonic clamp hides rather than reports the error.** Clamping forward makes
133+
the line physically possible and the count is disclosed, but the clamped points are
134+
still drawn as if measured. A gap would be more honest than a flat segment
135+
fabricated to preserve monotonicity.
136+
137+
**`line_ref` defaults to a hardcoded `"18663"`** with a dropdown of 8 "well-tracked"
138+
lines ([schedule_adherence_average.py:289](../analyses/schedule_adherence_average.py#L289)).
139+
Fine for a demo, but a card whose default is a known-good line will systematically
140+
overstate how well the method works on an arbitrary line.
141+
142+
**`ISRAEL_TZ` is a fixed `UTC+3`**
143+
([schedule_adherence_average.py:61](../analyses/schedule_adherence_average.py#L61)),
144+
not `ZoneInfo("Asia/Jerusalem")`. Israel is UTC+2 in winter. Every other module in
145+
the repo uses `ZoneInfo`, and [orion's caveat 5](days-with-no-cancellations.md)
146+
documents a fixed +3 spilling a 16th day into a 15-day November window. With a
147+
21-day default window in August this is currently harmless; on a winter date range
148+
it shifts the anchor hour by an hour and will silently anchor on a different
149+
departure. **This is a live bug, not a stylistic difference.**
150+
151+
**The 1-minute matching window is brittle.** A day whose departure was retimed by
152+
even 90 seconds in the published GTFS is counted as `no_plan` — indistinguishable
153+
in the skip counts from a day the line genuinely did not run.
154+
155+
**Three cards, one fetch, three chances to mislead.** All three read the same
156+
`_load` result, so any resolution error propagates identically to all of them. Three
157+
agreeing cards look like corroboration and are not.

0 commit comments

Comments
 (0)