anujkum0x commited on
Commit
01f3f69
·
verified ·
1 Parent(s): 44f55e1

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +464 -0
app.py ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 optimize_staffing(
25
+ csv_file,
26
+ beds_per_staff,
27
+ max_hours_per_staff,
28
+ hours_per_cycle,
29
+ rest_days_per_week,
30
+ clinic_start,
31
+ clinic_end,
32
+ overlap_time,
33
+ max_start_time_change
34
+ ):
35
+ # Load data
36
+ try:
37
+ if isinstance(csv_file, str):
38
+ # Handle the case when a filepath is passed directly
39
+ data = pd.read_csv(csv_file)
40
+ else:
41
+ # Handle the case when file object is uploaded through Gradio
42
+ data = pd.read_csv(io.StringIO(csv_file.decode('utf-8')))
43
+ except Exception as e:
44
+ print(f"Error loading CSV file: {e}")
45
+ return f"Error loading CSV file: {e}", None, None, None
46
+
47
+ # Rename the index column if necessary
48
+ if data.columns[0] not in ['day', 'Day', 'DAY']:
49
+ data = data.rename(columns={data.columns[0]: 'day'})
50
+
51
+ # Fill missing values
52
+ for col in data.columns:
53
+ if col.startswith('cycle'):
54
+ data[col].fillna(0, inplace=True)
55
+
56
+ # Calculate clinic hours
57
+ if clinic_end < clinic_start:
58
+ clinic_hours = 24 - clinic_start + clinic_end
59
+ else:
60
+ clinic_hours = clinic_end - clinic_start
61
+
62
+ # Parameters
63
+ BEDS_PER_STAFF = float(beds_per_staff)
64
+ MAX_HOURS_PER_STAFF = float(max_hours_per_staff)
65
+ HOURS_PER_CYCLE = float(hours_per_cycle)
66
+ REST_DAYS_PER_WEEK = int(rest_days_per_week)
67
+ SHIFT_TYPES = [6, 8, 10, 12] # Standard shift types
68
+ OVERLAP_TIME = float(overlap_time)
69
+ CLINIC_START = int(clinic_start)
70
+ CLINIC_END = int(clinic_end)
71
+ CLINIC_HOURS = clinic_hours
72
+ MAX_START_TIME_CHANGE = int(max_start_time_change)
73
+
74
+ # Calculate staff needed per cycle (beds/BEDS_PER_STAFF, rounded up)
75
+ for col in data.columns:
76
+ if col.startswith('cycle') and not col.endswith('_staff'):
77
+ data[f'{col}_staff'] = np.ceil(data[col] / BEDS_PER_STAFF)
78
+
79
+ # Get cycle names and number of cycles
80
+ cycle_cols = [col for col in data.columns if col.startswith('cycle') and not col.endswith('_staff')]
81
+ num_cycles = len(cycle_cols)
82
+
83
+ # Define cycle times
84
+ cycle_times = {}
85
+ for i, cycle in enumerate(cycle_cols):
86
+ cycle_start = (CLINIC_START + i * HOURS_PER_CYCLE) % 24
87
+ cycle_end = (CLINIC_START + (i + 1) * HOURS_PER_CYCLE) % 24
88
+ cycle_times[cycle] = (cycle_start, cycle_end)
89
+
90
+ # Get staff requirements
91
+ max_staff_needed = max([data[f'{cycle}_staff'].max() for cycle in cycle_cols])
92
+
93
+ # Define possible shift start times
94
+ shift_start_times = list(range(CLINIC_START, CLINIC_START + int(CLINIC_HOURS) - min(SHIFT_TYPES) + 1))
95
+
96
+ # Generate all possible shifts
97
+ possible_shifts = []
98
+ for duration in SHIFT_TYPES:
99
+ for start_time in shift_start_times:
100
+ end_time = (start_time + duration) % 24
101
+
102
+ # Create a shift with its coverage of cycles
103
+ shift = {
104
+ 'id': f"{duration}hr_{start_time:02d}",
105
+ 'start': start_time,
106
+ 'end': end_time,
107
+ 'duration': duration,
108
+ 'cycles_covered': set()
109
+ }
110
+
111
+ # Determine which cycles this shift covers
112
+ for cycle, (cycle_start, cycle_end) in cycle_times.items():
113
+ # Handle overnight cycles
114
+ if cycle_end < cycle_start: # overnight cycle
115
+ if start_time >= cycle_start or end_time <= cycle_end or (start_time < end_time and end_time > cycle_start):
116
+ shift['cycles_covered'].add(cycle)
117
+ else: # normal cycle
118
+ shift_end = end_time if end_time > start_time else end_time + 24
119
+ cycle_end_adj = cycle_end if cycle_end > cycle_start else cycle_end + 24
120
+
121
+ # Check for overlap
122
+ if not (shift_end <= cycle_start or start_time >= cycle_end_adj):
123
+ shift['cycles_covered'].add(cycle)
124
+
125
+ if shift['cycles_covered']: # Only add shifts that cover at least one cycle
126
+ possible_shifts.append(shift)
127
+
128
+ # Estimate minimum number of staff needed
129
+ total_staff_hours = 0
130
+ for _, row in data.iterrows():
131
+ for cycle in cycle_cols:
132
+ total_staff_hours += row[f'{cycle}_staff'] * HOURS_PER_CYCLE
133
+
134
+ min_staff_estimate = np.ceil(total_staff_hours / MAX_HOURS_PER_STAFF)
135
+
136
+ # Get number of days in the dataset
137
+ num_days = len(data)
138
+
139
+ # Add some buffer for constraints like rest days and shift changes
140
+ estimated_staff = max(min_staff_estimate, max_staff_needed + 1)
141
+
142
+ def optimize_schedule(num_staff):
143
+ # Create a binary linear programming model
144
+ model = pl.LpProblem("Staff_Scheduling", pl.LpMinimize)
145
+
146
+ # Decision variables
147
+ # x[s,d,shift] = 1 if staff s works shift on day d
148
+ x = pl.LpVariable.dicts("shift",
149
+ [(s, d, shift['id']) for s in range(1, num_staff+1)
150
+ for d in range(1, num_days+1)
151
+ for shift in possible_shifts],
152
+ cat='Binary')
153
+
154
+ # Objective: Minimize total staff hours while ensuring coverage
155
+ model += pl.lpSum(x[(s, d, shift['id'])] * shift['duration']
156
+ for s in range(1, num_staff+1)
157
+ for d in range(1, num_days+1)
158
+ for shift in possible_shifts)
159
+
160
+ # Constraint: Each staff works at most one shift per day
161
+ for s in range(1, num_staff+1):
162
+ for d in range(1, num_days+1):
163
+ model += pl.lpSum(x[(s, d, shift['id'])] for shift in possible_shifts) <= 1
164
+
165
+ # Constraint: Each staff has at least one rest day per week
166
+ for s in range(1, num_staff+1):
167
+ for w in range((num_days + 6) // 7): # Number of weeks
168
+ week_start = w*7 + 1
169
+ week_end = min(week_start + 6, num_days)
170
+ model += pl.lpSum(x[(s, d, shift['id'])]
171
+ for d in range(week_start, week_end+1)
172
+ for shift in possible_shifts) <= (week_end - week_start + 1) - REST_DAYS_PER_WEEK
173
+
174
+ # Constraint: Each staff works at most MAX_HOURS_PER_STAFF in the period
175
+ for s in range(1, num_staff+1):
176
+ model += pl.lpSum(x[(s, d, shift['id'])] * shift['duration']
177
+ for d in range(1, num_days+1)
178
+ for shift in possible_shifts) <= MAX_HOURS_PER_STAFF
179
+
180
+ # Constraint: Each cycle has enough staff each day
181
+ for d in range(1, num_days+1):
182
+ day_index = d - 1 # 0-indexed for DataFrame
183
+
184
+ for cycle in cycle_cols:
185
+ staff_needed = data.iloc[day_index][f'{cycle}_staff']
186
+
187
+ # Get all shifts that cover this cycle
188
+ covering_shifts = [shift for shift in possible_shifts if cycle in shift['cycles_covered']]
189
+
190
+ model += pl.lpSum(x[(s, d, shift['id'])]
191
+ for s in range(1, num_staff+1)
192
+ for shift in covering_shifts) >= staff_needed
193
+
194
+ # Solve model with a time limit
195
+ model.solve(pl.PULP_CBC_CMD(timeLimit=300, msg=0))
196
+
197
+ # Check if a feasible solution was found
198
+ if model.status == pl.LpStatusOptimal or model.status == pl.LpStatusNotSolved:
199
+ # Extract the solution
200
+ schedule = []
201
+ for s in range(1, num_staff+1):
202
+ for d in range(1, num_days+1):
203
+ for shift in possible_shifts:
204
+ if pl.value(x[(s, d, shift['id'])]) == 1:
205
+ # Find the shift details
206
+ shift_details = next((sh for sh in possible_shifts if sh['id'] == shift['id']), None)
207
+
208
+ schedule.append({
209
+ 'staff_id': s,
210
+ 'day': d,
211
+ 'shift_id': shift['id'],
212
+ 'start': shift_details['start'],
213
+ 'end': shift_details['end'],
214
+ 'duration': shift_details['duration'],
215
+ 'cycles_covered': list(shift_details['cycles_covered'])
216
+ })
217
+
218
+ return schedule, model.objective.value()
219
+ else:
220
+ return None, None
221
+
222
+ # Try to solve with estimated number of staff
223
+ staff_count = int(estimated_staff)
224
+ results = f"Trying with {staff_count} staff...\n"
225
+ schedule, objective = optimize_schedule(staff_count)
226
+
227
+ # If no solution found, increment staff count until a solution is found
228
+ while schedule is None and staff_count < 15: # Cap at 15 to avoid infinite loop
229
+ staff_count += 1
230
+ results += f"Trying with {staff_count} staff...\n"
231
+ schedule, objective = optimize_schedule(staff_count)
232
+
233
+ if schedule is None:
234
+ results += "Failed to find a feasible solution. Try relaxing some constraints."
235
+ return results, None, None, None
236
+
237
+ results += f"Optimal solution found with {staff_count} staff\n"
238
+ results += f"Total staff hours: {objective}\n"
239
+
240
+ # Convert to DataFrame for analysis
241
+ schedule_df = pd.DataFrame(schedule)
242
+
243
+ # Analyze staff workload
244
+ staff_hours = {}
245
+ for s in range(1, staff_count+1):
246
+ staff_shifts = schedule_df[schedule_df['staff_id'] == s]
247
+ total_hours = staff_shifts['duration'].sum()
248
+ staff_hours[s] = total_hours
249
+
250
+ results += "\nStaff Hours:\n"
251
+ for staff_id, hours in staff_hours.items():
252
+ utilization = (hours / MAX_HOURS_PER_STAFF) * 100
253
+ results += f"Staff {staff_id}: {hours} hours ({utilization:.1f}% utilization)\n"
254
+
255
+ avg_utilization = sum(staff_hours.values()) / (staff_count * MAX_HOURS_PER_STAFF) * 100
256
+ results += f"\nAverage staff utilization: {avg_utilization:.1f}%\n"
257
+
258
+ # Check coverage for each day and cycle
259
+ coverage_check = []
260
+ for d in range(1, num_days+1):
261
+ day_index = d - 1 # 0-indexed for DataFrame
262
+
263
+ day_schedule = schedule_df[schedule_df['day'] == d]
264
+
265
+ for cycle in cycle_cols:
266
+ required = data.iloc[day_index][f'{cycle}_staff']
267
+
268
+ # Count staff covering this cycle
269
+ assigned = sum(1 for _, shift in day_schedule.iterrows()
270
+ if cycle in shift['cycles_covered'])
271
+
272
+ coverage_check.append({
273
+ 'day': d,
274
+ 'cycle': cycle,
275
+ 'required': required,
276
+ 'assigned': assigned,
277
+ 'satisfied': assigned >= required
278
+ })
279
+
280
+ coverage_df = pd.DataFrame(coverage_check)
281
+ satisfaction = coverage_df['satisfied'].mean() * 100
282
+ results += f"Coverage satisfaction: {satisfaction:.1f}%\n"
283
+
284
+ if satisfaction < 100:
285
+ results += "Warning: Not all staffing requirements are met!\n"
286
+ unsatisfied = coverage_df[~coverage_df['satisfied']]
287
+ results += unsatisfied.to_string() + "\n"
288
+
289
+ # Generate detailed schedule report
290
+ detailed_schedule = "Detailed Schedule:\n"
291
+ for d in range(1, num_days+1):
292
+ day_schedule = schedule_df[schedule_df['day'] == d]
293
+ day_schedule = day_schedule.sort_values(['start'])
294
+
295
+ detailed_schedule += f"\nDay {d}:\n"
296
+ for _, shift in day_schedule.iterrows():
297
+ start_hour = shift['start']
298
+ end_hour = shift['end']
299
+
300
+ start_str = am_pm(start_hour)
301
+ end_str = am_pm(end_hour)
302
+
303
+ cycles = ", ".join(shift['cycles_covered'])
304
+ detailed_schedule += f" Staff {shift['staff_id']}: {start_str}-{end_str} ({shift['duration']} hrs), Cycles: {cycles}\n"
305
+
306
+ # Generate schedule visualization
307
+ fig, ax = plt.subplots(figsize=(15, 8))
308
+
309
+ # Prepare schedule for plotting
310
+ staff_days = {}
311
+ for s in range(1, staff_count+1):
312
+ staff_days[s] = [0] * num_days # 0 means off duty
313
+
314
+ for _, shift in schedule_df.iterrows():
315
+ staff_id = shift['staff_id']
316
+ day = shift['day'] - 1 # 0-indexed
317
+ staff_days[staff_id][day] = shift['duration']
318
+
319
+ # Plot the schedule
320
+ for s, hours in staff_days.items():
321
+ ax.bar(range(1, num_days+1), hours, label=f'Staff {s}')
322
+
323
+ ax.set_xlabel('Day')
324
+ ax.set_ylabel('Shift Hours')
325
+ ax.set_title('Staff Schedule')
326
+ ax.set_xticks(range(1, num_days+1))
327
+ ax.legend()
328
+
329
+ # Save the figure to a temporary file
330
+ plot_path = None
331
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
332
+ plt.savefig(f.name)
333
+ plt.close(fig)
334
+ plot_path = f.name
335
+
336
+ # Create a Gantt chart
337
+ gantt_fig, gantt_ax = plt.subplots(figsize=(30, 12)) # Increased figure width
338
+
339
+ # Set up colors for each staff
340
+ colors = plt.cm.tab20.colors # Use a visually distinct color palette
341
+
342
+ # Sort by staff then day
343
+ schedule_df['start_ampm'] = schedule_df['start'].apply(am_pm)
344
+ schedule_df['end_ampm'] = schedule_df['end'].apply(am_pm)
345
+ schedule_df = schedule_df.sort_values(['staff_id', 'day'])
346
+
347
+ # Plot Gantt chart
348
+ for staff_id in range(1, staff_count+1):
349
+ staff_shifts = schedule_df[schedule_df['staff_id'] == staff_id]
350
+
351
+ y_pos = staff_id
352
+ for i, shift in staff_shifts.iterrows():
353
+ day = shift['day']
354
+ start_hour = shift['start']
355
+ end_hour = shift['end']
356
+ duration = shift['duration']
357
+
358
+ start_ampm = shift['start_ampm']
359
+ end_ampm = shift['end_ampm']
360
+
361
+ # Handle overnight shifts
362
+ if end_hour < start_hour: # Overnight shift
363
+ gantt_ax.broken_barh([(day-1 + start_hour/24, (24-start_hour)/24),
364
+ (day, end_hour/24)],
365
+ (y_pos-0.3, 0.6), # Increased bar height
366
+ facecolors=colors[staff_id % len(colors)])
367
+ else:
368
+ gantt_ax.broken_barh([(day-1 + start_hour/24, duration/24)],
369
+ (y_pos-0.3, 0.6), # Increased bar height
370
+ facecolors=colors[staff_id % len(colors)])
371
+
372
+ # Staggered text labels
373
+ text_y_offset = 0.1 if (i % 2) == 0 else -0.1 # Alternate label position
374
+
375
+ # Add text label - prioritize staff ID, add time range if space allows
376
+ text_label = f"Staff {staff_id}"
377
+ if duration > 6: # Adjust this threshold as needed
378
+ text_label += f"\n{start_ampm}-{end_ampm}"
379
+
380
+ gantt_ax.text(day-1 + start_hour/24 + duration/48, y_pos + text_y_offset,
381
+ text_label,
382
+ horizontalalignment='center', verticalalignment='center', fontsize=7) # Slightly smaller font
383
+
384
+ gantt_ax.set_xlabel('Day')
385
+ gantt_ax.set_yticks(range(1, staff_count+1))
386
+ gantt_ax.set_yticklabels([f'Staff {s}' for s in range(1, staff_count+1)])
387
+ gantt_ax.set_xlim(0, num_days)
388
+ gantt_ax.set_title('Staff Schedule (Full Period)')
389
+ gantt_ax.grid(False) # Remove grid lines
390
+
391
+ # Save the Gantt chart
392
+ gantt_path = None
393
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
394
+ gantt_fig.savefig(f.name)
395
+ plt.close(gantt_fig)
396
+ gantt_path = f.name
397
+
398
+ # Convert schedule to CSV data
399
+ schedule_df['start_ampm'] = schedule_df['start'].apply(am_pm)
400
+ schedule_df['end_ampm'] = schedule_df['end'].apply(am_pm)
401
+ schedule_csv = schedule_df[['staff_id', 'day', 'start_ampm', 'end_ampm', 'duration', 'cycles_covered']].to_csv(index=False)
402
+
403
+ # Create a temporary file and write the CSV data into it
404
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".csv") as temp_file:
405
+ temp_file.write(schedule_csv)
406
+ schedule_csv_path = temp_file.name
407
+
408
+ return results, plot_path, schedule_csv_path, gantt_path
409
+
410
+ def convert_to_24h(time_str):
411
+ """Converts AM/PM time string to 24-hour format."""
412
+ try:
413
+ time_obj = datetime.strptime(time_str, "%I:00 %p")
414
+ return time_obj.hour
415
+ except ValueError:
416
+ return None
417
+
418
+ def gradio_wrapper(
419
+ csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
420
+ rest_days_per_week, clinic_start_ampm, clinic_end_ampm, overlap_time, max_start_time_change
421
+ ):
422
+ clinic_start = convert_to_24h(clinic_start_ampm)
423
+ clinic_end = convert_to_24h(clinic_end_ampm)
424
+
425
+ results, plot_img, schedule_csv_path, gantt_path = optimize_staffing(
426
+ csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
427
+ rest_days_per_week, clinic_start, clinic_end, overlap_time, max_start_time_change
428
+ )
429
+
430
+ # Load plot images if they exist
431
+ plot_img = plot_img if plot_img and os.path.exists(plot_img) else None
432
+ gantt_img = gantt_path if gantt_path and os.path.exists(gantt_path) else None
433
+
434
+ return results, plot_img, schedule_csv_path, gantt_img
435
+
436
+
437
+ # Define Gradio UI
438
+ 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)]
439
+
440
+ iface = gr.Interface(
441
+ fn=gradio_wrapper,
442
+ inputs=[
443
+ gr.File(label="Upload CSV"),
444
+ gr.Number(label="Beds per Staff", value=3),
445
+ gr.Number(label="Max Hours per Staff", value=40),
446
+ gr.Number(label="Hours per Cycle", value=4),
447
+ gr.Number(label="Rest Days per Week", value=2),
448
+ gr.Dropdown(label="Clinic Start Hour (AM/PM)", choices=am_pm_times, value="08:00 AM"),
449
+ gr.Dropdown(label="Clinic End Hour (AM/PM)", choices=am_pm_times, value="08:00 PM"),
450
+ gr.Number(label="Overlap Time", value=0),
451
+ gr.Number(label="Max Start Time Change", value=2)
452
+ ],
453
+ outputs=[
454
+ gr.Textbox(label="Optimization Results"),
455
+ gr.Image(label="Schedule Visualization"),
456
+ gr.File(label="Schedule CSV"),
457
+ gr.Image(label="Gantt Chart"),
458
+ ],
459
+ title="Staff Scheduling Optimizer",
460
+ description="Upload a CSV file with cycle data and configure parameters to generate an optimal staff schedule."
461
+ )
462
+
463
+ # Launch the Gradio app
464
+ iface.launch(share=True)