File size: 10,108 Bytes
f6c65ef
58e829b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a28f91
58e829b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a28f91
 
 
58e829b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6c65ef
58e829b
 
 
 
 
 
 
 
 
 
 
 
f6c65ef
58e829b
 
 
 
 
 
 
 
 
 
 
6a28f91
 
 
58e829b
 
6a28f91
 
 
58e829b
 
 
 
f6c65ef
58e829b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d0ffdd
f6c65ef
 
 
 
 
58e829b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6c65ef
58e829b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6c65ef
6a28f91
f6c65ef
 
58e829b
 
 
f6c65ef
6a28f91
f6c65ef
 
58e829b
 
 
 
 
 
 
 
 
 
f6c65ef
58e829b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a28f91
 
 
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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""Dynamic courtroom allocation system.

Allocates cases across multiple courtrooms using configurable strategies:
- LOAD_BALANCED: Distributes cases evenly across courtrooms
- TYPE_AFFINITY: Prefers courtrooms with history of similar case types (future)
- CONTINUITY: Keeps cases in same courtroom when possible (future)
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date
from enum import Enum
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from src.core.case import Case


class AllocationStrategy(Enum):
    """Strategies for allocating cases to courtrooms."""

    LOAD_BALANCED = "load_balanced"  # Minimize load variance across courtrooms
    TYPE_AFFINITY = "type_affinity"  # Group similar case types in same courtroom
    CONTINUITY = "continuity"  # Keep cases in same courtroom across hearings


@dataclass
class CourtroomState:
    """Tracks state of a single courtroom."""

    courtroom_id: int
    daily_load: int = 0  # Number of cases scheduled today
    total_cases_handled: int = 0  # Lifetime count
    case_type_distribution: dict[str, int] = field(
        default_factory=dict
    )  # Type -> count

    def add_case(self, case: Case) -> None:
        """Register a case assigned to this courtroom."""
        self.daily_load += 1
        self.total_cases_handled += 1
        self.case_type_distribution[case.case_type] = (
            self.case_type_distribution.get(case.case_type, 0) + 1
        )

    def reset_daily_load(self) -> None:
        """Reset daily load counter at start of new day."""
        self.daily_load = 0

    def has_capacity(self, max_capacity: int) -> bool:
        """Check if courtroom can accept more cases today."""
        return self.daily_load < max_capacity


class CourtroomAllocator:
    """Dynamically allocates cases to courtrooms using load balancing.

    Ensures fair distribution of workload across courtrooms while respecting
    capacity constraints. Future versions may add judge specialization matching
    and case type affinity.
    """

    def __init__(
        self,
        num_courtrooms: int = 5,
        per_courtroom_capacity: int = 10,
        strategy: AllocationStrategy = AllocationStrategy.LOAD_BALANCED,
    ):
        """Initialize allocator.

        Args:
            num_courtrooms: Number of courtrooms to allocate across
            per_courtroom_capacity: Max cases per courtroom per day
            strategy: Allocation strategy to use
        """
        self.num_courtrooms = num_courtrooms
        self.per_courtroom_capacity = per_courtroom_capacity
        self.strategy = strategy

        # Initialize courtroom states
        self.courtrooms = {
            i: CourtroomState(courtroom_id=i) for i in range(1, num_courtrooms + 1)
        }

        # Metrics tracking
        self.daily_loads: dict[
            date, dict[int, int]
        ] = {}  # date -> {courtroom_id -> load}
        self.allocation_changes: int = 0  # Cases that switched courtrooms
        self.capacity_rejections: int = 0  # Cases that couldn't be allocated

    def allocate(self, cases: list[Case], current_date: date) -> dict[str, int]:
        """Allocate cases to courtrooms for a given date.

        Args:
            cases: List of cases to allocate (already prioritized by caller)
            current_date: Date of allocation

        Returns:
            Mapping of case_id -> courtroom_id for allocated cases
        """
        # Reset daily loads for new day
        for courtroom in self.courtrooms.values():
            courtroom.reset_daily_load()

        allocations: dict[str, int] = {}

        for case in cases:
            # Find best courtroom based on strategy
            courtroom_id = self._find_best_courtroom(case)

            if courtroom_id is None:
                # No courtroom has capacity
                self.capacity_rejections += 1
                continue

            # Track if courtroom changed (only count actual switches, not initial assignments)
            if (
                case.courtroom_id is not None
                and case.courtroom_id != 0
                and case.courtroom_id != courtroom_id
            ):
                self.allocation_changes += 1

            # Assign case to courtroom
            case.courtroom_id = courtroom_id
            self.courtrooms[courtroom_id].add_case(case)
            allocations[case.case_id] = courtroom_id

        # Record daily loads
        self.daily_loads[current_date] = {
            cid: court.daily_load for cid, court in self.courtrooms.items()
        }

        return allocations

    def _find_best_courtroom(self, case: Case) -> int | None:
        """Find best courtroom for a case based on allocation strategy.

        Args:
            case: Case to allocate

        Returns:
            Courtroom ID or None if all at capacity
        """
        if self.strategy == AllocationStrategy.LOAD_BALANCED:
            return self._find_least_loaded_courtroom()
        elif self.strategy == AllocationStrategy.TYPE_AFFINITY:
            return self._find_type_affinity_courtroom(case)
        elif self.strategy == AllocationStrategy.CONTINUITY:
            return self._find_continuity_courtroom(case)
        else:
            return self._find_least_loaded_courtroom()

    def _find_least_loaded_courtroom(self) -> int | None:
        """Find courtroom with lowest daily load that has capacity."""
        available = [
            (cid, court)
            for cid, court in self.courtrooms.items()
            if court.has_capacity(self.per_courtroom_capacity)
        ]

        if not available:
            return None

        # Return courtroom with minimum load
        return min(available, key=lambda x: x[1].daily_load)[0]

    def _find_type_affinity_courtroom(self, case: Case) -> int | None:
        """Find courtroom with most similar case type history.

        Currently uses load balancing. Can be enhanced with case type distribution scoring.
        """
        return self._find_least_loaded_courtroom()

    def _find_continuity_courtroom(self, case: Case) -> int | None:
        """Keep case in same courtroom as previous hearing when possible.

        Maintains courtroom continuity if capacity available, otherwise uses load balancing.
        """
        # If case already has courtroom assignment and it has capacity, keep it there
        if case.courtroom_id is not None:
            courtroom = self.courtrooms.get(case.courtroom_id)
            if courtroom and courtroom.has_capacity(self.per_courtroom_capacity):
                return case.courtroom_id

        # Otherwise fall back to load balancing
        return self._find_least_loaded_courtroom()

    def get_utilization_stats(self) -> dict:
        """Calculate courtroom utilization statistics.

        Returns:
            Dictionary with utilization metrics
        """
        if not self.daily_loads:
            return {}

        # Flatten daily loads into list of loads per courtroom
        all_loads = [
            loads[cid]
            for loads in self.daily_loads.values()
            for cid in range(1, self.num_courtrooms + 1)
        ]

        # Calculate per-courtroom averages
        courtroom_totals = {cid: 0 for cid in range(1, self.num_courtrooms + 1)}
        for loads in self.daily_loads.values():
            for cid, load in loads.items():
                courtroom_totals[cid] += load

        num_days = len(self.daily_loads)
        courtroom_avgs = {
            cid: total / num_days for cid, total in courtroom_totals.items()
        }

        # Calculate Gini coefficient for fairness
        sorted_totals = sorted(courtroom_totals.values())
        n = len(sorted_totals)
        if n == 0 or sum(sorted_totals) == 0:
            gini = 0.0
        else:
            cumsum = 0
            for i, total in enumerate(sorted_totals):
                cumsum += (i + 1) * total
            gini = (2 * cumsum) / (n * sum(sorted_totals)) - (n + 1) / n

        return {
            "avg_daily_load": sum(all_loads) / len(all_loads) if all_loads else 0,
            "max_daily_load": max(all_loads) if all_loads else 0,
            "min_daily_load": min(all_loads) if all_loads else 0,
            "courtroom_averages": courtroom_avgs,
            "courtroom_totals": courtroom_totals,
            "load_balance_gini": gini,
            "allocation_changes": self.allocation_changes,
            "capacity_rejections": self.capacity_rejections,
            "total_days": num_days,
        }

    def get_courtroom_summary(self) -> str:
        """Generate human-readable summary of courtroom allocation."""
        stats = self.get_utilization_stats()

        if not stats:
            return "No allocations performed yet"

        lines = [
            "Courtroom Allocation Summary",
            "=" * 50,
            f"Strategy: {self.strategy.value}",
            f"Number of courtrooms: {self.num_courtrooms}",
            f"Per-courtroom capacity: {self.per_courtroom_capacity} cases/day",
            f"Total simulation days: {stats['total_days']}",
            "",
            "Load Distribution:",
            f"  Average daily load: {stats['avg_daily_load']:.1f} cases",
            f"  Max daily load: {stats['max_daily_load']} cases",
            f"  Min daily load: {stats['min_daily_load']} cases",
            f"  Load balance fairness (Gini): {stats['load_balance_gini']:.3f}",
            "",
            "Courtroom-wise totals:",
        ]

        for cid in range(1, self.num_courtrooms + 1):
            total = stats["courtroom_totals"][cid]
            avg = stats["courtroom_averages"][cid]
            lines.append(f"  Courtroom {cid}: {total:,} cases ({avg:.1f}/day)")

        lines.extend(
            [
                "",
                "Allocation behavior:",
                f"  Cases switched courtrooms: {stats['allocation_changes']:,}",
                f"  Capacity rejections: {stats['capacity_rejections']:,}",
            ]
        )

        return "\n".join(lines)