Spaces:
Sleeping
Sleeping
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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 129 |
diff_disposal = avg_disposal2 - avg_disposal1
|
| 130 |
st.metric("Disposal Rate Δ", f"{diff_disposal:+.2%}")
|
| 131 |
-
if
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 341 |
case_dates["age_days"] = (
|
| 342 |
-
pd.to_datetime(case_dates["max"])
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 360 |
with col2:
|
| 361 |
-
st.metric(
|
|
|
|
|
|
|
| 362 |
with col3:
|
| 363 |
-
st.metric(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 424 |
else:
|
| 425 |
st.info("Not enough data to plot Lorenz curve")
|
| 426 |
except Exception:
|
| 427 |
-
st.info(
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 460 |
)
|
| 461 |
top_types = (
|
| 462 |
-
age_with_type["case_type"]
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 553 |
report_sections.append(
|
| 554 |
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
| 555 |
)
|
| 556 |
-
report_sections.append(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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
|
| 595 |
ripeness_dist[status] = ripeness_dist.get(status, 0) + 1
|
| 596 |
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
|
|
|
|
|
|
| 601 |
for status, count in sorted(ripeness_dist.items()):
|
| 602 |
pct = (count / len(active_cases) * 100) if active_cases else 0
|
| 603 |
-
|
| 604 |
|
| 605 |
-
#
|
| 606 |
-
|
|
|
|
| 607 |
|
| 608 |
-
#
|
| 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 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
f" Scheduled at least once: {len(scheduled_at_least_once):,} ({len(scheduled_at_least_once) / total_cases * 100:.1f}%)"
|
| 624 |
)
|
| 625 |
-
|
| 626 |
-
f" - Disposed: {len(disposed_cases):,} ({len(disposed_cases) / total_cases * 100:.1f}%)"
|
| 627 |
)
|
| 628 |
-
|
| 629 |
-
f" - Active (not disposed): {len(scheduled_but_not_disposed):,} ({len(scheduled_but_not_disposed) / total_cases * 100:.1f}%)"
|
| 630 |
)
|
| 631 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 649 |
-
|
| 650 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
)
|