anujkum0x commited on
Commit
25fbc1d
·
verified ·
1 Parent(s): 79b3c7d

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1275 -0
app.py ADDED
@@ -0,0 +1,1275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(csv_file, beds_per_staff, max_hours_per_staff, overlap_time):
41
+ try:
42
+ # Load data
43
+ data = pd.read_csv(csv_file)
44
+ num_days = len(data)
45
+
46
+ # Define cycles and their hours
47
+ cycles = {
48
+ 'cycle_1': range(7, 12), # 7 AM - 12 PM
49
+ 'cycle_2': range(12, 17), # 12 PM - 5 PM
50
+ 'cycle_3': range(17, 22), # 5 PM - 10 PM
51
+ 'cycle_4': range(22, 24) + range(0, 3), # 10 PM - 3 AM
52
+ }
53
+
54
+ # Calculate hourly demand
55
+ hourly_demand = {}
56
+ for d in range(num_days):
57
+ cycle_demands = {}
58
+ for col in data.columns:
59
+ if col.startswith('cycle'):
60
+ # Handle NaN values by filling with 0
61
+ beds = float(data.iloc[d][col]) if not pd.isna(data.iloc[d][col]) else 0
62
+ cycle_demands[col] = int(np.ceil(beds / beds_per_staff))
63
+
64
+ for h in range(24):
65
+ for cycle, hours in cycles.items():
66
+ if h in hours:
67
+ hourly_demand[(d,h)] = cycle_demands.get(cycle, 0)
68
+ break
69
+ else:
70
+ hourly_demand[(d,h)] = 0 # No demand for hours not in any cycle
71
+
72
+ # Check for NaN in hourly_demand
73
+ if any(pd.isna(value) for value in hourly_demand.values()):
74
+ raise ValueError("Hourly demand calculation resulted in NaN values.")
75
+
76
+ min_staff = max(hourly_demand.values()) + 1
77
+
78
+ # Create model
79
+ model = cp_model.CpModel()
80
+
81
+ # Variables
82
+ x = {} # x[s,d,h] = 1 if staff s works hour h on day d
83
+ working = {} # working[s,d] = 1 if staff s works on day d
84
+ start = {} # start[s,d] = start hour for staff s on day d
85
+
86
+ for s in range(min_staff):
87
+ for d in range(num_days):
88
+ working[s,d] = model.NewBoolVar(f'working_{s}_{d}')
89
+ start[s,d] = model.NewIntVar(0, 23, f'start_{s}_{d}')
90
+ for h in range(24):
91
+ x[s,d,h] = model.NewBoolVar(f'x_{s}_{d}_{h}')
92
+
93
+ # 1. Coverage constraints
94
+ for (d,h), demand in hourly_demand.items():
95
+ if demand > 0:
96
+ model.Add(sum(x[s,d,h] for s in range(min_staff)) >= demand)
97
+
98
+ # 2. Shift constraints (6-12 hours)
99
+ for s in range(min_staff):
100
+ for d in range(num_days):
101
+ # Link working to shifts
102
+ model.Add(sum(x[s,d,h] for h in range(24)) >= 6).OnlyEnforceIf(working[s,d])
103
+ model.Add(sum(x[s,d,h] for h in range(24)) <= 12).OnlyEnforceIf(working[s,d])
104
+ model.Add(sum(x[s,d,h] for h in range(24)) == 0).OnlyEnforceIf(working[s,d].Not())
105
+
106
+ # Link start hour to shift start
107
+ for h in range(24):
108
+ model.Add(start[s,d] == h).OnlyEnforceIf([x[s,d,h], working[s,d]])
109
+ if h > 0:
110
+ model.Add(x[s,d,h-1] == 0).OnlyEnforceIf([x[s,d,h], working[s,d]])
111
+
112
+ # 3. Consecutive days timing (±1 hour)
113
+ for s in range(min_staff):
114
+ for d in range(num_days-1):
115
+ model.Add(start[s,d+1] - start[s,d] >= -1).OnlyEnforceIf([working[s,d], working[s,d+1]])
116
+ model.Add(start[s,d+1] - start[s,d] <= 1).OnlyEnforceIf([working[s,d], working[s,d+1]])
117
+
118
+ # 4. Weekly rest day
119
+ for s in range(min_staff):
120
+ for w in range((num_days + 6) // 7):
121
+ week_start = w * 7
122
+ week_end = min((w + 1) * 7, num_days)
123
+ model.Add(sum(working[s,d] for d in range(week_start, week_end)) <= 6)
124
+
125
+ # 5. Monthly hours limit
126
+ for s in range(min_staff):
127
+ model.Add(sum(x[s,d,h] for d in range(num_days) for h in range(24)) <= max_hours_per_staff)
128
+
129
+ # Solve
130
+ solver = cp_model.CpSolver()
131
+ solver.parameters.max_time_in_seconds = 300
132
+ solver.parameters.num_search_workers = 8
133
+
134
+ status = solver.Solve(model)
135
+
136
+ if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE:
137
+ solution = np.zeros((min_staff, num_days, 24))
138
+ for s in range(min_staff):
139
+ for d in range(num_days):
140
+ for h in range(24):
141
+ if solver.Value(x[s,d,h]) == 1:
142
+ solution[s,d,h] = 1
143
+ return process_solution(solution, min_staff, num_days)
144
+
145
+ return None
146
+
147
+ except Exception as e:
148
+ print(f"Optimization error: {str(e)}")
149
+ return None
150
+
151
+ def convert_to_24h(time_str):
152
+ """Converts AM/PM time string to 24-hour format."""
153
+ try:
154
+ time_obj = datetime.strptime(time_str, "%I:00 %p")
155
+ return time_obj.hour
156
+ except ValueError:
157
+ return None
158
+
159
+ def gradio_wrapper(
160
+ csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
161
+ rest_days_per_week, clinic_start_ampm, clinic_end_ampm, overlap_time, max_start_time_change,
162
+ exact_staff_count=None, overtime_percent=100
163
+ ):
164
+ try:
165
+ # Convert AM/PM times to 24-hour format
166
+ clinic_start = convert_to_24h(clinic_start_ampm)
167
+ clinic_end = convert_to_24h(clinic_end_ampm)
168
+
169
+ # Call the optimization function
170
+ results, staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv_path, staff_assignment_csv_path = optimize_staffing(
171
+ csv_file, beds_per_staff, max_hours_per_staff, overlap_time
172
+ )
173
+
174
+ # Return the results
175
+ return staff_assignment_df, gantt_path, schedule_df, plot_path, staff_assignment_csv_path, schedule_csv_path
176
+ except Exception as e:
177
+ # If there's an error in the optimization process, return a meaningful error message
178
+ empty_staff_df = pd.DataFrame(columns=["Day"])
179
+ error_message = f"Error during optimization: {str(e)}\n\nPlease try with different parameters or a simpler dataset."
180
+ # Return error in the first output
181
+ return empty_staff_df, None, None, None, None, None
182
+
183
+ # Create a Gantt chart with advanced visuals and alternating labels - only showing active staff
184
+ def create_gantt_chart(schedule_df, num_days, staff_count):
185
+ # Get the list of active staff IDs (staff who have at least one shift)
186
+ active_staff_ids = sorted(schedule_df['staff_id'].unique())
187
+ active_staff_count = len(active_staff_ids)
188
+
189
+ # Create a mapping from original staff ID to position in the chart
190
+ staff_position = {staff_id: i+1 for i, staff_id in enumerate(active_staff_ids)}
191
+
192
+ # Create a larger figure with higher DPI
193
+ plt.figure(figsize=(max(30, num_days * 1.5), max(12, active_staff_count * 0.8)), dpi=200)
194
+
195
+ # Use a more sophisticated color palette - only for active staff
196
+ colors = plt.cm.viridis(np.linspace(0.1, 0.9, active_staff_count))
197
+
198
+ # Set a modern style
199
+ plt.style.use('seaborn-v0_8-whitegrid')
200
+
201
+ # Create a new axis with a slight background color
202
+
203
+ ax = plt.gca()
204
+ ax.set_facecolor('#f8f9fa')
205
+
206
+ # Sort by staff then day
207
+ schedule_df = schedule_df.sort_values(['staff_id', 'day'])
208
+
209
+ # Plot Gantt chart - only for active staff
210
+ for i, staff_id in enumerate(active_staff_ids):
211
+ staff_shifts = schedule_df[schedule_df['staff_id'] == staff_id]
212
+
213
+ y_pos = active_staff_count - i # Position based on index in active staff list
214
+
215
+ # Add staff label with a background box
216
+ ax.text(-0.7, y_pos, f"Staff {staff_id}", fontsize=12, fontweight='bold',
217
+ ha='right', va='center', bbox=dict(facecolor='white', edgecolor='gray',
218
+ boxstyle='round,pad=0.5', alpha=0.9))
219
+
220
+ # Add a subtle background for each staff row
221
+ ax.axhspan(y_pos-0.4, y_pos+0.4, color='white', alpha=0.4, zorder=-5)
222
+
223
+ # Track shift positions to avoid label overlap
224
+ shift_positions = []
225
+
226
+ for idx, shift in enumerate(staff_shifts.iterrows()):
227
+ _, shift = shift
228
+ day = shift['day']
229
+ start_hour = shift['start']
230
+ end_hour = shift['end']
231
+ duration = shift['duration']
232
+
233
+ # Format times for display
234
+ start_ampm = am_pm(start_hour)
235
+ end_ampm = am_pm(end_hour)
236
+
237
+ # Calculate shift position
238
+ shift_start_pos = day-1+start_hour/24
239
+
240
+ # Handle overnight shifts
241
+ if end_hour < start_hour: # Overnight shift
242
+ # First part of shift (until midnight)
243
+ rect1 = ax.barh(y_pos, (24-start_hour)/24, left=shift_start_pos,
244
+ height=0.6, color=colors[i], alpha=0.9,
245
+ edgecolor='black', linewidth=1, zorder=10)
246
+
247
+ # Add gradient effect
248
+ for r in rect1:
249
+ r.set_edgecolor('black')
250
+ r.set_linewidth(1)
251
+
252
+ # Second part of shift (after midnight)
253
+ rect2 = ax.barh(y_pos, end_hour/24, left=day,
254
+ height=0.6, color=colors[i], alpha=0.9,
255
+ edgecolor='black', linewidth=1, zorder=10)
256
+
257
+ # Add gradient effect
258
+ for r in rect2:
259
+ r.set_edgecolor('black')
260
+ r.set_linewidth(1)
261
+
262
+ # For overnight shifts, we'll place the label in the first part if it's long enough
263
+ shift_width = (24-start_hour)/24
264
+ if shift_width >= 0.1: # Only add label if there's enough space
265
+ label_pos = shift_start_pos + shift_width/2
266
+
267
+ # Alternate labels above and below
268
+ y_offset = 0.35 if idx % 2 == 0 else -0.35
269
+
270
+ # Add label with background for better readability
271
+ label = f"{start_ampm}-{end_ampm}"
272
+ text = ax.text(label_pos, y_pos + y_offset, label,
273
+ ha='center', va='center', fontsize=9, fontweight='bold',
274
+ color='black', bbox=dict(facecolor='white', alpha=0.9, pad=3,
275
+ boxstyle='round,pad=0.3', edgecolor='gray'),
276
+ zorder=20)
277
+
278
+ shift_positions.append(label_pos)
279
+ else:
280
+ # Regular shift
281
+ shift_width = duration/24
282
+ rect = ax.barh(y_pos, shift_width, left=shift_start_pos,
283
+ height=0.6, color=colors[i], alpha=0.9,
284
+ edgecolor='black', linewidth=1, zorder=10)
285
+
286
+ # Add gradient effect
287
+ for r in rect:
288
+ r.set_edgecolor('black')
289
+ r.set_linewidth(1)
290
+
291
+ # Only add label if there's enough space
292
+ if shift_width >= 0.1:
293
+ label_pos = shift_start_pos + shift_width/2
294
+
295
+ # Alternate labels above and below
296
+ y_offset = 0.35 if idx % 2 == 0 else -0.35
297
+
298
+ # Add label with background for better readability
299
+ label = f"{start_ampm}-{end_ampm}"
300
+ text = ax.text(label_pos, y_pos + y_offset, label,
301
+ ha='center', va='center', fontsize=9, fontweight='bold',
302
+ color='black', bbox=dict(facecolor='white', alpha=0.9, pad=3,
303
+ boxstyle='round,pad=0.3', edgecolor='gray'),
304
+ zorder=20)
305
+
306
+ shift_positions.append(label_pos)
307
+
308
+ # Add weekend highlighting with a more sophisticated look
309
+ for day in range(1, num_days + 1):
310
+ # Determine if this is a weekend (assuming day 1 is Monday)
311
+ is_weekend = (day % 7 == 0) or (day % 7 == 6) # Saturday or Sunday
312
+
313
+ if is_weekend:
314
+ ax.axvspan(day-1, day, alpha=0.15, color='#ff9999', zorder=-10)
315
+ day_label = "Saturday" if day % 7 == 6 else "Sunday"
316
+ ax.text(day-0.5, 0.2, day_label, ha='center', fontsize=10, color='#cc0000',
317
+ fontweight='bold', bbox=dict(facecolor='white', alpha=0.7, pad=2, boxstyle='round'))
318
+
319
+ # Set x-axis ticks for each day with better formatting
320
+ ax.set_xticks(np.arange(0.5, num_days, 1))
321
+ day_labels = [f"Day {d}" for d in range(1, num_days+1)]
322
+ ax.set_xticklabels(day_labels, rotation=0, ha='center', fontsize=10)
323
+
324
+ # Add vertical lines between days with better styling
325
+ for day in range(1, num_days):
326
+ ax.axvline(x=day, color='#aaaaaa', linestyle='-', alpha=0.5, zorder=-5)
327
+
328
+ # Set y-axis ticks for each staff
329
+ ax.set_yticks(np.arange(1, active_staff_count+1))
330
+ ax.set_yticklabels([]) # Remove default labels as we've added custom ones
331
+
332
+ # Set axis limits with some padding
333
+ ax.set_xlim(-0.8, num_days)
334
+ ax.set_ylim(0.5, active_staff_count + 0.5)
335
+
336
+ # Add grid for hours (every 6 hours) with better styling
337
+ for day in range(num_days):
338
+ for hour in [6, 12, 18]:
339
+ ax.axvline(x=day + hour/24, color='#cccccc', linestyle=':', alpha=0.5, zorder=-5)
340
+ # Add small hour markers at the bottom
341
+ hour_label = "6AM" if hour == 6 else "Noon" if hour == 12 else "6PM"
342
+ ax.text(day + hour/24, 0, hour_label, ha='center', va='bottom', fontsize=7,
343
+ color='#666666', rotation=90, alpha=0.7)
344
+
345
+ # Add title and labels with more sophisticated styling
346
+ plt.title(f'Staff Schedule ({active_staff_count} Active Staff)', fontsize=24, fontweight='bold', pad=20, color='#333333')
347
+ plt.xlabel('Day', fontsize=16, labelpad=10, color='#333333')
348
+
349
+ # Add a legend for time reference with better styling
350
+ time_box = plt.figtext(0.01, 0.01, "Time Reference:", ha='left', fontsize=10,
351
+ fontweight='bold', color='#333333')
352
+ time_markers = ['6 AM', 'Noon', '6 PM', 'Midnight']
353
+ for i, time in enumerate(time_markers):
354
+ plt.figtext(0.08 + i*0.06, 0.01, time, ha='left', fontsize=9, color='#555555')
355
+
356
+ # Remove spines
357
+ for spine in ['top', 'right', 'left']:
358
+ ax.spines[spine].set_visible(False)
359
+
360
+ # Add a note about weekends with better styling
361
+ weekend_note = plt.figtext(0.01, 0.97, "Red areas = Weekends", fontsize=12,
362
+ color='#cc0000', fontweight='bold',
363
+ bbox=dict(facecolor='white', alpha=0.7, pad=5, boxstyle='round'))
364
+
365
+ # Add a subtle border around the entire chart
366
+ plt.box(False)
367
+
368
+ # Save the Gantt chart with high quality
369
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
370
+ plt.tight_layout()
371
+ plt.savefig(f.name, dpi=200, bbox_inches='tight', facecolor='white')
372
+ plt.close()
373
+ return f.name
374
+
375
+ # Define Gradio UI
376
+ 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)]
377
+
378
+ # Add CSS for chart containers
379
+ css = """
380
+ .chart-container {
381
+ height: 800px !important;
382
+ width: 100% !important;
383
+ margin: 20px 0;
384
+ padding: 20px;
385
+ border: 1px solid #ddd;
386
+ border-radius: 8px;
387
+ background: white;
388
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
389
+ }
390
+
391
+ .weekly-chart-container {
392
+ height: 1000px !important;
393
+ width: 100% !important;
394
+ margin: 20px 0;
395
+ padding: 20px;
396
+ border: 1px solid #ddd;
397
+ border-radius: 8px;
398
+ background: white;
399
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
400
+ }
401
+
402
+ /* Ensure plotly charts are visible */
403
+ .js-plotly-plot {
404
+ width: 100% !important;
405
+ height: 100% !important;
406
+ }
407
+
408
+ /* Improve visibility of chart titles */
409
+ .gtitle {
410
+ font-weight: bold !important;
411
+ font-size: 20px !important;
412
+ }
413
+ """
414
+
415
+ with gr.Blocks(title="Staff Scheduling Optimizer", css=css) as iface:
416
+
417
+ gr.Markdown("# Staff Scheduling Optimizer")
418
+ gr.Markdown("Upload a CSV file with cycle data and configure parameters to generate an optimal staff schedule.")
419
+
420
+ with gr.Row():
421
+ # LEFT PANEL - Inputs
422
+ with gr.Column(scale=1):
423
+ gr.Markdown("### Input Parameters")
424
+
425
+ # Input parameters
426
+ csv_input = gr.File(label="Upload CSV")
427
+ beds_per_staff = gr.Number(label="Beds per Staff", value=3)
428
+ max_hours_per_staff = gr.Number(label="Maximum monthly hours", value=160)
429
+ hours_per_cycle = gr.Number(label="Hours per Cycle", value=4)
430
+ rest_days_per_week = gr.Number(label="Rest Days per Week", value=2)
431
+ clinic_start_ampm = gr.Dropdown(label="Clinic Start Hour (AM/PM)", choices=am_pm_times, value="08:00 AM")
432
+ clinic_end_ampm = gr.Dropdown(label="Clinic End Hour (AM/PM)", choices=am_pm_times, value="08:00 PM")
433
+ overlap_time = gr.Number(label="Overlap Time", value=0)
434
+ max_start_time_change = gr.Number(label="Max Start Time Change", value=2)
435
+ exact_staff_count = gr.Number(label="Exact Staff Count (optional)", value=None)
436
+ overtime_percent = gr.Slider(label="Overtime Allowed (%)", minimum=0, maximum=100, value=100, step=10)
437
+
438
+ optimize_btn = gr.Button("Optimize Schedule", variant="primary", size="lg")
439
+
440
+ # RIGHT PANEL - Outputs
441
+ with gr.Column(scale=2):
442
+ gr.Markdown("### Results")
443
+
444
+ # Tabs for different outputs - reordered
445
+ with gr.Tabs():
446
+ with gr.TabItem("Detailed Schedule"):
447
+ with gr.Row():
448
+ csv_schedule = gr.Dataframe(label="Detailed Schedule", elem_id="csv_schedule")
449
+
450
+ with gr.Row():
451
+ schedule_download_file = gr.File(label="Download Detailed Schedule", visible=True)
452
+
453
+ with gr.TabItem("Gantt Chart"):
454
+ gantt_chart = gr.Image(label="Staff Schedule Visualization", elem_id="gantt_chart")
455
+
456
+ with gr.TabItem("Staff Coverage by Cycle"):
457
+ with gr.Row():
458
+ staff_assignment_table = gr.Dataframe(label="Staff Count in Each Cycle (Staff May Overlap)", elem_id="staff_assignment_table")
459
+
460
+ with gr.Row():
461
+ staff_download_file = gr.File(label="Download Coverage Table", visible=True)
462
+
463
+ with gr.TabItem("Constraints and Analytics"):
464
+ with gr.Row():
465
+ with gr.Column(scale=1):
466
+ gr.Markdown("### Applied Constraints")
467
+ constraints_text = gr.TextArea(
468
+ label="",
469
+ interactive=False,
470
+ show_label=False
471
+ )
472
+
473
+ with gr.Row():
474
+ with gr.Column(scale=1):
475
+ gr.Markdown("### Monthly Distribution")
476
+ monthly_chart = gr.HTML(
477
+ label="Monthly Hours Distribution",
478
+ show_label=False,
479
+ elem_classes="chart-container"
480
+ )
481
+
482
+ with gr.Row():
483
+ with gr.Column(scale=1):
484
+ gr.Markdown("### Weekly Distribution")
485
+ weekly_charts = gr.HTML(
486
+ label="Weekly Hours Distribution",
487
+ show_label=False,
488
+ elem_classes="weekly-chart-container"
489
+ )
490
+
491
+ with gr.TabItem("Staff Overlap"):
492
+ with gr.Row():
493
+ overlap_chart = gr.HTML(
494
+ label="Staff Overlap Visualization",
495
+ show_label=False
496
+ )
497
+ with gr.Row():
498
+ gr.Markdown("""
499
+ This heatmap shows the number of staff members working simultaneously throughout each day.
500
+ - Darker colors indicate more staff overlap
501
+ - The x-axis shows time of day in 30-minute intervals
502
+ - The y-axis shows each day of the schedule
503
+ """)
504
+
505
+ with gr.TabItem("Staff Absence Handler"):
506
+ with gr.Row():
507
+ with gr.Column():
508
+ gr.Markdown("### Handle Staff Absence")
509
+ absent_staff = gr.Number(label="Staff ID to be absent", precision=0)
510
+ absence_start = gr.Number(label="Start Day", precision=0)
511
+ absence_end = gr.Number(label="End Day", precision=0)
512
+ handle_absence_btn = gr.Button("Redistribute Shifts", variant="primary")
513
+
514
+ with gr.Column():
515
+ absence_result = gr.TextArea(label="Redistribution Results", interactive=False)
516
+ updated_schedule = gr.DataFrame(label="Updated Schedule")
517
+ absence_gantt_chart = gr.Image(label="Absence Schedule Visualization", elem_id="absence_gantt_chart")
518
+
519
+ # Define download functions
520
+ def create_download_link(df, filename="data.csv"):
521
+ """Create a CSV download link for a dataframe"""
522
+ if df is None or df.empty:
523
+ return None
524
+
525
+ csv_data = df.to_csv(index=False)
526
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv') as f:
527
+ f.write(csv_data)
528
+ return f.name
529
+
530
+ # Update the optimize_and_display function
531
+ def optimize_and_display(csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
532
+ rest_days_per_week, clinic_start_ampm, clinic_end_ampm,
533
+ overlap_time, max_start_time_change, exact_staff_count, overtime_percent):
534
+ try:
535
+ print(f"Starting optimization with parameters: beds={beds_per_staff}, hours={max_hours_per_staff}")
536
+
537
+ # Convert AM/PM times to 24-hour format
538
+ clinic_start = convert_to_24h(clinic_start_ampm)
539
+ clinic_end = convert_to_24h(clinic_end_ampm)
540
+
541
+ # Call optimization with proper error handling
542
+ result = optimize_staffing(csv_file, beds_per_staff, max_hours_per_staff, overlap_time)
543
+
544
+ if result is None:
545
+ return (
546
+ pd.DataFrame({"Error": ["No feasible solution found"]}),
547
+ None,
548
+ None,
549
+ None,
550
+ "Optimization failed to find a valid schedule",
551
+ "<div>No solution found</div>",
552
+ "<div>No solution found</div>",
553
+ "<div>No solution found</div>"
554
+ )
555
+
556
+ staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv, staff_csv = result
557
+
558
+ # Generate additional visualizations
559
+ constraints_info = get_constraints_summary(max_hours_per_staff, rest_days_per_week, overtime_percent)
560
+ monthly_html = create_monthly_distribution_chart(schedule_df)
561
+ weekly_html = create_weekly_distribution_charts(schedule_df)
562
+ overlap_html = create_overlap_visualization(schedule_df)
563
+
564
+ return (
565
+ staff_assignment_df,
566
+ gantt_path,
567
+ schedule_df,
568
+ schedule_csv,
569
+ constraints_info,
570
+ monthly_html,
571
+ weekly_html,
572
+ overlap_html
573
+ )
574
+
575
+ except Exception as e:
576
+ print(f"Error: {str(e)}")
577
+ return (
578
+ pd.DataFrame({"Error": [str(e)]}),
579
+ None,
580
+ None,
581
+ None,
582
+ "Error occurred during optimization",
583
+ "<div>Error</div>",
584
+ "<div>Error</div>",
585
+ "<div>Error</div>"
586
+ )
587
+
588
+ def get_constraints_summary(max_hours, rest_days, overtime_percent):
589
+ """Generate a summary of all applied constraints from actual parameters"""
590
+ constraints = [
591
+ "Applied Scheduling Constraints:",
592
+ "----------------------------",
593
+ f"1. Maximum Hours per Month: {max_hours} hours",
594
+ f"2. Required Rest Days per Week: {rest_days} days",
595
+ f"3. Maximum Weekly Hours: 60 hours per staff member",
596
+ "4. Minimum Rest Period: 11 hours between shifts",
597
+ "5. Maximum Consecutive Days: 6 working days",
598
+ f"6. Overtime Allowance: {overtime_percent}% of standard hours",
599
+ "7. Coverage Requirements:",
600
+ " - All cycles must be fully staffed",
601
+ " - No understaffing allowed",
602
+ " - Staff assigned based on required beds/staff ratio",
603
+ "8. Shift Constraints:",
604
+ " - Available shift durations: 5, 10 hours",
605
+ " - Shifts must align with cycle times",
606
+ "9. Staff Scheduling Rules:",
607
+ " - Equal distribution of workload when possible",
608
+ " - Consistent shift patterns preferred",
609
+ " - Weekend rotations distributed fairly"
610
+ ]
611
+ return "\n".join(constraints)
612
+
613
+ def create_monthly_distribution_chart(schedule_df):
614
+ """Create Seaborn pie chart for monthly hours distribution"""
615
+ if schedule_df is None or schedule_df.empty:
616
+ return "<div>No data available for visualization</div>"
617
+
618
+ try:
619
+ # Calculate total hours per staff member
620
+ staff_hours = schedule_df.groupby('staff_id')['duration'].sum()
621
+
622
+ # Create pie chart
623
+ fig, ax = plt.subplots(figsize=(8, 8))
624
+ sns.set_palette("pastel")
625
+ ax.pie(staff_hours, labels=staff_hours.index, autopct='%1.1f%%', startangle=90)
626
+ ax.axis('equal') # Equal aspect ratio ensures that pie is drawn as a circle.
627
+ plt.title("Monthly Hours Distribution")
628
+
629
+ # Convert plot to PNG image
630
+ img = io.BytesIO()
631
+ plt.savefig(img, format='png', bbox_inches='tight') # Added bbox_inches='tight'
632
+ plt.close(fig)
633
+ img.seek(0)
634
+
635
+ # Encode to base64
636
+ img_base64 = base64.b64encode(img.read()).decode('utf-8')
637
+ img_html = f'<img src="data:image/png;base64,{img_base64}" style="max-width:100%; max-height:600px;">'
638
+
639
+ return img_html
640
+ except Exception as e:
641
+ print(f"Error in monthly chart: {e}")
642
+ return f"<div>Error creating monthly chart: {str(e)}</div>"
643
+
644
+ def create_weekly_distribution_charts(schedule_df):
645
+ """Create Plotly pie charts for weekly hours distribution"""
646
+ if schedule_df is None or schedule_df.empty:
647
+ return "<div>No data available for visualization</div>"
648
+
649
+ try:
650
+ # Calculate total hours per staff member for each week
651
+ schedule_df['week'] = schedule_df['day'] // 7 # Assuming each week starts on day 0, 7, 14, etc.
652
+ weekly_hours = schedule_df.groupby(['week', 'staff_id'])['duration'].sum().reset_index()
653
+
654
+ # Create staff labels
655
+ weekly_hours['staff_label'] = weekly_hours.apply(
656
+ lambda x: f"Staff {x['staff_id']} ({x['duration']:.1f}hrs)",
657
+ axis=1
658
+ )
659
+
660
+ # Get unique weeks
661
+ weeks = sorted(weekly_hours['week'].unique())
662
+
663
+ # Define color palette
664
+ colors = px.colors.qualitative.Set3
665
+
666
+ # Create subplots
667
+ fig = make_subplots(
668
+ rows=len(weeks),
669
+ cols=1,
670
+ subplot_titles=[f'Week {week}' for week in weeks],
671
+ specs=[[{'type': 'domain'}] for week in weeks]
672
+ )
673
+
674
+ # Add pie charts for each week
675
+ for i, week in enumerate(weeks, start=1):
676
+ week_data = weekly_hours[weekly_hours['week'] == week]
677
+
678
+ fig.add_trace(
679
+ go.Pie(
680
+ values=week_data['duration'],
681
+ labels=week_data['staff_label'],
682
+ name=f'Week {week}',
683
+ showlegend=(i == 1),
684
+ marker_colors=colors,
685
+ textposition='inside',
686
+ textinfo='percent+label',
687
+ hovertemplate=(
688
+ "Staff: %{label}<br>"
689
+ "Hours: %{value:.1f}<br>"
690
+ "Percentage: %{percent:.1f}%"
691
+ "<extra></extra>"
692
+ )
693
+ ),
694
+ row=i,
695
+ col=1
696
+ )
697
+
698
+ fig.update_layout(
699
+ height=300 * len(weeks),
700
+ width=800,
701
+ title_text="Weekly Hours Distribution",
702
+ title_x=0.5,
703
+ title_font_size=20,
704
+ margin=dict(t=50, l=50, r=50, b=50),
705
+ showlegend=True
706
+ )
707
+
708
+ return fig.to_html(include_plotlyjs='cdn', full_html=False)
709
+ except Exception as e:
710
+ print(f"Error in weekly charts: {e}")
711
+ return f"<div>Error creating weekly charts: {str(e)}</div>"
712
+
713
+ # Add this new function for creating the overlap visualization
714
+ def create_overlap_visualization(schedule_df):
715
+ """Create Seaborn heatmap for staff overlap"""
716
+ if schedule_df is None or schedule_df.empty:
717
+ return "<div>No data available for visualization</div>"
718
+
719
+ try:
720
+ # Create 24-hour timeline with 30-minute intervals
721
+ intervals = 48 # 24 hours * 2 (30-minute intervals)
722
+ days = sorted(schedule_df['day'].unique())
723
+
724
+ # Initialize overlap matrix
725
+ overlap_data = np.zeros((len(days), intervals))
726
+
727
+ # Calculate overlaps
728
+ for day_idx, day in enumerate(days):
729
+ day_shifts = schedule_df[schedule_df['day'] == day]
730
+
731
+ for i in range(intervals):
732
+ time = i * 0.5
733
+ staff_working = 0
734
+
735
+ for _, shift in day_shifts.iterrows():
736
+ start = shift['start']
737
+ end = shift['end']
738
+
739
+ if end < start: # Overnight shift
740
+ if time >= start or time < end:
741
+ staff_working += 1
742
+ else:
743
+ if start <= time < end:
744
+ staff_working += 1
745
+
746
+ overlap_data[day_idx, i] = staff_working
747
+
748
+ # Create time labels
749
+ time_labels = [f"{int(i//2):02d}:{int((i%2)*30):02d}" for i in range(intervals)]
750
+
751
+ # Create heatmap
752
+ fig, ax = plt.subplots(figsize=(12, 8))
753
+ sns.heatmap(overlap_data, cmap="viridis", ax=ax, cbar_kws={'label': 'Staff Count'})
754
+
755
+ # Set labels
756
+ ax.set_xticks(np.arange(len(time_labels[::4])))
757
+ ax.set_xticklabels(time_labels[::4], rotation=45, ha="right")
758
+ ax.set_yticks(np.arange(len(days)))
759
+ ax.set_yticklabels(days)
760
+
761
+ # Add title
762
+ ax.set_title("Staff Overlap Throughout the Day")
763
+
764
+ # Ensure layout is tight
765
+ plt.tight_layout()
766
+
767
+ # Convert plot to PNG image
768
+ img = io.BytesIO()
769
+ plt.savefig(img, format='png', bbox_inches='tight') # Added bbox_inches='tight'
770
+ plt.close(fig)
771
+ img.seek(0)
772
+
773
+ # Encode to base64
774
+ img_base64 = base64.b64encode(img.read()).decode('utf-8')
775
+ img_html = f'<img src="data:image/png;base64,{img_base64}" style="max-width:100%; max-height:800px;">'
776
+
777
+ return img_html
778
+ except Exception as e:
779
+ print(f"Error in overlap visualization: {e}")
780
+ return f"<div>Error creating overlap visualization: {str(e)}</div>"
781
+
782
+ # Connect the button to the optimization function
783
+ optimize_btn.click(
784
+ fn=optimize_and_display,
785
+ inputs=[
786
+ csv_input, beds_per_staff, max_hours_per_staff, hours_per_cycle,
787
+ rest_days_per_week, clinic_start_ampm, clinic_end_ampm,
788
+ overlap_time, max_start_time_change, exact_staff_count, overtime_percent
789
+ ],
790
+ outputs=[
791
+ staff_assignment_table, # Staff coverage table
792
+ gantt_chart, # Gantt chart
793
+ csv_schedule, # Detailed schedule
794
+ schedule_download_file, # Download file
795
+ constraints_text, # Constraints text
796
+ monthly_chart, # Monthly distribution
797
+ weekly_charts, # Weekly distribution
798
+ overlap_chart # Staff overlap visualization
799
+ ]
800
+ )
801
+
802
+ # Add the handler function
803
+ def handle_absence_click(staff_id, start_day, end_day, current_schedule, max_hours_per_staff, overtime_percent):
804
+ if current_schedule is None or current_schedule.empty:
805
+ return "No current schedule loaded.", None, None
806
+
807
+ absence_dates = list(range(int(start_day), int(end_day) + 1))
808
+ summary, absence_schedule, absence_gantt_path = handle_staff_absence(
809
+ current_schedule,
810
+ int(staff_id),
811
+ absence_dates,
812
+ max_hours_per_staff,
813
+ overtime_percent
814
+ )
815
+
816
+ return summary, absence_schedule, absence_gantt_path
817
+
818
+ # Connect the absence handler button
819
+ handle_absence_btn.click(
820
+ fn=handle_absence_click,
821
+ inputs=[
822
+ absent_staff,
823
+ absence_start,
824
+ absence_end,
825
+ csv_schedule, # Current schedule
826
+ max_hours_per_staff, # Add this parameter
827
+ overtime_percent # Add this parameter
828
+ ],
829
+ outputs=[
830
+ absence_result,
831
+ updated_schedule,
832
+ absence_gantt_chart
833
+ ]
834
+ )
835
+
836
+ # Launch the Gradio app
837
+ iface.launch(share=True)
838
+
839
+ def create_interface():
840
+ with gr.Blocks() as demo:
841
+ gr.Markdown("# NEF Scheduling System")
842
+
843
+ with gr.Tabs() as tabs:
844
+ with gr.Tab("Schedule Input"):
845
+ # Schedule input components
846
+ with gr.Row():
847
+ csv_input = gr.File(label="Upload Schedule Data (CSV)")
848
+ schedule_preview = gr.DataFrame(label="Schedule Preview")
849
+
850
+ with gr.Tab("Schedule Output"):
851
+ # Schedule output components
852
+ with gr.Row():
853
+ schedule_output = gr.DataFrame(label="Generated Schedule")
854
+ download_btn = gr.Button("Download Schedule")
855
+
856
+ with gr.Tab("Constraints and Analytics"):
857
+ with gr.Row():
858
+ with gr.Column():
859
+ gr.Markdown("### Applied Constraints")
860
+ constraints_text = gr.TextArea(label="", interactive=False)
861
+
862
+ with gr.Row():
863
+ with gr.Column():
864
+ gr.Markdown("### Monthly Distribution")
865
+ monthly_chart = gr.HTML(label="Monthly Hours Distribution")
866
+
867
+ with gr.Row():
868
+ with gr.Column():
869
+ gr.Markdown("### Weekly Distribution")
870
+ weekly_charts = gr.HTML(label="Weekly Hours Distribution")
871
+
872
+ with gr.TabItem("Staff Absence Handler"):
873
+ with gr.Row():
874
+ with gr.Column():
875
+ gr.Markdown("### Handle Staff Absence")
876
+ absent_staff = gr.Number(label="Staff ID to be absent", precision=0)
877
+ absence_start = gr.Number(label="Start Day", precision=0)
878
+ absence_end = gr.Number(label="End Day", precision=0)
879
+ handle_absence_btn = gr.Button("Redistribute Shifts", variant="primary")
880
+
881
+ with gr.Column():
882
+ absence_result = gr.TextArea(label="Redistribution Results", interactive=False)
883
+ updated_schedule = gr.DataFrame(label="Updated Schedule")
884
+ absence_gantt_chart = gr.Image(label="Absence Schedule Visualization", elem_id="absence_gantt_chart")
885
+
886
+ return demo
887
+
888
+ def handle_staff_absence(schedule_df, absent_staff_id, absence_dates, max_hours_per_staff, overtime_percent):
889
+ """
890
+ Redistribute shifts of absent staff member to others, prioritizing staff with lowest monthly hours
891
+ """
892
+ try:
893
+ # Create a copy of the original schedule
894
+ new_schedule = schedule_df.copy()
895
+
896
+ # Get shifts that need to be redistributed
897
+ absent_shifts = new_schedule[
898
+ (new_schedule['staff_id'] == absent_staff_id) &
899
+ (new_schedule['day'].isin(absence_dates))
900
+ ]
901
+
902
+ if absent_shifts.empty:
903
+ return "No shifts found for the specified staff member on given dates.", None, None
904
+
905
+ # Get available staff (excluding absent staff)
906
+ available_staff = sorted(list(set(new_schedule['staff_id']) - {absent_staff_id}))
907
+
908
+ # Calculate current hours for each staff member
909
+ current_hours = new_schedule.groupby('staff_id')['duration'].sum()
910
+
911
+ # Sort staff by current hours (ascending) to prioritize those with fewer hours
912
+ staff_hours_sorted = current_hours.reindex(available_staff).sort_values()
913
+ available_staff = staff_hours_sorted.index.tolist()
914
+
915
+ # Calculate remaining available hours for each staff
916
+ max_allowed_hours = max_hours_per_staff * (1 + overtime_percent/100)
917
+ available_hours = {
918
+ staff_id: max_allowed_hours - current_hours.get(staff_id, 0)
919
+ for staff_id in available_staff
920
+ }
921
+
922
+ results = []
923
+ unassigned_shifts = []
924
+
925
+ # Process each shift that needs to be redistributed
926
+ for _, shift in absent_shifts.iterrows():
927
+ # Find eligible staff for this shift, prioritizing those with fewer hours
928
+ eligible_staff = []
929
+ eligible_staff_hours = {}
930
+
931
+ for staff_id in available_staff:
932
+ # Check if staff has enough remaining hours
933
+ if available_hours[staff_id] >= shift['duration']:
934
+ # Check if staff is not already working that day
935
+ staff_shifts_that_day = new_schedule[
936
+ (new_schedule['staff_id'] == staff_id) &
937
+ (new_schedule['day'] == shift['day'])
938
+ ]
939
+
940
+ if staff_shifts_that_day.empty:
941
+ # Check minimum rest period (11 hours)
942
+ day_before = new_schedule[
943
+ (new_schedule['staff_id'] == staff_id) &
944
+ (new_schedule['day'] == shift['day'] - 1)
945
+ ]
946
+
947
+ day_after = new_schedule[
948
+ (new_schedule['staff_id'] == staff_id) &
949
+ (new_schedule['day'] == shift['day'] + 1)
950
+ ]
951
+
952
+ can_work = True
953
+ if not day_before.empty:
954
+ end_time_before = day_before.iloc[0]['end']
955
+ if (shift['start'] + 24 - end_time_before) < 11:
956
+ can_work = False
957
+
958
+ if not day_after.empty and can_work:
959
+ start_time_after = day_after.iloc[0]['start']
960
+ if (starttime_after + 24 - shift['end']) < 11:
961
+ can_work = False
962
+
963
+ if can_work:
964
+ eligible_staff.append(staff_id)
965
+ eligible_staff_hours[staff_id] = current_hours.get(staff_id, 0)
966
+
967
+ if eligible_staff:
968
+ # Sort eligible staff by current hours to prioritize those with fewer hours
969
+ sorted_eligible = sorted(eligible_staff, key=lambda x: eligible_staff_hours[x])
970
+ best_staff = sorted_eligible[0] # Select staff with lowest hours
971
+
972
+ # Update the schedule
973
+ new_schedule.loc[shift.name, 'staff_id'] = best_staff
974
+
975
+ # Update available hours and current hours
976
+ available_hours[best_staff] -= shift['duration']
977
+ current_hours[best_staff] = current_hours.get(best_staff, 0) + shift['duration']
978
+
979
+ results.append(
980
+ f"Shift on Day {shift['day']} ({shift['duration']} hours) "
981
+ f"reassigned to Staff {best_staff} (current hours: {current_hours[best_staff]:.1f})"
982
+ )
983
+ else:
984
+ unassigned_shifts.append(
985
+ f"Could not reassign shift on Day {shift['day']} ({shift['duration']} hours)"
986
+ )
987
+
988
+ # Generate detailed summary with hours distribution
989
+ summary = "\n".join([
990
+ "Shift Redistribution Summary:",
991
+ "----------------------------",
992
+ f"Staff {absent_staff_id} absent for {len(absence_dates)} days",
993
+ f"Successfully reassigned: {len(results)} shifts",
994
+ f"Failed to reassign: {len(unassigned_shifts)} shifts",
995
+ "\nCurrent Hours Distribution:",
996
+ "-------------------------"
997
+ ] + [
998
+ f"Staff {s}: {current_hours.get(s, 0):.1f} hours (of max {max_allowed_hours:.1f})"
999
+ for s in sorted(available_staff)
1000
+ ] + [
1001
+ "\nReassignment Details:",
1002
+ *results,
1003
+ "\nUnassigned Shifts:",
1004
+ *unassigned_shifts
1005
+ ])
1006
+
1007
+ # Filter the schedule for the absence period
1008
+ absence_schedule = new_schedule[new_schedule['day'].isin(absence_dates)].copy()
1009
+
1010
+ # Create a Gantt chart for the absence period
1011
+ absence_gantt_path = create_gantt_chart(absence_schedule, len(absence_dates), len(set(absence_schedule['staff_id'])))
1012
+
1013
+ if unassigned_shifts:
1014
+ return summary, None, None
1015
+ else:
1016
+ return summary, absence_schedule, absence_gantt_path
1017
+
1018
+ except Exception as e:
1019
+ return f"Error redistributing shifts: {str(e)}", None, None
1020
+
1021
+ class FastScheduler:
1022
+ def __init__(self, num_staff, num_days, possible_shifts, staff_requirements, constraints):
1023
+ self.num_staff = num_staff
1024
+ self.num_days = num_days
1025
+ self.possible_shifts = possible_shifts
1026
+ self.staff_requirements = staff_requirements
1027
+ self.constraints = constraints
1028
+ self.best_schedule = None
1029
+ self.best_score = float('inf')
1030
+ # Pre-compute shift lookups for faster access
1031
+ self.shift_lookup = {shift['id']: shift for shift in possible_shifts}
1032
+ self.cycle_shifts = self._precompute_cycle_shifts()
1033
+ # Track staff state
1034
+ self.staff_sequences = {}
1035
+ self.staff_hours = {}
1036
+ self.max_monthly_hours = constraints['max_hours_per_staff']
1037
+
1038
+ def _precompute_cycle_shifts(self):
1039
+ """Pre-compute which shifts can cover each cycle"""
1040
+ cycle_shifts = {}
1041
+ for cycle in self.staff_requirements[0].keys():
1042
+ cycle_shifts[cycle] = [shift for shift in self.possible_shifts if cycle in shift['cycles_covered']]
1043
+ return cycle_shifts
1044
+
1045
+ def optimize(self, time_limit=300):
1046
+ """Main optimization method"""
1047
+ start_time = time.time()
1048
+ schedule = []
1049
+
1050
+ # Process each day
1051
+ for day in range(1, self.num_days + 1):
1052
+ # Get requirements for this day
1053
+ day_requirements = self.staff_requirements[day-1]
1054
+
1055
+ # Process each cycle
1056
+ for cycle, staff_needed in day_requirements.items():
1057
+ staff_assigned = 0
1058
+
1059
+ # Try each staff member until we meet the requirement
1060
+ for staff_id in range(1, self.num_staff + 1):
1061
+ # Check if we've met the requirement
1062
+ if staff_assigned >= staff_needed:
1063
+ break
1064
+
1065
+ # Check if we're out of time
1066
+ if time.time() - start_time > time_limit:
1067
+ return None
1068
+
1069
+ # Try to assign a shift
1070
+ shift = self._find_optimal_shift(staff_id, day, cycle, self.staff_hours)
1071
+ if shift:
1072
+ schedule.append(shift)
1073
+ staff_assigned += 1
1074
+
1075
+ # Validate the schedule after each day
1076
+ score = self._evaluate_schedule(schedule)
1077
+ if score == float('inf'):
1078
+ return None
1079
+
1080
+ # Final validation
1081
+ final_score = self._evaluate_schedule(schedule)
1082
+ if final_score == float('inf'):
1083
+ return None
1084
+
1085
+ return schedule
1086
+
1087
+ def _find_optimal_shift(self, staff_id, day, cycle, staff_hours):
1088
+ """Optimized shift finding with early exits and pre-computed lookups"""
1089
+ # Quick access to staff's current state
1090
+ staff_info = self.staff_sequences.get(staff_id)
1091
+ current_hours = self.staff_hours.get(staff_id, 0)
1092
+
1093
+ # Early exit if staff has reached maximum hours
1094
+ if current_hours >= self.max_monthly_hours:
1095
+ return None
1096
+
1097
+ # Use pre-computed valid shifts for this cycle
1098
+ valid_shifts = self.cycle_shifts.get(cycle, [])
1099
+ if not valid_shifts:
1100
+ return None
1101
+
1102
+ # Quick consecutive days check
1103
+ if staff_info and staff_info.get('consecutive_days', 0) >= 6 and day - staff_info['last_day'] == 1:
1104
+ return None
1105
+
1106
+ # Filter shifts based on timing consistency (highest priority)
1107
+ if staff_info and day - staff_info['last_day'] == 1:
1108
+ required_start = staff_info['last_time']
1109
+ valid_shifts = [s for s in valid_shifts if s['start'] == required_start]
1110
+ if not valid_shifts:
1111
+ return None
1112
+
1113
+ # Quick hours check
1114
+ valid_shifts = [s for s in valid_shifts if current_hours + s['duration'] <= self.max_monthly_hours]
1115
+ if not valid_shifts:
1116
+ return None
1117
+
1118
+ # Check if staff already has a shift this day
1119
+ if any(s['staff_id'] == staff_id and s['day'] == day for s in self.best_schedule or []):
1120
+ return None
1121
+
1122
+ # Select first valid shift (optimization over finding "best" shift)
1123
+ shift = valid_shifts[0]
1124
+ assigned_shift = {
1125
+ 'staff_id': staff_id,
1126
+ 'day': day,
1127
+ 'shift_id': shift['id'],
1128
+ 'start': shift['start'],
1129
+ 'end': shift['end'],
1130
+ 'duration': shift['duration'],
1131
+ 'cycles_covered': list(shift['cycles_covered'])
1132
+ }
1133
+
1134
+ # Update staff tracking
1135
+ consecutive_days = 1 if not staff_info else (
1136
+ staff_info['consecutive_days'] + 1 if day - staff_info['last_day'] == 1 else 1
1137
+ )
1138
+
1139
+ self.staff_sequences[staff_id] = {
1140
+ 'last_day': day,
1141
+ 'last_time': shift['start'],
1142
+ 'consecutive_days': consecutive_days
1143
+ }
1144
+ self.staff_hours[staff_id] = current_hours + shift['duration']
1145
+
1146
+ return assigned_shift
1147
+
1148
+ def _evaluate_schedule(self, schedule):
1149
+ """Optimized schedule evaluation with early exits"""
1150
+ if not schedule:
1151
+ return float('inf')
1152
+
1153
+ # Pre-compute staff shifts dictionary
1154
+ staff_shifts = {}
1155
+ for shift in schedule:
1156
+ staff_id = shift['staff_id']
1157
+ if staff_id not in staff_shifts:
1158
+ staff_shifts[staff_id] = []
1159
+ staff_shifts[staff_id].append(shift)
1160
+
1161
+ # Early exit on hours violation
1162
+ if self.staff_hours.get(staff_id, 0) > self.max_monthly_hours:
1163
+ return float('inf')
1164
+
1165
+ # Quick timing consistency check with early exit
1166
+ for shifts in staff_shifts.values():
1167
+ shifts.sort(key=lambda x: x['day'])
1168
+ for i in range(1, len(shifts)):
1169
+ if (shifts[i]['day'] - shifts[i-1]['day'] == 1 and
1170
+ shifts[i]['start'] != shifts[i-1]['start']):
1171
+ return float('inf')
1172
+
1173
+ # Coverage check with early exit
1174
+ coverage = self._check_coverage_requirements(schedule)
1175
+ if coverage > 0:
1176
+ return float('inf')
1177
+
1178
+ return 0 # Valid schedule found
1179
+
1180
+ def _check_coverage_requirements(self, schedule):
1181
+ """Optimized coverage check using pre-computed data"""
1182
+ day_cycle_coverage = {}
1183
+
1184
+ # Pre-compute coverage needs
1185
+ for shift in schedule:
1186
+ day = shift['day']
1187
+ if day not in day_cycle_coverage:
1188
+ day_cycle_coverage[day] = {cycle: 0 for cycle in self.staff_requirements[0].keys()}
1189
+
1190
+ for cycle in shift['cycles_covered']:
1191
+ day_cycle_coverage[day][cycle] += 1
1192
+
1193
+ # Check coverage
1194
+ violations = 0
1195
+ for day in range(1, self.num_days + 1):
1196
+ if day not in day_cycle_coverage:
1197
+ return float('inf') # Missing day coverage
1198
+
1199
+ day_coverage = day_cycle_coverage[day]
1200
+ required = self.staff_requirements[day-1]
1201
+
1202
+ for cycle, needed in required.items():
1203
+ if day_coverage[cycle] < needed:
1204
+ violations += needed - day_coverage[cycle]
1205
+ if violations > 0: # Early exit on any violation
1206
+ return violations
1207
+
1208
+ return violations
1209
+
1210
+ def reset(self):
1211
+ """Reset the scheduler state"""
1212
+ self.best_schedule = None
1213
+ self.best_score = float('inf')
1214
+ self.staff_sequences = {}
1215
+ self.staff_hours = {}
1216
+
1217
+ def process_solution(solution, min_staff, num_days):
1218
+ try:
1219
+ # Create schedule from solution
1220
+ schedule = []
1221
+
1222
+ for s in range(min_staff):
1223
+ for d in range(num_days):
1224
+ # Find continuous blocks of working hours
1225
+ working_hours = []
1226
+ for h in range(24):
1227
+ if solution[s,d,h] == 1:
1228
+ working_hours.append(h)
1229
+
1230
+ if working_hours:
1231
+ # Find shift blocks
1232
+ shift_start = working_hours[0]
1233
+ shift_end = working_hours[-1] + 1
1234
+
1235
+ schedule.append({
1236
+ 'staff_id': s + 1,
1237
+ 'day': d + 1,
1238
+ 'start': shift_start,
1239
+ 'end': shift_end,
1240
+ 'duration': shift_end - shift_start
1241
+ })
1242
+
1243
+ if schedule:
1244
+ schedule_df = pd.DataFrame(schedule)
1245
+
1246
+ # Create staff assignment table
1247
+ staff_assignment = {}
1248
+ for s in range(min_staff):
1249
+ staff_assignment[f'Staff {s+1}'] = []
1250
+ for d in range(num_days):
1251
+ hours = sum(solution[s,d,h] for h in range(24))
1252
+ staff_assignment_df = pd.DataFrame(staff_assignment)
1253
+ staff_assignment_df.index = [f'Day {d+1}' for d in range(num_days)]
1254
+
1255
+ # Create visualizations
1256
+ gantt_path = create_gantt_chart(schedule_df, num_days, min_staff)
1257
+
1258
+ # Create downloadable files
1259
+ schedule_csv = schedule_df.to_csv(index=False)
1260
+ staff_csv = staff_assignment_df.to_csv()
1261
+
1262
+ return (
1263
+ staff_assignment_df,
1264
+ gantt_path,
1265
+ schedule_df,
1266
+ None, # plot_path not used
1267
+ schedule_csv,
1268
+ staff_csv
1269
+ )
1270
+ else:
1271
+ raise ValueError("No valid schedule found in solution")
1272
+
1273
+ except Exception as e:
1274
+ print(f"Error processing solution: {str(e)}")
1275
+ return None