|
| 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