Spaces:
Sleeping
Sleeping
import pandas as pd | |
import gurobipy as gp | |
from enum import Enum | |
def optimize_schedule_for_pool(customer_schedule: pd.DataFrame) -> pd.DataFrame: | |
"""This function assumes the user submitted a pool of 3 with one child per family and school, each with the same hourly needs""" | |
# customer schedule has names capitalized as it is front facing | |
customer_schedule.columns = map(str.lower, customer_schedule.columns) | |
# Import inside function are very bad. It can be fixed by having a proper database | |
from database import offer | |
offer = pd.concat([offer[customer_schedule.columns], customer_schedule]).set_index( | |
"family", verify_integrity=True | |
) | |
assert set(offer.values.reshape(-1)).issubset( | |
{0, 1} | |
), "Incorrect schedule format, only 0 or 1 is allowed as inputs" | |
m = gp.Model("Carebuds") | |
workers = offer.index.to_list() | |
# Number of workers required for each shift. | |
shifts, shiftRequirements = gp.multidict( | |
{"monday": 1, "tuesday": 1, "wednesday": 1, "thursday": 1, "friday": 1} | |
) | |
availability = [] | |
for family, row in offer.iterrows(): | |
availability.extend([(family, day) for day in offer.columns[row == 1]]) | |
# Initialize assignment decision variables. | |
x = m.addVars(availability, vtype=gp.GRB.BINARY, name="x") | |
# Slack decision variables determine the number of extra workers required to satisfy the requirements | |
# of each shift | |
slacks = m.addVars(shifts, name="Slack") | |
# Auxiliary variable totSlack to represent the total number of extra workers required to satisfy the | |
# requirements of all the shifts. | |
totSlack = m.addVar(name="totSlack") | |
# Auxiliary variable totShifts counts the total shifts worked by each employed worker | |
totShifts = m.addVars(workers, name="TotShifts") | |
# Constraint: All shifts requirements most be satisfied. | |
shift_reqmts = m.addConstrs( | |
(x.sum("*", s) + slacks[s] == shiftRequirements[s] for s in shifts), | |
name="shiftRequirement", | |
) | |
# Constraint: set the auxiliary variable (totSlack) equal to the total number of extra workers | |
# required to satisfy shift requirements | |
num_temps = m.addConstr(totSlack == slacks.sum(), name="totSlack") | |
# Constraint: compute the total number of shifts for each worker | |
num_shifts = m.addConstrs( | |
(totShifts[w] == x.sum(w, "*") for w in workers), name="totShifts" | |
) | |
# Auxiliary variables. | |
# minShift is the minimum number of shifts allocated to workers | |
# maxShift is the maximum number of shifts allocated to workers | |
minShift = m.addVar(name="minShift") | |
maxShift = m.addVar(name="maxShift") | |
# Constraint: | |
# The addGenConstrMin() method of the model object m adds a new general constraint that | |
# determines the minimum value among a set of variables. | |
# The first argument is the variable whose value will be equal to the minimum of the other variables, | |
# minShift in this case. | |
# The second argument is the set variables over which the minimum will be taken, (totShifts) in | |
# this case. | |
# Recall that the totShifts variable is defined over the set of worker and determines the number of | |
# shifts that an employed worker will work. The third argument is the name of this constraint. | |
min_constr = m.addGenConstrMin(minShift, totShifts, name="minShift") | |
# Constraint: | |
# Similarly, the addGenConstrMax() method of the model object m adds a new general | |
# constraint that determines the maximum value among a set of variables. | |
max_constr = m.addGenConstrMax(maxShift, totShifts, name="maxShift") | |
# Set global sense for ALL objectives. | |
# This means that all objectives of the model object m are going to be minimized | |
m.ModelSense = gp.GRB.MINIMIZE | |
# set_single_objective(m=m, totSlack=totSlack) | |
set_objectives(m=m, totSlack=totSlack, maxShift=maxShift, minShift=minShift) | |
# Optimize | |
# This method runs the optimization engine to solve the MIP problem in the model object m | |
m.optimize() | |
check_status(m) | |
assignments_all = get_solution(workers, availability, x, totSlack, totShifts) | |
calendar = KPI(assignments_all, shifts) | |
calendar.columns = [c.capitalize() for c in calendar.columns] | |
return calendar | |
class Duty(Enum): | |
OnDuty = "On Duty" | |
OffDuty = "Off Duty" | |
def check_status(m: gp.Model) -> None: | |
# The Status attribute provides current optimization status of the model object m | |
# In workforce model, we check if the model is infeasible or unbounded and report this situation | |
status = m.Status | |
if ( | |
status == gp.GRB.Status.INF_OR_UNBD | |
or status == gp.GRB.Status.INFEASIBLE | |
or status == gp.GRB.Status.UNBOUNDED | |
): | |
m.write("workforce.iis") | |
raise ValueError( | |
"The model cannot be solved because it is infeasible or unbounded" | |
) | |
# If the optimization status of the model is not optimal for some other reason, we report that | |
# situation. | |
if status != gp.GRB.Status.OPTIMAL: | |
print("Optimization was stopped with status " + str(status)) | |
return None | |
def set_single_objective(m: gp.Model, totSlack: gp.Var) -> None: | |
m.setObjective(totSlack) | |
def set_objectives( | |
m: gp.Model, totSlack: gp.Var, maxShift: gp.Var, minShift: gp.Var | |
) -> None: | |
# The setObjectiveN() method of the model object m allows to define multiple objectives. | |
# The first argument is the linear expression defining the most important objective, called primary | |
# objective, in this case it is the minimization of extra workers required to satisfy shift | |
# requirements. | |
# The second argument is the index of the objective function, we set the index of the primary | |
# objective to be equal to 0. | |
# The third argument is the priority of the objective. | |
# The fourth argument is the relative tolerance to degrade this objective when a lower priority | |
# objective is optimized. The fifth argument is the name of this objective. | |
# A hierarchical or lexicographic approach assigns a priority to each objective, and optimizes | |
# for the objectives in decreasing priority order. | |
# For this problem, we have two objectives, and the primary objective has the highest priority | |
# which is equal to 2. | |
# When the secondary objective is minimized, since the relative tolerance is 0.2, we can only | |
# increase the minimum number of extra workers up to 20%. | |
# For example if the minimum number extra workers is 10, then when optimizing the secondary objective | |
# we can have up to 12 extra workers. | |
m.setObjectiveN(totSlack, index=0, priority=2, reltol=0.2, name="TotalSlack") | |
# Set up secondary objective. | |
# The secondary objective is called fairness and its goal is to balance the workload assigned | |
# to the employed workers. | |
# To balance the workload assigned to the employed workers, we can minimize the difference | |
# between the maximum number of shifts assigned to an employed worker and the minimum number | |
# of shifts assigned to an employed worker. | |
m.setObjectiveN(maxShift - minShift, index=1, priority=1, name="Fairness") | |
return None | |
def get_solution(workers, availability, x: gp.Var, totSlack: gp.Var, totShifts: gp.Var): | |
# Print total slack and the number of shifts worked for each worker | |
solution = {} | |
shifts_sol = {} | |
solution["Total slack required"] = str(totSlack.X) | |
assignments_all = {} | |
assignments = dict() | |
for [w, s] in availability: | |
if x[w, s].x == 1: | |
if w in assignments: | |
assignments[w].append(s) | |
else: | |
assignments[w] = [s] | |
print(pd.DataFrame.from_records(list(solution.items()), columns=["KPI", "Value"])) | |
print("-" * 50) | |
for w in workers: | |
shifts_sol[w] = totShifts[w].X | |
assignments_all[w] = assignments.get(w, []) | |
print("Shifts") | |
sol_df = pd.DataFrame.from_records( | |
list(shifts_sol.items()), columns=["Worker", "Number of shifts"] | |
) | |
print(sol_df) | |
return assignments_all | |
def KPI(assignments_all, shifts) -> pd.DataFrame: | |
# The KPIs for this optimization number is the number of extra worked required to satisfy | |
# demand and the number of shifts that each employed worker is working. | |
gant = {} | |
print("-" * 50) | |
for w in assignments_all: | |
gant[w] = [w] | |
for d in shifts: | |
gant[w].append(Duty.OnDuty if d in assignments_all[w] else Duty.OffDuty) | |
return pd.DataFrame.from_records(list(gant.values()), columns=["family"] + shifts) | |
if __name__ == "__main__": | |
solution = optimize_schedule_for_pool( | |
customer_schedule=pd.DataFrame( | |
# [("Doe", 1, 0, 1, 1, 1)], | |
[("Doe", 1, 0, 0, 0, 0)], | |
columns=["family", "monday", "tuesday", "wednesday", "thursday", "friday"], | |
) | |
) | |
print("Assigments") | |
print("Symbols: '-': not working, '*': working") | |
pd.set_option("display.width", 1000) | |
print(solution) | |