RoyAalekh commited on
Commit
f163245
·
1 Parent(s): c34bd5c

enhance context

Browse files
src/dashboard/pages/3_Simulation_Workflow.py CHANGED
@@ -530,6 +530,7 @@ elif st.session_state.workflow_step == 3:
530
  st.session_state.sim_results = {
531
  "success": True,
532
  "output": result["summary"],
 
533
  "log_dir": str(run_dir),
534
  "completed_at": datetime.now().isoformat(),
535
  }
@@ -583,6 +584,13 @@ elif st.session_state.workflow_step == 4:
583
  with st.expander("View simulation output"):
584
  st.code(results["output"], language="text")
585
 
 
 
 
 
 
 
 
586
  # Check for generated files
587
  log_dir = Path(results["log_dir"])
588
 
 
530
  st.session_state.sim_results = {
531
  "success": True,
532
  "output": result["summary"],
533
+ "insights": result.get("insights"),
534
  "log_dir": str(run_dir),
535
  "completed_at": datetime.now().isoformat(),
536
  }
 
584
  with st.expander("View simulation output"):
585
  st.code(results["output"], language="text")
586
 
587
+ # Key Insights from engine (if available)
588
+ insights_text = results.get("insights")
589
+ if insights_text:
590
+ st.markdown("### Key Insights")
591
+ with st.expander("Show engine insights", expanded=True):
592
+ st.code(insights_text, language="text")
593
+
594
  # Check for generated files
595
  log_dir = Path(results["log_dir"])
596
 
src/dashboard/pages/6_Analytics_And_Reports.py CHANGED
@@ -43,14 +43,18 @@ tab1, tab2, tab3, tab4 = st.tabs(
43
  # TAB 1: Simulation Comparison
44
  with tab1:
45
  st.markdown("### Simulation Comparison")
46
- st.markdown("Compare multiple simulation runs to evaluate different policies and parameters.")
 
 
47
 
48
  # Check for available simulation runs
49
  outputs_dir = Path("outputs")
50
  runs_dir = outputs_dir / "simulation_runs"
51
 
52
  if not runs_dir.exists():
53
- st.warning("No simulation outputs found. Run simulations first to generate data.")
 
 
54
  else:
55
  # Collect all run directories that actually contain a metrics.csv file.
56
  # Some runs may be nested (version folder inside timestamp). We treat every
@@ -100,6 +104,33 @@ with tab1:
100
 
101
  st.success("Loaded metrics successfully")
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  # Summary comparison
104
  st.markdown("#### Summary Comparison")
105
 
@@ -125,10 +156,16 @@ with tab1:
125
 
126
  with col3:
127
  st.markdown("**Difference**")
128
- if "disposal_rate" in df1.columns and "disposal_rate" in df2.columns:
 
 
 
129
  diff_disposal = avg_disposal2 - avg_disposal1
130
  st.metric("Disposal Rate Δ", f"{diff_disposal:+.2%}")
131
- if "utilization" in df1.columns and "utilization" in df2.columns:
 
 
 
132
  diff_util = avg_util2 - avg_util1
133
  st.metric("Utilization Δ", f"{diff_util:+.2%}")
134
 
@@ -137,7 +174,10 @@ with tab1:
137
  # Time series comparison
138
  st.markdown("#### Performance Over Time")
139
 
140
- if "disposal_rate" in df1.columns and "disposal_rate" in df2.columns:
 
 
 
141
  fig = go.Figure()
142
 
143
  fig.add_trace(
@@ -169,7 +209,10 @@ with tab1:
169
 
170
  st.plotly_chart(fig, use_container_width=True)
171
 
172
- if "utilization" in df1.columns and "utilization" in df2.columns:
 
 
 
173
  fig = go.Figure()
174
 
175
  fig.add_trace(
@@ -271,7 +314,10 @@ with tab2:
271
  x="run",
272
  y="disposal_rate",
273
  title="Disposal Rate Distribution by Run",
274
- labels={"disposal_rate": "Disposal Rate", "run": "Simulation Run"},
 
 
 
275
  )
276
  fig.update_layout(height=400)
277
  st.plotly_chart(fig, use_container_width=True)
@@ -325,7 +371,9 @@ with tab3:
325
  events_path = label_to_path[selected_run] / "events.csv"
326
 
327
  if not events_path.exists():
328
- st.warning("Events file not found. Fairness analysis requires detailed event logs.")
 
 
329
  else:
330
  try:
331
  events_df = pd.read_csv(events_path)
@@ -337,9 +385,12 @@ with tab3:
337
  st.markdown("#### Case Age Distribution")
338
 
339
  # Calculate case ages (simplified - would need filed_date for accurate calculation)
340
- case_dates = events_df.groupby("case_id")["date"].agg(["min", "max"])
 
 
341
  case_dates["age_days"] = (
342
- pd.to_datetime(case_dates["max"]) - pd.to_datetime(case_dates["min"])
 
343
  ).dt.days
344
 
345
  fig = px.histogram(
@@ -347,7 +398,10 @@ with tab3:
347
  x="age_days",
348
  nbins=30,
349
  title="Distribution of Case Ages",
350
- labels={"age_days": "Age (days)", "count": "Number of Cases"},
 
 
 
351
  )
352
  fig.update_layout(height=400)
353
  st.plotly_chart(fig, use_container_width=True)
@@ -356,11 +410,18 @@ with tab3:
356
  col1, col2, col3 = st.columns(3)
357
 
358
  with col1:
359
- st.metric("Median Age", f"{case_dates['age_days'].median():.0f} days")
 
 
 
360
  with col2:
361
- st.metric("Mean Age", f"{case_dates['age_days'].mean():.0f} days")
 
 
362
  with col3:
363
- st.metric("Max Age", f"{case_dates['age_days'].max():.0f} days")
 
 
364
 
365
  # Additional Fairness Metrics: Gini and Lorenz Curve
366
  st.markdown("#### Inequality Metrics (Fairness)")
@@ -403,7 +464,12 @@ with tab3:
403
  lorenz = cum_ages / cum_ages[-1]
404
  fig_lorenz = go.Figure()
405
  fig_lorenz.add_trace(
406
- go.Scatter(x=cum_pop, y=lorenz, mode="lines", name="Lorenz")
 
 
 
 
 
407
  )
408
  fig_lorenz.add_trace(
409
  go.Scatter(
@@ -420,18 +486,24 @@ with tab3:
420
  yaxis_title="Cumulative share of total age",
421
  height=350,
422
  )
423
- st.plotly_chart(fig_lorenz, use_container_width=True)
 
 
424
  else:
425
  st.info("Not enough data to plot Lorenz curve")
426
  except Exception:
427
- st.info("Unable to compute Lorenz curve for current data")
 
 
428
 
429
  # Case type fairness
430
  if "case_type" in events_df.columns:
431
  st.markdown("---")
432
  st.markdown("#### Case Type Balance")
433
 
434
- case_type_counts = events_df["case_type"].value_counts().reset_index()
 
 
435
  case_type_counts.columns = ["case_type", "count"]
436
 
437
  fig = px.bar(
@@ -439,7 +511,10 @@ with tab3:
439
  x="case_type",
440
  y="count",
441
  title="Top 10 Case Types by Hearing Count",
442
- labels={"case_type": "Case Type", "count": "Number of Hearings"},
 
 
 
443
  )
444
  fig.update_layout(height=400, xaxis_tickangle=-45)
445
  st.plotly_chart(fig, use_container_width=True)
@@ -456,10 +531,15 @@ with tab3:
456
  age_with_type = (
457
  case_dates[["age_days"]]
458
  .join(cid_to_type, how="left")
459
- .dropna(subset=["case_type"]) # keep only cases with type
 
 
460
  )
461
  top_types = (
462
- age_with_type["case_type"].value_counts().head(8).index.tolist()
 
 
 
463
  )
464
  filt = age_with_type["case_type"].isin(top_types)
465
  fig_box = px.box(
@@ -468,7 +548,10 @@ with tab3:
468
  y="age_days",
469
  points="outliers",
470
  title="Case Age by Case Type (Top 8)",
471
- labels={"case_type": "Case Type", "age_days": "Age (days)"},
 
 
 
472
  )
473
  fig_box.update_layout(height=420, xaxis_tickangle=-45)
474
  st.plotly_chart(fig_box, use_container_width=True)
@@ -498,7 +581,9 @@ with tab3:
498
  else:
499
  st.info("Insufficient data to compute per-type Gini")
500
  except Exception as _:
501
- st.info("Unable to compute per-type age distributions for current data")
 
 
502
 
503
  except Exception as e:
504
  st.error(f"Error loading events data: {e}")
@@ -506,7 +591,9 @@ with tab3:
506
  # TAB 4: Report Generation
507
  with tab4:
508
  st.markdown("### Report Generation")
509
- st.markdown("Generate comprehensive reports summarizing system performance and analysis.")
 
 
510
 
511
  outputs_dir = Path("outputs")
512
  runs_dir = outputs_dir / "simulation_runs"
@@ -549,11 +636,15 @@ with tab4:
549
  report_sections = []
550
 
551
  # Header
552
- report_sections.append("# Court Scheduling System - Performance Report")
 
 
553
  report_sections.append(
554
  f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
555
  )
556
- report_sections.append(f"Runs included: {', '.join(selected_runs)}")
 
 
557
  report_sections.append("")
558
 
559
  # Performance metrics
@@ -579,7 +670,9 @@ with tab4:
579
  f"- Average Utilization: {avg_util:.2%}"
580
  )
581
 
582
- report_sections.append(f"- Simulation Days: {len(df)}")
 
 
583
  report_sections.append("")
584
 
585
  # Comparison
 
43
  # TAB 1: Simulation Comparison
44
  with tab1:
45
  st.markdown("### Simulation Comparison")
46
+ st.markdown(
47
+ "Compare multiple simulation runs to evaluate different policies and parameters."
48
+ )
49
 
50
  # Check for available simulation runs
51
  outputs_dir = Path("outputs")
52
  runs_dir = outputs_dir / "simulation_runs"
53
 
54
  if not runs_dir.exists():
55
+ st.warning(
56
+ "No simulation outputs found. Run simulations first to generate data."
57
+ )
58
  else:
59
  # Collect all run directories that actually contain a metrics.csv file.
60
  # Some runs may be nested (version folder inside timestamp). We treat every
 
104
 
105
  st.success("Loaded metrics successfully")
106
 
107
+ # Show Key Insights from report.txt for both runs
108
+ st.markdown("#### Key Insights (from report.txt)")
109
+ col_ins_1, col_ins_2 = st.columns(2)
110
+
111
+ report1_path = run_map[run1_label] / "report.txt"
112
+ report2_path = run_map[run2_label] / "report.txt"
113
+
114
+ with col_ins_1:
115
+ st.markdown(f"**{run1_label}**")
116
+ if report1_path.exists():
117
+ st.code(
118
+ report1_path.read_text(encoding="utf-8"),
119
+ language="text",
120
+ )
121
+ else:
122
+ st.info("No report.txt found for this run.")
123
+
124
+ with col_ins_2:
125
+ st.markdown(f"**{run2_label}**")
126
+ if report2_path.exists():
127
+ st.code(
128
+ report2_path.read_text(encoding="utf-8"),
129
+ language="text",
130
+ )
131
+ else:
132
+ st.info("No report.txt found for this run.")
133
+
134
  # Summary comparison
135
  st.markdown("#### Summary Comparison")
136
 
 
156
 
157
  with col3:
158
  st.markdown("**Difference**")
159
+ if (
160
+ "disposal_rate" in df1.columns
161
+ and "disposal_rate" in df2.columns
162
+ ):
163
  diff_disposal = avg_disposal2 - avg_disposal1
164
  st.metric("Disposal Rate Δ", f"{diff_disposal:+.2%}")
165
+ if (
166
+ "utilization" in df1.columns
167
+ and "utilization" in df2.columns
168
+ ):
169
  diff_util = avg_util2 - avg_util1
170
  st.metric("Utilization Δ", f"{diff_util:+.2%}")
171
 
 
174
  # Time series comparison
175
  st.markdown("#### Performance Over Time")
176
 
177
+ if (
178
+ "disposal_rate" in df1.columns
179
+ and "disposal_rate" in df2.columns
180
+ ):
181
  fig = go.Figure()
182
 
183
  fig.add_trace(
 
209
 
210
  st.plotly_chart(fig, use_container_width=True)
211
 
212
+ if (
213
+ "utilization" in df1.columns
214
+ and "utilization" in df2.columns
215
+ ):
216
  fig = go.Figure()
217
 
218
  fig.add_trace(
 
314
  x="run",
315
  y="disposal_rate",
316
  title="Disposal Rate Distribution by Run",
317
+ labels={
318
+ "disposal_rate": "Disposal Rate",
319
+ "run": "Simulation Run",
320
+ },
321
  )
322
  fig.update_layout(height=400)
323
  st.plotly_chart(fig, use_container_width=True)
 
371
  events_path = label_to_path[selected_run] / "events.csv"
372
 
373
  if not events_path.exists():
374
+ st.warning(
375
+ "Events file not found. Fairness analysis requires detailed event logs."
376
+ )
377
  else:
378
  try:
379
  events_df = pd.read_csv(events_path)
 
385
  st.markdown("#### Case Age Distribution")
386
 
387
  # Calculate case ages (simplified - would need filed_date for accurate calculation)
388
+ case_dates = events_df.groupby("case_id")["date"].agg(
389
+ ["min", "max"]
390
+ )
391
  case_dates["age_days"] = (
392
+ pd.to_datetime(case_dates["max"])
393
+ - pd.to_datetime(case_dates["min"])
394
  ).dt.days
395
 
396
  fig = px.histogram(
 
398
  x="age_days",
399
  nbins=30,
400
  title="Distribution of Case Ages",
401
+ labels={
402
+ "age_days": "Age (days)",
403
+ "count": "Number of Cases",
404
+ },
405
  )
406
  fig.update_layout(height=400)
407
  st.plotly_chart(fig, use_container_width=True)
 
410
  col1, col2, col3 = st.columns(3)
411
 
412
  with col1:
413
+ st.metric(
414
+ "Median Age",
415
+ f"{case_dates['age_days'].median():.0f} days",
416
+ )
417
  with col2:
418
+ st.metric(
419
+ "Mean Age", f"{case_dates['age_days'].mean():.0f} days"
420
+ )
421
  with col3:
422
+ st.metric(
423
+ "Max Age", f"{case_dates['age_days'].max():.0f} days"
424
+ )
425
 
426
  # Additional Fairness Metrics: Gini and Lorenz Curve
427
  st.markdown("#### Inequality Metrics (Fairness)")
 
464
  lorenz = cum_ages / cum_ages[-1]
465
  fig_lorenz = go.Figure()
466
  fig_lorenz.add_trace(
467
+ go.Scatter(
468
+ x=cum_pop,
469
+ y=lorenz,
470
+ mode="lines",
471
+ name="Lorenz",
472
+ )
473
  )
474
  fig_lorenz.add_trace(
475
  go.Scatter(
 
486
  yaxis_title="Cumulative share of total age",
487
  height=350,
488
  )
489
+ st.plotly_chart(
490
+ fig_lorenz, use_container_width=True
491
+ )
492
  else:
493
  st.info("Not enough data to plot Lorenz curve")
494
  except Exception:
495
+ st.info(
496
+ "Unable to compute Lorenz curve for current data"
497
+ )
498
 
499
  # Case type fairness
500
  if "case_type" in events_df.columns:
501
  st.markdown("---")
502
  st.markdown("#### Case Type Balance")
503
 
504
+ case_type_counts = (
505
+ events_df["case_type"].value_counts().reset_index()
506
+ )
507
  case_type_counts.columns = ["case_type", "count"]
508
 
509
  fig = px.bar(
 
511
  x="case_type",
512
  y="count",
513
  title="Top 10 Case Types by Hearing Count",
514
+ labels={
515
+ "case_type": "Case Type",
516
+ "count": "Number of Hearings",
517
+ },
518
  )
519
  fig.update_layout(height=400, xaxis_tickangle=-45)
520
  st.plotly_chart(fig, use_container_width=True)
 
531
  age_with_type = (
532
  case_dates[["age_days"]]
533
  .join(cid_to_type, how="left")
534
+ .dropna(
535
+ subset=["case_type"]
536
+ ) # keep only cases with type
537
  )
538
  top_types = (
539
+ age_with_type["case_type"]
540
+ .value_counts()
541
+ .head(8)
542
+ .index.tolist()
543
  )
544
  filt = age_with_type["case_type"].isin(top_types)
545
  fig_box = px.box(
 
548
  y="age_days",
549
  points="outliers",
550
  title="Case Age by Case Type (Top 8)",
551
+ labels={
552
+ "case_type": "Case Type",
553
+ "age_days": "Age (days)",
554
+ },
555
  )
556
  fig_box.update_layout(height=420, xaxis_tickangle=-45)
557
  st.plotly_chart(fig_box, use_container_width=True)
 
581
  else:
582
  st.info("Insufficient data to compute per-type Gini")
583
  except Exception as _:
584
+ st.info(
585
+ "Unable to compute per-type age distributions for current data"
586
+ )
587
 
588
  except Exception as e:
589
  st.error(f"Error loading events data: {e}")
 
591
  # TAB 4: Report Generation
592
  with tab4:
593
  st.markdown("### Report Generation")
594
+ st.markdown(
595
+ "Generate comprehensive reports summarizing system performance and analysis."
596
+ )
597
 
598
  outputs_dir = Path("outputs")
599
  runs_dir = outputs_dir / "simulation_runs"
 
636
  report_sections = []
637
 
638
  # Header
639
+ report_sections.append(
640
+ "# Court Scheduling System - Performance Report"
641
+ )
642
  report_sections.append(
643
  f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
644
  )
645
+ report_sections.append(
646
+ f"Runs included: {', '.join(selected_runs)}"
647
+ )
648
  report_sections.append("")
649
 
650
  # Performance metrics
 
670
  f"- Average Utilization: {avg_util:.2%}"
671
  )
672
 
673
+ report_sections.append(
674
+ f"- Simulation Days: {len(df)}"
675
+ )
676
  report_sections.append("")
677
 
678
  # Comparison
src/dashboard/utils/simulation_runner.py CHANGED
@@ -94,8 +94,13 @@ Efficiency:
94
  Utilization: {res.utilization:.2%}
95
  Avg hearings/day: {res.hearings_total / max(1, cfg.days):.2f}
96
  """
97
-
98
- (run_dir / "report.txt").write_text(summary_text, encoding="utf-8")
 
 
 
 
 
99
 
100
  # -------------------------------------------------------
101
  # Locate generated CSVs (if they exist)
@@ -105,6 +110,7 @@ Efficiency:
105
 
106
  return {
107
  "summary": summary_text,
 
108
  "end_date": res.end_date,
109
  "metrics_path": metrics_path if metrics_path.exists() else None,
110
  "events_path": events_path if events_path.exists() else None,
 
94
  Utilization: {res.utilization:.2%}
95
  Avg hearings/day: {res.hearings_total / max(1, cfg.days):.2f}
96
  """
97
+ # Merge engine insights into report.txt
98
+ insights_text = (getattr(res, "insights_text", "") or "").strip()
99
+ if insights_text:
100
+ full_report = summary_text.rstrip() + "\n\n" + insights_text + "\n"
101
+ else:
102
+ full_report = summary_text
103
+ (run_dir / "report.txt").write_text(full_report, encoding="utf-8")
104
 
105
  # -------------------------------------------------------
106
  # Locate generated CSVs (if they exist)
 
110
 
111
  return {
112
  "summary": summary_text,
113
+ "insights": insights_text,
114
  "end_date": res.end_date,
115
  "metrics_path": metrics_path if metrics_path.exists() else None,
116
  "events_path": events_path if events_path.exists() else None,
src/simulation/engine.py CHANGED
@@ -67,6 +67,7 @@ class CourtSimResult:
67
  end_date: date
68
  ripeness_transitions: int = 0 # Number of ripeness status changes
69
  unripe_filtered: int = 0 # Cases filtered out due to unripeness
 
70
 
71
 
72
  class CourtSim:
@@ -587,25 +588,31 @@ class CourtSim:
587
  else 0.0
588
  )
589
 
590
- # Generate ripeness summary
 
 
 
591
  active_cases = [c for c in self.cases if c.status != CaseStatus.DISPOSED]
592
- ripeness_dist = {}
593
  for c in active_cases:
594
- status = c.ripeness_status # Already a string
595
  ripeness_dist[status] = ripeness_dist.get(status, 0) + 1
596
 
597
- print("\n=== Ripeness Summary ===")
598
- print(f"Total ripeness transitions: {self._ripeness_transitions}")
599
- print(f"Cases filtered (unripe): {self._unripe_filtered}")
600
- print("\nFinal ripeness distribution:")
 
 
601
  for status, count in sorted(ripeness_dist.items()):
602
  pct = (count / len(active_cases) * 100) if active_cases else 0
603
- print(f" {status}: {count} ({pct:.1f}%)")
604
 
605
- # Generate courtroom allocation summary
606
- print(f"\n{self.allocator.get_courtroom_summary()}")
 
607
 
608
- # Generate comprehensive case status breakdown
609
  total_cases = len(self.cases)
610
  disposed_cases = [c for c in self.cases if c.status == CaseStatus.DISPOSED]
611
  scheduled_at_least_once = [
@@ -616,27 +623,29 @@ class CourtSim:
616
  c for c in scheduled_at_least_once if c.status != CaseStatus.DISPOSED
617
  ]
618
 
619
- print("\n=== Case Status Breakdown ===")
620
- print(f"Total cases in system: {total_cases:,}")
621
- print("\nScheduling outcomes:")
622
- print(
623
- f" Scheduled at least once: {len(scheduled_at_least_once):,} ({len(scheduled_at_least_once) / total_cases * 100:.1f}%)"
624
  )
625
- print(
626
- f" - Disposed: {len(disposed_cases):,} ({len(disposed_cases) / total_cases * 100:.1f}%)"
627
  )
628
- print(
629
- f" - Active (not disposed): {len(scheduled_but_not_disposed):,} ({len(scheduled_but_not_disposed) / total_cases * 100:.1f}%)"
630
  )
631
- print(
632
- f" Never scheduled: {len(never_scheduled):,} ({len(never_scheduled) / total_cases * 100:.1f}%)"
633
  )
634
 
635
  if scheduled_at_least_once:
636
  avg_hearings = sum(c.hearing_count for c in scheduled_at_least_once) / len(
637
  scheduled_at_least_once
638
  )
639
- print(f"\nAverage hearings per scheduled case: {avg_hearings:.1f}")
 
 
640
 
641
  if disposed_cases:
642
  avg_hearings_to_disposal = sum(
@@ -645,9 +654,18 @@ class CourtSim:
645
  avg_days_to_disposal = sum(
646
  (c.disposal_date - c.filed_date).days for c in disposed_cases
647
  ) / len(disposed_cases)
648
- print("\nDisposal metrics:")
649
- print(f" Average hearings to disposal: {avg_hearings_to_disposal:.1f}")
650
- print(f" Average days to disposal: {avg_days_to_disposal:.0f}")
 
 
 
 
 
 
 
 
 
651
 
652
  return CourtSimResult(
653
  hearings_total=self._hearings_total,
@@ -658,4 +676,5 @@ class CourtSim:
658
  end_date=working_days[-1] if working_days else self.cfg.start,
659
  ripeness_transitions=self._ripeness_transitions,
660
  unripe_filtered=self._unripe_filtered,
 
661
  )
 
67
  end_date: date
68
  ripeness_transitions: int = 0 # Number of ripeness status changes
69
  unripe_filtered: int = 0 # Cases filtered out due to unripeness
70
+ insights_text: str = "" # Collected insights as plain text
71
 
72
 
73
  class CourtSim:
 
588
  else 0.0
589
  )
590
 
591
+ # Collect insights text (previously printed inline)
592
+ insights_lines: List[str] = []
593
+
594
+ # Ripeness summary
595
  active_cases = [c for c in self.cases if c.status != CaseStatus.DISPOSED]
596
+ ripeness_dist: Dict[str, int] = {}
597
  for c in active_cases:
598
+ status = c.ripeness_status
599
  ripeness_dist[status] = ripeness_dist.get(status, 0) + 1
600
 
601
+ insights_lines.append("=== Ripeness Summary ===")
602
+ insights_lines.append(
603
+ f"Total ripeness transitions: {self._ripeness_transitions}"
604
+ )
605
+ insights_lines.append(f"Cases filtered (unripe): {self._unripe_filtered}")
606
+ insights_lines.append("\nFinal ripeness distribution:")
607
  for status, count in sorted(ripeness_dist.items()):
608
  pct = (count / len(active_cases) * 100) if active_cases else 0
609
+ insights_lines.append(f" {status}: {count} ({pct:.1f}%)")
610
 
611
+ # Courtroom allocation summary
612
+ insights_lines.append("")
613
+ insights_lines.append(self.allocator.get_courtroom_summary())
614
 
615
+ # Comprehensive case status breakdown
616
  total_cases = len(self.cases)
617
  disposed_cases = [c for c in self.cases if c.status == CaseStatus.DISPOSED]
618
  scheduled_at_least_once = [
 
623
  c for c in scheduled_at_least_once if c.status != CaseStatus.DISPOSED
624
  ]
625
 
626
+ insights_lines.append("\n=== Case Status Breakdown ===")
627
+ insights_lines.append(f"Total cases in system: {total_cases:,}")
628
+ insights_lines.append("\nScheduling outcomes:")
629
+ insights_lines.append(
630
+ f" Scheduled at least once: {len(scheduled_at_least_once):,} ({len(scheduled_at_least_once) / max(1, total_cases) * 100:.1f}%)"
631
  )
632
+ insights_lines.append(
633
+ f" - Disposed: {len(disposed_cases):,} ({len(disposed_cases) / max(1, total_cases) * 100:.1f}%)"
634
  )
635
+ insights_lines.append(
636
+ f" - Active (not disposed): {len(scheduled_but_not_disposed):,} ({len(scheduled_but_not_disposed) / max(1, total_cases) * 100:.1f}%)"
637
  )
638
+ insights_lines.append(
639
+ f" Never scheduled: {len(never_scheduled):,} ({len(never_scheduled) / max(1, total_cases) * 100:.1f}%)"
640
  )
641
 
642
  if scheduled_at_least_once:
643
  avg_hearings = sum(c.hearing_count for c in scheduled_at_least_once) / len(
644
  scheduled_at_least_once
645
  )
646
+ insights_lines.append(
647
+ f"\nAverage hearings per scheduled case: {avg_hearings:.1f}"
648
+ )
649
 
650
  if disposed_cases:
651
  avg_hearings_to_disposal = sum(
 
654
  avg_days_to_disposal = sum(
655
  (c.disposal_date - c.filed_date).days for c in disposed_cases
656
  ) / len(disposed_cases)
657
+ insights_lines.append("\nDisposal metrics:")
658
+ insights_lines.append(
659
+ f" Average hearings to disposal: {avg_hearings_to_disposal:.1f}"
660
+ )
661
+ insights_lines.append(
662
+ f" Average days to disposal: {avg_days_to_disposal:.0f}"
663
+ )
664
+
665
+ insights_text = "\n".join(insights_lines)
666
+
667
+ # Still echo to console for CLI users
668
+ print("\n" + insights_text)
669
 
670
  return CourtSimResult(
671
  hearings_total=self._hearings_total,
 
676
  end_date=working_days[-1] if working_days else self.cfg.start,
677
  ripeness_transitions=self._ripeness_transitions,
678
  unripe_filtered=self._unripe_filtered,
679
+ insights_text=insights_text,
680
  )