File size: 7,356 Bytes
58e829b
 
 
54c8522
 
58e829b
 
 
 
 
 
54c8522
58e829b
 
54c8522
58e829b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
from __future__ import annotations

import argparse
import os
import sys
from datetime import date
from pathlib import Path

# Ensure project root on sys.path
sys.path.append(os.path.dirname(os.path.dirname(__file__)))

from scheduler.core.case import CaseStatus
from scheduler.data.case_generator import CaseGenerator
from scheduler.metrics.basic import gini
from scheduler.simulation.engine import CourtSim, CourtSimConfig


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--cases-csv", type=str, default="data/generated/cases.csv")
    ap.add_argument("--days", type=int, default=60)
    ap.add_argument("--seed", type=int, default=42)
    ap.add_argument("--start", type=str, default=None, help="YYYY-MM-DD; default first of current month")
    ap.add_argument("--policy", choices=["fifo", "age", "readiness"], default="readiness")
    ap.add_argument("--duration-percentile", choices=["median", "p90"], default="median")
    ap.add_argument("--log-dir", type=str, default=None, help="Directory to write metrics and suggestions")
    args = ap.parse_args()

    path = Path(args.cases_csv)
    if path.exists():
        cases = CaseGenerator.from_csv(path)
        # Simulation should start AFTER cases have been filed and have history
        # Default: start from the latest filed date (end of case generation period)
        if args.start:
            start = date.fromisoformat(args.start)
        else:
            # Start simulation from end of case generation period
            # This way all cases have been filed and have last_hearing_date set
            start = max(c.filed_date for c in cases) if cases else date.today()
    else:
        # fallback: quick generate 5*capacity cases
        if args.start:
            start = date.fromisoformat(args.start)
        else:
            start = date.today().replace(day=1)
        gen = CaseGenerator(start=start, end=start.replace(day=28), seed=args.seed)
        cases = gen.generate(n_cases=5 * 151)

    cfg = CourtSimConfig(start=start, days=args.days, seed=args.seed, policy=args.policy, duration_percentile=args.duration_percentile, log_dir=Path(args.log_dir) if args.log_dir else None)
    sim = CourtSim(cfg, cases)
    res = sim.run()
    
    # Get allocator stats
    allocator_stats = sim.allocator.get_utilization_stats()

    # Fairness/report: disposal times
    disp_times = [ (c.disposal_date - c.filed_date).days for c in cases if c.disposal_date is not None and c.status == CaseStatus.DISPOSED ]
    gini_disp = gini(disp_times) if disp_times else 0.0
    
    # Disposal rates by case type
    case_type_stats = {}
    for c in cases:
        if c.case_type not in case_type_stats:
            case_type_stats[c.case_type] = {"total": 0, "disposed": 0}
        case_type_stats[c.case_type]["total"] += 1
        if c.is_disposed:
            case_type_stats[c.case_type]["disposed"] += 1
    
    # Ripeness distribution
    active_cases = [c for c in cases if not c.is_disposed]
    ripeness_dist = {}
    for c in active_cases:
        status = c.ripeness_status
        ripeness_dist[status] = ripeness_dist.get(status, 0) + 1

    report_path = Path(args.log_dir)/"report.txt" if args.log_dir else Path("report.txt")
    report_path.parent.mkdir(parents=True, exist_ok=True)
    with report_path.open("w", encoding="utf-8") as rf:
        rf.write("=" * 80 + "\n")
        rf.write("SIMULATION REPORT\n")
        rf.write("=" * 80 + "\n\n")
        
        rf.write(f"Configuration:\n")
        rf.write(f"  Cases: {len(cases)}\n")
        rf.write(f"  Days simulated: {args.days}\n")
        rf.write(f"  Policy: {args.policy}\n")
        rf.write(f"  Horizon end: {res.end_date}\n\n")
        
        rf.write(f"Hearing Metrics:\n")
        rf.write(f"  Total hearings: {res.hearings_total:,}\n")
        rf.write(f"  Heard: {res.hearings_heard:,} ({res.hearings_heard/max(1,res.hearings_total):.1%})\n")
        rf.write(f"  Adjourned: {res.hearings_adjourned:,} ({res.hearings_adjourned/max(1,res.hearings_total):.1%})\n\n")
        
        rf.write(f"Disposal Metrics:\n")
        rf.write(f"  Cases disposed: {res.disposals:,}\n")
        rf.write(f"  Disposal rate: {res.disposals/len(cases):.1%}\n")
        rf.write(f"  Gini coefficient: {gini_disp:.3f}\n\n")
        
        rf.write(f"Disposal Rates by Case Type:\n")
        for ct in sorted(case_type_stats.keys()):
            stats = case_type_stats[ct]
            rate = (stats["disposed"] / stats["total"] * 100) if stats["total"] > 0 else 0
            rf.write(f"  {ct:4s}: {stats['disposed']:4d}/{stats['total']:4d} ({rate:5.1f}%)\n")
        rf.write("\n")
        
        rf.write(f"Efficiency Metrics:\n")
        rf.write(f"  Court utilization: {res.utilization:.1%}\n")
        rf.write(f"  Avg hearings/day: {res.hearings_total/args.days:.1f}\n\n")
        
        rf.write(f"Ripeness Impact:\n")
        rf.write(f"  Transitions: {res.ripeness_transitions:,}\n")
        rf.write(f"  Cases filtered (unripe): {res.unripe_filtered:,}\n")
        if res.hearings_total + res.unripe_filtered > 0:
            rf.write(f"  Filter rate: {res.unripe_filtered/(res.hearings_total + res.unripe_filtered):.1%}\n")
        rf.write("\nFinal Ripeness Distribution:\n")
        for status in sorted(ripeness_dist.keys()):
            count = ripeness_dist[status]
            pct = (count / len(active_cases) * 100) if active_cases else 0
            rf.write(f"  {status}: {count} ({pct:.1f}%)\n")
        
        # Courtroom allocation metrics
        if allocator_stats:
            rf.write("\nCourtroom Allocation:\n")
            rf.write(f"  Strategy: load_balanced\n")
            rf.write(f"  Load balance fairness (Gini): {allocator_stats['load_balance_gini']:.3f}\n")
            rf.write(f"  Avg daily load: {allocator_stats['avg_daily_load']:.1f} cases\n")
            rf.write(f"  Allocation changes: {allocator_stats['allocation_changes']:,}\n")
            rf.write(f"  Capacity rejections: {allocator_stats['capacity_rejections']:,}\n\n")
            rf.write("  Courtroom-wise totals:\n")
            for cid in range(1, sim.cfg.courtrooms + 1):
                total = allocator_stats['courtroom_totals'][cid]
                avg = allocator_stats['courtroom_averages'][cid]
                rf.write(f"    Courtroom {cid}: {total:,} cases ({avg:.1f}/day)\n")

    print("\n" + "=" * 80)
    print("SIMULATION SUMMARY")
    print("=" * 80)
    print(f"\nHorizon: {cfg.start}{res.end_date} ({args.days} days)")
    print(f"\nHearing Metrics:")
    print(f"  Total: {res.hearings_total:,}")
    print(f"  Heard: {res.hearings_heard:,} ({res.hearings_heard/max(1,res.hearings_total):.1%})")
    print(f"  Adjourned: {res.hearings_adjourned:,} ({res.hearings_adjourned/max(1,res.hearings_total):.1%})")
    print(f"\nDisposal Metrics:")
    print(f"  Cases disposed: {res.disposals:,} ({res.disposals/len(cases):.1%})")
    print(f"  Gini coefficient: {gini_disp:.3f}")
    print(f"\nEfficiency:")
    print(f"  Utilization: {res.utilization:.1%}")
    print(f"  Avg hearings/day: {res.hearings_total/args.days:.1f}")
    print(f"\nRipeness Impact:")
    print(f"  Transitions: {res.ripeness_transitions:,}")
    print(f"  Cases filtered: {res.unripe_filtered:,}")
    print(f"\n✓ Report saved to: {report_path}")


if __name__ == "__main__":
    main()