3535 stop_coverage ,
3636)
3737from 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
3942from 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)
5051from 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
343325def _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
0 commit comments