Skip to content

Commit 7aa4fc4

Browse files
committed
Merge PR #1: Integrate cancellations quality scores and beautify arrival charts
This merge integrates the 'orion' group's 'Days without cancellations' quality scores materials from PR #1 and further optimizes the dashboard to support high-fidelity matplotlib renderings with fallback interactive tables. 1. 'Days without cancellations' Integration: - Checked out the 'orion/' directory from 'pr-1' containing the core scripts and markdown. - Built a brand new dynamic analysis card 'days-with-no-cancellations' that supports both Method 1 (single line daily report) and Method 2 (operator overview horizontal matplotlib plot), responding dynamically to global filters. 2. Visual Beautification of Bus Arrival Cards: - Refactored 'bus_arrival_reliability.py' to use the teammates' original matplotlib plotting functions ('plot_segment_times', 'plot_marey', and 'plot_segment_hour_heatmap') rather than generic client-side Recharts. - Preserved full accessibility/screen-reader compliance by generating interactive relief tables ('Table') as a dual fallback view on each card. 3. Global Input Wiring: - Wired schedule adherence maps and diagrams in 'schedule_adherence_average.py' to global search filters so that any query (e.g. line 480) re-resolves and synchronizes all 11 dashboard cards instantly.
1 parent 6a398e8 commit 7aa4fc4

11 files changed

Lines changed: 788 additions & 93 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,7 @@ GitHub Actions (`.github/workflows/ci.yml`) runs on every push/PR to `main`:
240240
a `python` job that `uv sync`s and runs `./dev check` (plus a non-blocking
241241
`ruff check`), and a `frontend` job that type-checks with `tsc` and runs the
242242
Playwright suite, uploading the HTML report as an artifact.
243+
244+
## Desired quality checks for the hackathon
245+
246+
https://docs.google.com/spreadsheets/d/1uFikn1oFehRSQzr4VxS09NjVna7_gvvluq2YKs1pl5Y/edit?gid=0#gid=0

analyses/bus_arrival_reliability.py

Lines changed: 65 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,18 @@
3535
stop_coverage,
3636
)
3737
from bus_times.config import DEFAULT_LAG_DAYS, DEFAULT_MIN_SAMPLES
38+
from bus_times.viz.segment_bars import plot_segment_times
39+
from bus_times.viz.marey import plot_marey
40+
from bus_times.viz.heatmap import plot_segment_hour_heatmap
3841

3942
from openbus_hack import (
4043
AnalysisRequest,
4144
AnalysisResult,
4245
OptionSpec,
43-
metrics,
44-
Point,
45-
Series,
46+
Table,
4647
analysis,
47-
bar_chart,
48-
heatmap,
48+
image,
49+
metrics,
4950
)
5051
from openbus_hack.diskcache import cached
5152

@@ -229,24 +230,13 @@ def run_segments(req: AnalysisRequest):
229230
except NoMatch as exc:
230231
return _no_match_card(exc)
231232
aggregated = aggregate_segments(ride_segments, DEFAULT_MIN_SAMPLES).sort_values("segment_index")
232-
labels = [f"{r.from_name}{r.to_name}" for r in aggregated.itertuples()]
233-
234-
# Long-format frame bar_chart() expects: one row per (segment, {Actual, Planned}).
235-
# Only the "Actual" rows carry a p25/p75 whisker — "Planned" is a single number,
236-
# nothing to spread.
237-
long = pd.DataFrame({
238-
"segment": [*labels, *labels],
239-
"kind": ["Actual (median)"] * len(aggregated) + ["Planned"] * len(aggregated),
240-
"minutes": [*(aggregated["actual_median_s"] / 60), *(aggregated["planned_duration_s"] / 60)],
241-
"p25": [*(aggregated["actual_p25_s"] / 60), *([None] * len(aggregated))],
242-
"p75": [*(aggregated["actual_p75_s"] / 60), *([None] * len(aggregated))],
243-
})
244233

245234
weak = int((~aggregated["is_reliable"]).sum())
246235
notes = [
247236
quality_summary(aggregated),
248237
"The whisker on each Actual bar is the ride-to-ride interquartile spread "
249238
"(p25-p75), not a plain min-max range.",
239+
"Toggle 'Table view' at the top-right of the card to see exact numeric median and percentile values.",
250240
_CREDIT,
251241
]
252242
if weak:
@@ -255,14 +245,25 @@ def run_segments(req: AnalysisRequest):
255245
"'not enough evidence'.")
256246
notes = [*_match_notes(line, alts), *notes]
257247

258-
return bar_chart(
259-
long, x="segment", y="minutes", series="kind", low="p25", high="p75", horizontal=True,
260-
title="Where the timetable is optimistic",
261-
subtitle=f"{line.label} · {subtitle}",
262-
x_label="segment", y_label="minutes",
263-
notes=notes,
248+
t = Table(
249+
columns=["segment", "actual_median_min", "actual_p25_min", "actual_p75_min", "planned_duration_min"],
250+
rows=[
251+
[
252+
f"{r.from_name}{r.to_name}",
253+
round(r.actual_median_s / 60, 2),
254+
round(r.actual_p25_s / 60, 2),
255+
round(r.actual_p75_s / 60, 2),
256+
round(r.planned_duration_s / 60, 2),
257+
]
258+
for r in aggregated.itertuples()
259+
]
264260
)
265261

262+
fig = plot_segment_times(aggregated, line.label, subtitle, mode="light", stops_on_x=False)
263+
res = image(fig, title="Where the timetable is optimistic", subtitle=f"{line.label} · {subtitle}", notes=notes)
264+
res.table = t
265+
return res
266+
266267

267268
@analysis(
268269
name="bus-marey-diagram",
@@ -283,61 +284,42 @@ def run_marey(req: AnalysisRequest):
283284
actual, planned = elapsed_profiles(stop_events)
284285
coverage = stop_coverage(stop_events)
285286

286-
planned = planned.sort_values("stop_sequence")
287+
planned_sorted = planned.sort_values("stop_sequence")
287288
coverage_by_seq = coverage.set_index("stop_sequence")["coverage"]
288-
y_tick_labels = planned["stop_name"].tolist()
289289
y_tick_weak = [
290290
bool(coverage_by_seq.get(seq, 1.0) < _WEAK_COVERAGE)
291-
for seq in planned["stop_sequence"]
291+
for seq in planned_sorted["stop_sequence"]
292292
]
293-
294-
# Same cap the static chart used (plot_marey's own max_rides default) — past
295-
# ~60 overlapping trajectories the fan turns into a solid block and every
296-
# extra ride costs payload without adding anything readable.
297-
max_rides = 60
298-
ride_ids = actual["siri_ride_id"].drop_duplicates().to_numpy()
299-
if len(ride_ids) > max_rides:
300-
idx = [round(i) for i in _linspace(0, len(ride_ids) - 1, max_rides)]
301-
ride_ids = ride_ids[idx]
302-
303-
series = []
304-
for rid in ride_ids:
305-
ride = actual[actual["siri_ride_id"] == rid].sort_values("stop_sequence")
306-
series.append(Series(
307-
name=f"ride_{rid}",
308-
points=[Point(x=float(r.elapsed_min), y=float(r.stop_sequence))
309-
for r in ride.itertuples() if pd.notna(r.elapsed_min)],
310-
))
311-
series.append(Series(
312-
name="Planned",
313-
emphasis=True,
314-
points=[Point(x=float(r.elapsed_min), y=float(r.stop_sequence))
315-
for r in planned.itertuples()],
316-
))
317-
318293
n_weak = sum(y_tick_weak)
294+
319295
notes = [
320296
"Each faint line is one sampled ride; the bold dashed line is the schedule. "
321297
"Steep = moving, flat = stuck, and the width of the fan is the route's "
322298
"unreliability.",
299+
"Toggle 'Table view' to see the scheduled elapsed running time per stop sequence.",
323300
_CREDIT,
324301
]
325302
if n_weak:
326-
weak_names = [name for name, w in zip(y_tick_labels, y_tick_weak) if w][:5]
303+
weak_names = [name for name, w in zip(planned_sorted["stop_name"], y_tick_weak) if w][:5]
327304
more = f" (+{n_weak - 5} more)" if n_weak > 5 else ""
328305
notes.insert(1, f"Dimmed, italic stop labels ({n_weak} of them) are stops the GPS "
329306
f"rarely resolved, incl. {', '.join(weak_names)}{more} — trajectories "
330307
"through them are interpolation more than measurement.")
331308

332309
notes = [*_match_notes(line, alts), *notes]
333-
return AnalysisResult(
334-
kind="chart", chart_type="trajectories", series=series,
335-
title="Where the bus loses time",
336-
subtitle=f"{line.label} · {subtitle}",
337-
x_label="elapsed minutes", y_label=None,
338-
y_tick_labels=y_tick_labels, y_tick_weak=y_tick_weak,
339-
notes=notes,
340-
).ensure_table()
310+
311+
t = Table(
312+
columns=["stop_sequence", "stop_name", "planned_elapsed_min"],
313+
rows=[
314+
[int(r.stop_sequence), r.stop_name, round(r.elapsed_min, 2)]
315+
for r in planned_sorted.itertuples()
316+
]
317+
)
318+
319+
fig = plot_marey(*elapsed_profiles(stop_events), line.label, subtitle, mode="light", coverage=coverage, stops_on_x=False)
320+
res = image(fig, title="Where the bus loses time", subtitle=f"{line.label} · {subtitle}", notes=notes)
321+
res.table = t
322+
return res
341323

342324

343325
def _linspace(start: float, stop: float, num: int) -> list[float]:
@@ -363,27 +345,29 @@ def run_heatmap(req: AnalysisRequest):
363345
line, _stop_events, ride_segments, subtitle, alts = _fetch(req)
364346
except NoMatch as exc:
365347
return _no_match_card(exc)
348+
matrix_data = segment_hour_matrix(ride_segments)
366349
matrix = segment_hour_matrix(ride_segments, DEFAULT_MIN_SAMPLES)
367-
# matrix.ratio is indexed by (segment_index, from_name, to_name); the segment
368-
# pair is what a reader actually recognises, so label rows with that.
369350
labels = [f"{from_name}{to_name}" for _, from_name, to_name in matrix.ratio.index]
370-
return heatmap(
371-
matrix.ratio,
372-
matrix.count,
373-
row_labels=labels,
374-
col_labels=[f"{int(h):02d}" for h in matrix.ratio.columns],
375-
min_count=DEFAULT_MIN_SAMPLES,
376-
center=1.0,
377-
title="Which segments break down at rush hour",
378-
subtitle=f"{line.label} · {subtitle}",
379-
row_axis_label="segment",
380-
col_axis_label="departure hour",
381-
value_label="actual / planned",
382-
notes=[
383-
*_match_notes(line, alts),
384-
"1.00 means exactly on schedule; above that the segment ran longer than "
385-
"the timetable allows. Hatched cells are measured but rest on fewer than "
386-
f"{DEFAULT_MIN_SAMPLES} rides; empty cells had no usable ride at all.",
387-
_CREDIT,
388-
],
351+
cols = [f"{int(h):02d}" for h in matrix.ratio.columns]
352+
353+
notes = [
354+
*_match_notes(line, alts),
355+
"1.00 means exactly on schedule; above that the segment ran longer than "
356+
"the timetable allows. Hatched cells are measured but rest on fewer than "
357+
f"{DEFAULT_MIN_SAMPLES} rides; empty cells had no usable ride at all.",
358+
"Toggle 'Table view' at the top-right of the card to see the precise ratio values.",
359+
_CREDIT,
360+
]
361+
362+
t = Table(
363+
columns=["segment", *cols],
364+
rows=[
365+
[labels[i], *(None if pd.isna(val) else round(val, 2) for val in matrix.ratio.iloc[i])]
366+
for i in range(len(labels))
367+
]
389368
)
369+
370+
fig = plot_segment_hour_heatmap(matrix_data, line.label, subtitle, min_samples=DEFAULT_MIN_SAMPLES, mode="light", stops_on_x=False)
371+
res = image(fig, title="Which segments break down at rush hour", subtitle=f"{line.label} · {subtitle}", notes=notes)
372+
res.table = t
373+
return res
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Days without cancellations quality score dashboard card.
2+
3+
Integrates the "orion" group materials from orion/days_with_no_cancellations.py.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import base64
9+
import datetime
10+
import pandas as pd
11+
12+
from openbus_hack import (
13+
AnalysisRequest,
14+
AnalysisResult,
15+
Table,
16+
analysis,
17+
bar_chart,
18+
metrics,
19+
)
20+
from orion.days_with_no_cancellations import (
21+
daily_report,
22+
operator_line_scores,
23+
plot_operator_scores,
24+
)
25+
26+
27+
@analysis(
28+
name="days-with-no-cancellations",
29+
title="Days with zero cancellations",
30+
description="Quality score based on how many days all buses operated as expected (zero cancellations). "
31+
"A day with >=1 cancellation counts as 0, a day with full operations is 1.",
32+
author="orion",
33+
tags=["reliability", "cancellations", "data.gov.il"],
34+
inputs=["lines", "operators", "dates"],
35+
)
36+
def run(req: AnalysisRequest):
37+
days_back = 15 # Core metric looks at last 15 days
38+
# Default window end date (retaining LAG_DAYS to be consistent with data freshness)
39+
end_date = min(req.date_to, datetime.date.today() - datetime.timedelta(days=1))
40+
41+
# ── Method 1: Single Line ────────────────────────────────────────────────
42+
if req.line:
43+
operators = req.operators if req.operators else None
44+
df = daily_report(req.line, days=days_back, end=end_date, operators=operators)
45+
if df.empty:
46+
return metrics(
47+
("No data", 0),
48+
notes=[
49+
f"No planned rides for line {req.line} in the last {days_back} days.",
50+
"Ensure you have selected the correct operator or clear filters.",
51+
]
52+
)
53+
54+
# Reshape to long format for bar_chart (Operated vs Cancelled)
55+
df["date_str"] = df["date"].astype(str)
56+
df["Operated"] = df["planned"] - df["cancelled"]
57+
df_long = df.melt(
58+
id_vars="date_str",
59+
value_vars=["Operated", "cancelled"],
60+
var_name="Status",
61+
value_name="rides",
62+
)
63+
64+
good_days = int(df["good"].sum())
65+
total_days = len(df)
66+
score = good_days / total_days if total_days > 0 else 0.0
67+
68+
return bar_chart(
69+
df_long,
70+
x="date_str",
71+
y="rides",
72+
series="Status",
73+
stacked=True,
74+
title=f"Line {req.line} — Daily cancellations and runs",
75+
subtitle=f"Score: {score:.2f} ({good_days}/{total_days} clean days in the last {days_back} days)",
76+
notes=[
77+
"A 'clean day' is one with absolutely zero cancellations.",
78+
"Data source is the Ministry's rides execution endpoint (/rides_execution/list), "
79+
"where scheduled departures with no matching GPS arrival record represent cancellations.",
80+
],
81+
)
82+
83+
# ── Method 2: Operator Overview ──────────────────────────────────────────
84+
else:
85+
operator = req.operator or "סופרבוס" # Fallback to Superbus
86+
scores = operator_line_scores(
87+
operator, days=days_back, end=end_date, max_lines=15, progress=False
88+
)
89+
if scores.empty:
90+
return metrics(
91+
("No data", 0),
92+
notes=[f"No lines with planned rides found for operator {operator}."]
93+
)
94+
95+
# Generate horizontal bar chart using the custom matplotlib plotter
96+
fig_path = plot_operator_scores(scores, operator, days=days_back)
97+
98+
# Read the written file directly as bytes to avoid re-rendering
99+
png_bytes = fig_path.read_bytes()
100+
b64_str = base64.b64encode(png_bytes).decode("ascii")
101+
102+
# Expose precise numbers as the relief table
103+
t = Table(
104+
columns=["line", "score", "good_days", "days_scored", "planned", "cancelled"],
105+
rows=[
106+
[
107+
row.line,
108+
f"{row.score:.2f}",
109+
int(row.good_days),
110+
int(row.days_scored),
111+
int(row.planned),
112+
int(row.cancelled),
113+
]
114+
for row in scores.itertuples()
115+
],
116+
)
117+
118+
return AnalysisResult(
119+
kind="image",
120+
title=f"{operator} — Days without cancellations",
121+
subtitle=f"Scores for worst performing lines, last {days_back} days",
122+
image_png=b64_str,
123+
image_alt=f"{operator} cancellation scores",
124+
table=t,
125+
notes=[
126+
"Each bar represents the fraction of days (out of last 15) with zero cancellations.",
127+
"Hatched bars ('///') indicate lines with zero actual GPS reports, which represent data gaps rather than actual 100% cancellations.",
128+
"Toggle 'Table view' at the top-right of the card to see exact planned, operated, and cancelled counts for each line.",
129+
f"Data retrieved live from /rides_execution/list.",
130+
],
131+
)

0 commit comments

Comments
 (0)