anujkum0x commited on
Commit
7481eb1
·
verified ·
1 Parent(s): c4313c2

Update old.py

Browse files
Files changed (1) hide show
  1. old.py +912 -0
old.py CHANGED
@@ -0,0 +1,912 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
13
+ def am_pm(hour):
14
+ """Converts 24-hour time to AM/PM format."""
15
+ period = "AM"
16
+ if hour >= 12:
17
+ period = "PM"
18
+ if hour > 12:
19
+ hour -= 12
20
+ elif hour == 0:
21
+ hour = 12 # Midnight
22
+ return f"{int(hour):02d}:00 {period}"
23
+
24
+ def show_dataframe(csv_path):
25
+ """Reads a CSV file and returns a Pandas DataFrame."""
26
+ try:
27
+ df = pd.read_csv(csv_path)
28
+ return df
29
+ except Exception as e:
30
+ return f"Error loading CSV: {e}"
31
+
32
+ def optimize_staffing(
33
+ csv_file,
34
+ beds_per_staff,
35
+ max_hours_per_staff, # This will now be interpreted as hours per 28-day period
36
+ hours_per_cycle,
37
+ rest_days_per_week,
38
+ clinic_start,
39
+ clinic_end,
40
+ overlap_time,
41
+ max_start_time_change,
42
+ exact_staff_count=None,
43
+ overtime_percent=100
44
+ ):
45
+ # Load data
46
+ try:
47
+ if isinstance(csv_file, str):
48
+ # Handle the case when a filepath is passed directly
49
+ data = pd.read_csv(csv_file)
50
+ else:
51
+ # Handle the case when file object is uploaded through Gradio
52
+ data = pd.read_csv(io.StringIO(csv_file.decode('utf-8')))
53
+ except Exception as e:
54
+ print(f"Error loading CSV file: {e}")
55
+ return f"Error loading CSV file: {e}", None, None, None, None
56
+
57
+ # Rename the index column if necessary
58
+ if data.columns[0] not in ['day', 'Day', 'DAY']:
59
+ data = data.rename(columns={data.columns[0]: 'day'})
60
+
61
+ # Fill missing values
62
+ for col in data.columns:
63
+ if col.startswith('cycle'):
64
+ data[col] = data[col].fillna(0)
65
+
66
+ # Calculate clinic hours
67
+ if clinic_end < clinic_start: # overnight clinic (e.g., 7 AM to 3 AM next day)
68
+ clinic_hours = 24 - clinic_start + clinic_end
69
+ else:
70
+ clinic_hours = clinic_end - clinic_start
71
+
72
+ # Get number of days in the dataset
73
+ num_days = len(data)
74
+
75
+ # Parameters
76
+ BEDS_PER_STAFF = float(beds_per_staff)
77
+ STANDARD_PERIOD_DAYS = 30 # Standard 4-week period
78
+
79
+ # Scale MAX_HOURS_PER_STAFF based on the ratio of actual days to standard period
80
+ BASE_MAX_HOURS = float(max_hours_per_staff) # This is for a 28-day period
81
+ MAX_HOURS_PER_STAFF = BASE_MAX_HOURS * (num_days / STANDARD_PERIOD_DAYS)
82
+
83
+ # Log the adjustment for transparency
84
+ original_results = f"Input max hours per staff (28-day period): {BASE_MAX_HOURS}\n"
85
+ original_results += f"Adjusted max hours for {num_days}-day period: {MAX_HOURS_PER_STAFF:.1f}\n\n"
86
+
87
+ HOURS_PER_CYCLE = float(hours_per_cycle)
88
+ REST_DAYS_PER_WEEK = int(rest_days_per_week)
89
+ SHIFT_TYPES = [6, 8, 10, 12] # Standard shift types
90
+ OVERLAP_TIME = float(overlap_time)
91
+ CLINIC_START = int(clinic_start)
92
+ CLINIC_END = int(clinic_end)
93
+ CLINIC_HOURS = clinic_hours
94
+ MAX_START_TIME_CHANGE = int(max_start_time_change)
95
+ OVERTIME_ALLOWED = 1 + (overtime_percent / 100) # Convert percentage to multiplier
96
+
97
+ # Calculate staff needed per cycle (beds/BEDS_PER_STAFF, rounded up)
98
+ for col in data.columns:
99
+ if col.startswith('cycle') and not col.endswith('_staff'):
100
+ data[f'{col}_staff'] = np.ceil(data[col] / BEDS_PER_STAFF)
101
+
102
+ # Get cycle names and number of cycles
103
+ cycle_cols = [col for col in data.columns if col.startswith('cycle') and not col.endswith('_staff')]
104
+ num_cycles = len(cycle_cols)
105
+
106
+ # Define cycle times - adjusted for overnight clinic
107
+ cycle_times = {}
108
+ for i, cycle in enumerate(cycle_cols):
109
+ # Ensure first cycle starts exactly at clinic start time
110
+ cycle_start = CLINIC_START if i == 0 else (CLINIC_START + i * HOURS_PER_CYCLE) % 24
111
+ cycle_end = (cycle_start + HOURS_PER_CYCLE) % 24
112
+ cycle_times[cycle] = (cycle_start, cycle_end)
113
+
114
+ # Get staff requirements
115
+ max_staff_needed = max([data[f'{cycle}_staff'].max() for cycle in cycle_cols])
116
+
117
+ # Define possible shift start times for overnight clinic
118
+ shift_start_times = []
119
+ if CLINIC_END < CLINIC_START: # overnight clinic
120
+ # Always include clinic start time first to ensure coverage
121
+ shift_start_times.append(CLINIC_START)
122
+ # Add remaining morning shifts
123
+ shift_start_times.extend([t for t in range(CLINIC_START + 1, 24)])
124
+ # Add evening shifts that end next day
125
+ shift_start_times.extend(range(0, CLINIC_END + 1))
126
+ else:
127
+ # Always include clinic start time first
128
+ shift_start_times.append(CLINIC_START)
129
+ # Add remaining times
130
+ shift_start_times.extend([t for t in range(CLINIC_START + 1, CLINIC_END - min(SHIFT_TYPES) + 1)])
131
+
132
+ # Generate all possible shifts with better overnight handling
133
+ possible_shifts = []
134
+ # First generate shifts starting at clinic start time
135
+ for duration in sorted(SHIFT_TYPES, reverse=True): # Try longer shifts first
136
+ start_time = CLINIC_START
137
+ end_time = (start_time + duration) % 24
138
+
139
+ shift = {
140
+ 'id': f"{duration}hr_{start_time:02d}",
141
+ 'start': start_time,
142
+ 'end': end_time,
143
+ 'duration': duration,
144
+ 'cycles_covered': set()
145
+ }
146
+
147
+ # Determine which cycles this shift covers
148
+ for cycle, (cycle_start, cycle_end) in cycle_times.items():
149
+ # Handle overnight cycles
150
+ if cycle_end < cycle_start: # overnight cycle
151
+ if start_time >= cycle_start or end_time <= cycle_end:
152
+ shift['cycles_covered'].add(cycle)
153
+ elif start_time < end_time and end_time > cycle_start:
154
+ shift['cycles_covered'].add(cycle)
155
+ elif end_time < start_time and (start_time < cycle_end or end_time > cycle_start):
156
+ shift['cycles_covered'].add(cycle)
157
+ else: # normal cycle
158
+ shift_end = end_time if end_time > start_time else end_time + 24
159
+ cycle_end_adj = cycle_end if cycle_end > cycle_start else cycle_end + 24
160
+
161
+ # Check for overlap
162
+ if not (shift_end <= cycle_start or start_time >= cycle_end_adj):
163
+ shift['cycles_covered'].add(cycle)
164
+
165
+ if shift['cycles_covered']: # Only add shifts that cover at least one cycle
166
+ possible_shifts.append(shift)
167
+
168
+ # Then generate remaining shifts
169
+ for duration in SHIFT_TYPES:
170
+ for start_time in shift_start_times:
171
+ if start_time == CLINIC_START: # Skip as we already handled clinic start time
172
+ continue
173
+
174
+ end_time = (start_time + duration) % 24
175
+
176
+ # Skip shifts that don't align with clinic hours
177
+ if CLINIC_END < CLINIC_START: # overnight clinic
178
+ if start_time < CLINIC_START and start_time > CLINIC_END:
179
+ continue
180
+ if (start_time + duration) % 24 < CLINIC_START and (start_time + duration) % 24 > CLINIC_END:
181
+ continue
182
+ else:
183
+ if start_time < CLINIC_START or end_time > CLINIC_END:
184
+ continue
185
+
186
+ shift = {
187
+ 'id': f"{duration}hr_{start_time:02d}",
188
+ 'start': start_time,
189
+ 'end': end_time,
190
+ 'duration': duration,
191
+ 'cycles_covered': set()
192
+ }
193
+
194
+ # Determine which cycles this shift covers
195
+ for cycle, (cycle_start, cycle_end) in cycle_times.items():
196
+ if cycle_end < cycle_start: # overnight cycle
197
+ if start_time >= cycle_start or end_time <= cycle_end:
198
+ shift['cycles_covered'].add(cycle)
199
+ elif start_time < end_time and end_time > cycle_start:
200
+ shift['cycles_covered'].add(cycle)
201
+ elif end_time < start_time and (start_time < cycle_end or end_time > cycle_start):
202
+ shift['cycles_covered'].add(cycle)
203
+ else: # normal cycle
204
+ shift_end = end_time if end_time > start_time else end_time + 24
205
+ cycle_end_adj = cycle_end if cycle_end > cycle_start else cycle_end + 24
206
+
207
+ if not (shift_end <= cycle_start or start_time >= cycle_end_adj):
208
+ shift['cycles_covered'].add(cycle)
209
+
210
+ if shift['cycles_covered']: # Only add shifts that cover at least one cycle
211
+ possible_shifts.append(shift)
212
+
213
+ # Estimate minimum number of staff needed - more precise calculation
214
+ total_staff_hours = 0
215
+ for _, row in data.iterrows():
216
+ for cycle in cycle_cols:
217
+ total_staff_hours += row[f'{cycle}_staff'] * HOURS_PER_CYCLE
218
+
219
+ # Calculate theoretical minimum staff with perfect utilization
220
+ theoretical_min_staff = np.ceil(total_staff_hours / MAX_HOURS_PER_STAFF)
221
+
222
+ # Add a small buffer for rest day constraints
223
+ min_staff_estimate = np.ceil(theoretical_min_staff * (7 / (7 - REST_DAYS_PER_WEEK)))
224
+
225
+ # Use exact_staff_count if provided, otherwise estimate
226
+ if exact_staff_count is not None and exact_staff_count > 0:
227
+ # When exact staff count is provided, only create that many staff in the model
228
+ estimated_staff = exact_staff_count
229
+ num_staff_to_create = exact_staff_count # Only create exactly this many staff
230
+ else:
231
+ # Add some buffer for constraints like rest days and shift changes
232
+ estimated_staff = max(min_staff_estimate, max_staff_needed + 1)
233
+ num_staff_to_create = int(estimated_staff) # Create the estimated number of staff
234
+
235
+ def optimize_schedule(num_staff, time_limit=600):
236
+ try:
237
+ # Create a binary linear programming model
238
+ model = pl.LpProblem("Staff_Scheduling", pl.LpMinimize)
239
+
240
+ # Decision variables
241
+ x = pl.LpVariable.dicts("shift",
242
+ [(s, d, shift['id']) for s in range(1, num_staff+1)
243
+ for d in range(1, num_days+1)
244
+ for shift in possible_shifts],
245
+ cat='Binary')
246
+
247
+ # Staff usage variable (1 if staff s is used at all, 0 otherwise)
248
+ staff_used = pl.LpVariable.dicts("staff_used", range(1, num_staff+1), cat='Binary')
249
+
250
+ # Total hours worked by all staff
251
+ total_hours = pl.LpVariable("total_hours", lowBound=0)
252
+
253
+ # CRITICAL CHANGE: Remove coverage violation variables - make coverage a hard constraint
254
+ # CRITICAL CHANGE: Remove overtime variables - make overtime a hard constraint
255
+
256
+ # Objective function now only focuses on minimizing staff count and total hours
257
+ model += (
258
+ 10**10 * pl.lpSum(staff_used[s] for s in range(1, num_staff+1)) +
259
+ 1 * total_hours
260
+ )
261
+
262
+ # Link total_hours to the sum of all hours worked
263
+ model += total_hours == pl.lpSum(x[(s, d, shift['id'])] * shift['duration']
264
+ for s in range(1, num_staff+1)
265
+ for d in range(1, num_days+1)
266
+ for shift in possible_shifts)
267
+
268
+ # Link staff_used variable with shift assignments
269
+ for s in range(1, num_staff+1):
270
+ model += pl.lpSum(x[(s, d, shift['id'])]
271
+ for d in range(1, num_days+1)
272
+ for shift in possible_shifts) <= num_days * staff_used[s]
273
+
274
+ # If staff is used, they must work at least one shift
275
+ model += pl.lpSum(x[(s, d, shift['id'])]
276
+ for d in range(1, num_days+1)
277
+ for shift in possible_shifts) >= staff_used[s]
278
+
279
+ # Maintain staff ordering (to avoid symmetrical solutions)
280
+ for s in range(1, num_staff):
281
+ model += staff_used[s] >= staff_used[s+1]
282
+
283
+ # Each staff works at most one shift per day
284
+ for s in range(1, num_staff+1):
285
+ for d in range(1, num_days+1):
286
+ model += pl.lpSum(x[(s, d, shift['id'])] for shift in possible_shifts) <= 1
287
+
288
+ # Rest day constraints (with some flexibility)
289
+ min_rest_days = max(1, REST_DAYS_PER_WEEK - 1)
290
+ for s in range(1, num_staff+1):
291
+ for w in range((num_days + 6) // 7):
292
+ week_start = w*7 + 1
293
+ week_end = min(week_start + 6, num_days)
294
+ days_in_this_week = week_end - week_start + 1
295
+
296
+ if days_in_this_week < 7:
297
+ adjusted_rest_days = max(1, int(min_rest_days * days_in_this_week / 7))
298
+ else:
299
+ adjusted_rest_days = min_rest_days
300
+
301
+ model += pl.lpSum(x[(s, d, shift['id'])]
302
+ for d in range(week_start, week_end+1)
303
+ for shift in possible_shifts) <= days_in_this_week - adjusted_rest_days
304
+
305
+ # HARD CONSTRAINT: No overtime allowed - strict limit at MAX_HOURS_PER_STAFF
306
+ for s in range(1, num_staff+1):
307
+ # Calculate total hours worked by this staff
308
+ staff_hours = pl.lpSum(x[(s, d, shift['id'])] * shift['duration']
309
+ for d in range(1, num_days+1)
310
+ for shift in possible_shifts)
311
+
312
+ # STRICT constraint: No overtime allowed
313
+ model += staff_hours <= MAX_HOURS_PER_STAFF
314
+
315
+ # HARD CONSTRAINT: Full coverage required
316
+ for d in range(1, num_days+1):
317
+ day_index = d - 1 # 0-indexed for DataFrame
318
+
319
+ for cycle in cycle_cols:
320
+ staff_needed = data.iloc[day_index][f'{cycle}_staff']
321
+ cycle_start, cycle_end = cycle_times[cycle]
322
+
323
+ # Get all shifts that cover this cycle
324
+ covering_shifts = [shift for shift in possible_shifts if cycle in shift['cycles_covered']]
325
+
326
+ # For the first cycle of the day (starting at clinic start time)
327
+ if cycle_start == CLINIC_START:
328
+ # Only consider shifts that start at clinic start time
329
+ early_shifts = [shift for shift in covering_shifts if shift['start'] == CLINIC_START]
330
+
331
+ # Must have enough staff starting at clinic start time
332
+ model += (pl.lpSum(x[(s, d, shift['id'])]
333
+ for s in range(1, num_staff+1)
334
+ for shift in early_shifts) >= staff_needed)
335
+
336
+ # General coverage constraint for all cycles
337
+ model += (pl.lpSum(x[(s, d, shift['id'])]
338
+ for s in range(1, num_staff+1)
339
+ for shift in covering_shifts) >= staff_needed)
340
+
341
+ # HARD CONSTRAINT: Maximum 60 hours per week for each staff
342
+ for s in range(1, num_staff+1):
343
+ for w in range((num_days + 6) // 7):
344
+ week_start = w*7 + 1
345
+ week_end = min(week_start + 6, num_days)
346
+
347
+ # Calculate total hours worked by this staff in this week
348
+ weekly_hours = pl.lpSum(x[(s, d, shift['id'])] * shift['duration']
349
+ for d in range(week_start, week_end+1)
350
+ for shift in possible_shifts)
351
+
352
+ # STRICT constraint: No more than 60 hours per week
353
+ model += weekly_hours <= 60
354
+
355
+ # Solve with extended time limit
356
+ solver = pl.PULP_CBC_CMD(timeLimit=time_limit, msg=1, gapRel=0.01) # Tighter gap for better solutions
357
+ model.solve(solver)
358
+
359
+ # Check if a feasible solution was found
360
+ if model.status == pl.LpStatusOptimal or model.status == pl.LpStatusNotSolved:
361
+ # Extract the solution
362
+ schedule = []
363
+ for s in range(1, num_staff+1):
364
+ for d in range(1, num_days+1):
365
+ for shift in possible_shifts:
366
+ if pl.value(x[(s, d, shift['id'])]) == 1:
367
+ # Find the shift details
368
+ shift_details = next((sh for sh in possible_shifts if sh['id'] == shift['id']), None)
369
+
370
+ schedule.append({
371
+ 'staff_id': s,
372
+ 'day': d,
373
+ 'shift_id': shift['id'],
374
+ 'start': shift_details['start'],
375
+ 'end': shift_details['end'],
376
+ 'duration': shift_details['duration'],
377
+ 'cycles_covered': list(shift_details['cycles_covered'])
378
+ })
379
+
380
+ return schedule, model.objective.value()
381
+ else:
382
+ return None, None
383
+ except Exception as e:
384
+ print(f"Error in optimization: {e}")
385
+ return None, None
386
+
387
+ # Try to solve with estimated number of staff
388
+ if exact_staff_count is not None and exact_staff_count > 0:
389
+ # If exact staff count is specified, only try with that count
390
+ staff_count = int(exact_staff_count)
391
+ results = f"Using exactly {staff_count} staff as specified...\n"
392
+
393
+ # Try to solve with exactly this many staff
394
+ schedule, objective = optimize_schedule(staff_count)
395
+
396
+ if schedule is None:
397
+ results += f"Failed to find a feasible solution with exactly {staff_count} staff.\n"
398
+ results += "Try increasing the staff count.\n"
399
+ return results, None, None, None, None
400
+ else:
401
+ # Start from theoretical minimum and work up
402
+ min_staff = max(1, int(theoretical_min_staff)) # Start from theoretical minimum
403
+ max_staff = int(min_staff_estimate) + 5 # Allow some buffer
404
+
405
+ results = f"Theoretical minimum staff needed: {theoretical_min_staff:.1f}\n"
406
+ results += f"Searching for minimum staff count starting from {min_staff}...\n"
407
+
408
+ # Try each staff count from min to max
409
+ for staff_count in range(min_staff, max_staff + 1):
410
+ results += f"Trying with {staff_count} staff...\n"
411
+
412
+ # Increase time limit for each attempt to give the solver more time
413
+ time_limit = 300 + (staff_count - min_staff) * 100 # More time for larger staff counts
414
+ schedule, objective = optimize_schedule(staff_count, time_limit)
415
+
416
+ if schedule is not None:
417
+ results += f"Found feasible solution with {staff_count} staff.\n"
418
+ break
419
+
420
+ if schedule is None:
421
+ results += "Failed to find a feasible solution with the attempted staff counts.\n"
422
+ results += "Try increasing the staff count manually or relaxing constraints.\n"
423
+ return results, None, None, None, None
424
+
425
+ results += f"Optimal solution found with {staff_count} staff\n"
426
+ results += f"Total staff hours: {objective}\n"
427
+
428
+ # Convert to DataFrame for analysis
429
+ schedule_df = pd.DataFrame(schedule)
430
+
431
+ # Analyze staff workload
432
+ staff_hours = {}
433
+ for s in range(1, staff_count+1):
434
+ staff_shifts = schedule_df[schedule_df['staff_id'] == s]
435
+ total_hours = staff_shifts['duration'].sum()
436
+ staff_hours[s] = total_hours
437
+
438
+ # After calculating staff hours, filter out staff with 0 hours before displaying
439
+ active_staff_hours = {s: hours for s, hours in staff_hours.items() if hours > 0}
440
+
441
+ results += "\nStaff Hours:\n"
442
+ for staff_id, hours in active_staff_hours.items():
443
+ utilization = (hours / MAX_HOURS_PER_STAFF) * 100
444
+ results += f"Staff {staff_id}: {hours} hours ({utilization:.1f}% utilization)\n"
445
+ # Add overtime information
446
+ if hours > MAX_HOURS_PER_STAFF:
447
+ overtime = hours - MAX_HOURS_PER_STAFF
448
+ overtime_percent = (overtime / MAX_HOURS_PER_STAFF) * 100
449
+ results += f" Overtime: {overtime:.1f} hours ({overtime_percent:.1f}%)\n"
450
+
451
+ # Use active_staff_hours for average utilization calculation
452
+ active_staff_count = len(active_staff_hours)
453
+ avg_utilization = sum(active_staff_hours.values()) / (active_staff_count * MAX_HOURS_PER_STAFF) * 100
454
+ results += f"\nAverage staff utilization: {avg_utilization:.1f}%\n"
455
+
456
+ # Check coverage for each day and cycle
457
+ coverage_check = []
458
+ for d in range(1, num_days+1):
459
+ day_index = d - 1 # 0-indexed for DataFrame
460
+
461
+ day_schedule = schedule_df[schedule_df['day'] == d]
462
+
463
+ for cycle in cycle_cols:
464
+ required = data.iloc[day_index][f'{cycle}_staff']
465
+
466
+ # Count staff covering this cycle
467
+ assigned = sum(1 for _, shift in day_schedule.iterrows()
468
+ if cycle in shift['cycles_covered'])
469
+
470
+ coverage_check.append({
471
+ 'day': d,
472
+ 'cycle': cycle,
473
+ 'required': required,
474
+ 'assigned': assigned,
475
+ 'satisfied': assigned >= required
476
+ })
477
+
478
+ coverage_df = pd.DataFrame(coverage_check)
479
+ satisfaction = coverage_df['satisfied'].mean() * 100
480
+ results += f"Coverage satisfaction: {satisfaction:.1f}%\n"
481
+
482
+ if satisfaction < 100:
483
+ results += "Warning: Not all staffing requirements are met!\n"
484
+ unsatisfied = coverage_df[~coverage_df['satisfied']]
485
+ results += unsatisfied.to_string() + "\n"
486
+
487
+ # Generate detailed schedule report
488
+ detailed_schedule = "Detailed Schedule:\n"
489
+ for d in range(1, num_days+1):
490
+ day_schedule = schedule_df[schedule_df['day'] == d]
491
+ day_schedule = day_schedule.sort_values(['start'])
492
+
493
+ detailed_schedule += f"\nDay {d}:\n"
494
+ for _, shift in day_schedule.iterrows():
495
+ start_hour = shift['start']
496
+ end_hour = shift['end']
497
+
498
+ start_str = am_pm(start_hour)
499
+ end_str = am_pm(end_hour)
500
+
501
+ cycles = ", ".join(shift['cycles_covered'])
502
+ detailed_schedule += f" Staff {shift['staff_id']}: {start_str}-{end_str} ({shift['duration']} hrs), Cycles: {cycles}\n"
503
+
504
+ # Generate schedule visualization
505
+ fig, ax = plt.subplots(figsize=(15, 8))
506
+
507
+ # Prepare schedule for plotting
508
+ staff_days = {}
509
+ for s in range(1, staff_count+1):
510
+ staff_days[s] = [0] * num_days # 0 means off duty
511
+
512
+ for _, shift in schedule_df.iterrows():
513
+ staff_id = shift['staff_id']
514
+ day = shift['day'] - 1 # 0-indexed
515
+ staff_days[staff_id][day] = shift['duration']
516
+
517
+ # Plot the schedule
518
+ for s, hours in staff_days.items():
519
+ ax.bar(range(1, num_days+1), hours, label=f'Staff {s}')
520
+
521
+ ax.set_xlabel('Day')
522
+ ax.set_ylabel('Shift Hours')
523
+ ax.set_title('Staff Schedule')
524
+ ax.set_xticks(range(1, num_days+1))
525
+ ax.legend()
526
+
527
+ # Save the figure to a temporary file
528
+ plot_path = None
529
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
530
+ plt.savefig(f.name)
531
+ plt.close(fig)
532
+ plot_path = f.name
533
+
534
+ # Create a Gantt chart with advanced visuals and alternating labels - only showing active staff
535
+ gantt_path = create_gantt_chart(schedule_df, num_days, staff_count)
536
+
537
+ # Convert schedule to CSV data
538
+ schedule_df['start_ampm'] = schedule_df['start'].apply(am_pm)
539
+ schedule_df['end_ampm'] = schedule_df['end'].apply(am_pm)
540
+ schedule_csv = schedule_df[['staff_id', 'day', 'start_ampm', 'end_ampm', 'duration', 'cycles_covered']].to_csv(index=False)
541
+
542
+ # Create a temporary file and write the CSV data into it
543
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".csv") as temp_file:
544
+ temp_file.write(schedule_csv)
545
+ schedule_csv_path = temp_file.name
546
+
547
+ # Create staff assignment table
548
+ staff_assignment_data = []
549
+ for d in range(1, num_days + 1):
550
+ cycle_staff = {}
551
+ for cycle in cycle_cols:
552
+ # Get staff IDs assigned to this cycle on this day
553
+ staff_ids = schedule_df[(schedule_df['day'] == d) & (schedule_df['cycles_covered'].apply(lambda x: cycle in x))]['staff_id'].tolist()
554
+ cycle_staff[cycle] = len(staff_ids)
555
+ staff_assignment_data.append([d] + [cycle_staff[cycle] for cycle in cycle_cols])
556
+
557
+ staff_assignment_df = pd.DataFrame(staff_assignment_data, columns=['Day'] + cycle_cols)
558
+
559
+ # Create CSV files for download
560
+ staff_assignment_csv_path = None
561
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".csv") as temp_file:
562
+ staff_assignment_df.to_csv(temp_file.name, index=False)
563
+ staff_assignment_csv_path = temp_file.name
564
+
565
+ # Return all required values in the correct order
566
+ return results, staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv_path, staff_assignment_csv_path
567
+
568
+ def convert_to_24h(time_str):
569
+ """Converts AM/PM time string to 24-hour format."""
570
+ try:
571
+ time_obj = datetime.strptime(time_str, "%I:00 %p")
572
+ return time_obj.hour
573
+ except ValueError:
574
+ return None
575
+
576
+ def gradio_wrapper(
577
+ csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
578
+ rest_days_per_week, clinic_start_ampm, clinic_end_ampm, overlap_time, max_start_time_change,
579
+ exact_staff_count=None, overtime_percent=100
580
+ ):
581
+ try:
582
+ # Convert AM/PM times to 24-hour format
583
+ clinic_start = convert_to_24h(clinic_start_ampm)
584
+ clinic_end = convert_to_24h(clinic_end_ampm)
585
+
586
+ # Call the optimization function
587
+ results, staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv_path, staff_assignment_csv_path = optimize_staffing(
588
+ csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
589
+ rest_days_per_week, clinic_start, clinic_end, overlap_time, max_start_time_change,
590
+ exact_staff_count, overtime_percent
591
+ )
592
+
593
+ # Return the results
594
+ return staff_assignment_df, gantt_path, schedule_df, plot_path, staff_assignment_csv_path, schedule_csv_path
595
+ except Exception as e:
596
+ # If there's an error in the optimization process, return a meaningful error message
597
+ empty_staff_df = pd.DataFrame(columns=["Day"])
598
+ error_message = f"Error during optimization: {str(e)}\n\nPlease try with different parameters or a simpler dataset."
599
+ # Return error in the first output
600
+ return empty_staff_df, None, None, None, None, None
601
+
602
+ # Create a Gantt chart with advanced visuals and alternating labels - only showing active staff
603
+ def create_gantt_chart(schedule_df, num_days, staff_count):
604
+ # Get the list of active staff IDs (staff who have at least one shift)
605
+ active_staff_ids = sorted(schedule_df['staff_id'].unique())
606
+ active_staff_count = len(active_staff_ids)
607
+
608
+ # Create a mapping from original staff ID to position in the chart
609
+ staff_position = {staff_id: i+1 for i, staff_id in enumerate(active_staff_ids)}
610
+
611
+ # Create a larger figure with higher DPI
612
+ plt.figure(figsize=(max(30, num_days * 1.5), max(12, active_staff_count * 0.8)), dpi=200)
613
+
614
+ # Use a more sophisticated color palette - only for active staff
615
+ colors = plt.cm.viridis(np.linspace(0.1, 0.9, active_staff_count))
616
+
617
+ # Set a modern style
618
+ plt.style.use('seaborn-v0_8-whitegrid')
619
+
620
+ # Create a new axis with a slight background color
621
+ ax = plt.gca()
622
+ ax.set_facecolor('#f8f9fa')
623
+
624
+ # Sort by staff then day
625
+ schedule_df = schedule_df.sort_values(['staff_id', 'day'])
626
+
627
+ # Plot Gantt chart - only for active staff
628
+ for i, staff_id in enumerate(active_staff_ids):
629
+ staff_shifts = schedule_df[schedule_df['staff_id'] == staff_id]
630
+
631
+ y_pos = active_staff_count - i # Position based on index in active staff list
632
+
633
+ # Add staff label with a background box
634
+ ax.text(-0.7, y_pos, f"Staff {staff_id}", fontsize=12, fontweight='bold',
635
+ ha='right', va='center', bbox=dict(facecolor='white', edgecolor='gray',
636
+ boxstyle='round,pad=0.5', alpha=0.9))
637
+
638
+ # Add a subtle background for each staff row
639
+ ax.axhspan(y_pos-0.4, y_pos+0.4, color='white', alpha=0.4, zorder=-5)
640
+
641
+ # Track shift positions to avoid label overlap
642
+ shift_positions = []
643
+
644
+ for idx, shift in enumerate(staff_shifts.iterrows()):
645
+ _, shift = shift
646
+ day = shift['day']
647
+ start_hour = shift['start']
648
+ end_hour = shift['end']
649
+ duration = shift['duration']
650
+
651
+ # Format times for display
652
+ start_ampm = am_pm(start_hour)
653
+ end_ampm = am_pm(end_hour)
654
+
655
+ # Calculate shift position
656
+ shift_start_pos = day-1+start_hour/24
657
+
658
+ # Handle overnight shifts
659
+ if end_hour < start_hour: # Overnight shift
660
+ # First part of shift (until midnight)
661
+ rect1 = ax.barh(y_pos, (24-start_hour)/24, left=shift_start_pos,
662
+ height=0.6, color=colors[i], alpha=0.9,
663
+ edgecolor='black', linewidth=1, zorder=10)
664
+
665
+ # Add gradient effect
666
+ for r in rect1:
667
+ r.set_edgecolor('black')
668
+ r.set_linewidth(1)
669
+
670
+ # Second part of shift (after midnight)
671
+ rect2 = ax.barh(y_pos, end_hour/24, left=day,
672
+ height=0.6, color=colors[i], alpha=0.9,
673
+ edgecolor='black', linewidth=1, zorder=10)
674
+
675
+ # Add gradient effect
676
+ for r in rect2:
677
+ r.set_edgecolor('black')
678
+ r.set_linewidth(1)
679
+
680
+ # For overnight shifts, we'll place the label in the first part if it's long enough
681
+ shift_width = (24-start_hour)/24
682
+ if shift_width >= 0.1: # Only add label if there's enough space
683
+ label_pos = shift_start_pos + shift_width/2
684
+
685
+ # Alternate labels above and below
686
+ y_offset = 0.35 if idx % 2 == 0 else -0.35
687
+
688
+ # Add label with background for better readability
689
+ label = f"{start_ampm}-{end_ampm}"
690
+ text = ax.text(label_pos, y_pos + y_offset, label,
691
+ ha='center', va='center', fontsize=9, fontweight='bold',
692
+ color='black', bbox=dict(facecolor='white', alpha=0.9, pad=3,
693
+ boxstyle='round,pad=0.3', edgecolor='gray'),
694
+ zorder=20)
695
+
696
+ shift_positions.append(label_pos)
697
+ else:
698
+ # Regular shift
699
+ shift_width = duration/24
700
+ rect = ax.barh(y_pos, shift_width, left=shift_start_pos,
701
+ height=0.6, color=colors[i], alpha=0.9,
702
+ edgecolor='black', linewidth=1, zorder=10)
703
+
704
+ # Add gradient effect
705
+ for r in rect:
706
+ r.set_edgecolor('black')
707
+ r.set_linewidth(1)
708
+
709
+ # Only add label if there's enough space
710
+ if shift_width >= 0.1:
711
+ label_pos = shift_start_pos + shift_width/2
712
+
713
+ # Alternate labels above and below
714
+ y_offset = 0.35 if idx % 2 == 0 else -0.35
715
+
716
+ # Add label with background for better readability
717
+ label = f"{start_ampm}-{end_ampm}"
718
+ text = ax.text(label_pos, y_pos + y_offset, label,
719
+ ha='center', va='center', fontsize=9, fontweight='bold',
720
+ color='black', bbox=dict(facecolor='white', alpha=0.9, pad=3,
721
+ boxstyle='round,pad=0.3', edgecolor='gray'),
722
+ zorder=20)
723
+
724
+ shift_positions.append(label_pos)
725
+
726
+ # Add weekend highlighting with a more sophisticated look
727
+ for day in range(1, num_days + 1):
728
+ # Determine if this is a weekend (assuming day 1 is Monday)
729
+ is_weekend = (day % 7 == 0) or (day % 7 == 6) # Saturday or Sunday
730
+
731
+ if is_weekend:
732
+ ax.axvspan(day-1, day, alpha=0.15, color='#ff9999', zorder=-10)
733
+ day_label = "Saturday" if day % 7 == 6 else "Sunday"
734
+ ax.text(day-0.5, 0.2, day_label, ha='center', fontsize=10, color='#cc0000',
735
+ fontweight='bold', bbox=dict(facecolor='white', alpha=0.7, pad=2, boxstyle='round'))
736
+
737
+ # Set x-axis ticks for each day with better formatting
738
+ ax.set_xticks(np.arange(0.5, num_days, 1))
739
+ day_labels = [f"Day {d}" for d in range(1, num_days+1)]
740
+ ax.set_xticklabels(day_labels, rotation=0, ha='center', fontsize=10)
741
+
742
+ # Add vertical lines between days with better styling
743
+ for day in range(1, num_days):
744
+ ax.axvline(x=day, color='#aaaaaa', linestyle='-', alpha=0.5, zorder=-5)
745
+
746
+ # Set y-axis ticks for each staff
747
+ ax.set_yticks(np.arange(1, active_staff_count+1))
748
+ ax.set_yticklabels([]) # Remove default labels as we've added custom ones
749
+
750
+ # Set axis limits with some padding
751
+ ax.set_xlim(-0.8, num_days)
752
+ ax.set_ylim(0.5, active_staff_count + 0.5)
753
+
754
+ # Add grid for hours (every 6 hours) with better styling
755
+ for day in range(num_days):
756
+ for hour in [6, 12, 18]:
757
+ ax.axvline(x=day + hour/24, color='#cccccc', linestyle=':', alpha=0.5, zorder=-5)
758
+ # Add small hour markers at the bottom
759
+ hour_label = "6AM" if hour == 6 else "Noon" if hour == 12 else "6PM"
760
+ ax.text(day + hour/24, 0, hour_label, ha='center', va='bottom', fontsize=7,
761
+ color='#666666', rotation=90, alpha=0.7)
762
+
763
+ # Add title and labels with more sophisticated styling
764
+ plt.title(f'Staff Schedule ({active_staff_count} Active Staff)', fontsize=24, fontweight='bold', pad=20, color='#333333')
765
+ plt.xlabel('Day', fontsize=16, labelpad=10, color='#333333')
766
+
767
+ # Add a legend for time reference with better styling
768
+ time_box = plt.figtext(0.01, 0.01, "Time Reference:", ha='left', fontsize=10,
769
+ fontweight='bold', color='#333333')
770
+ time_markers = ['6 AM', 'Noon', '6 PM', 'Midnight']
771
+ for i, time in enumerate(time_markers):
772
+ plt.figtext(0.08 + i*0.06, 0.01, time, ha='left', fontsize=9, color='#555555')
773
+
774
+ # Remove spines
775
+ for spine in ['top', 'right', 'left']:
776
+ ax.spines[spine].set_visible(False)
777
+
778
+ # Add a note about weekends with better styling
779
+ weekend_note = plt.figtext(0.01, 0.97, "Red areas = Weekends", fontsize=12,
780
+ color='#cc0000', fontweight='bold',
781
+ bbox=dict(facecolor='white', alpha=0.7, pad=5, boxstyle='round'))
782
+
783
+ # Add a subtle border around the entire chart
784
+ plt.box(False)
785
+
786
+ # Save the Gantt chart with high quality
787
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
788
+ plt.tight_layout()
789
+ plt.savefig(f.name, dpi=200, bbox_inches='tight', facecolor='white')
790
+ plt.close()
791
+ return f.name
792
+
793
+ # Define Gradio UI
794
+ 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)]
795
+
796
+ with gr.Blocks(title="Staff Scheduling Optimizer", css="""
797
+ #staff_assignment_table {
798
+ width: 100% !important;
799
+ }
800
+ #csv_schedule {
801
+ width: 100% !important;
802
+ }
803
+ .container {
804
+ max-width: 100% !important;
805
+ padding: 0 !important;
806
+ }
807
+ .download-btn {
808
+ margin-top: 10px !important;
809
+ }
810
+ """) as iface:
811
+
812
+ gr.Markdown("# Staff Scheduling Optimizer")
813
+ gr.Markdown("Upload a CSV file with cycle data and configure parameters to generate an optimal staff schedule.")
814
+
815
+ with gr.Row():
816
+ # LEFT PANEL - Inputs
817
+ with gr.Column(scale=1):
818
+ gr.Markdown("### Input Parameters")
819
+
820
+ # Input parameters
821
+ csv_input = gr.File(label="Upload CSV")
822
+ beds_per_staff = gr.Number(label="Beds per Staff", value=3)
823
+ max_hours_per_staff = gr.Number(label="Maximum monthly hours", value=160)
824
+ hours_per_cycle = gr.Number(label="Hours per Cycle", value=4)
825
+ rest_days_per_week = gr.Number(label="Rest Days per Week", value=2)
826
+ clinic_start_ampm = gr.Dropdown(label="Clinic Start Hour (AM/PM)", choices=am_pm_times, value="08:00 AM")
827
+ clinic_end_ampm = gr.Dropdown(label="Clinic End Hour (AM/PM)", choices=am_pm_times, value="08:00 PM")
828
+ overlap_time = gr.Number(label="Overlap Time", value=0)
829
+ max_start_time_change = gr.Number(label="Max Start Time Change", value=2)
830
+ exact_staff_count = gr.Number(label="Exact Staff Count (optional)", value=None)
831
+ overtime_percent = gr.Slider(label="Overtime Allowed (%)", minimum=0, maximum=100, value=100, step=10)
832
+
833
+ optimize_btn = gr.Button("Optimize Schedule", variant="primary", size="lg")
834
+
835
+ # RIGHT PANEL - Outputs
836
+ with gr.Column(scale=2):
837
+ gr.Markdown("### Results")
838
+
839
+ # Tabs for different outputs - reordered
840
+ with gr.Tabs():
841
+ with gr.TabItem("Detailed Schedule"):
842
+ with gr.Row():
843
+ csv_schedule = gr.Dataframe(label="Detailed Schedule", elem_id="csv_schedule")
844
+
845
+ with gr.Row():
846
+ schedule_download_file = gr.File(label="Download Detailed Schedule", visible=True)
847
+
848
+ with gr.TabItem("Gantt Chart"):
849
+ gantt_chart = gr.Image(label="Staff Schedule Visualization", elem_id="gantt_chart")
850
+
851
+ with gr.TabItem("Staff Coverage by Cycle"):
852
+ with gr.Row():
853
+ staff_assignment_table = gr.Dataframe(label="Staff Count in Each Cycle (Staff May Overlap)", elem_id="staff_assignment_table")
854
+
855
+ with gr.Row():
856
+ staff_download_file = gr.File(label="Download Coverage Table", visible=True)
857
+
858
+ with gr.TabItem("Hours Visualization"):
859
+ schedule_visualization = gr.Image(label="Hours by Day Visualization", elem_id="schedule_visualization")
860
+
861
+ # Define download functions
862
+ def create_download_link(df, filename="data.csv"):
863
+ """Create a CSV download link for a dataframe"""
864
+ if df is None or df.empty:
865
+ return None
866
+
867
+ csv_data = df.to_csv(index=False)
868
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv') as f:
869
+ f.write(csv_data)
870
+ return f.name
871
+
872
+ # Update the optimize_and_display function
873
+ def optimize_and_display(csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
874
+ rest_days_per_week, clinic_start_ampm, clinic_end_ampm,
875
+ overlap_time, max_start_time_change, exact_staff_count, overtime_percent):
876
+ try:
877
+ # Convert AM/PM times to 24-hour format
878
+ clinic_start = convert_to_24h(clinic_start_ampm)
879
+ clinic_end = convert_to_24h(clinic_end_ampm)
880
+
881
+ # Call the optimization function
882
+ results, staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv_path, staff_assignment_csv_path = optimize_staffing(
883
+ csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
884
+ rest_days_per_week, clinic_start, clinic_end, overlap_time, max_start_time_change,
885
+ exact_staff_count, overtime_percent
886
+ )
887
+
888
+ # Return the results
889
+ return staff_assignment_df, gantt_path, schedule_df, plot_path, staff_assignment_csv_path, schedule_csv_path
890
+ except Exception as e:
891
+ # If there's an error in the optimization process, return a meaningful error message
892
+ empty_staff_df = pd.DataFrame(columns=["Day"])
893
+ error_message = f"Error during optimization: {str(e)}\n\nPlease try with different parameters or a simpler dataset."
894
+ # Return error in the first output
895
+ return empty_staff_df, None, None, None, None, None
896
+
897
+ # Connect the button to the optimization function
898
+ optimize_btn.click(
899
+ fn=optimize_and_display,
900
+ inputs=[
901
+ csv_input, 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
+ ],
905
+ outputs=[
906
+ staff_assignment_table, gantt_chart, csv_schedule, schedule_visualization,
907
+ staff_download_file, schedule_download_file
908
+ ]
909
+ )
910
+
911
+ # Launch the Gradio app
912
+ iface.launch(share=True)