Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -74,15 +74,16 @@ def optimize_staffing(
|
|
| 74 |
|
| 75 |
# Parameters
|
| 76 |
BEDS_PER_STAFF = float(beds_per_staff)
|
| 77 |
-
STANDARD_PERIOD_DAYS = 30 # Standard
|
| 78 |
|
| 79 |
-
# Scale MAX_HOURS_PER_STAFF based on the ratio of actual days to standard
|
| 80 |
-
BASE_MAX_HOURS = float(max_hours_per_staff) # This is for a
|
| 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 (
|
| 85 |
-
original_results += f"Adjusted max hours for {num_days}-day period: {MAX_HOURS_PER_STAFF:.1f}\n
|
|
|
|
| 86 |
|
| 87 |
HOURS_PER_CYCLE = float(hours_per_cycle)
|
| 88 |
REST_DAYS_PER_WEEK = int(rest_days_per_week)
|
|
@@ -224,13 +225,15 @@ def optimize_staffing(
|
|
| 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,
|
|
|
|
|
|
|
| 228 |
estimated_staff = exact_staff_count
|
| 229 |
-
num_staff_to_create = exact_staff_count
|
| 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)
|
| 234 |
|
| 235 |
def optimize_schedule(num_staff, time_limit=600):
|
| 236 |
try:
|
|
@@ -250,35 +253,59 @@ def optimize_staffing(
|
|
| 250 |
# Total hours worked by all staff
|
| 251 |
total_hours = pl.LpVariable("total_hours", lowBound=0)
|
| 252 |
|
| 253 |
-
#
|
| 254 |
-
|
| 255 |
|
| 256 |
-
# Objective function
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
-
# Link
|
| 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 |
-
|
| 272 |
-
|
| 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 |
-
#
|
| 280 |
-
for s in range(1, num_staff)
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
# Each staff works at most one shift per day
|
| 284 |
for s in range(1, num_staff+1):
|
|
@@ -305,12 +332,12 @@ def optimize_staffing(
|
|
| 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 |
-
|
| 309 |
for d in range(1, num_days+1)
|
| 310 |
for shift in possible_shifts)
|
| 311 |
|
| 312 |
# STRICT constraint: No overtime allowed
|
| 313 |
-
model +=
|
| 314 |
|
| 315 |
# HARD CONSTRAINT: Full coverage required
|
| 316 |
for d in range(1, num_days+1):
|
|
@@ -388,21 +415,28 @@ def optimize_staffing(
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 =
|
|
|
|
| 406 |
results += f"Searching for minimum staff count starting from {min_staff}...\n"
|
| 407 |
|
| 408 |
# Try each staff count from min to max
|
|
@@ -435,19 +469,39 @@ def optimize_staffing(
|
|
| 435 |
total_hours = staff_shifts['duration'].sum()
|
| 436 |
staff_hours[s] = total_hours
|
| 437 |
|
| 438 |
-
#
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 74 |
|
| 75 |
# Parameters
|
| 76 |
BEDS_PER_STAFF = float(beds_per_staff)
|
| 77 |
+
STANDARD_PERIOD_DAYS = 30 # Standard month period (changed from 28 to 30)
|
| 78 |
|
| 79 |
+
# Scale MAX_HOURS_PER_STAFF based on the ratio of actual days to standard month
|
| 80 |
+
BASE_MAX_HOURS = float(max_hours_per_staff) # This is for a 30-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 (30-day period): {BASE_MAX_HOURS}\n"
|
| 85 |
+
original_results += f"Adjusted max hours for {num_days}-day period: {MAX_HOURS_PER_STAFF:.1f}\n"
|
| 86 |
+
original_results += f"(Adjustment ratio: {num_days}/{STANDARD_PERIOD_DAYS} = {(num_days/STANDARD_PERIOD_DAYS):.2f})\n\n"
|
| 87 |
|
| 88 |
HOURS_PER_CYCLE = float(hours_per_cycle)
|
| 89 |
REST_DAYS_PER_WEEK = int(rest_days_per_week)
|
|
|
|
| 225 |
|
| 226 |
# Use exact_staff_count if provided, otherwise estimate
|
| 227 |
if exact_staff_count is not None and exact_staff_count > 0:
|
| 228 |
+
# When exact staff count is provided, use it regardless of minimum required
|
| 229 |
+
if exact_staff_count < min_staff_estimate:
|
| 230 |
+
original_results += f"\nWarning: Provided staff count ({exact_staff_count}) is below estimated minimum ({min_staff_estimate:.1f}). Solution may not be feasible.\n"
|
| 231 |
estimated_staff = exact_staff_count
|
| 232 |
+
num_staff_to_create = exact_staff_count
|
| 233 |
else:
|
| 234 |
# Add some buffer for constraints like rest days and shift changes
|
| 235 |
estimated_staff = max(min_staff_estimate, max_staff_needed + 1)
|
| 236 |
+
num_staff_to_create = int(estimated_staff)
|
| 237 |
|
| 238 |
def optimize_schedule(num_staff, time_limit=600):
|
| 239 |
try:
|
|
|
|
| 253 |
# Total hours worked by all staff
|
| 254 |
total_hours = pl.LpVariable("total_hours", lowBound=0)
|
| 255 |
|
| 256 |
+
# Individual staff hours variables for balancing
|
| 257 |
+
staff_hours = pl.LpVariable.dicts("staff_hours", range(1, num_staff+1), lowBound=0)
|
| 258 |
|
| 259 |
+
# Objective function modification for exact staff count
|
| 260 |
+
if exact_staff_count is not None:
|
| 261 |
+
# When exact staff count is specified, focus on balancing hours between staff
|
| 262 |
+
avg_hours = total_staff_hours / num_staff
|
| 263 |
+
model += pl.lpSum(staff_hours[s] for s in range(1, num_staff+1))
|
| 264 |
+
|
| 265 |
+
# Add penalty for deviation from average
|
| 266 |
+
for s in range(1, num_staff+1):
|
| 267 |
+
model += staff_hours[s] >= avg_hours * 0.8 # Each staff must get at least 80% of average hours
|
| 268 |
+
model += staff_hours[s] <= avg_hours * 1.2 # Each staff must not exceed 120% of average hours
|
| 269 |
+
else:
|
| 270 |
+
# Original objective for minimizing staff and total hours
|
| 271 |
+
model += 10**10 * pl.lpSum(staff_used[s] for s in range(1, num_staff+1)) + total_hours
|
| 272 |
|
| 273 |
+
# Link staff_hours to actual hours worked
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
for s in range(1, num_staff+1):
|
| 275 |
+
model += staff_hours[s] == pl.lpSum(x[(s, d, shift['id'])] * shift['duration']
|
| 276 |
+
for d in range(1, num_days+1)
|
| 277 |
+
for shift in possible_shifts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
+
# Link total_hours to sum of staff_hours
|
| 280 |
+
model += total_hours == pl.lpSum(staff_hours[s] for s in range(1, num_staff+1))
|
| 281 |
+
|
| 282 |
+
# When exact staff count is provided, ensure all staff are used
|
| 283 |
+
if exact_staff_count is not None:
|
| 284 |
+
for s in range(1, num_staff+1):
|
| 285 |
+
# Ensure each staff works at least some minimum shifts
|
| 286 |
+
min_shifts = max(1, int(num_days / (num_staff * 2))) # At least this many shifts per staff
|
| 287 |
+
model += pl.lpSum(x[(s, d, shift['id'])]
|
| 288 |
+
for d in range(1, num_days+1)
|
| 289 |
+
for shift in possible_shifts) >= min_shifts
|
| 290 |
+
|
| 291 |
+
# Maximum shifts per staff (to prevent overloading)
|
| 292 |
+
max_shifts = int(num_days * 0.8) # At most 80% of days
|
| 293 |
+
model += pl.lpSum(x[(s, d, shift['id'])]
|
| 294 |
+
for d in range(1, num_days+1)
|
| 295 |
+
for shift in possible_shifts) <= max_shifts
|
| 296 |
+
else:
|
| 297 |
+
# Original staff usage constraints
|
| 298 |
+
for s in range(1, num_staff+1):
|
| 299 |
+
model += pl.lpSum(x[(s, d, shift['id'])]
|
| 300 |
+
for d in range(1, num_days+1)
|
| 301 |
+
for shift in possible_shifts) <= num_days * staff_used[s]
|
| 302 |
+
model += pl.lpSum(x[(s, d, shift['id'])]
|
| 303 |
+
for d in range(1, num_days+1)
|
| 304 |
+
for shift in possible_shifts) >= staff_used[s]
|
| 305 |
+
|
| 306 |
+
# Maintain staff ordering only when not using exact staff count
|
| 307 |
+
for s in range(1, num_staff):
|
| 308 |
+
model += staff_used[s] >= staff_used[s+1]
|
| 309 |
|
| 310 |
# Each staff works at most one shift per day
|
| 311 |
for s in range(1, num_staff+1):
|
|
|
|
| 332 |
# HARD CONSTRAINT: No overtime allowed - strict limit at MAX_HOURS_PER_STAFF
|
| 333 |
for s in range(1, num_staff+1):
|
| 334 |
# Calculate total hours worked by this staff
|
| 335 |
+
staff_hours_value = pl.lpSum(x[(s, d, shift['id'])] * shift['duration']
|
| 336 |
for d in range(1, num_days+1)
|
| 337 |
for shift in possible_shifts)
|
| 338 |
|
| 339 |
# STRICT constraint: No overtime allowed
|
| 340 |
+
model += staff_hours_value <= MAX_HOURS_PER_STAFF
|
| 341 |
|
| 342 |
# HARD CONSTRAINT: Full coverage required
|
| 343 |
for d in range(1, num_days+1):
|
|
|
|
| 415 |
if exact_staff_count is not None and exact_staff_count > 0:
|
| 416 |
# If exact staff count is specified, only try with that count
|
| 417 |
staff_count = int(exact_staff_count)
|
| 418 |
+
results = original_results # Include the hours adjustment information
|
| 419 |
+
results += f"\nUsing exactly {staff_count} staff as specified"
|
| 420 |
+
if staff_count < min_staff_estimate:
|
| 421 |
+
results += f" (Warning: This is below estimated minimum of {min_staff_estimate:.1f})"
|
| 422 |
+
results += "...\n"
|
| 423 |
|
| 424 |
# Try to solve with exactly this many staff
|
| 425 |
schedule, objective = optimize_schedule(staff_count)
|
| 426 |
|
| 427 |
if schedule is None:
|
| 428 |
results += f"Failed to find a feasible solution with exactly {staff_count} staff.\n"
|
| 429 |
+
if staff_count < min_staff_estimate:
|
| 430 |
+
results += f"This is likely because the staff count is below the estimated minimum of {min_staff_estimate:.1f}.\n"
|
| 431 |
+
results += "Try increasing the staff count or adjusting other parameters.\n"
|
| 432 |
return results, None, None, None, None
|
| 433 |
else:
|
| 434 |
# Start from theoretical minimum and work up
|
| 435 |
min_staff = max(1, int(theoretical_min_staff)) # Start from theoretical minimum
|
| 436 |
max_staff = int(min_staff_estimate) + 5 # Allow some buffer
|
| 437 |
|
| 438 |
+
results = original_results # Include the hours adjustment information
|
| 439 |
+
results += f"Theoretical minimum staff needed: {theoretical_min_staff:.1f}\n"
|
| 440 |
results += f"Searching for minimum staff count starting from {min_staff}...\n"
|
| 441 |
|
| 442 |
# Try each staff count from min to max
|
|
|
|
| 469 |
total_hours = staff_shifts['duration'].sum()
|
| 470 |
staff_hours[s] = total_hours
|
| 471 |
|
| 472 |
+
# Handle staff hours display based on whether exact count was specified
|
| 473 |
+
if exact_staff_count is not None:
|
| 474 |
+
# When exact count is specified, show all staff including those with 0 hours
|
| 475 |
+
active_staff_hours = staff_hours
|
| 476 |
+
else:
|
| 477 |
+
# Otherwise, only show active staff
|
| 478 |
+
active_staff_hours = {s: hours for s, hours in staff_hours.items() if hours > 0}
|
| 479 |
|
| 480 |
results += "\nStaff Hours:\n"
|
| 481 |
+
total_active_hours = sum(active_staff_hours.values())
|
| 482 |
+
avg_hours = total_active_hours / len(active_staff_hours) if active_staff_hours else 0
|
| 483 |
+
|
| 484 |
for staff_id, hours in active_staff_hours.items():
|
| 485 |
utilization = (hours / MAX_HOURS_PER_STAFF) * 100
|
| 486 |
+
deviation_from_avg = ((hours - avg_hours) / avg_hours * 100) if avg_hours > 0 else 0
|
| 487 |
+
results += f"Staff {staff_id}: {hours:.1f} hours ({utilization:.1f}% utilization)"
|
| 488 |
+
if exact_staff_count is not None:
|
| 489 |
+
results += f" [Deviation from avg: {deviation_from_avg:+.1f}%]"
|
| 490 |
+
results += "\n"
|
| 491 |
+
|
| 492 |
# Add overtime information
|
| 493 |
if hours > MAX_HOURS_PER_STAFF:
|
| 494 |
overtime = hours - MAX_HOURS_PER_STAFF
|
| 495 |
overtime_percent = (overtime / MAX_HOURS_PER_STAFF) * 100
|
| 496 |
results += f" Overtime: {overtime:.1f} hours ({overtime_percent:.1f}%)\n"
|
| 497 |
|
| 498 |
+
if exact_staff_count is not None:
|
| 499 |
+
results += f"\nWorkload Distribution Stats:\n"
|
| 500 |
+
results += f"Average hours per staff: {avg_hours:.1f}\n"
|
| 501 |
+
if active_staff_hours:
|
| 502 |
+
max_deviation = max(abs((hours - avg_hours) / avg_hours * 100) for hours in active_staff_hours.values()) if avg_hours > 0 else 0
|
| 503 |
+
results += f"Maximum deviation from average: {max_deviation:.1f}%\n"
|
| 504 |
+
|
| 505 |
# Use active_staff_hours for average utilization calculation
|
| 506 |
active_staff_count = len(active_staff_hours)
|
| 507 |
avg_utilization = sum(active_staff_hours.values()) / (active_staff_count * MAX_HOURS_PER_STAFF) * 100
|