anujkum0x commited on
Commit
dba52ab
·
verified ·
1 Parent(s): ea40ba2

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1586 -0
app.py ADDED
@@ -0,0 +1,1586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import numpy as np
3
+ import pulp as pl
4
+ import matplotlib.pyplot as plt
5
+ import gradio as gr
6
+ from itertools import product
7
+ import io
8
+ import base64
9
+ import tempfile
10
+ import os
11
+ from datetime import datetime
12
+ import plotly.express as px
13
+ from plotly.subplots import make_subplots
14
+ import plotly.graph_objects as go
15
+ import seaborn as sns
16
+ from ortools.sat.python import cp_model
17
+ import random
18
+ from deap import base, creator, tools, algorithms
19
+ import time
20
+
21
+ def am_pm(hour):
22
+ """Converts 24-hour time to AM/PM format."""
23
+ period = "AM"
24
+ if hour >= 12:
25
+ period = "PM"
26
+ if hour > 12:
27
+ hour -= 12
28
+ elif hour == 0:
29
+ hour = 12 # Midnight
30
+ return f"{int(hour):02d}:00 {period}"
31
+
32
+ def show_dataframe(csv_path):
33
+ """Reads a CSV file and returns a Pandas DataFrame."""
34
+ try:
35
+ df = pd.read_csv(csv_path)
36
+ return df
37
+ except Exception as e:
38
+ return f"Error loading CSV: {e}"
39
+
40
+ def optimize_staffing(
41
+ csv_file,
42
+ beds_per_staff,
43
+ max_hours_per_staff, # This will now be interpreted as hours per 28-day period
44
+ hours_per_cycle,
45
+ rest_days_per_week,
46
+ clinic_start,
47
+ clinic_end,
48
+ overlap_time,
49
+ max_start_time_change,
50
+ exact_staff_count=None,
51
+ overtime_percent=100
52
+ ):
53
+ try:
54
+ # Load data
55
+ if isinstance(csv_file, str):
56
+ # Handle the case when a filepath is passed directly
57
+ data = pd.read_csv(csv_file)
58
+ elif hasattr(csv_file, 'name'):
59
+ # Handle the case when file object is uploaded through Gradio
60
+ data = pd.read_csv(csv_file.name)
61
+ elif csv_file is None:
62
+ # Create a default DataFrame for testing
63
+ days = range(1, 21) # 20 days
64
+ data = pd.DataFrame({'day': days})
65
+ # Add 4 cycles per day (5-hour cycles)
66
+ for cycle in range(1, 5):
67
+ data[f'cycle{cycle}'] = 3 # Default 3 beds per cycle
68
+ else:
69
+ # Try direct CSV reading
70
+ data = pd.read_csv(io.StringIO(csv_file.decode('utf-8')))
71
+ except Exception as e:
72
+ print(f"Error loading CSV file: {e}")
73
+ # Create a default DataFrame
74
+ days = range(1, 21) # 20 days
75
+ data = pd.DataFrame({'day': days})
76
+ # Add 4 cycles per day (5-hour cycles)
77
+ for cycle in range(1, 5):
78
+ data[f'cycle{cycle}'] = 3 # Default 3 beds per cycle
79
+ print("Created default schedule with 20 days and 4 cycles per day")
80
+
81
+ # Rename the index column if necessary
82
+ if data.columns[0] not in ['day', 'Day', 'DAY']:
83
+ data = data.rename(columns={data.columns[0]: 'day'})
84
+
85
+ # Fill missing values
86
+ for col in data.columns:
87
+ if col.startswith('cycle'):
88
+ data[col] = data[col].fillna(0)
89
+
90
+ # Calculate clinic hours
91
+ if clinic_end < clinic_start:
92
+ clinic_hours = 24 - clinic_start + clinic_end
93
+ else:
94
+ clinic_hours = clinic_end - clinic_start
95
+
96
+ # Get number of days in the dataset
97
+ num_days = len(data)
98
+
99
+ # Parameters
100
+ BEDS_PER_STAFF = float(beds_per_staff)
101
+ STANDARD_PERIOD_DAYS = 30 # Standard 4-week period
102
+
103
+ # Scale MAX_HOURS_PER_STAFF based on the ratio of actual days to standard period
104
+ BASE_MAX_HOURS = float(max_hours_per_staff) # This is for a 28-day period
105
+ MAX_HOURS_PER_STAFF = BASE_MAX_HOURS * (num_days / STANDARD_PERIOD_DAYS)
106
+
107
+ # Log the adjustment for transparency
108
+ original_results = f"Input max hours per staff (28-day period): {BASE_MAX_HOURS}\n"
109
+ original_results += f"Adjusted max hours for {num_days}-day period: {MAX_HOURS_PER_STAFF:.1f}\n\n"
110
+
111
+ HOURS_PER_CYCLE = float(hours_per_cycle)
112
+ REST_DAYS_PER_WEEK = int(rest_days_per_week)
113
+ SHIFT_TYPES = [5, 10] # Modified to match 5-hour cycles
114
+ OVERLAP_TIME = float(overlap_time)
115
+ CLINIC_START = int(clinic_start)
116
+ CLINIC_END = int(clinic_end)
117
+ CLINIC_HOURS = clinic_hours
118
+ MAX_START_TIME_CHANGE = int(max_start_time_change)
119
+ OVERTIME_ALLOWED = 1 + (overtime_percent / 100) # Convert percentage to multiplier
120
+
121
+ # Calculate staff needed per cycle (beds/BEDS_PER_STAFF, rounded up)
122
+ for col in data.columns:
123
+ if col.startswith('cycle') and not col.endswith('_staff'):
124
+ data[f'{col}_staff'] = np.ceil(data[col] / BEDS_PER_STAFF)
125
+
126
+ # Get cycle names and number of cycles
127
+ cycle_cols = [col for col in data.columns if col.startswith('cycle') and not col.endswith('_staff')]
128
+ num_cycles = len(cycle_cols)
129
+
130
+ # Define cycle times
131
+ cycle_times = {}
132
+ for i, cycle in enumerate(cycle_cols):
133
+ cycle_start = (CLINIC_START + i * HOURS_PER_CYCLE) % 24
134
+ cycle_end = (CLINIC_START + (i + 1) * HOURS_PER_CYCLE) % 24
135
+ cycle_times[cycle] = (cycle_start, cycle_end)
136
+
137
+ # Get staff requirements
138
+ max_staff_needed = max([data[f'{cycle}_staff'].max() for cycle in cycle_cols], default=0)
139
+ if max_staff_needed <= 0:
140
+ return "Error: No staff requirements found in the input data.", None, None, None, None, None, None
141
+
142
+ # Generate all possible shifts
143
+ possible_shifts = []
144
+ for duration in SHIFT_TYPES:
145
+ for start_time in range(24):
146
+ end_time = (start_time + duration) % 24
147
+
148
+ # Create a shift with its coverage of cycles
149
+ shift = {
150
+ 'id': f"{int(duration)}hr_{int(start_time):02d}",
151
+ 'start': start_time,
152
+ 'end': end_time,
153
+ 'duration': duration,
154
+ 'cycles_covered': set()
155
+ }
156
+
157
+ # Determine which cycles this shift covers
158
+ for cycle, (cycle_start, cycle_end) in cycle_times.items():
159
+ # Handle overnight cycles
160
+ if cycle_end < cycle_start: # overnight cycle
161
+ if start_time >= cycle_start or end_time <= cycle_end or (start_time < end_time and end_time > cycle_start):
162
+ shift['cycles_covered'].add(cycle)
163
+ else: # normal cycle
164
+ shift_end = end_time if end_time > start_time else end_time + 24
165
+ cycle_end_adj = cycle_end if cycle_end > cycle_start else cycle_end + 24
166
+
167
+ # Check for overlap
168
+ if not (shift_end <= cycle_start or start_time >= cycle_end_adj):
169
+ shift['cycles_covered'].add(cycle)
170
+
171
+ if shift['cycles_covered']: # Only add shifts that cover at least one cycle
172
+ possible_shifts.append(shift)
173
+
174
+ if not possible_shifts:
175
+ return "Error: No valid shifts could be generated with the given parameters.", None, None, None, None, None, None
176
+
177
+ # Estimate minimum number of staff needed - more precise calculation
178
+ total_staff_hours = 0
179
+ for _, row in data.iterrows():
180
+ for cycle in cycle_cols:
181
+ total_staff_hours += row[f'{cycle}_staff'] * HOURS_PER_CYCLE
182
+
183
+ # Calculate theoretical minimum staff with perfect utilization
184
+ if total_staff_hours <= 0:
185
+ return "Error: No staff hours required based on input data.", None, None, None, None, None, None
186
+
187
+ theoretical_min_staff = np.ceil(total_staff_hours / MAX_HOURS_PER_STAFF)
188
+
189
+ if theoretical_min_staff <= 0:
190
+ return "Error: Invalid staff calculation. Please check your input parameters.", None, None, None, None, None, None
191
+
192
+ # Add a small buffer for rest day constraints
193
+ min_staff_estimate = np.ceil(theoretical_min_staff * (7 / (7 - REST_DAYS_PER_WEEK)))
194
+
195
+ # Use exact_staff_count if provided, otherwise estimate
196
+ if exact_staff_count is not None and exact_staff_count > 0:
197
+ # When exact staff count is provided, only create that many staff in the model
198
+ estimated_staff = exact_staff_count
199
+ num_staff_to_create = exact_staff_count # Only create exactly this many staff
200
+ else:
201
+ # Add some buffer for constraints like rest days and shift changes
202
+ estimated_staff = max(min_staff_estimate, max_staff_needed + 1)
203
+ num_staff_to_create = int(estimated_staff) # Create the estimated number of staff
204
+
205
+ def optimize_schedule(num_staff, time_limit=600):
206
+ try:
207
+ # Create a binary linear programming model
208
+ model = pl.LpProblem("Staff_Scheduling", pl.LpMinimize)
209
+
210
+ # Decision variables
211
+ x = pl.LpVariable.dicts("shift",
212
+ [(s, d, shift['id']) for s in range(1, num_staff+1)
213
+ for d in range(1, num_days+1)
214
+ for shift in possible_shifts],
215
+ cat='Binary')
216
+
217
+ # Variables for tracking violations (all must be 0 in final solution)
218
+ timing_violations = pl.LpVariable.dicts("timing_violation",
219
+ [(s, d) for s in range(1, num_staff+1)
220
+ for d in range(2, num_days+1)],
221
+ lowBound=0)
222
+
223
+ rest_violations = pl.LpVariable.dicts("rest_violation",
224
+ [(s, d) for s in range(1, num_staff+1)
225
+ for d in range(1, num_days+1)],
226
+ lowBound=0)
227
+
228
+ consecutive_violations = pl.LpVariable.dicts("consecutive_violation",
229
+ [(s, d) for s in range(1, num_staff+1)
230
+ for d in range(1, num_days+1)],
231
+ lowBound=0)
232
+
233
+ hours_violations = pl.LpVariable.dicts("hours_violation",
234
+ [s for s in range(1, num_staff+1)],
235
+ lowBound=0)
236
+
237
+ coverage_violations = pl.LpVariable.dicts("coverage_violation",
238
+ [(d, c) for d in range(1, num_days+1)
239
+ for c in cycle_cols],
240
+ lowBound=0)
241
+
242
+ # Objective: Minimize all violations (must all be 0 for valid solution)
243
+ model += (pl.lpSum(timing_violations.values()) * 1000000 +
244
+ pl.lpSum(rest_violations.values()) * 100000 +
245
+ pl.lpSum(consecutive_violations.values()) * 50000 +
246
+ pl.lpSum(hours_violations.values()) * 10000 +
247
+ pl.lpSum(coverage_violations.values()) * 5000)
248
+
249
+ # 1. HARD CONSTRAINT: Timing Consistency
250
+ for s in range(1, num_staff+1):
251
+ for d in range(2, num_days+1):
252
+ # If working consecutive days, times must match exactly
253
+ for shift1 in possible_shifts:
254
+ for shift2 in possible_shifts:
255
+ if shift1['start'] != shift2['start']:
256
+ model += x[(s, d-1, shift1['id'])] + x[(s, d, shift2['id'])] <= 1 + timing_violations[s,d]
257
+
258
+ # 2. HARD CONSTRAINT: Rest Period (11 hours)
259
+ for s in range(1, num_staff+1):
260
+ for d in range(1, num_days):
261
+ for shift1 in possible_shifts:
262
+ for shift2 in possible_shifts:
263
+ if (shift2['start'] - shift1['end']) % 24 < 11:
264
+ model += x[(s, d, shift1['id'])] + x[(s, d+1, shift2['id'])] <= 1 + rest_violations[s,d]
265
+
266
+ # 3. HARD CONSTRAINT: Maximum Consecutive Days (6)
267
+ for s in range(1, num_staff+1):
268
+ for d in range(1, num_days-5):
269
+ consecutive_sum = pl.lpSum(x[(s, d+i, shift['id'])]
270
+ for i in range(7)
271
+ for shift in possible_shifts)
272
+ model += consecutive_sum <= 6 + consecutive_violations[s,d]
273
+
274
+ # 4. HARD CONSTRAINT: Monthly Hours
275
+ for s in range(1, num_staff+1):
276
+ monthly_hours = pl.lpSum(x[(s, d, shift['id'])] * shift['duration']
277
+ for d in range(1, num_days+1)
278
+ for shift in possible_shifts)
279
+ model += monthly_hours <= MAX_HOURS_PER_STAFF + hours_violations[s]
280
+
281
+ # 5. HARD CONSTRAINT: Coverage Requirements
282
+ for d in range(1, num_days+1):
283
+ day_index = d - 1
284
+ for cycle in cycle_cols:
285
+ staff_needed = data.iloc[day_index][f'{cycle}_staff']
286
+ covering_shifts = [shift for shift in possible_shifts if cycle in shift['cycles_covered']]
287
+ model += (pl.lpSum(x[(s, d, shift['id'])]
288
+ for s in range(1, num_staff+1)
289
+ for shift in covering_shifts) >=
290
+ staff_needed - coverage_violations[d,cycle])
291
+
292
+ # 6. Basic feasibility: One shift per day per staff
293
+ for s in range(1, num_staff+1):
294
+ for d in range(1, num_days+1):
295
+ model += pl.lpSum(x[(s, d, shift['id'])] for shift in possible_shifts) <= 1
296
+
297
+ # Solve with extended time limit
298
+ solver = pl.PULP_CBC_CMD(timeLimit=time_limit, msg=1, gapRel=0.01)
299
+ model.solve(solver)
300
+
301
+ # Check if a feasible solution was found
302
+ if model.status == pl.LpStatusOptimal:
303
+ # Verify ALL constraints are met (no violations)
304
+ total_violations = (sum(pl.value(v) for v in timing_violations.values()) +
305
+ sum(pl.value(v) for v in rest_violations.values()) +
306
+ sum(pl.value(v) for v in consecutive_violations.values()) +
307
+ sum(pl.value(v) for v in hours_violations.values()) +
308
+ sum(pl.value(v) for v in coverage_violations.values()))
309
+
310
+ if total_violations > 0:
311
+ print(f"Solution found but has {total_violations} constraint violations")
312
+ return None, None
313
+
314
+ # Extract the solution
315
+ schedule = []
316
+ for s in range(1, num_staff+1):
317
+ for d in range(1, num_days+1):
318
+ for shift in possible_shifts:
319
+ if pl.value(x[(s, d, shift['id'])]) == 1:
320
+ schedule.append({
321
+ 'staff_id': s,
322
+ 'day': d,
323
+ 'shift_id': shift['id'],
324
+ 'start': shift['start'],
325
+ 'end': shift['end'],
326
+ 'duration': shift['duration'],
327
+ 'cycles_covered': list(shift['cycles_covered'])
328
+ })
329
+
330
+ return schedule, model.objective.value()
331
+ else:
332
+ return None, None
333
+
334
+ except Exception as e:
335
+ print(f"Error in optimization: {e}")
336
+ return None, None
337
+
338
+ # Try to solve with estimated number of staff
339
+ if exact_staff_count is not None and exact_staff_count > 0:
340
+ # If exact staff count is specified, only try with that count
341
+ staff_count = int(exact_staff_count)
342
+ results = f"Using exactly {staff_count} staff as specified...\n"
343
+
344
+ # Try to solve with exactly this many staff
345
+ schedule, objective = optimize_schedule(staff_count)
346
+
347
+ if schedule is None:
348
+ results += f"Failed to find a feasible solution with exactly {staff_count} staff.\n"
349
+ results += "Try increasing the staff count.\n"
350
+ return results, None, None, None, None, None, None
351
+ else:
352
+ # Start from theoretical minimum and work up
353
+ min_staff = max(1, int(theoretical_min_staff)) # Start from theoretical minimum
354
+ max_staff = int(min_staff_estimate) + 5 # Allow some buffer
355
+
356
+ results = f"Theoretical minimum staff needed: {theoretical_min_staff:.1f}\n"
357
+ results += f"Searching for minimum staff count starting from {min_staff}...\n"
358
+
359
+ # Try each staff count from min to max
360
+ for staff_count in range(min_staff, max_staff + 1):
361
+ results += f"Trying with {staff_count} staff...\n"
362
+
363
+ # Increase time limit for each attempt to give the solver more time
364
+ time_limit = 300 + (staff_count - min_staff) * 100 # More time for larger staff counts
365
+ schedule, objective = optimize_schedule(staff_count, time_limit)
366
+
367
+ if schedule is not None:
368
+ results += f"Found feasible solution with {staff_count} staff.\n"
369
+ break
370
+
371
+ if schedule is None:
372
+ results += "Failed to find a feasible solution with the attempted staff counts.\n"
373
+ results += "Try increasing the staff count manually or relaxing constraints.\n"
374
+ return results, None, None, None, None, None, None
375
+
376
+ results += f"Optimal solution found with {staff_count} staff\n"
377
+ results += f"Total staff hours: {objective}\n"
378
+
379
+ # Convert to DataFrame for analysis
380
+ schedule_df = pd.DataFrame(schedule)
381
+
382
+ # Analyze staff workload
383
+ staff_hours = {}
384
+ for s in range(1, staff_count+1):
385
+ staff_shifts = schedule_df[schedule_df['staff_id'] == s]
386
+ total_hours = staff_shifts['duration'].sum()
387
+ staff_hours[s] = total_hours
388
+
389
+ # After calculating staff hours, filter out staff with 0 hours before displaying
390
+ active_staff_hours = {s: hours for s, hours in staff_hours.items() if hours > 0}
391
+
392
+ results += "\nStaff Hours:\n"
393
+ for staff_id, hours in active_staff_hours.items():
394
+ utilization = (hours / MAX_HOURS_PER_STAFF) * 100
395
+ results += f"Staff {staff_id}: {hours} hours ({utilization:.1f}% utilization)\n"
396
+ # Add overtime information
397
+ if hours > MAX_HOURS_PER_STAFF:
398
+ overtime = hours - MAX_HOURS_PER_STAFF
399
+ overtime_percent = (overtime / MAX_HOURS_PER_STAFF) * 100
400
+ results += f" Overtime: {overtime:.1f} hours ({overtime_percent:.1f}%)\n"
401
+
402
+ # Use active_staff_hours for average utilization calculation
403
+ active_staff_count = len(active_staff_hours)
404
+ avg_utilization = sum(active_staff_hours.values()) / (active_staff_count * MAX_HOURS_PER_STAFF) * 100
405
+ results += f"\nAverage staff utilization: {avg_utilization:.1f}%\n"
406
+
407
+ # Check coverage for each day and cycle
408
+ coverage_check = []
409
+ for d in range(1, num_days+1):
410
+ day_index = d - 1 # 0-indexed for DataFrame
411
+
412
+ day_schedule = schedule_df[schedule_df['day'] == d]
413
+
414
+ for cycle in cycle_cols:
415
+ required = data.iloc[day_index][f'{cycle}_staff']
416
+
417
+ # Count staff covering this cycle
418
+ assigned = sum(1 for _, shift in day_schedule.iterrows()
419
+ if cycle in shift['cycles_covered'])
420
+
421
+ coverage_check.append({
422
+ 'day': d,
423
+ 'cycle': cycle,
424
+ 'required': required,
425
+ 'assigned': assigned,
426
+ 'satisfied': assigned >= required
427
+ })
428
+
429
+ coverage_df = pd.DataFrame(coverage_check)
430
+ satisfaction = coverage_df['satisfied'].mean() * 100
431
+ results += f"Coverage satisfaction: {satisfaction:.1f}%\n"
432
+
433
+ if satisfaction < 100:
434
+ results += "Warning: Not all staffing requirements are met!\n"
435
+ unsatisfied = coverage_df[~coverage_df['satisfied']]
436
+ results += unsatisfied.to_string() + "\n"
437
+
438
+ # Generate detailed schedule report
439
+ detailed_schedule = "Detailed Schedule:\n"
440
+ for d in range(1, num_days+1):
441
+ day_schedule = schedule_df[schedule_df['day'] == d]
442
+ day_schedule = day_schedule.sort_values(['start'])
443
+
444
+ detailed_schedule += f"\nDay {d}:\n"
445
+ for _, shift in day_schedule.iterrows():
446
+ start_hour = shift['start']
447
+ end_hour = shift['end']
448
+
449
+ start_str = am_pm(start_hour)
450
+ end_str = am_pm(end_hour)
451
+
452
+ cycles = ", ".join(shift['cycles_covered'])
453
+ detailed_schedule += f" Staff {shift['staff_id']}: {start_str}-{end_str} ({shift['duration']} hrs), Cycles: {cycles}\n"
454
+
455
+ # Generate schedule visualization
456
+ fig, ax = plt.subplots(figsize=(15, 8))
457
+
458
+ # Prepare schedule for plotting
459
+ staff_days = {}
460
+ for s in range(1, staff_count+1):
461
+ staff_days[s] = [0] * num_days # 0 means off duty
462
+
463
+ for _, shift in schedule_df.iterrows():
464
+ staff_id = shift['staff_id']
465
+ day = shift['day'] - 1 # 0-indexed
466
+ staff_days[staff_id][day] = shift['duration']
467
+
468
+ # Plot the schedule
469
+ for s, hours in staff_days.items():
470
+ ax.bar(range(1, num_days+1), hours, label=f'Staff {s}')
471
+
472
+ ax.set_xlabel('Day')
473
+ ax.set_ylabel('Shift Hours')
474
+ ax.set_title('Staff Schedule')
475
+ ax.set_xticks(range(1, num_days+1))
476
+ ax.legend()
477
+
478
+ # Save the figure to a temporary file
479
+ plot_path = None
480
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
481
+ plt.savefig(f.name)
482
+ plt.close(fig)
483
+ plot_path = f.name
484
+
485
+ # Create a Gantt chart with advanced visuals and alternating labels - only showing active staff
486
+ gantt_path = create_gantt_chart(schedule_df, num_days, staff_count)
487
+
488
+ # Convert schedule to CSV data
489
+ schedule_df['start_ampm'] = schedule_df['start'].apply(am_pm)
490
+ schedule_df['end_ampm'] = schedule_df['end'].apply(am_pm)
491
+ schedule_csv = schedule_df[['staff_id', 'day', 'start_ampm', 'end_ampm', 'duration', 'cycles_covered']].to_csv(index=False)
492
+
493
+ # Create a temporary file and write the CSV data into it
494
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".csv") as temp_file:
495
+ temp_file.write(schedule_csv)
496
+ schedule_csv_path = temp_file.name
497
+
498
+ # Create staff assignment table
499
+ staff_assignment_data = []
500
+ for d in range(1, num_days + 1):
501
+ cycle_staff = {}
502
+ for cycle in cycle_cols:
503
+ # Get staff IDs assigned to this cycle on this day
504
+ staff_ids = schedule_df[(schedule_df['day'] == d) & (schedule_df['cycles_covered'].apply(lambda x: cycle in x))]['staff_id'].tolist()
505
+ cycle_staff[cycle] = len(staff_ids)
506
+ staff_assignment_data.append([d] + [cycle_staff[cycle] for cycle in cycle_cols])
507
+
508
+ staff_assignment_df = pd.DataFrame(staff_assignment_data, columns=['Day'] + cycle_cols)
509
+
510
+ # Create CSV files for download
511
+ staff_assignment_csv_path = None
512
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".csv") as temp_file:
513
+ staff_assignment_df.to_csv(temp_file.name, index=False)
514
+ staff_assignment_csv_path = temp_file.name
515
+
516
+ # Return all required values in the correct order
517
+ return results, staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv_path, staff_assignment_csv_path
518
+
519
+ def convert_to_24h(time_str):
520
+ """Converts AM/PM time string to 24-hour format."""
521
+ try:
522
+ time_obj = datetime.strptime(time_str, "%I:00 %p")
523
+ return time_obj.hour
524
+ except ValueError:
525
+ return None
526
+
527
+ def gradio_wrapper(
528
+ csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
529
+ rest_days_per_week, clinic_start_ampm, clinic_end_ampm, overlap_time, max_start_time_change,
530
+ exact_staff_count=None, overtime_percent=100
531
+ ):
532
+ try:
533
+ # Convert AM/PM times to 24-hour format
534
+ clinic_start = convert_to_24h(clinic_start_ampm)
535
+ clinic_end = convert_to_24h(clinic_end_ampm)
536
+
537
+ # Call the optimization function
538
+ results, staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv_path, staff_assignment_csv_path = optimize_staffing(
539
+ csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
540
+ rest_days_per_week, clinic_start, clinic_end, overlap_time, max_start_time_change,
541
+ exact_staff_count, overtime_percent
542
+ )
543
+
544
+ # Return the results
545
+ return staff_assignment_df, gantt_path, schedule_df, plot_path, staff_assignment_csv_path, schedule_csv_path
546
+ except Exception as e:
547
+ # If there's an error in the optimization process, return a meaningful error message
548
+ empty_staff_df = pd.DataFrame(columns=["Day"])
549
+ error_message = f"Error during optimization: {str(e)}\n\nPlease try with different parameters or a simpler dataset."
550
+ # Return error in the first output
551
+ return empty_staff_df, None, None, None, None, None
552
+
553
+ # Create a Gantt chart with advanced visuals and alternating labels - only showing active staff
554
+ def create_gantt_chart(schedule_df, num_days, staff_count):
555
+ # Get the list of active staff IDs (staff who have at least one shift)
556
+ active_staff_ids = sorted(schedule_df['staff_id'].unique())
557
+ active_staff_count = len(active_staff_ids)
558
+
559
+ # Create a mapping from original staff ID to position in the chart
560
+ staff_position = {staff_id: i+1 for i, staff_id in enumerate(active_staff_ids)}
561
+
562
+ # Create a larger figure with higher DPI
563
+ plt.figure(figsize=(max(30, num_days * 1.5), max(12, active_staff_count * 0.8)), dpi=200)
564
+
565
+ # Use a more sophisticated color palette - only for active staff
566
+ colors = plt.cm.viridis(np.linspace(0.1, 0.9, active_staff_count))
567
+
568
+ # Set a modern style
569
+ plt.style.use('seaborn-v0_8-whitegrid')
570
+
571
+ # Create a new axis with a slight background color
572
+
573
+ ax = plt.gca()
574
+ ax.set_facecolor('#f8f9fa')
575
+
576
+ # Sort by staff then day
577
+ schedule_df = schedule_df.sort_values(['staff_id', 'day'])
578
+
579
+ # Plot Gantt chart - only for active staff
580
+ for i, staff_id in enumerate(active_staff_ids):
581
+ staff_shifts = schedule_df[schedule_df['staff_id'] == staff_id]
582
+
583
+ y_pos = active_staff_count - i # Position based on index in active staff list
584
+
585
+ # Add staff label with a background box
586
+ ax.text(-0.7, y_pos, f"Staff {staff_id}", fontsize=12, fontweight='bold',
587
+ ha='right', va='center', bbox=dict(facecolor='white', edgecolor='gray',
588
+ boxstyle='round,pad=0.5', alpha=0.9))
589
+
590
+ # Add a subtle background for each staff row
591
+ ax.axhspan(y_pos-0.4, y_pos+0.4, color='white', alpha=0.4, zorder=-5)
592
+
593
+ # Track shift positions to avoid label overlap
594
+ shift_positions = []
595
+
596
+ for idx, shift in enumerate(staff_shifts.iterrows()):
597
+ _, shift = shift
598
+ day = shift['day']
599
+ start_hour = shift['start']
600
+ end_hour = shift['end']
601
+ duration = shift['duration']
602
+
603
+ # Format times for display
604
+ start_ampm = am_pm(start_hour)
605
+ end_ampm = am_pm(end_hour)
606
+
607
+ # Calculate shift position
608
+ shift_start_pos = day-1+start_hour/24
609
+
610
+ # Handle overnight shifts
611
+ if end_hour < start_hour: # Overnight shift
612
+ # First part of shift (until midnight)
613
+ rect1 = ax.barh(y_pos, (24-start_hour)/24, left=shift_start_pos,
614
+ height=0.6, color=colors[i], alpha=0.9,
615
+ edgecolor='black', linewidth=1, zorder=10)
616
+
617
+ # Add gradient effect
618
+ for r in rect1:
619
+ r.set_edgecolor('black')
620
+ r.set_linewidth(1)
621
+
622
+ # Second part of shift (after midnight)
623
+ rect2 = ax.barh(y_pos, end_hour/24, left=day,
624
+ height=0.6, color=colors[i], alpha=0.9,
625
+ edgecolor='black', linewidth=1, zorder=10)
626
+
627
+ # Add gradient effect
628
+ for r in rect2:
629
+ r.set_edgecolor('black')
630
+ r.set_linewidth(1)
631
+
632
+ # For overnight shifts, we'll place the label in the first part if it's long enough
633
+ shift_width = (24-start_hour)/24
634
+ if shift_width >= 0.1: # Only add label if there's enough space
635
+ label_pos = shift_start_pos + shift_width/2
636
+
637
+ # Alternate labels above and below
638
+ y_offset = 0.35 if idx % 2 == 0 else -0.35
639
+
640
+ # Add label with background for better readability
641
+ label = f"{start_ampm}-{end_ampm}"
642
+ text = ax.text(label_pos, y_pos + y_offset, label,
643
+ ha='center', va='center', fontsize=9, fontweight='bold',
644
+ color='black', bbox=dict(facecolor='white', alpha=0.9, pad=3,
645
+ boxstyle='round,pad=0.3', edgecolor='gray'),
646
+ zorder=20)
647
+
648
+ shift_positions.append(label_pos)
649
+ else:
650
+ # Regular shift
651
+ shift_width = duration/24
652
+ rect = ax.barh(y_pos, shift_width, left=shift_start_pos,
653
+ height=0.6, color=colors[i], alpha=0.9,
654
+ edgecolor='black', linewidth=1, zorder=10)
655
+
656
+ # Add gradient effect
657
+ for r in rect:
658
+ r.set_edgecolor('black')
659
+ r.set_linewidth(1)
660
+
661
+ # Only add label if there's enough space
662
+ if shift_width >= 0.1:
663
+ label_pos = shift_start_pos + shift_width/2
664
+
665
+ # Alternate labels above and below
666
+ y_offset = 0.35 if idx % 2 == 0 else -0.35
667
+
668
+ # Add label with background for better readability
669
+ label = f"{start_ampm}-{end_ampm}"
670
+ text = ax.text(label_pos, y_pos + y_offset, label,
671
+ ha='center', va='center', fontsize=9, fontweight='bold',
672
+ color='black', bbox=dict(facecolor='white', alpha=0.9, pad=3,
673
+ boxstyle='round,pad=0.3', edgecolor='gray'),
674
+ zorder=20)
675
+
676
+ shift_positions.append(label_pos)
677
+
678
+ # Add weekend highlighting with a more sophisticated look
679
+ for day in range(1, num_days + 1):
680
+ # Determine if this is a weekend (assuming day 1 is Monday)
681
+ is_weekend = (day % 7 == 0) or (day % 7 == 6) # Saturday or Sunday
682
+
683
+ if is_weekend:
684
+ ax.axvspan(day-1, day, alpha=0.15, color='#ff9999', zorder=-10)
685
+ day_label = "Saturday" if day % 7 == 6 else "Sunday"
686
+ ax.text(day-0.5, 0.2, day_label, ha='center', fontsize=10, color='#cc0000',
687
+ fontweight='bold', bbox=dict(facecolor='white', alpha=0.7, pad=2, boxstyle='round'))
688
+
689
+ # Set x-axis ticks for each day with better formatting
690
+ ax.set_xticks(np.arange(0.5, num_days, 1))
691
+ day_labels = [f"Day {d}" for d in range(1, num_days+1)]
692
+ ax.set_xticklabels(day_labels, rotation=0, ha='center', fontsize=10)
693
+
694
+ # Add vertical lines between days with better styling
695
+ for day in range(1, num_days):
696
+ ax.axvline(x=day, color='#aaaaaa', linestyle='-', alpha=0.5, zorder=-5)
697
+
698
+ # Set y-axis ticks for each staff
699
+ ax.set_yticks(np.arange(1, active_staff_count+1))
700
+ ax.set_yticklabels([]) # Remove default labels as we've added custom ones
701
+
702
+ # Set axis limits with some padding
703
+ ax.set_xlim(-0.8, num_days)
704
+ ax.set_ylim(0.5, active_staff_count + 0.5)
705
+
706
+ # Add grid for hours (every 6 hours) with better styling
707
+ for day in range(num_days):
708
+ for hour in [6, 12, 18]:
709
+ ax.axvline(x=day + hour/24, color='#cccccc', linestyle=':', alpha=0.5, zorder=-5)
710
+ # Add small hour markers at the bottom
711
+ hour_label = "6AM" if hour == 6 else "Noon" if hour == 12 else "6PM"
712
+ ax.text(day + hour/24, 0, hour_label, ha='center', va='bottom', fontsize=7,
713
+ color='#666666', rotation=90, alpha=0.7)
714
+
715
+ # Add title and labels with more sophisticated styling
716
+ plt.title(f'Staff Schedule ({active_staff_count} Active Staff)', fontsize=24, fontweight='bold', pad=20, color='#333333')
717
+ plt.xlabel('Day', fontsize=16, labelpad=10, color='#333333')
718
+
719
+ # Add a legend for time reference with better styling
720
+ time_box = plt.figtext(0.01, 0.01, "Time Reference:", ha='left', fontsize=10,
721
+ fontweight='bold', color='#333333')
722
+ time_markers = ['6 AM', 'Noon', '6 PM', 'Midnight']
723
+ for i, time in enumerate(time_markers):
724
+ plt.figtext(0.08 + i*0.06, 0.01, time, ha='left', fontsize=9, color='#555555')
725
+
726
+ # Remove spines
727
+ for spine in ['top', 'right', 'left']:
728
+ ax.spines[spine].set_visible(False)
729
+
730
+ # Add a note about weekends with better styling
731
+ weekend_note = plt.figtext(0.01, 0.97, "Red areas = Weekends", fontsize=12,
732
+ color='#cc0000', fontweight='bold',
733
+ bbox=dict(facecolor='white', alpha=0.7, pad=5, boxstyle='round'))
734
+
735
+ # Add a subtle border around the entire chart
736
+ plt.box(False)
737
+
738
+ # Save the Gantt chart with high quality
739
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
740
+ plt.tight_layout()
741
+ plt.savefig(f.name, dpi=200, bbox_inches='tight', facecolor='white')
742
+ plt.close()
743
+ return f.name
744
+
745
+ # Define Gradio UI
746
+ am_pm_times = [f"{i:02d}:00 AM" for i in range(1, 13)] + [f"{i:02d}:00 PM" for i in range(1, 13)]
747
+
748
+ # Add CSS for chart containers
749
+ css = """
750
+ .chart-container {
751
+ height: 800px !important;
752
+ width: 100% !important;
753
+ margin: 20px 0;
754
+ padding: 20px;
755
+ border: 1px solid #ddd;
756
+ border-radius: 8px;
757
+ background: white;
758
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
759
+ }
760
+
761
+ .weekly-chart-container {
762
+ height: 1000px !important;
763
+ width: 100% !important;
764
+ margin: 20px 0;
765
+ padding: 20px;
766
+ border: 1px solid #ddd;
767
+ border-radius: 8px;
768
+ background: white;
769
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
770
+ }
771
+
772
+ /* Ensure plotly charts are visible */
773
+ .js-plotly-plot {
774
+ width: 100% !important;
775
+ height: 100% !important;
776
+ }
777
+
778
+ /* Improve visibility of chart titles */
779
+ .gtitle {
780
+ font-weight: bold !important;
781
+ font-size: 20px !important;
782
+ }
783
+ """
784
+
785
+ with gr.Blocks(title="Staff Scheduling Optimizer", css=css) as iface:
786
+
787
+ gr.Markdown("# Staff Scheduling Optimizer")
788
+ gr.Markdown("Upload a CSV file with cycle data and configure parameters to generate an optimal staff schedule.")
789
+
790
+ with gr.Row():
791
+ # LEFT PANEL - Inputs
792
+ with gr.Column(scale=1):
793
+ gr.Markdown("### Input Parameters")
794
+
795
+ # Input parameters
796
+ csv_input = gr.File(label="Upload CSV")
797
+ beds_per_staff = gr.Number(label="Beds per Staff", value=3)
798
+ max_hours_per_staff = gr.Number(label="Maximum monthly hours", value=160)
799
+ hours_per_cycle = gr.Number(label="Hours per Cycle", value=4)
800
+ rest_days_per_week = gr.Number(label="Rest Days per Week", value=2)
801
+ clinic_start_ampm = gr.Dropdown(label="Clinic Start Hour (AM/PM)", choices=am_pm_times, value="08:00 AM")
802
+ clinic_end_ampm = gr.Dropdown(label="Clinic End Hour (AM/PM)", choices=am_pm_times, value="08:00 PM")
803
+ overlap_time = gr.Number(label="Overlap Time", value=0)
804
+ max_start_time_change = gr.Number(label="Max Start Time Change", value=2)
805
+ exact_staff_count = gr.Number(label="Exact Staff Count (optional)", value=None)
806
+ overtime_percent = gr.Slider(label="Overtime Allowed (%)", minimum=0, maximum=100, value=100, step=10)
807
+
808
+ optimize_btn = gr.Button("Optimize Schedule", variant="primary", size="lg")
809
+
810
+ # RIGHT PANEL - Outputs
811
+ with gr.Column(scale=2):
812
+ gr.Markdown("### Results")
813
+
814
+ # Tabs for different outputs - reordered
815
+ with gr.Tabs():
816
+ with gr.TabItem("Detailed Schedule"):
817
+ with gr.Row():
818
+ csv_schedule = gr.Dataframe(label="Detailed Schedule", elem_id="csv_schedule")
819
+
820
+ with gr.Row():
821
+ schedule_download_file = gr.File(label="Download Detailed Schedule", visible=True)
822
+
823
+ with gr.TabItem("Gantt Chart"):
824
+ gantt_chart = gr.Image(label="Staff Schedule Visualization", elem_id="gantt_chart")
825
+
826
+ with gr.TabItem("Staff Coverage by Cycle"):
827
+ with gr.Row():
828
+ staff_assignment_table = gr.Dataframe(label="Staff Count in Each Cycle (Staff May Overlap)", elem_id="staff_assignment_table")
829
+
830
+ with gr.Row():
831
+ staff_download_file = gr.File(label="Download Coverage Table", visible=True)
832
+
833
+ with gr.TabItem("Constraints and Analytics"):
834
+ with gr.Row():
835
+ with gr.Column(scale=1):
836
+ gr.Markdown("### Applied Constraints")
837
+ constraints_text = gr.TextArea(
838
+ label="",
839
+ interactive=False,
840
+ show_label=False
841
+ )
842
+
843
+ with gr.Row():
844
+ with gr.Column(scale=1):
845
+ gr.Markdown("### Monthly Distribution")
846
+ monthly_chart = gr.HTML(
847
+ label="Monthly Hours Distribution",
848
+ show_label=False,
849
+ elem_classes="chart-container"
850
+ )
851
+
852
+ with gr.Row():
853
+ with gr.Column(scale=1):
854
+ gr.Markdown("### Weekly Distribution")
855
+ weekly_charts = gr.HTML(
856
+ label="Weekly Hours Distribution",
857
+ show_label=False,
858
+ elem_classes="weekly-chart-container"
859
+ )
860
+
861
+ with gr.TabItem("Staff Overlap"):
862
+ with gr.Row():
863
+ overlap_chart = gr.HTML(
864
+ label="Staff Overlap Visualization",
865
+ show_label=False
866
+ )
867
+ with gr.Row():
868
+ gr.Markdown("""
869
+ This heatmap shows the number of staff members working simultaneously throughout each day.
870
+ - Darker colors indicate more staff overlap
871
+ - The x-axis shows time of day in 30-minute intervals
872
+ - The y-axis shows each day of the schedule
873
+ """)
874
+
875
+ with gr.TabItem("Staff Absence Handler"):
876
+ with gr.Row():
877
+ with gr.Column():
878
+ gr.Markdown("### Handle Staff Absence")
879
+ absent_staff = gr.Number(label="Staff ID to be absent", precision=0)
880
+ absence_start = gr.Number(label="Start Day", precision=0)
881
+ absence_end = gr.Number(label="End Day", precision=0)
882
+ handle_absence_btn = gr.Button("Redistribute Shifts", variant="primary")
883
+
884
+ with gr.Column():
885
+ absence_result = gr.TextArea(label="Redistribution Results", interactive=False)
886
+ updated_schedule = gr.DataFrame(label="Updated Schedule")
887
+ absence_gantt_chart = gr.Image(label="Absence Schedule Visualization", elem_id="absence_gantt_chart")
888
+
889
+ # Define download functions
890
+ def create_download_link(df, filename="data.csv"):
891
+ """Create a CSV download link for a dataframe"""
892
+ if df is None or df.empty:
893
+ return None
894
+
895
+ csv_data = df.to_csv(index=False)
896
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv') as f:
897
+ f.write(csv_data)
898
+ return f.name
899
+
900
+ # Update the optimize_and_display function
901
+ def optimize_and_display(csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
902
+ rest_days_per_week, clinic_start_ampm, clinic_end_ampm,
903
+ overlap_time, max_start_time_change, exact_staff_count, overtime_percent):
904
+ try:
905
+ # Convert AM/PM times to 24-hour format
906
+ clinic_start = convert_to_24h(clinic_start_ampm)
907
+ clinic_end = convert_to_24h(clinic_end_ampm)
908
+
909
+ # Call the optimization function
910
+ results, staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv_path, staff_assignment_csv_path = optimize_staffing(
911
+ csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
912
+ rest_days_per_week, clinic_start, clinic_end, overlap_time, max_start_time_change,
913
+ exact_staff_count, overtime_percent
914
+ )
915
+
916
+ if schedule_df is not None:
917
+ try:
918
+ # Generate analytics data
919
+ constraints_info = get_constraints_summary(
920
+ max_hours_per_staff,
921
+ rest_days_per_week,
922
+ overtime_percent
923
+ )
924
+
925
+ # Create visualizations directly as HTML
926
+ monthly_html = create_monthly_distribution_chart(schedule_df)
927
+ weekly_html = create_weekly_distribution_charts(schedule_df)
928
+ overlap_html = create_overlap_visualization(schedule_df)
929
+
930
+ return (
931
+ staff_assignment_df,
932
+ gantt_path,
933
+ schedule_df,
934
+ schedule_csv_path,
935
+ constraints_info,
936
+ monthly_html,
937
+ weekly_html,
938
+ overlap_html
939
+ )
940
+ except Exception as e:
941
+ print(f"Error in visualization: {str(e)}")
942
+ return (
943
+ staff_assignment_df,
944
+ gantt_path,
945
+ schedule_df,
946
+ schedule_csv_path,
947
+ "Error in constraints",
948
+ "<div>Error creating monthly chart</div>",
949
+ "<div>Error creating weekly charts</div>",
950
+ "<div>Error creating overlap visualization</div>"
951
+ )
952
+ else:
953
+ return (None,) * 8
954
+
955
+ except Exception as e:
956
+ print(f"Error in optimization: {str(e)}")
957
+ return (None,) * 8
958
+
959
+ def get_constraints_summary(max_hours, rest_days, overtime_percent):
960
+ """Generate a summary of all applied constraints from actual parameters"""
961
+ constraints = [
962
+ "Applied Scheduling Constraints:",
963
+ "----------------------------",
964
+ f"1. Maximum Hours per Month: {max_hours} hours",
965
+ f"2. Required Rest Days per Week: {rest_days} days",
966
+ f"3. Maximum Weekly Hours: 60 hours per staff member",
967
+ "4. Minimum Rest Period: 11 hours between shifts",
968
+ "5. Maximum Consecutive Days: 6 working days",
969
+ f"6. Overtime Allowance: {overtime_percent}% of standard hours",
970
+ "7. Coverage Requirements:",
971
+ " - All cycles must be fully staffed",
972
+ " - No understaffing allowed",
973
+ " - Staff assigned based on required beds/staff ratio",
974
+ "8. Shift Constraints:",
975
+ " - Available shift durations: 5, 10 hours",
976
+ " - Shifts must align with cycle times",
977
+ "9. Staff Scheduling Rules:",
978
+ " - Equal distribution of workload when possible",
979
+ " - Consistent shift patterns preferred",
980
+ " - Weekend rotations distributed fairly"
981
+ ]
982
+ return "\n".join(constraints)
983
+
984
+ def create_monthly_distribution_chart(schedule_df):
985
+ """Create Seaborn pie chart for monthly hours distribution"""
986
+ if schedule_df is None or schedule_df.empty:
987
+ return "<div>No data available for visualization</div>"
988
+
989
+ try:
990
+ # Calculate total hours per staff member
991
+ staff_hours = schedule_df.groupby('staff_id')['duration'].sum()
992
+
993
+ # Create pie chart
994
+ fig, ax = plt.subplots(figsize=(8, 8))
995
+ sns.set_palette("pastel")
996
+ ax.pie(staff_hours, labels=staff_hours.index, autopct='%1.1f%%', startangle=90)
997
+ ax.axis('equal') # Equal aspect ratio ensures that pie is drawn as a circle.
998
+ plt.title("Monthly Hours Distribution")
999
+
1000
+ # Convert plot to PNG image
1001
+ img = io.BytesIO()
1002
+ plt.savefig(img, format='png', bbox_inches='tight') # Added bbox_inches='tight'
1003
+ plt.close(fig)
1004
+ img.seek(0)
1005
+
1006
+ # Encode to base64
1007
+ img_base64 = base64.b64encode(img.read()).decode('utf-8')
1008
+ img_html = f'<img src="data:image/png;base64,{img_base64}" style="max-width:100%; max-height:600px;">'
1009
+
1010
+ return img_html
1011
+ except Exception as e:
1012
+ print(f"Error in monthly chart: {e}")
1013
+ return f"<div>Error creating monthly chart: {str(e)}</div>"
1014
+
1015
+ def create_weekly_distribution_charts(schedule_df):
1016
+ """Create Plotly pie charts for weekly hours distribution"""
1017
+ if schedule_df is None or schedule_df.empty:
1018
+ return "<div>No data available for visualization</div>"
1019
+
1020
+ try:
1021
+ # Calculate total hours per staff member for each week
1022
+ schedule_df['week'] = schedule_df['day'] // 7 # Assuming each week starts on day 0, 7, 14, etc.
1023
+ weekly_hours = schedule_df.groupby(['week', 'staff_id'])['duration'].sum().reset_index()
1024
+
1025
+ # Create staff labels
1026
+ weekly_hours['staff_label'] = weekly_hours.apply(
1027
+ lambda x: f"Staff {x['staff_id']} ({x['duration']:.1f}hrs)",
1028
+ axis=1
1029
+ )
1030
+
1031
+ # Get unique weeks
1032
+ weeks = sorted(weekly_hours['week'].unique())
1033
+
1034
+ # Define color palette
1035
+ colors = px.colors.qualitative.Set3
1036
+
1037
+ # Create subplots
1038
+ fig = make_subplots(
1039
+ rows=len(weeks),
1040
+ cols=1,
1041
+ subplot_titles=[f'Week {week}' for week in weeks],
1042
+ specs=[[{'type': 'domain'}] for week in weeks]
1043
+ )
1044
+
1045
+ # Add pie charts for each week
1046
+ for i, week in enumerate(weeks, start=1):
1047
+ week_data = weekly_hours[weekly_hours['week'] == week]
1048
+
1049
+ fig.add_trace(
1050
+ go.Pie(
1051
+ values=week_data['duration'],
1052
+ labels=week_data['staff_label'],
1053
+ name=f'Week {week}',
1054
+ showlegend=(i == 1),
1055
+ marker_colors=colors,
1056
+ textposition='inside',
1057
+ textinfo='percent+label',
1058
+ hovertemplate=(
1059
+ "Staff: %{label}<br>"
1060
+ "Hours: %{value:.1f}<br>"
1061
+ "Percentage: %{percent:.1f}%"
1062
+ "<extra></extra>"
1063
+ )
1064
+ ),
1065
+ row=i,
1066
+ col=1
1067
+ )
1068
+
1069
+ fig.update_layout(
1070
+ height=300 * len(weeks),
1071
+ width=800,
1072
+ title_text="Weekly Hours Distribution",
1073
+ title_x=0.5,
1074
+ title_font_size=20,
1075
+ margin=dict(t=50, l=50, r=50, b=50),
1076
+ showlegend=True
1077
+ )
1078
+
1079
+ return fig.to_html(include_plotlyjs='cdn', full_html=False)
1080
+ except Exception as e:
1081
+ print(f"Error in weekly charts: {e}")
1082
+ return f"<div>Error creating weekly charts: {str(e)}</div>"
1083
+
1084
+ # Add this new function for creating the overlap visualization
1085
+ def create_overlap_visualization(schedule_df):
1086
+ """Create Seaborn heatmap for staff overlap"""
1087
+ if schedule_df is None or schedule_df.empty:
1088
+ return "<div>No data available for visualization</div>"
1089
+
1090
+ try:
1091
+ # Create 24-hour timeline with 30-minute intervals
1092
+ intervals = 48 # 24 hours * 2 (30-minute intervals)
1093
+ days = sorted(schedule_df['day'].unique())
1094
+
1095
+ # Initialize overlap matrix
1096
+ overlap_data = np.zeros((len(days), intervals))
1097
+
1098
+ # Calculate overlaps
1099
+ for day_idx, day in enumerate(days):
1100
+ day_shifts = schedule_df[schedule_df['day'] == day]
1101
+
1102
+ for i in range(intervals):
1103
+ time = i * 0.5
1104
+ staff_working = 0
1105
+
1106
+ for _, shift in day_shifts.iterrows():
1107
+ start = shift['start']
1108
+ end = shift['end']
1109
+
1110
+ if end < start: # Overnight shift
1111
+ if time >= start or time < end:
1112
+ staff_working += 1
1113
+ else:
1114
+ if start <= time < end:
1115
+ staff_working += 1
1116
+
1117
+ overlap_data[day_idx, i] = staff_working
1118
+
1119
+ # Create time labels
1120
+ time_labels = [f"{int(i//2):02d}:{int((i%2)*30):02d}" for i in range(intervals)]
1121
+
1122
+ # Create heatmap
1123
+ fig, ax = plt.subplots(figsize=(12, 8))
1124
+ sns.heatmap(overlap_data, cmap="viridis", ax=ax, cbar_kws={'label': 'Staff Count'})
1125
+
1126
+ # Set labels
1127
+ ax.set_xticks(np.arange(len(time_labels[::4])))
1128
+ ax.set_xticklabels(time_labels[::4], rotation=45, ha="right")
1129
+ ax.set_yticks(np.arange(len(days)))
1130
+ ax.set_yticklabels(days)
1131
+
1132
+ # Add title
1133
+ ax.set_title("Staff Overlap Throughout the Day")
1134
+
1135
+ # Ensure layout is tight
1136
+ plt.tight_layout()
1137
+
1138
+ # Convert plot to PNG image
1139
+ img = io.BytesIO()
1140
+ plt.savefig(img, format='png', bbox_inches='tight') # Added bbox_inches='tight'
1141
+ plt.close(fig)
1142
+ img.seek(0)
1143
+
1144
+ # Encode to base64
1145
+ img_base64 = base64.b64encode(img.read()).decode('utf-8')
1146
+ img_html = f'<img src="data:image/png;base64,{img_base64}" style="max-width:100%; max-height:800px;">'
1147
+
1148
+ return img_html
1149
+ except Exception as e:
1150
+ print(f"Error in overlap visualization: {e}")
1151
+ return f"<div>Error creating overlap visualization: {str(e)}</div>"
1152
+
1153
+ # Connect the button to the optimization function
1154
+ optimize_btn.click(
1155
+ fn=optimize_and_display,
1156
+ inputs=[
1157
+ csv_input, beds_per_staff, max_hours_per_staff, hours_per_cycle,
1158
+ rest_days_per_week, clinic_start_ampm, clinic_end_ampm,
1159
+ overlap_time, max_start_time_change, exact_staff_count, overtime_percent
1160
+ ],
1161
+ outputs=[
1162
+ staff_assignment_table, # Staff coverage table
1163
+ gantt_chart, # Gantt chart
1164
+ csv_schedule, # Detailed schedule
1165
+ schedule_download_file, # Download file
1166
+ constraints_text, # Constraints text
1167
+ monthly_chart, # Monthly distribution
1168
+ weekly_charts, # Weekly distribution
1169
+ overlap_chart # Staff overlap visualization
1170
+ ]
1171
+ )
1172
+
1173
+ # Add the handler function
1174
+ def handle_absence_click(staff_id, start_day, end_day, current_schedule, max_hours_per_staff, overtime_percent):
1175
+ if current_schedule is None or current_schedule.empty:
1176
+ return "No current schedule loaded.", None, None
1177
+
1178
+ absence_dates = list(range(int(start_day), int(end_day) + 1))
1179
+ summary, absence_schedule, absence_gantt_path = handle_staff_absence(
1180
+ current_schedule,
1181
+ int(staff_id),
1182
+ absence_dates,
1183
+ max_hours_per_staff,
1184
+ overtime_percent
1185
+ )
1186
+
1187
+ return summary, absence_schedule, absence_gantt_path
1188
+
1189
+ # Connect the absence handler button
1190
+ handle_absence_btn.click(
1191
+ fn=handle_absence_click,
1192
+ inputs=[
1193
+ absent_staff,
1194
+ absence_start,
1195
+ absence_end,
1196
+ csv_schedule, # Current schedule
1197
+ max_hours_per_staff, # Add this parameter
1198
+ overtime_percent # Add this parameter
1199
+ ],
1200
+ outputs=[
1201
+ absence_result,
1202
+ updated_schedule,
1203
+ absence_gantt_chart
1204
+ ]
1205
+ )
1206
+
1207
+ # Launch the Gradio app
1208
+ iface.launch(share=True)
1209
+
1210
+ def create_interface():
1211
+ with gr.Blocks() as demo:
1212
+ gr.Markdown("# NEF Scheduling System")
1213
+
1214
+ with gr.Tabs() as tabs:
1215
+ with gr.Tab("Schedule Input"):
1216
+ # Schedule input components
1217
+ with gr.Row():
1218
+ csv_input = gr.File(label="Upload Schedule Data (CSV)")
1219
+ schedule_preview = gr.DataFrame(label="Schedule Preview")
1220
+
1221
+ with gr.Tab("Schedule Output"):
1222
+ # Schedule output components
1223
+ with gr.Row():
1224
+ schedule_output = gr.DataFrame(label="Generated Schedule")
1225
+ download_btn = gr.Button("Download Schedule")
1226
+
1227
+ with gr.Tab("Constraints and Analytics"):
1228
+ with gr.Row():
1229
+ with gr.Column():
1230
+ gr.Markdown("### Applied Constraints")
1231
+ constraints_text = gr.TextArea(label="", interactive=False)
1232
+
1233
+ with gr.Row():
1234
+ with gr.Column():
1235
+ gr.Markdown("### Monthly Distribution")
1236
+ monthly_chart = gr.HTML(label="Monthly Hours Distribution")
1237
+
1238
+ with gr.Row():
1239
+ with gr.Column():
1240
+ gr.Markdown("### Weekly Distribution")
1241
+ weekly_charts = gr.HTML(label="Weekly Hours Distribution")
1242
+
1243
+ with gr.TabItem("Staff Absence Handler"):
1244
+ with gr.Row():
1245
+ with gr.Column():
1246
+ gr.Markdown("### Handle Staff Absence")
1247
+ absent_staff = gr.Number(label="Staff ID to be absent", precision=0)
1248
+ absence_start = gr.Number(label="Start Day", precision=0)
1249
+ absence_end = gr.Number(label="End Day", precision=0)
1250
+ handle_absence_btn = gr.Button("Redistribute Shifts", variant="primary")
1251
+
1252
+ with gr.Column():
1253
+ absence_result = gr.TextArea(label="Redistribution Results", interactive=False)
1254
+ updated_schedule = gr.DataFrame(label="Updated Schedule")
1255
+ absence_gantt_chart = gr.Image(label="Absence Schedule Visualization", elem_id="absence_gantt_chart")
1256
+
1257
+ return demo
1258
+
1259
+ def handle_staff_absence(schedule_df, absent_staff_id, absence_dates, max_hours_per_staff, overtime_percent):
1260
+ """
1261
+ Redistribute shifts of absent staff member to others, prioritizing staff with lowest monthly hours
1262
+ """
1263
+ try:
1264
+ # Create a copy of the original schedule
1265
+ new_schedule = schedule_df.copy()
1266
+
1267
+ # Get shifts that need to be redistributed
1268
+ absent_shifts = new_schedule[
1269
+ (new_schedule['staff_id'] == absent_staff_id) &
1270
+ (new_schedule['day'].isin(absence_dates))
1271
+ ]
1272
+
1273
+ if absent_shifts.empty:
1274
+ return "No shifts found for the specified staff member on given dates.", None, None
1275
+
1276
+ # Get available staff (excluding absent staff)
1277
+ available_staff = sorted(list(set(new_schedule['staff_id']) - {absent_staff_id}))
1278
+
1279
+ # Calculate current hours for each staff member
1280
+ current_hours = new_schedule.groupby('staff_id')['duration'].sum()
1281
+
1282
+ # Sort staff by current hours (ascending) to prioritize those with fewer hours
1283
+ staff_hours_sorted = current_hours.reindex(available_staff).sort_values()
1284
+ available_staff = staff_hours_sorted.index.tolist()
1285
+
1286
+ # Calculate remaining available hours for each staff
1287
+ max_allowed_hours = max_hours_per_staff * (1 + overtime_percent/100)
1288
+ available_hours = {
1289
+ staff_id: max_allowed_hours - current_hours.get(staff_id, 0)
1290
+ for staff_id in available_staff
1291
+ }
1292
+
1293
+ results = []
1294
+ unassigned_shifts = []
1295
+
1296
+ # Process each shift that needs to be redistributed
1297
+ for _, shift in absent_shifts.iterrows():
1298
+ # Find eligible staff for this shift, prioritizing those with fewer hours
1299
+ eligible_staff = []
1300
+ eligible_staff_hours = {}
1301
+
1302
+ for staff_id in available_staff:
1303
+ # Check if staff has enough remaining hours
1304
+ if available_hours[staff_id] >= shift['duration']:
1305
+ # Check if staff is not already working that day
1306
+ staff_shifts_that_day = new_schedule[
1307
+ (new_schedule['staff_id'] == staff_id) &
1308
+ (new_schedule['day'] == shift['day'])
1309
+ ]
1310
+
1311
+ if staff_shifts_that_day.empty:
1312
+ # Check minimum rest period (11 hours)
1313
+ day_before = new_schedule[
1314
+ (new_schedule['staff_id'] == staff_id) &
1315
+ (new_schedule['day'] == shift['day'] - 1)
1316
+ ]
1317
+
1318
+ day_after = new_schedule[
1319
+ (new_schedule['staff_id'] == staff_id) &
1320
+ (new_schedule['day'] == shift['day'] + 1)
1321
+ ]
1322
+
1323
+ can_work = True
1324
+ if not day_before.empty:
1325
+ end_time_before = day_before.iloc[0]['end']
1326
+ if (shift['start'] + 24 - end_time_before) < 11:
1327
+ can_work = False
1328
+
1329
+ if not day_after.empty and can_work:
1330
+ start_time_after = day_after.iloc[0]['start']
1331
+ if (starttime_after + 24 - shift['end']) < 11:
1332
+ can_work = False
1333
+
1334
+ if can_work:
1335
+ eligible_staff.append(staff_id)
1336
+ eligible_staff_hours[staff_id] = current_hours.get(staff_id, 0)
1337
+
1338
+ if eligible_staff:
1339
+ # Sort eligible staff by current hours to prioritize those with fewer hours
1340
+ sorted_eligible = sorted(eligible_staff, key=lambda x: eligible_staff_hours[x])
1341
+ best_staff = sorted_eligible[0] # Select staff with lowest hours
1342
+
1343
+ # Update the schedule
1344
+ new_schedule.loc[shift.name, 'staff_id'] = best_staff
1345
+
1346
+ # Update available hours and current hours
1347
+ available_hours[best_staff] -= shift['duration']
1348
+ current_hours[best_staff] = current_hours.get(best_staff, 0) + shift['duration']
1349
+
1350
+ results.append(
1351
+ f"Shift on Day {shift['day']} ({shift['duration']} hours) "
1352
+ f"reassigned to Staff {best_staff} (current hours: {current_hours[best_staff]:.1f})"
1353
+ )
1354
+ else:
1355
+ unassigned_shifts.append(
1356
+ f"Could not reassign shift on Day {shift['day']} ({shift['duration']} hours)"
1357
+ )
1358
+
1359
+ # Generate detailed summary with hours distribution
1360
+ summary = "\n".join([
1361
+ "Shift Redistribution Summary:",
1362
+ "----------------------------",
1363
+ f"Staff {absent_staff_id} absent for {len(absence_dates)} days",
1364
+ f"Successfully reassigned: {len(results)} shifts",
1365
+ f"Failed to reassign: {len(unassigned_shifts)} shifts",
1366
+ "\nCurrent Hours Distribution:",
1367
+ "-------------------------"
1368
+ ] + [
1369
+ f"Staff {s}: {current_hours.get(s, 0):.1f} hours (of max {max_allowed_hours:.1f})"
1370
+ for s in sorted(available_staff)
1371
+ ] + [
1372
+ "\nReassignment Details:",
1373
+ *results,
1374
+ "\nUnassigned Shifts:",
1375
+ *unassigned_shifts
1376
+ ])
1377
+
1378
+ # Filter the schedule for the absence period
1379
+ absence_schedule = new_schedule[new_schedule['day'].isin(absence_dates)].copy()
1380
+
1381
+ # Create a Gantt chart for the absence period
1382
+ absence_gantt_path = create_gantt_chart(absence_schedule, len(absence_dates), len(set(absence_schedule['staff_id'])))
1383
+
1384
+ if unassigned_shifts:
1385
+ return summary, None, None
1386
+ else:
1387
+ return summary, absence_schedule, absence_gantt_path
1388
+
1389
+ except Exception as e:
1390
+ return f"Error redistributing shifts: {str(e)}", None, None
1391
+
1392
+ class FastScheduler:
1393
+ def __init__(self, num_staff, num_days, possible_shifts, staff_requirements, constraints):
1394
+ self.num_staff = num_staff
1395
+ self.num_days = num_days
1396
+ self.possible_shifts = possible_shifts
1397
+ self.staff_requirements = staff_requirements
1398
+ self.constraints = constraints
1399
+ self.best_schedule = None
1400
+ self.best_score = float('inf')
1401
+ # Pre-compute shift lookups for faster access
1402
+ self.shift_lookup = {shift['id']: shift for shift in possible_shifts}
1403
+ self.cycle_shifts = self._precompute_cycle_shifts()
1404
+ # Track staff state
1405
+ self.staff_sequences = {}
1406
+ self.staff_hours = {}
1407
+ self.max_monthly_hours = constraints['max_hours_per_staff']
1408
+
1409
+ def _precompute_cycle_shifts(self):
1410
+ """Pre-compute which shifts can cover each cycle"""
1411
+ cycle_shifts = {}
1412
+ for cycle in self.staff_requirements[0].keys():
1413
+ cycle_shifts[cycle] = [shift for shift in self.possible_shifts if cycle in shift['cycles_covered']]
1414
+ return cycle_shifts
1415
+
1416
+ def optimize(self, time_limit=300):
1417
+ """Main optimization method"""
1418
+ start_time = time.time()
1419
+ schedule = []
1420
+
1421
+ # Process each day
1422
+ for day in range(1, self.num_days + 1):
1423
+ # Get requirements for this day
1424
+ day_requirements = self.staff_requirements[day-1]
1425
+
1426
+ # Process each cycle
1427
+ for cycle, staff_needed in day_requirements.items():
1428
+ staff_assigned = 0
1429
+
1430
+ # Try each staff member until we meet the requirement
1431
+ for staff_id in range(1, self.num_staff + 1):
1432
+ # Check if we've met the requirement
1433
+ if staff_assigned >= staff_needed:
1434
+ break
1435
+
1436
+ # Check if we're out of time
1437
+ if time.time() - start_time > time_limit:
1438
+ return None
1439
+
1440
+ # Try to assign a shift
1441
+ shift = self._find_optimal_shift(staff_id, day, cycle, self.staff_hours)
1442
+ if shift:
1443
+ schedule.append(shift)
1444
+ staff_assigned += 1
1445
+
1446
+ # Validate the schedule after each day
1447
+ score = self._evaluate_schedule(schedule)
1448
+ if score == float('inf'):
1449
+ return None
1450
+
1451
+ # Final validation
1452
+ final_score = self._evaluate_schedule(schedule)
1453
+ if final_score == float('inf'):
1454
+ return None
1455
+
1456
+ return schedule
1457
+
1458
+ def _find_optimal_shift(self, staff_id, day, cycle, staff_hours):
1459
+ """Optimized shift finding with early exits and pre-computed lookups"""
1460
+ # Quick access to staff's current state
1461
+ staff_info = self.staff_sequences.get(staff_id)
1462
+ current_hours = self.staff_hours.get(staff_id, 0)
1463
+
1464
+ # Early exit if staff has reached maximum hours
1465
+ if current_hours >= self.max_monthly_hours:
1466
+ return None
1467
+
1468
+ # Use pre-computed valid shifts for this cycle
1469
+ valid_shifts = self.cycle_shifts.get(cycle, [])
1470
+ if not valid_shifts:
1471
+ return None
1472
+
1473
+ # Quick consecutive days check
1474
+ if staff_info and staff_info.get('consecutive_days', 0) >= 6 and day - staff_info['last_day'] == 1:
1475
+ return None
1476
+
1477
+ # Filter shifts based on timing consistency (highest priority)
1478
+ if staff_info and day - staff_info['last_day'] == 1:
1479
+ required_start = staff_info['last_time']
1480
+ valid_shifts = [s for s in valid_shifts if s['start'] == required_start]
1481
+ if not valid_shifts:
1482
+ return None
1483
+
1484
+ # Quick hours check
1485
+ valid_shifts = [s for s in valid_shifts if current_hours + s['duration'] <= self.max_monthly_hours]
1486
+ if not valid_shifts:
1487
+ return None
1488
+
1489
+ # Check if staff already has a shift this day
1490
+ if any(s['staff_id'] == staff_id and s['day'] == day for s in self.best_schedule or []):
1491
+ return None
1492
+
1493
+ # Select first valid shift (optimization over finding "best" shift)
1494
+ shift = valid_shifts[0]
1495
+ assigned_shift = {
1496
+ 'staff_id': staff_id,
1497
+ 'day': day,
1498
+ 'shift_id': shift['id'],
1499
+ 'start': shift['start'],
1500
+ 'end': shift['end'],
1501
+ 'duration': shift['duration'],
1502
+ 'cycles_covered': list(shift['cycles_covered'])
1503
+ }
1504
+
1505
+ # Update staff tracking
1506
+ consecutive_days = 1 if not staff_info else (
1507
+ staff_info['consecutive_days'] + 1 if day - staff_info['last_day'] == 1 else 1
1508
+ )
1509
+
1510
+ self.staff_sequences[staff_id] = {
1511
+ 'last_day': day,
1512
+ 'last_time': shift['start'],
1513
+ 'consecutive_days': consecutive_days
1514
+ }
1515
+ self.staff_hours[staff_id] = current_hours + shift['duration']
1516
+
1517
+ return assigned_shift
1518
+
1519
+ def _evaluate_schedule(self, schedule):
1520
+ """Optimized schedule evaluation with early exits"""
1521
+ if not schedule:
1522
+ return float('inf')
1523
+
1524
+ # Pre-compute staff shifts dictionary
1525
+ staff_shifts = {}
1526
+ for shift in schedule:
1527
+ staff_id = shift['staff_id']
1528
+ if staff_id not in staff_shifts:
1529
+ staff_shifts[staff_id] = []
1530
+ staff_shifts[staff_id].append(shift)
1531
+
1532
+ # Early exit on hours violation
1533
+ if self.staff_hours.get(staff_id, 0) > self.max_monthly_hours:
1534
+ return float('inf')
1535
+
1536
+ # Quick timing consistency check with early exit
1537
+ for shifts in staff_shifts.values():
1538
+ shifts.sort(key=lambda x: x['day'])
1539
+ for i in range(1, len(shifts)):
1540
+ if (shifts[i]['day'] - shifts[i-1]['day'] == 1 and
1541
+ shifts[i]['start'] != shifts[i-1]['start']):
1542
+ return float('inf')
1543
+
1544
+ # Coverage check with early exit
1545
+ coverage = self._check_coverage_requirements(schedule)
1546
+ if coverage > 0:
1547
+ return float('inf')
1548
+
1549
+ return 0 # Valid schedule found
1550
+
1551
+ def _check_coverage_requirements(self, schedule):
1552
+ """Optimized coverage check using pre-computed data"""
1553
+ day_cycle_coverage = {}
1554
+
1555
+ # Pre-compute coverage needs
1556
+ for shift in schedule:
1557
+ day = shift['day']
1558
+ if day not in day_cycle_coverage:
1559
+ day_cycle_coverage[day] = {cycle: 0 for cycle in self.staff_requirements[0].keys()}
1560
+
1561
+ for cycle in shift['cycles_covered']:
1562
+ day_cycle_coverage[day][cycle] += 1
1563
+
1564
+ # Check coverage
1565
+ violations = 0
1566
+ for day in range(1, self.num_days + 1):
1567
+ if day not in day_cycle_coverage:
1568
+ return float('inf') # Missing day coverage
1569
+
1570
+ day_coverage = day_cycle_coverage[day]
1571
+ required = self.staff_requirements[day-1]
1572
+
1573
+ for cycle, needed in required.items():
1574
+ if day_coverage[cycle] < needed:
1575
+ violations += needed - day_coverage[cycle]
1576
+ if violations > 0: # Early exit on any violation
1577
+ return violations
1578
+
1579
+ return violations
1580
+
1581
+ def reset(self):
1582
+ """Reset the scheduler state"""
1583
+ self.best_schedule = None
1584
+ self.best_score = float('inf')
1585
+ self.staff_sequences = {}
1586
+ self.staff_hours = {}