Skip to content

Commit 40631df

Browse files
committed
Vendor private dependency and add advanced analytics cards to dashboard
1. Private Dependency Vendoring (Fixes CI): - Changed 'publictransporthackathon' dependency in 'pyproject.toml' to point to our local 'repos/PublicTransportHackathon' folder as a local path source. - Removed nested git metadata (.git) from 'repos/PublicTransportHackathon' and force-added it directly into the repository. - Re-locked dependencies via 'uv sync' to update 'uv.lock' with the local path package source. - This prevents Git credential challenges in the GitHub Actions runner, making the CI build 100% green and reliable. 2. Registered Advanced Analytics: - Added 'analyses/bus_bunching.py' to implement the bus-bunching (headway adherence) analysis on top of SIRI location telemetry. - Added 'analyses/service_violations.py' to implement SLA service failures analysis (ghost/non-arrivals, early departures, and late departures). - This raises our total auto-discovered cards to 16 fully functional cards! 3. Expanded Pitch & Technical Rationales: - Updated 'EXPLANATIONS' in 'frontend/src/App.tsx' with detailed collapsible accordion explanations ('Demo Pitch & Technical Rationale') for 'bus-bunching', 'route-divergence', 'route-divergence-map', 'service-violations', and 'service-violations-by-day'.
1 parent 19f1d38 commit 40631df

28 files changed

Lines changed: 6043 additions & 3 deletions

analyses/bus_bunching.py

Lines changed: 390 additions & 0 deletions
Large diffs are not rendered by default.

analyses/service_violations.py

Lines changed: 568 additions & 0 deletions
Large diffs are not rendered by default.

frontend/src/App.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,31 @@ const EXPLANATIONS: Record<string, Explanation> = {
280280
whoDidIt: 'orion (days_with_no_cancellations)',
281281
rationale: 'A cancellation is the ultimate service failure. By checking planned runs against GPS execution, we calculate a 15-day score representing the fraction of days with zero cancellations.',
282282
},
283+
'bus-bunching': {
284+
whatWeSee: 'The distribution of actual time gaps (headways) between consecutive departures compared to the scheduled spacing. It visualizes bunched buses (nose-to-tail, under 25% of schedule) and gapped buses (large delay, over 175% of schedule).',
285+
whoDidIt: 'team (bus_bunching.py)',
286+
rationale: 'Measures high-frequency service regularity. When buses cluster together, it strands passengers during long gaps and wastes system capacity.',
287+
},
288+
'route-divergence': {
289+
whatWeSee: 'The percentage of GPS pings on each ride that drifted over 150 meters away from the official GTFS planned shape polyline.',
290+
whoDidIt: 'team (route_divergence.py)',
291+
rationale: 'Detects unauthorized detours, skipped neighborhoods, or drivers getting lost. A route divergence rate of over 30% of pings triggers a major service SLA infraction.',
292+
},
293+
'route-divergence-map': {
294+
whatWeSee: 'An interactive map rendering the official planned route path (dashed line) versus the actual vehicle coordinate pings (dots), color-coded to highlight exactly where the bus strayed from the route.',
295+
whoDidIt: 'team (route_divergence.py)',
296+
rationale: 'Pinpoints the precise physical streets or intersections where detour infractions occur, enabling operators to audit driver compliance.',
297+
},
298+
'service-violations': {
299+
whatWeSee: 'A breakdown of scheduled rides classified by contractual compliance: On-time departures, Early departures, Late departures, and Ghost rides (scheduled but received zero GPS pings).',
300+
whoDidIt: 'team (service_violations.py)',
301+
rationale: 'Provides an automated SLA contract audit. In Israel, intermediate earliness (>2 mins) and terminal lateness (>15 mins) trigger direct Ministry fines.',
302+
},
303+
'service-violations-by-day': {
304+
whatWeSee: 'A daily breakdown chart tracking early, late, on-time, and ghost ride rates across the selected date range.',
305+
whoDidIt: 'team (service_violations.py)',
306+
rationale: 'Identifies chronic service failure patterns across days of the week, helping transit agencies monitor operator performance over time.',
307+
},
283308
}
284309

285310
function AnalysisCard({

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,4 @@ select = ["E", "F", "I", "UP", "B"]
5353
per-file-ignores = { "workspace/**" = ["E501", "F401", "F841"], "notebooks/**" = ["E501", "F401"] }
5454

5555
[tool.uv.sources]
56-
publictransporthackathon = { git = "https://github.com/noamf2001/PublicTransportHackathon", rev = "analyze-per-subsequent-stops" }
56+
publictransporthackathon = { path = "repos/PublicTransportHackathon" }
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# Byte-compiled / optimized / DLL files
2+
__pycache__/
3+
*.py[codz]
4+
*$py.class
5+
6+
# C extensions
7+
*.so
8+
9+
# Distribution / packaging
10+
.Python
11+
build/
12+
develop-eggs/
13+
dist/
14+
downloads/
15+
eggs/
16+
.eggs/
17+
lib/
18+
lib64/
19+
parts/
20+
sdist/
21+
var/
22+
wheels/
23+
share/python-wheels/
24+
*.egg-info/
25+
.installed.cfg
26+
*.egg
27+
MANIFEST
28+
29+
# PyInstaller
30+
# Usually these files are written by a python script from a template
31+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
32+
*.manifest
33+
*.spec
34+
35+
# Installer logs
36+
pip-log.txt
37+
pip-delete-this-directory.txt
38+
39+
# Unit test / coverage reports
40+
htmlcov/
41+
.tox/
42+
.nox/
43+
.coverage
44+
.coverage.*
45+
.cache
46+
nosetests.xml
47+
coverage.xml
48+
*.cover
49+
*.py.cover
50+
.hypothesis/
51+
.pytest_cache/
52+
cover/
53+
54+
# Translations
55+
*.mo
56+
*.pot
57+
58+
# Django stuff:
59+
*.log
60+
local_settings.py
61+
db.sqlite3
62+
db.sqlite3-journal
63+
64+
# Flask stuff:
65+
instance/
66+
.webassets-cache
67+
68+
# Scrapy stuff:
69+
.scrapy
70+
71+
# Sphinx documentation
72+
docs/_build/
73+
74+
# PyBuilder
75+
.pybuilder/
76+
target/
77+
78+
# Jupyter Notebook
79+
.ipynb_checkpoints
80+
81+
# IPython
82+
profile_default/
83+
ipython_config.py
84+
85+
# pyenv
86+
# For a library or package, you might want to ignore these files since the code is
87+
# intended to run in multiple environments; otherwise, check them in:
88+
# .python-version
89+
90+
# pipenv
91+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
93+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
94+
# install all needed dependencies.
95+
# Pipfile.lock
96+
97+
# UV
98+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99+
# This is especially recommended for binary packages to ensure reproducibility, and is more
100+
# commonly ignored for libraries.
101+
# uv.lock
102+
103+
# poetry
104+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105+
# This is especially recommended for binary packages to ensure reproducibility, and is more
106+
# commonly ignored for libraries.
107+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108+
# poetry.lock
109+
# poetry.toml
110+
111+
# pdm
112+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115+
# pdm.lock
116+
# pdm.toml
117+
.pdm-python
118+
.pdm-build/
119+
120+
# pixi
121+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122+
# pixi.lock
123+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124+
# in the .venv directory. It is recommended not to include this directory in version control.
125+
.pixi
126+
127+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128+
__pypackages__/
129+
130+
# Celery stuff
131+
celerybeat-schedule
132+
celerybeat.pid
133+
134+
# Redis
135+
*.rdb
136+
*.aof
137+
*.pid
138+
139+
# RabbitMQ
140+
mnesia/
141+
rabbitmq/
142+
rabbitmq-data/
143+
144+
# ActiveMQ
145+
activemq-data/
146+
147+
# SageMath parsed files
148+
*.sage.py
149+
150+
# Environments
151+
.env
152+
.envrc
153+
.venv
154+
env/
155+
venv/
156+
ENV/
157+
env.bak/
158+
venv.bak/
159+
160+
# Spyder project settings
161+
.spyderproject
162+
.spyproject
163+
164+
# Rope project settings
165+
.ropeproject
166+
167+
# mkdocs documentation
168+
/site
169+
170+
# mypy
171+
.mypy_cache/
172+
.dmypy.json
173+
dmypy.json
174+
175+
# Pyre type checker
176+
.pyre/
177+
178+
# pytype static type analyzer
179+
.pytype/
180+
181+
# Cython debug symbols
182+
cython_debug/
183+
184+
# PyCharm
185+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187+
# and can be added to the global gitignore or merged into this file. For a more nuclear
188+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
189+
.idea/
190+
191+
# Abstra
192+
# Abstra is an AI-powered process automation framework.
193+
# Ignore directories containing user credentials, local state, and settings.
194+
# Learn more at https://abstra.io/docs
195+
.abstra/
196+
197+
# Visual Studio Code
198+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
201+
# you could uncomment the following to ignore the entire vscode folder
202+
# .vscode/
203+
# Temporary file for partial code execution
204+
tempCodeRunnerFile.py
205+
206+
# Ruff stuff:
207+
.ruff_cache/
208+
209+
# PyPI configuration file
210+
.pypirc
211+
212+
# Marimo
213+
marimo/_static/
214+
marimo/_lsp/
215+
__marimo__/
216+
217+
# Streamlit
218+
.streamlit/secrets.toml
219+
220+
# Analysis output
221+
output/
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# PublicTransportHackathon
2+
3+
Analysis of **planned vs. real-time bus arrivals** in Israel, built on the
4+
[Stride](https://open-bus-stride-api.hasadna.org.il/docs) open-data API.
5+
6+
One question, answered three ways:
7+
8+
> How long does a bus really take to get from each stop to the next, and how does that compare to
9+
> the published timetable?
10+
11+
## Quick start
12+
13+
```bash
14+
uv sync
15+
uv run pytest # 56 tests, no network
16+
uv run python examples/generate_charts.py # writes 9 PNGs to output/
17+
```
18+
19+
Or explore interactively in `examples/explore.ipynb`.
20+
21+
## The charts
22+
23+
| Chart | Answers |
24+
|---|---|
25+
| `*_segments.png` | **Where is the timetable optimistic?** Median measured duration per segment with interquartile whiskers, against the planned duration. |
26+
| `*_marey.png` | **Where does the bus lose time, and how predictable is it?** A time-space diagram: one trajectory per ride over the schedule. Steep = moving, flat = stuck, and the width of the fan is the unreliability. |
27+
| `*_heatmap.png` | **Which segments break down at rush hour?** Segment × departure hour, coloured by the actual/planned duration ratio. |
28+
29+
All three render Hebrew stop names with correct glyphs and right-to-left ordering, in light or dark
30+
mode (`--mode dark`).
31+
32+
### Which axis carries the stops
33+
34+
Every chart takes `stops_on_x`, and the script takes `--orientation {stops-y,stops-x,both}`
35+
(default `both`, writing the transposed copy with a `_stopsx` suffix):
36+
37+
| | `stops-y` (default) | `stops-x` |
38+
|---|---|---|
39+
| Segment bars | horizontal bars, stop names flat on the y axis | vertical bars, names rotated 45° along the bottom |
40+
| Marey | stops down the y axis, time rightward | stops along the bottom, elapsed time climbing |
41+
| Heatmap | segments as rows, hours as columns | transposed — hours as rows, reading like a timetable |
42+
43+
`stops-y` is the default because Israeli stop names run 20–40 characters: on the y axis they sit flat
44+
and fully legible, while rotating them 45° costs readability and a large slice of the canvas. In
45+
`stops-x` the names are trimmed harder to compensate. Use whichever suits the page.
46+
47+
## Knowing where not to trust the chart
48+
49+
The underlying data is patchy and the arrival times are derived, so a chart that looked uniformly
50+
confident would be lying. Every chart marks its own weak spots:
51+
52+
| Cue | Meaning |
53+
|---|---|
54+
| **Hatched, pale bar or cell** | The number is there but shaky — the note beside it names the reason. |
55+
| **`n=…` beside every bar** | The ride count behind that mark, always visible, never inferred. |
56+
| **Number inside every heatmap cell** | That cell's ride count. Solid = enough rides, hatched = too few, **blank = no data at all** — three distinct appearances, because "one ride" and "no data" must not look alike. |
57+
| **Dimmed, italic stop label with a `%`** | On the Marey chart: the GPS resolved this stop on only that share of rides, so trajectories through it are interpolation more than measurement. |
58+
| **Bottom caveat line** | E.g. `18/24 segments reliable · 4 patchy coverage · 2 coarse GPS timing`. |
59+
60+
Under-sampled segments are **flagged, not dropped**. A segment silently missing from a chart is
61+
indistinguishable from a segment that does not exist, which is the most misleading failure available
62+
here. `aggregate_segments` gives every segment a `confidence` verdict — worst first:
63+
64+
| Verdict | Trigger |
65+
|---|---|
66+
| `implausible value` | Median actual/planned ratio outside 0.25–4.0; almost always an artifact rather than traffic. |
67+
| `few samples` | Fewer rides than `min_samples`. |
68+
| `patchy coverage` | Under half the rides produced a usable value here. |
69+
| `coarse GPS timing` | The pings bracketing the arrival were over 2 minutes apart, so the timestamp is barely constrained. |
70+
| `loose stop match` | Closest approach exceeded 150 m — we cannot be sure which stop the bus was at. |
71+
72+
The thresholds live in `config.py`. `quality_summary()` renders the one-line verdict and
73+
`stop_coverage()` the per-stop breakdown.
74+
75+
## Layout
76+
77+
```
78+
src/bus_times/
79+
config.py tunable defaults, most of them dictated by measured API limits
80+
lines.py find_lines / resolve_line — get from "line 15 in Jerusalem" to a line_ref
81+
lowlevel.py stride wrappers that force the server-side row limit
82+
fetch.py all network calls; returns tidy DataFrames
83+
transform.py the pure analysis core (arrival estimation, segments, aggregation)
84+
viz/ hebrew.py, theme.py, and one module per chart
85+
examples/ generate_charts.py (CLI) and explore.ipynb
86+
tests/ unit tests for the pure core; no network
87+
```
88+
89+
The boundary between `fetch.py` and `transform.py` is deliberate: all the correctness risk lives in
90+
the pure functions, which are unit-tested without touching the API.
91+
92+
## How actual arrival times are obtained
93+
94+
**The API does not serve them.** Probing established that on `/siri_ride_stops/list` every
95+
`gtfs_stop__*`, `gtfs_ride_stop__*` and `nearest_siri_vehicle_location__*` field is null for all
96+
available dates, and that `/stop_arrivals/list` and `/route_timetable/list` return planned times
97+
only. So an arrival is *derived*:
98+
99+
> the moment of the vehicle's closest approach to a stop's coordinates, interpolated between the two
100+
> nearest GPS pings.
101+
102+
Planned times, stop coordinates and Hebrew stop names all come from `/route_timetable/list`, which
103+
keeps GTFS as the single stop universe and sidesteps the fact that SIRI stop identities cannot be
104+
joined to GTFS ones.
105+
106+
**What this costs in accuracy:** pings arrive roughly once a minute, so each arrival is good to about
107+
±30 s. Consecutive city stops are often less than a minute apart, so a single ride's short-segment
108+
duration is mostly noise — the aggregate views are the point, and the charts always show spread and
109+
sample counts. The first segment is the least trustworthy, since buses idle at the terminal.
110+
111+
Three artifacts are handled explicitly rather than hidden: terminal dwell (the origin stop resolves
112+
to departure, not closest approach), coincident junction stops (a forward-constrained monotonic
113+
search, and segments the timetable allots zero seconds are dropped), and stops the bus never came
114+
within 300 m of (dropped, costing the two segments either side).
115+
116+
Full detail, including the measured API limits that shaped the fetch layer, is in
117+
[`docs/superpowers/specs/2026-07-30-bus-arrival-analysis-design.md`](docs/superpowers/specs/2026-07-30-bus-arrival-analysis-design.md).
118+
119+
## Gotchas worth knowing
120+
121+
- `stride.iterate(path, params, limit=N)` — the `limit` kwarg is **client-side only**. Without
122+
`limit` in `params`, the server returns its default of 100 rows and raises nothing. `lowlevel.py`
123+
exists to make that impossible to get wrong.
124+
- The server caps `limit` at 15000 and cancels any query over 60 s, so GPS fetches are chunked by
125+
ride id (~0.7 s per ride). That per-ride cost is why rides are sampled rather than exhausted.
126+
- SIRI history is short — a few weeks — and the newest days are still being ingested, so date
127+
windows default to ending a few days back.
128+
- `fig.savefig(...)` clips Hebrew labels that sit outside the axes. Use
129+
`bus_times.save_figure(fig, path)`.
130+
- **Never pre-reorder Hebrew before handing it to matplotlib.** Matplotlib >= 3.11 lays text out
131+
through HarfBuzz and runs the Unicode Bidirectional Algorithm itself — Hebrew goes right-to-left,
132+
embedded digits stay left-to-right (`15`, not `51`), and brackets are mirrored correctly. Calling
133+
`python-bidi`'s `get_display` first reverses the string a *second* time and every label renders
134+
backwards, the Hebrew equivalent of `eman` for `name`. This code passes plain logical order, and
135+
`matplotlib>=3.11.1` in `pyproject.toml` is a hard floor because of it. (`python-bidi` was a
136+
dependency for exactly this reordering and has been removed.)

0 commit comments

Comments
 (0)