├── README.md ├── airport ├── airport_simulation.py ├── instance_generation.py ├── main.py └── model_benders.py └── nursing ├── DataNursingHome.csv ├── helper_functions.py ├── instance_generation.py ├── main.py ├── model_IP.py ├── model_benders.py └── nursing_simulation.py /README.md: -------------------------------------------------------------------------------- 1 | # Combining-Optimization-and-Simulation-Using-Logic-Based-Benders-Decomposition 2 | An associated Github for our paper _Combining Optimization and Simulation Using Logic-Based Benders Decomposition_ containing code and results. Published by the European Journal of Operational Research: https://doi.org/10.1016/j.ejor.2023.07.032 3 | 4 | You will need a working Gurobi (www.gurobi.com) installation with a current license to run the code. 5 | The repository contains logic-based Benders decomposition formulations of a Nursing Home Shift Scheduling (NHSS) problem, and an Airport Check-in Counter Allocation (ACCA) problem. There is also a direct IP formulation of the NHSS problem. 6 | -------------------------------------------------------------------------------- /airport/airport_simulation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import time 3 | import heapq 4 | 5 | 6 | # A simulation engine with caching 7 | # and other efficiencies 8 | class Airport_Simulation: 9 | def __init__(self, T, L, MaxDesks, Scenarios): 10 | 11 | self.Simulations = {} 12 | self.PassStart = {} 13 | self.DeskStart = {} 14 | self.T = T 15 | self.L = L 16 | self.MaxDesks = MaxDesks 17 | self.Scenarios = Scenarios 18 | 19 | # Initialize data structures 20 | for s in range(len(Scenarios)): 21 | self.Simulate(s, None) 22 | 23 | 24 | # Simulate scenario s with counter levels (t, Y[t]) 25 | def Simulate(self, s, LevelsList): 26 | # Note the start time of the simulation 27 | sim_start = time.time() 28 | 29 | # Set up counter levels 30 | Levels = [self.MaxDesks for t in self.T] 31 | init = False 32 | # Trigger initialization 33 | if not LevelsList: 34 | init = True 35 | else: 36 | for (t, level) in LevelsList: 37 | Levels[t] = level 38 | 39 | # Check the cache 40 | TupleLevels = tuple(Levels) 41 | if (s, TupleLevels) in self.Simulations: 42 | return self.Simulations[s, TupleLevels] 43 | 44 | # Performance measures 45 | QueuingTime = {t: 0 for t in self.T} 46 | 47 | # Stopping criteria 48 | if init: 49 | tEnd = self.T[-1] + 1 50 | else: 51 | tEnd = LevelsList[-1][0] + 1 52 | 53 | # Set up the simulation 54 | if init or LevelsList[0][0] == 0: 55 | tUpto = 0 56 | Desks = [] 57 | for i in range(Levels[0]): 58 | heapq.heappush(Desks, 0) 59 | Arrivals = self.Scenarios[s] 60 | 61 | # Get the starting state 62 | else: 63 | tUpto = LevelsList[0][0] 64 | Desks = list(self.DeskStart[s, tUpto]) 65 | for i in range(Levels[tUpto], self.MaxDesks): 66 | heapq.heappop(Desks) 67 | Arrivals = self.Scenarios[s][self.PassStart[s, tUpto]:] 68 | 69 | # Schedule the ith passenger 70 | for (i, k) in enumerate(Arrivals): 71 | 72 | # Stopping criteria 73 | if k[0] > tEnd * self.L: 74 | break 75 | # Boundary condition 76 | OverFlow = False 77 | 78 | # If initializing then populate the data structures 79 | if init: 80 | # If the next passenger doesn't arrive until the next 81 | if k[0] >= self.L * (tUpto + 1): # time period then move on 82 | tUpto += 1 83 | self.DeskStart[s, tUpto] = tuple(Desks) 84 | self.PassStart[s, tUpto] = i 85 | 86 | # Open and shut counters if necessary 87 | else: 88 | # If the next passenger hasn't arrived yet then move on 89 | while len(Desks) == 0 or max(k[0], Desks[0]) >= self.L*(tUpto + 1): 90 | # If we run out of time without finishing 91 | if tUpto >= self.T[-1]: 92 | OverFlow = True 93 | break 94 | # Increase the counter levels if necessary 95 | if Levels[tUpto + 1] > Levels[tUpto]: 96 | for i in range(Levels[tUpto], Levels[tUpto + 1]): 97 | heapq.heappush(Desks, self.L*(tUpto + 1)) 98 | # Decrease the counter levels if necessary 99 | if Levels[tUpto + 1] < Levels[tUpto]: 100 | for i in range(Levels[tUpto + 1], Levels[tUpto]): 101 | heapq.heappop(Desks) 102 | # Move on to the next time period 103 | tUpto += 1 104 | 105 | # If OverFlow was triggered then incur a penalty 106 | if OverFlow: 107 | QueuingTime[k[0] // self.L] += len(self.T) * self.L 108 | 109 | else: 110 | # Get the next counter from the queue 111 | Desk = heapq.heappop(Desks) 112 | # Compute the queuing time 113 | ThisQTime = max(0, Desk - k[0]) 114 | # Update the performance 115 | QueuingTime[k[0] // self.L] += ThisQTime 116 | # Get the next idle time of the counter 117 | Desk = max(Desk, k[0]) + k[2] 118 | # Put the desk back on the queue 119 | heapq.heappush(Desks, Desk) 120 | 121 | # When initializing, if we finish scheduling the passengers before 122 | if init: # we get to the end, then finish populating the 123 | for tUpto in self.T[tUpto + 1:]: # data structures 124 | self.DeskStart[s, tUpto] = tuple(Desks) 125 | self.PassStart[s, tUpto] = len(self.Scenarios[s]) 126 | 127 | # Cache and return the performance 128 | self.Simulations[s, TupleLevels] = QueuingTime 129 | return QueuingTime 130 | 131 | 132 | -------------------------------------------------------------------------------- /airport/instance_generation.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def GenerateScenarios(NumScenarios, ServiceTime, Passengers, Start, Arrive, L): 5 | 6 | # Set of flights 7 | F = range(len(Passengers)) 8 | 9 | # Set of arrivals 10 | Scenarios = [[] for s in range(NumScenarios)] 11 | S = range(len(Scenarios)) 12 | 13 | # Get the arrival and service time 14 | # of each passenger on each flight 15 | # Scenario[p] = (arrival time, flight, service time) 16 | for s in S: 17 | for f in F: 18 | for p in range(Passengers[f]): 19 | 20 | Scenarios[s].append((float(L * (np.random.choice( 21 | len(Arrive), 1, p = [i * 0.01 for i in Arrive])[0] 22 | + Start[f]) + np.random.randint(0, L + 1)), 23 | f, float(np.random.exponential(ServiceTime)))) 24 | 25 | Scenarios[s].sort() 26 | 27 | # Sort and return 28 | return Scenarios -------------------------------------------------------------------------------- /airport/main.py: -------------------------------------------------------------------------------- 1 | from instance_generation import GenerateScenarios 2 | from airport_simulation import Airport_Simulation 3 | from model_benders import airport_BendersDecomposition 4 | from pathlib import Path 5 | import csv 6 | import sys 7 | 8 | 9 | def main(): 10 | 11 | 12 | # Parameters 13 | DeskCost = 40 # Cost of opening a desk for one time period 14 | QueueCost = 40 # Cost per time period for queuing time 15 | MaxDesks = 20 # Maximum desks which can be open at a time 16 | ServiceTime = 2 # Average service time 17 | # V The number of passengers on each flight 18 | Passengers = [150, 210, 240, 180, 270, 150, 210, 300, 180, 270] 19 | # V The start of the arrival periods for each flight 20 | Start = [0, 2, 4, 4, 6, 8, 10, 12, 12, 14] 21 | # V The distribution of arrivals over each arrival period 22 | Arrive = [5, 10, 20, 30, 20, 15, 0] 23 | H = max(Start) + len(Arrive) # (*) The number of time periods 24 | T = range(H) # The set of time periods 25 | L = 30 # The length of each time period 26 | 27 | 28 | # Scenario generation 29 | NumScenarios = 1 30 | Scenarios = GenerateScenarios(NumScenarios, ServiceTime, Passengers, 31 | Start, Arrive, L) 32 | 33 | 34 | # Use initial cuts? 35 | Inits = True 36 | 37 | 38 | # Create a simulation 39 | SimEngine = Airport_Simulation(T, L, MaxDesks, Scenarios) 40 | 41 | 42 | # Solve using Benders decomposition 43 | modelBD, YSol = airport_BendersDecomposition( 44 | SimEngine, Inits, H, L, Start, Arrive, MaxDesks, DeskCost, QueueCost) 45 | 46 | 47 | if __name__=="__main__": 48 | main() 49 | 50 | 51 | -------------------------------------------------------------------------------- /airport/model_benders.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import time 3 | import gurobipy as gp 4 | import heapq 5 | 6 | 7 | def airport_BendersDecomposition(SimEngine, Inits, H, L, Start, Arrive, 8 | MaxDesks, DeskCost, QueueCost): 9 | 10 | 11 | # Small number 12 | EPS = 0.0001 13 | 14 | # Set of time periods 15 | T = range(H) 16 | 17 | # Set of scenarios 18 | S = range(len(SimEngine.Scenarios)) 19 | 20 | # Set of counter levels 21 | N = range(MaxDesks + 1) 22 | 23 | # -------------------- 24 | # --- Gurobi model --- 25 | # -------------------- 26 | Model = gp.Model() 27 | Model._InitialCutsTime = 0 28 | Model._InitialCutsAdded = 0 29 | 30 | # Y[t] is the number of check-in counters open at time t 31 | Y = {t: Model.addVar(ub=MaxDesks, vtype=gp.GRB.INTEGER) for t in T} 32 | 33 | # Z[xi, t] = 1 if and only if Y[t] = xi 34 | Z = {(xi, t): Model.addVar(vtype=gp.GRB.BINARY) for xi in N for t in T} 35 | 36 | # Theta[s, t] estiamtes the waiting time of passengers 37 | Theta = {(s, t): Model.addVar() for s in S for t in T} 38 | 39 | # Constraints 40 | for t in T: 41 | 42 | # Link the Y and Z variables 43 | Model.addConstr(gp.quicksum(Z[xi, t] for xi in N) == 1) 44 | Model.addConstr(gp.quicksum(Z[xi, t] * xi for xi in N) == Y[t]) 45 | 46 | # Total processing time of some passengers about t 47 | After = max(sum(k[2] for k in SimEngine.Scenarios[s] if k[0] >= t*L) for s in S) 48 | Before = max(sum(k[2] for k in SimEngine.Scenarios[s] 49 | if Start[k[1]] + len(Arrive) <= t) for s in S) 50 | 51 | # Open enough counters to clear the passengers 52 | Model.addConstr(L * gp.quicksum(Y[tt] for tt in T[t:]) >= After) 53 | Model.addConstr(L * gp.quicksum(Y[tt] for tt in T[:t + 1]) >= Before) 54 | 55 | # Objective function 56 | Model.setObjective(DeskCost * gp.quicksum(Y[t] for t in T) + 57 | QueueCost * gp.quicksum(Theta[s, t]/(L * len(S)) 58 | for s in S for t in T)) 59 | 60 | 61 | # -------------------- 62 | # --- Initial Cuts --- 63 | # -------------------- 64 | if Inits: 65 | InitialCutsStartTime = time.time() 66 | print("# Initial cuts:", end=" ") 67 | for s in S: 68 | print(s, end=" ") 69 | 70 | # W[t, xi_1] is the queuing for time period t with xi_1 71 | # counters open in time period t 72 | W1 = {} 73 | 74 | # W2[t, xi_2, xi_1] is the queuing time for time period t 75 | # with xi_1 counters in time period t and xi_2 in t - 1 76 | W2 = {} 77 | 78 | for t in T: 79 | for xi_1 in N: 80 | 81 | # One dimensional information 82 | QueuingTime = SimEngine.Simulate(s, [(t, xi_1)]) 83 | W1[t, xi_1] = QueuingTime[t] 84 | 85 | # Initial values for W2 86 | for xi_2 in N: 87 | W2[t, xi_2, xi_1] = QueuingTime[t] 88 | 89 | if t > 0 and xi_1 < N[-1]: 90 | for xi_2 in N[:-1]: 91 | 92 | # Fix the 2D queuing times 93 | QueuingTime = SimEngine.Simulate(s, [(t-1, xi_2), (t, xi_1)]) 94 | W2[t, xi_2, xi_1] = QueuingTime[t] 95 | if QueuingTime[t] <= W1[t, xi_1] + EPS: 96 | break 97 | 98 | # We can stop fixing W2 if there is no delay 99 | elif QueuingTime[t] == 0: 100 | break 101 | 102 | # Add cuts 103 | for xi_1 in N: 104 | if t > 0: 105 | 106 | # First initial cut 107 | Model._InitialCutsAdded += 1 108 | Model.addConstr(Theta[s, t] >= 109 | gp.quicksum(Z[xi_2, t-1]*W2[t, xi_2, xi_1] 110 | for xi_2 in N) - 111 | gp.quicksum(max(W2[t, xi_2, xi_1] - 112 | W2[t, xi_2, xi_p] 113 | for xi_2 in N)*Z[xi_p, t] 114 | for xi_p in N[xi_1+1:])) 115 | for xi_2 in N: 116 | if t > 0: 117 | 118 | # Second initial cut 119 | Model._InitialCutsAdded += 1 120 | Model.addConstr(Theta[s, t] >= 121 | gp.quicksum(Z[xi_1, t]*W2[t, xi_2, xi_1] 122 | for xi_1 in N) - 123 | gp.quicksum(max(W2[t, xi_2, xi_1] - 124 | W2[t, xi_p, xi_1] 125 | for xi_1 in N)*Z[xi_p, t-1] 126 | for xi_p in N[xi_2+1:])) 127 | 128 | # Time spent generating initial cuts 129 | Model._InitialCutsTime += time.time() - InitialCutsStartTime 130 | print("Done \n") 131 | 132 | # ---------------- 133 | # --- Callback --- 134 | # ---------------- 135 | Model._TimeInCallback = 0 136 | Model._BestObj = gp.GRB.INFINITY 137 | Model._CutsAdded = 0 138 | Model._CutSizes = {t + 1: 0 for t in T} 139 | 140 | # Put the variables into lists 141 | YVar = list(Y.values()) 142 | ZVar = list(Z.values()) 143 | ThetaVar = list(Theta.values()) 144 | 145 | def Callback(model, where): 146 | CallbackStartTime = time.time() 147 | 148 | # Integer solutions 149 | if where == gp.GRB.Callback.MIPSOL: 150 | 151 | # Retrieve the current solution 152 | YVal = model.cbGetSolution(Y) 153 | ThetaVal = model.cbGetSolution(Theta) 154 | 155 | # Get the current levels 156 | Levels = [round(YVal[t]) for t in T] 157 | 158 | # Total performance of the current solution 159 | SumDelay = 0 160 | 161 | # Cuts on s 162 | for s in S: 163 | 164 | # Simulate the current solution; note total performance 165 | QueueTime = SimEngine.Simulate(s, [(t, Levels[t]) for t in T]) 166 | SumDelay += sum(QueueTime[t] for t in T) 167 | 168 | # Cuts on t 169 | for t in T: 170 | 171 | # If Theta[s, t] is correct then move on 172 | if ThetaVal[s, t] >= QueueTime[t] - EPS: 173 | continue 174 | 175 | # Updata Thetas 176 | ThetaVal[s, t] = QueueTime[t] 177 | 178 | # -------------------- 179 | # --- Neighborhood --- 180 | # -------------------- 181 | tLow = t 182 | tHigh = t 183 | 184 | # Two-sided window expansion 185 | while True: 186 | tLow = max(tLow - 1, 0) 187 | tHigh = min(tHigh + 1, T[-1]) 188 | if SimEngine.Simulate(s, [(tt, Levels[tt]) for tt in range( 189 | tLow, tHigh + 1)])[t] >= QueueTime[t] - EPS: 190 | break 191 | 192 | # Contract from the right if we can 193 | while tHigh > t and SimEngine.Simulate( 194 | s, [(tt, Levels[tt]) for tt in range( 195 | tLow, tHigh - 1)])[t] >= QueueTime[t] - EPS: 196 | tHigh -= 1 197 | 198 | # Contract from the left if we can 199 | while tLow < t and SimEngine.Simulate( 200 | s, [(tt, Levels[tt]) for tt in range( 201 | tLow + 1, tHigh)])[t] >= QueueTime[t] - EPS: 202 | tLow += 1 203 | 204 | 205 | # Base is a valid bound if we don't increase y[k] 206 | Base = SimEngine.Simulate(s, [(t, Levels[t])])[t] 207 | 208 | # Increase levels at t 209 | Term1 = gp.quicksum((QueueTime[t] - SimEngine.Simulate( 210 | s, [(t, xi)])[t]) * Z[xi, t] for xi in N[N.index(Levels[t]) + 1:]) 211 | 212 | # Increase levels at others 213 | Term2 = (QueueTime[t] - Base) * (gp.quicksum( 214 | Z[xi, tt] for tt in range(tLow, tHigh + 1) 215 | for xi in N[N.index(Levels[tt]) + 1:] if tt != t)) 216 | 217 | # Add the cut 218 | Model._CutsAdded += 1 219 | Model._CutSizes[round(tHigh - tLow + 1)] += 1 220 | model.cbLazy(Theta[s, t] >= QueueTime[t] - Term1 - Term2) 221 | 222 | # Primal heuristic 223 | CurrentObj = sum(Levels)*DeskCost + SumDelay*QueueCost/(L * len(S)) 224 | if CurrentObj < Model._BestObj: 225 | 226 | # Store the better solution 227 | Model._BestObj = CurrentObj 228 | Model._BestY = model.cbGetSolution(YVar) 229 | Model._BestZ = model.cbGetSolution(ZVar) 230 | Model._BestTheta = [ThetaVal[k] for k in Theta] 231 | 232 | # Pass the better solution to Gurobi 233 | if where == gp.GRB.callback.MIPNODE and \ 234 | model.cbGet(gp.GRB.callback.MIPNODE_STATUS) == gp.GRB.OPTIMAL and \ 235 | model.cbGet(gp.GRB.callback.MIPNODE_OBJBST) > Model._BestObj + EPS: 236 | 237 | # Pass the better solution to Gurobi 238 | Model.cbSetSolution(YVar, Model._BestY) 239 | Model.cbSetSolution(ZVar, Model._BestZ) 240 | Model.cbSetSolution(ThetaVar, Model._BestTheta) 241 | 242 | Model._TimeInCallback += time.time() - CallbackStartTime 243 | 244 | # --------------- 245 | # --- Results --- 246 | # --------------- 247 | Model.Params.LazyConstraints = 1 248 | Model.Params.MIPGap = 0 249 | Model.optimize(Callback) 250 | Model._TotalTime = Model.Runtime + Model._InitialCutsTime 251 | 252 | return Model, {t: round(Y[t].x) for t in T} 253 | 254 | 255 | -------------------------------------------------------------------------------- /nursing/DataNursingHome.csv: -------------------------------------------------------------------------------- 1 | Client ID,Request,Expected service time 2 | 27B,7:00:00,5 3 | 28B,7:00:00,25 4 | 2B,7:00:00,30 5 | 19B,7:15:00,25 6 | 14B,7:15:00,30 7 | 3B,7:15:00,35 8 | 15B,7:15:00,45 9 | 17B,7:15:00,30 10 | 3B,7:36:00,5 11 | 12B,7:45:00,25 12 | 10B,7:45:00,25 13 | 20B,7:45:00,30 14 | 8B,7:45:00,40 15 | 23B,7:50:00,40 16 | 28B,7:55:00,5 17 | 18B,7:58:00,5 18 | 12B,8:00:00,5 19 | 14B,8:00:00,5 20 | 3B,8:00:00,5 21 | 11B,8:00:00,5 22 | 22B,8:00:00,5 23 | 28B,8:00:00,5 24 | 2B,8:00:00,5 25 | 13B,8:00:00,5 26 | 24B,8:00:00,5 27 | 25B,8:00:00,5 28 | 18B,8:00:00,5 29 | 4B,8:00:00,5 30 | 9B,8:00:00,5 31 | 16B,8:00:00,5 32 | 1B,8:00:00,5 33 | 14B,8:01:00,10 34 | 20B,8:03:00,5 35 | 10B,8:05:00,10 36 | 11B,8:10:00,5 37 | 22B,8:14:00,10 38 | 20B,8:15:00,5 39 | 6B,8:15:00,5 40 | 10B,8:15:00,5 41 | 27B,8:15:00,5 42 | 5B,8:15:00,10 43 | 25B,8:15:00,10 44 | 17B,8:30:00,5 45 | 6B,8:30:00,5 46 | 12B,8:30:00,5 47 | 15B,8:30:00,5 48 | 4B,8:30:00,5 49 | 9B,8:30:00,5 50 | 24B,8:30:00,5 51 | 19B,8:30:00,10 52 | 22B,8:30:00,20 53 | 26B,8:30:00,30 54 | 16B,8:34:00,5 55 | 18B,8:40:00,5 56 | 16B,8:40:00,5 57 | 9B,8:45:00,5 58 | 15B,8:45:00,10 59 | 27B,8:45:00,40 60 | 11B,9:00:00,5 61 | 27B,9:00:00,5 62 | 1B,9:00:00,5 63 | 7B,9:00:00,5 64 | 26B,9:00:00,15 65 | 5B,9:00:00,55 66 | 22B,9:05:00,30 67 | 13B,9:15:00,40 68 | 24B,9:25:00,5 69 | 27B,9:30:00,5 70 | 22B,9:30:00,15 71 | 6B,9:30:00,20 72 | 21B,9:30:00,50 73 | 8B,9:45:00,25 74 | 16B,10:00:00,5 75 | 9B,10:00:00,5 76 | 12B,10:00:00,5 77 | 17B,10:00:00,5 78 | 20B,10:00:00,5 79 | 22B,10:00:00,5 80 | 4B,10:00:00,5 81 | 6B,10:00:00,5 82 | 14B,10:00:00,5 83 | 7B,10:00:00,20 84 | 11B,10:00:00,65 85 | 22B,10:01:00,5 86 | 18B,10:10:00,5 87 | 17B,10:15:00,5 88 | 14B,10:31:00,10 89 | 13B,10:45:00,5 90 | 2B,10:45:00,20 91 | 4B,10:45:00,25 92 | 27B,11:00:00,5 93 | 10B,11:45:00,10 94 | 22B,12:10:00,5 95 | 16B,12:15:00,5 96 | 10B,12:15:00,5 97 | 12B,12:15:00,5 98 | 20B,12:15:00,5 99 | 2B,12:15:00,5 100 | 5B,12:15:00,5 101 | 13B,12:15:00,5 102 | 22B,12:15:00,10 103 | 9B,12:15:00,10 104 | 19B,12:15:00,20 105 | 1B,12:15:00,30 106 | 23B,12:15:00,30 107 | 18B,12:20:00,5 108 | 15B,12:30:00,5 109 | 28B,12:30:00,5 110 | 7B,12:30:00,5 111 | 21B,12:30:00,10 112 | 24B,12:30:00,20 113 | 27B,13:00:00,5 114 | 5B,13:00:00,5 115 | 23B,13:00:00,5 116 | 4B,13:00:00,10 117 | 11B,13:00:00,10 118 | 13B,13:00:00,10 119 | 22B,13:00:00,10 120 | 24B,13:00:00,10 121 | 8B,13:00:00,15 122 | 7B,13:00:00,25 123 | 20B,13:05:00,20 124 | 12B,13:10:00,5 125 | 18B,13:15:00,5 126 | 16B,13:15:00,5 127 | 7B,13:45:00,10 128 | 24B,13:55:00,5 129 | 14B,14:29:00,5 130 | 11B,14:29:00,10 131 | 12B,14:30:00,5 132 | 17B,14:30:00,5 133 | 23B,14:30:00,5 134 | 2B,14:30:00,5 135 | 3B,14:30:00,5 136 | 6B,14:30:00,5 137 | 16B,14:30:00,5 138 | 19B,14:30:00,5 139 | 5B,14:30:00,5 140 | 24B,14:30:00,10 141 | 4B,14:35:00,5 142 | 16B,14:45:00,5 143 | 4B,14:45:00,5 144 | 16B,14:55:00,5 145 | 22B,15:00:00,5 146 | 27B,15:00:00,5 147 | 28B,15:00:00,5 148 | 14B,15:00:00,5 149 | 20B,15:00:00,10 150 | 13B,15:00:00,15 151 | 2B,15:00:00,20 152 | 8B,15:00:00,15 153 | 13B,15:15:00,5 154 | 9B,15:30:00,15 155 | 10B,16:45:00,15 156 | 7B,17:05:00,5 157 | 10B,17:13:00,5 158 | 17B,17:15:00,5 159 | 20B,17:15:00,5 160 | 14B,17:15:00,5 161 | 6B,17:15:00,5 162 | 22B,17:15:00,10 163 | 8B,17:18:00,10 164 | 18B,17:20:00,5 165 | 16B,17:30:00,5 166 | 12B,17:30:00,5 167 | 1B,17:30:00,5 168 | 2B,17:30:00,5 169 | 5B,17:30:00,5 170 | 22B,17:30:00,10 171 | 17B,17:30:00,10 172 | 19B,17:30:00,10 173 | 28B,17:30:00,10 174 | 14B,17:30:00,20 175 | 21B,17:30:00,20 176 | 23B,17:30:00,20 177 | 18B,18:00:00,5 178 | 19B,18:00:00,5 179 | 7B,18:00:00,5 180 | 8B,18:00:00,10 181 | 4B,18:00:00,20 182 | 8B,18:30:00,10 183 | 18B,19:00:00,5 184 | 15B,19:00:00,5 185 | 2B,19:00:00,15 186 | 13B,19:00:00,20 187 | 20B,19:00:00,25 188 | 3B,19:00:00,20 189 | 2B,19:15:00,5 190 | 8B,19:20:00,25 191 | 16B,19:30:00,5 192 | 12B,19:30:00,5 193 | 20B,19:30:00,5 194 | 22B,19:30:00,5 195 | 23B,19:30:00,5 196 | 5B,19:30:00,5 197 | 19B,19:30:00,5 198 | 8B,19:30:00,10 199 | 24B,19:30:00,15 200 | 16B,20:00:00,5 201 | 22B,20:01:00,10 202 | 23B,20:30:00,5 203 | 15B,20:30:00,25 204 | 22B,20:30:00,20 205 | 23B,20:45:00,10 206 | 10B,20:50:00,15 207 | 12B,21:00:00,5 208 | 20B,21:00:00,5 209 | 23B,21:00:00,5 210 | 14B,21:00:00,5 211 | 21B,21:00:00,5 212 | 27B,21:00:00,5 213 | 5B,21:00:00,5 214 | 15B,21:00:00,10 215 | 18B,21:00:00,10 216 | 1B,21:00:00,15 217 | 7B,21:05:00,5 218 | 10B,21:05:00,10 219 | 19B,21:30:00,5 220 | 9B,21:30:00,5 221 | 13B,21:30:00,15 222 | 18B,22:00:00,5 223 | 12B,22:15:00,10 224 | 1B,22:45:00,15 225 | 11B,22:59:59,10 226 | -------------------------------------------------------------------------------- /nursing/helper_functions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | # Generate the set of shifts of lengths in Lengths, starting 5 | def GenShifts(Lengths, H, L): # every L minutes for H hours 6 | assert 60 % L == 0, "L must be a divisor of 60 ..." 7 | 8 | # Generate the set of possible start times for shifts 9 | StartTimes = [60*t + l*L for t in range(H) for l in range(round(60 / L)) 10 | if 60*t + l*L + 60*min(Lengths) <= 60*H + 0.001] 11 | Shifts = [] 12 | for l in Lengths: 13 | for start in StartTimes: 14 | 15 | # Add the possible shifts of each length 16 | if start + 60*l <= 60*H + 0.001: 17 | Shifts.append([start, start + 60*l]) 18 | 19 | else: # If a shift of this length doesn't fit 20 | continue # now then it wont fit any later 21 | 22 | # Return 23 | return Shifts 24 | 25 | 26 | # Convert HH:MM:SS to minutes and subtract "start" 27 | def minutes(start, time): # minutes from the total 28 | t = time.split(':') 29 | return (int(t[0]) * 60 + int(t[1]) + int(t[2]) / 60) - start 30 | 31 | 32 | # Generate the matrix A used to link shift types 33 | def GenMatrix(Shifts, L, T): # to care worker levels 34 | 35 | # Most entries are zero 36 | A = [[0 for shift in Shifts] for t in T] 37 | for t in T: 38 | for (g, shift) in enumerate(Shifts): 39 | 40 | # Change A[t][shift] to 1 if shift type "shift" 41 | ttick = t * L + 0.01 # overlaps time period t 42 | if shift[0] <= ttick and ttick <= shift[1]: 43 | A[t][g] = 1 44 | 45 | # Return 46 | return A 47 | 48 | -------------------------------------------------------------------------------- /nursing/instance_generation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import numpy as np 3 | import pandas as pd 4 | from helper_functions import minutes 5 | 6 | 7 | # Generate a single scenario for one day by loading scheduled requests from a file 8 | # and generating unscheduled requests with the specified parameters 9 | def GenerateScenario(Filename, Seed, 10 | ArrivalRate, AvLowDuration, AvHighDuration, LowProb, H): 11 | 12 | # Set seed 13 | if type(Seed) == int: 14 | np.random.seed(seed=Seed) 15 | 16 | 17 | # Load scheduled requests 18 | data = pd.read_csv(Filename) 19 | length = data.shape[0] 20 | start = minutes(0, data["Request"][0]) 21 | 22 | # Set of requests 23 | Requests = [] 24 | 25 | # Scheduled requests with durations sampled from 26 | # expected service time 27 | for row in range(length): 28 | Requests.append((round(minutes(start, data["Request"][row])), 29 | round(max(1, np.random.exponential( 30 | data["Expected service time"][row]))))) 31 | 32 | 33 | tUpto = 0 # Generate unscheduled requests until 34 | while True: # we reach the end of the time horizon 35 | Interval = max(1, round(np.random.exponential(ArrivalRate))) 36 | NextTime = tUpto + Interval 37 | if NextTime >= H * 60: 38 | break 39 | 40 | # Add a short unscheduled request 41 | tUpto = NextTime # and move on 42 | if np.random.random() < LowProb: # Add a short unscheduled request 43 | Requests.append((round(NextTime), 44 | round(max(1, np.random.exponential(AvLowDuration))))) 45 | 46 | else: # Add a long unscheduled request 47 | Requests.append((round(NextTime), 48 | round(max(1, np.random.exponential(AvHighDuration))))) 49 | 50 | # Sort the requests by arrival time and 51 | Requests.sort() # then return the list 52 | return Requests -------------------------------------------------------------------------------- /nursing/main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from model_IP import nursing_IP 3 | from model_benders import nursing_BendersDecomposition 4 | from instance_generation import GenerateScenario 5 | from nursing_simulation import Nursing_Simulation 6 | from pathlib import Path 7 | 8 | 9 | def main(): 10 | Seed = None 11 | 12 | # Type A Parameters 13 | # Filename = "DataNursingHome.csv" 14 | # ArrivalRate = 20 15 | # AvLowDuration = 1.89 16 | # AvHighDuration = 9.28 17 | # LowProb = 0.8 18 | # H = 16 19 | # L = 60 20 | # T = range(H) 21 | # N = 8 22 | # ShiftLengths = [4, 8] 23 | # MinWorkers = 2 24 | # MaxWorkers = 20 25 | # MaxHours = 80 26 | 27 | # Type B Parameters 28 | DataFile = Path(__file__).parent / "DataNursingHome.csv" 29 | ArrivalRate = 20 30 | AvLowDuration = 1.89 31 | AvHighDuration = 9.28 32 | LowProb = 0.8 33 | H = 4 34 | L = 60 35 | T = range(H) 36 | N = 2 37 | ShiftLengths = [1, 2] 38 | MinWorkers = 1 39 | MaxWorkers = 10 40 | MaxHours = 15 41 | 42 | # Generate requests 43 | Scenario = GenerateScenario(DataFile, Seed, ArrivalRate, AvLowDuration, 44 | AvHighDuration, LowProb, H) 45 | 46 | # Purge every 4th request and quater the start times 47 | Scenario = Scenario[::4] 48 | for i in range(len(Scenario)): 49 | Scenario[i] = (round(Scenario[i][0] / 4), Scenario[i][1]) 50 | 51 | 52 | # Create a simulation 53 | SimEngine = Nursing_Simulation(T, L, MinWorkers, MaxWorkers, [Scenario]) 54 | 55 | 56 | # Solve by Benders decomposition 57 | modelBD, YSol = nursing_BendersDecomposition(SimEngine, True, True, H, L, 58 | ShiftLengths, MinWorkers, 59 | MaxWorkers, MaxHours, None) 60 | 61 | # Solve IP with solution given 62 | modelIPf, _ = nursing_IP(Scenario, H, N, T, L, ShiftLengths, 63 | MinWorkers, MaxWorkers, MaxHours, YSol) 64 | 65 | # Solve IP without solution given 66 | modelIP, _ = nursing_IP(Scenario, H, N, T, L, ShiftLengths, 67 | MinWorkers, MaxWorkers, MaxHours, None) 68 | 69 | 70 | if __name__ == "__main__": 71 | main() 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /nursing/model_IP.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import gurobipy as gp 3 | from gurobipy import GRB 4 | from helper_functions import GenShifts, GenMatrix 5 | 6 | 7 | def nursing_IP(Requests, H, Night, T, L, Lengths, MinWorkers, MaxWorkers, MaxHours, YFix): 8 | 9 | # (*) Extended set of time periods (the night shift is one long period) 10 | TT = range(len(T) + 1) 11 | 12 | # (*) Set of minutes during the day 13 | Times = range(H * L) 14 | 15 | # (*) Extended set of minutes (including night shift) 16 | TimesN = range((H + Night)*L) 17 | 18 | # BigM 19 | BigM = MaxWorkers - MinWorkers 20 | 21 | # (*) The set of shift types 22 | Shifts = GenShifts(Lengths, H, L) 23 | 24 | # (*) The matrix A linking shifts to levels 25 | A = GenMatrix(Shifts, L, T) 26 | 27 | # ------------------------------- 28 | # --- Helpful data structures --- 29 | # ------------------------------- 30 | 31 | # InPeriod[minute] is the period containing minute 'minute' 32 | # The night shift is one big time period 33 | InPeriod = {minute: TT[-1] for minute in TimesN} 34 | for minute in Times: 35 | InPeriod[minute] = minute // L 36 | 37 | # Set of shifts 38 | G = range(len(Shifts)) 39 | 40 | # Set of jobs 41 | J = range(len(Requests)) 42 | 43 | 44 | # CanStart[j] is the set of minutes that job j can start 45 | # Any time from release time, until last minute minus processing time 46 | CanStart = {j: range( 47 | Requests[j][0], TimesN[-Requests[j][1]]) for j in J} 48 | 49 | # Set of jobs released so far at each minute 50 | Avail = {minute: [j for j in J if Requests[j][0] <= minute] for minute in TimesN} 51 | 52 | 53 | # -------------------- 54 | # --- Gurobi model --- 55 | # -------------------- 56 | m = gp.Model() 57 | 58 | # The number of care workers working shift g on day d 59 | X = {g: m.addVar(vtype=GRB.INTEGER) for g in G} 60 | 61 | # The number of care workers working in time period t 62 | Y = {t: m.addVar(vtype=GRB.INTEGER, lb=MinWorkers, ub=MaxWorkers) for t in T} 63 | Y[TT[-1]] = m.addVar(vtype=GRB.INTEGER) 64 | m.addConstr(Y[TT[-1]] == MinWorkers) 65 | 66 | for t in T: # Link shifts to agents 67 | m.addConstr(Y[t] == gp.quicksum(A[t][g] * X[g] for g in G)) 68 | 69 | # Maximum working hours 70 | m.addConstr(gp.quicksum(L * Y[t] for t in T) <= 60 * MaxHours) 71 | 72 | # Fix solution if given 73 | if YFix is not None: 74 | for t in YFix: 75 | m.addConstr(Y[t] == YFix[t]) 76 | 77 | # Binary indicator that a job j 78 | # has started by minute minute 79 | Sigma = {(j, minute): m.addVar( 80 | vtype=GRB.BINARY) for j in J for minute in TimesN} 81 | 82 | # -1 index 83 | for j in J: 84 | Sigma[j, -1] = 0 85 | 86 | for j in J: # No job starts done 87 | m.addConstr(Sigma[j, min(CanStart[j]) - 1] == 0) 88 | 89 | # Constraints per job 90 | for j in J: 91 | 92 | # Finish every job 93 | m.addConstr(Sigma[j, max(CanStart[j])] == 1) 94 | 95 | # Constraints per minute 96 | for minute in TimesN: 97 | 98 | # Correct start times 99 | if minute - 1 in TimesN: 100 | m.addConstr(Sigma[j, minute - 1] <= Sigma[j, minute]) 101 | 102 | # FCFS Schedule 103 | if j - 1 in J: 104 | m.addConstr(Sigma[j, minute] <= Sigma[j - 1, minute]) 105 | 106 | # Expression for total active jobs at minute 107 | ActiveJobs = gp.quicksum( 108 | Sigma[jj, minute] - 109 | Sigma[jj, max(minute - Requests[jj][1], Requests[jj][0] - 1)] 110 | for jj in Avail[minute] if jj != j) 111 | 112 | # Add the constraint 113 | if minute - 1 in TimesN: 114 | m.addConstr(Sigma[j, minute] - Sigma[j, minute - 1] <= 115 | Y[InPeriod[minute]] - ActiveJobs 116 | + BigM * (1 - Sigma[j, minute] + Sigma[j, minute - 1])) 117 | 118 | 119 | # Minimize total delay 120 | m.setObjective(gp.quicksum((minute - Requests[j][0])*(Sigma[j, minute] - Sigma[j, minute - 1]) 121 | for j in J for minute in CanStart[j]), GRB.MINIMIZE) 122 | 123 | 124 | 125 | # m.setParam("OutputFlag", 0) 126 | m.optimize() 127 | return m, {t: round(Y[t].x) for t in T} 128 | 129 | 130 | 131 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /nursing/model_benders.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import time 3 | import gurobipy as gp 4 | from helper_functions import GenShifts, GenMatrix 5 | 6 | 7 | def nursing_BendersDecomposition(SimEngine, Inits, Heuristic, 8 | H, L, ShiftLengths, MinWorkers, MaxWorkers, MaxHours, YFix): 9 | 10 | # Small number 11 | EPS = 0.0001 12 | 13 | # Time periods 14 | T = range(H) 15 | 16 | # Set of scenarios 17 | S = range(len(SimEngine.Scenario)) 18 | 19 | # Set of worker levels 20 | N = range(MinWorkers, MaxWorkers + 1) 21 | 22 | # Set of shifts 23 | Shifts = GenShifts(ShiftLengths, H, L) 24 | 25 | # Set of shifts 26 | G = range(len(Shifts)) 27 | 28 | # Shift-time matrix 29 | A = GenMatrix(Shifts, L, T) 30 | 31 | # -------------------- 32 | # --- Gurobi model --- 33 | # -------------------- 34 | Model = gp.Model() 35 | Model._InitialCutsTime = 0 36 | Model._InitialCutsAdded = 0 37 | Model._HeuristicTime = 0 38 | Model._HeuristicValue = None 39 | 40 | # X[g] is the number of care workers rostered for shift type g 41 | X = {g: Model.addVar(vtype=gp.GRB.INTEGER) for g in G} 42 | 43 | # Y[t] is the number of care workers working time period t 44 | Y = {t: Model.addVar( 45 | vtype=gp.GRB.INTEGER, lb=MinWorkers, ub=MaxWorkers) for t in T} 46 | 47 | # Fix solution if given 48 | if YFix is not None: 49 | for t in YFix: 50 | Model.addConstr(Y[t] == YFix[t]) 51 | 52 | # Z[xi, t] = 1 if and only if Y[t] = xi 53 | Z = {(xi, t): Model.addVar(vtype=gp.GRB.BINARY) for xi in N for t in T} 54 | 55 | # Theta[s, t] estimates the delay of requests released in time period t 56 | Theta = {(s, t): Model.addVar() for s in S for t in T} 57 | 58 | # Scheduling constraints 59 | for t in T: 60 | 61 | # Correct care workers available given the shift schedule X 62 | Model.addConstr(Y[t] == gp.quicksum(A[t][g] * X[g] for g in G)) 63 | 64 | # Maximum working hours 65 | Model.addConstr( 66 | gp.quicksum(L * Y[t] for t in T) <= 60 * MaxHours) 67 | 68 | # Link Z Y 69 | for t in T: 70 | Model.addConstr(gp.quicksum(Z[xi, t] for xi in N) == 1) 71 | Model.addConstr(gp.quicksum(xi * Z[xi, t] for xi in N) == Y[t]) 72 | 73 | # Objective function 74 | Model.setObjective(gp.quicksum( 75 | Theta[_] for _ in Theta) / len(S), gp.GRB.MINIMIZE) 76 | 77 | # -------------------- 78 | # --- Initial Cuts --- 79 | # -------------------- 80 | if Inits: 81 | InitialCutsStartTime = time.time() 82 | for s in S: 83 | 84 | # W[t, xi_1] is the delay for time period 85 | # t with xi_1 workers in time period t 86 | W1 = {} 87 | 88 | # W2[t, xi_2, xi_1] is the delay time for time period t 89 | # with xi_1 workers in time period t and xi_2 in t - 1 90 | W2 = {} 91 | 92 | for t in T: 93 | for xi_1 in N: 94 | # One dimensional information 95 | Delay = SimEngine.Simulate(s, [(t, xi_1)]) 96 | W1[t, xi_1] = Delay[t] 97 | # Initial values for W2 98 | for xi_2 in N: 99 | W2[t, xi_2, xi_1] = Delay[t] 100 | # Fix the W2 values 101 | if t > 0 and xi_1 < N[-1]: 102 | for xi_2 in N[:-1]: 103 | Delay = SimEngine.Simulate(s, [(t - 1, xi_2), 104 | (t, xi_1)]) 105 | W2[t, xi_2, xi_1] = Delay[t] 106 | if Delay[t] <= W1[t, xi_1] + EPS: 107 | break 108 | 109 | # We can stop fixing W2 if there is no delay 110 | elif Delay[t] == 0: 111 | break 112 | 113 | # Add cuts 114 | for xi_1 in N: 115 | if t > 0: 116 | 117 | # First initial cut 118 | Model._InitialCutsAdded += 1 119 | Model.addConstr(Theta[s, t] >= 120 | gp.quicksum(Z[xi_2, t-1] * 121 | W2[t, xi_2, xi_1] 122 | for xi_2 in N) - 123 | gp.quicksum(max(W2[t, xi_2, xi_1] - 124 | W2[t, xi_2, xi_p] 125 | for xi_2 in N) * 126 | Z[xi_p, t] 127 | for xi_p in 128 | N[N.index(xi_1)+1:])) 129 | for xi_2 in N: 130 | if t > 0: 131 | 132 | # Second initial cut 133 | Model._InitialCutsAdded += 1 134 | Model.addConstr(Theta[s, t] >= 135 | gp.quicksum(Z[xi_1, t] * 136 | W2[t, xi_2, xi_1] 137 | for xi_1 in N) - 138 | gp.quicksum(max(W2[t, xi_2, xi_1] - 139 | W2[t, xi_p, xi_1] 140 | for xi_1 in N) * 141 | Z[xi_p, t-1] 142 | for xi_p in 143 | N[N.index(xi_2)+1:])) 144 | # Initial cuts time 145 | Model._InitialCutsTime += time.time() - InitialCutsStartTime 146 | 147 | # ----------------- 148 | # --- Heuristic --- 149 | # ----------------- 150 | HeuristicValue = None 151 | if Heuristic: 152 | HeuristicStartTime = time.time() 153 | 154 | # Get the total duration of requests which arrive 155 | Durations = {t: 0 for t in T} 156 | Durations[len(T)] = 0 157 | for s in S: 158 | for k in SimEngine.Scenario[s]: 159 | Durations[round(k[0] // L)] += k[1] 160 | 161 | # Curve[d][t] is the average number of service hours in 162 | # time period t of day d 163 | Curve = [Durations[t] / (len(S) * L) + 2 for t in T] 164 | 165 | # MIP model for the heuristic 166 | Heuristic = gp.Model() 167 | 168 | # Variables: equivalent to main model 169 | XH = {g: Heuristic.addVar(vtype=gp.GRB.INTEGER) for g in G} 170 | 171 | YH = {t: Heuristic.addVar( 172 | vtype=gp.GRB.INTEGER, lb=MinWorkers, ub=MaxWorkers) for t in T} 173 | 174 | # Dist[t, d] is the absolute distance between Curve[t] and Y[t] 175 | Dist = {t: Heuristic.addVar() for t in T} 176 | 177 | # Constraints 178 | for t in T: 179 | # Scheduling constraint 180 | Heuristic.addConstr( 181 | YH[t] == gp.quicksum(A[t][g] * XH[g] for g in G)) 182 | 183 | # Absolute value 184 | Heuristic.addConstr(Dist[t] >= Curve[t] - YH[t]) 185 | Heuristic.addConstr(Dist[t] >= YH[t] - Curve[t]) 186 | 187 | # Maxium working hours 188 | Heuristic.addConstr(gp.quicksum(L * YH[t] for t in T) <= 60 * MaxHours) 189 | 190 | # Heuristic objective: minimize difference between 191 | # total agent hours and hourly workloads 192 | Heuristic.setObjective(gp.quicksum(Dist[t] for t in T) / len(T)) 193 | 194 | # Solve heuristic 195 | Heuristic.setParam("OutputFlag", 0) 196 | Heuristic.optimize() 197 | 198 | # Retrieve the solution and give it to Gurobi 199 | HeuristicValue = 0 200 | for g in G: 201 | X[g].Start = round(XH[g].x) 202 | for t in T: 203 | Y[t].Start = round(YH[t].x) 204 | for xi in N: 205 | if round(YH[t].x) == xi: 206 | Z[xi, t].Start = 1 207 | else: 208 | Z[xi, t].Start = 0 209 | 210 | # Simulate the heuristic solution 211 | for s in S: 212 | ThetaH = SimEngine.Simulate(s, [(t, round(YH[t].x)) for t in T]) 213 | 214 | # Set starting Theta[s, t] 215 | for t in T: 216 | HeuristicValue += ThetaH[t] 217 | Theta[s, t].Start = ThetaH[t] 218 | 219 | Model._HeuristicValue = HeuristicValue / len(S) 220 | Model._HeuristicTime += time.time() - HeuristicStartTime 221 | 222 | # ---------------- 223 | # --- Optimize --- 224 | # ---------------- 225 | Model._TimeInCallback = 0 226 | Model._BestObj = gp.GRB.INFINITY 227 | Model._BestY = None 228 | Model._BestZ = None 229 | Model._BestTheta = None 230 | Model._CutsAdded = 0 231 | 232 | # Put the variables into lists 233 | YVar = list(Y.values()) 234 | ZVar = list(Z.values()) 235 | ThetaVar = list(Theta.values()) 236 | 237 | 238 | # Callback function 239 | def Callback(model, where): 240 | CallbackStartTime = time.time() 241 | if where == gp.GRB.Callback.MIPSOL: 242 | 243 | # Retrieve the current solution 244 | YV = model.cbGetSolution(Y) 245 | ThetaV = model.cbGetSolution(Theta) 246 | SumDelay = 0 247 | 248 | # Simulate each scenario 249 | for s in S: 250 | 251 | # Get the care worker levels of today 252 | Levels = [round(YV[t]) for t in T] 253 | 254 | Delay = SimEngine.Simulate(s, [(t, Levels[t]) for t in T]) 255 | SumDelay += sum(Delay[t] for t in T) 256 | 257 | # Add cuts 258 | for t in T: 259 | 260 | # No cut if Theta is high enough 261 | if ThetaV[s, t] >= Delay[t] - EPS: 262 | continue 263 | 264 | # Update Theta and log the cut 265 | ThetaV[s, t] = Delay[t] 266 | Model._CutsAdded += 1 267 | 268 | # Get the starting neighborhood 269 | tLow = t 270 | tHigh = t 271 | 272 | # Expand the neighborhood while legal 273 | while True: 274 | tLow = max(tLow - 1, 0) 275 | tHigh = min(tHigh + 1, T[-1]) 276 | 277 | # If the neighborhood is good enough then stop 278 | if SimEngine.Simulate(s, [(tt, Levels[tt]) for tt in 279 | range(tLow, tHigh + 1)])[t] >= \ 280 | Delay[t] - EPS: 281 | break 282 | 283 | # Contract from the right if we can 284 | while tHigh > t and \ 285 | SimEngine.Simulate(s, [(tt, Levels[tt]) for tt in 286 | range(tLow, tHigh - 1)])[t] >= \ 287 | Delay[t] - EPS: 288 | tHigh -= 1 289 | 290 | # Contract from the left if we can 291 | while tLow < t and \ 292 | SimEngine.Simulate(s, [(tt, Levels[tt]) for tt in 293 | range(tLow + 1, tHigh)])[t] >= \ 294 | Delay[t] - EPS: 295 | tLow += 1 296 | 297 | # Base is a valid bound if we don't increase Y[t] 298 | Base = SimEngine.Simulate(s, [(t, Levels[t])])[t] 299 | 300 | # If we increase workers at t 301 | Term1 = gp.quicksum((Delay[t] - 302 | SimEngine.Simulate(s, [(t, xi)])[t]) * 303 | Z[xi, t] for xi in 304 | N[N.index(Levels[t]) + 1:]) 305 | 306 | # If we dont increase workers at t 307 | Term2 = (Delay[t] - Base) * (gp.quicksum( 308 | Z[xi, tt] for tt in range(tLow, tHigh + 1) 309 | for xi in N[N.index(Levels[tt]) + 1:] if tt != t)) 310 | 311 | # Add the Benders cut as a lazy constraint 312 | Model._CutsAdded += 1 313 | model.cbLazy(Theta[s, t] >= Delay[t] - Term1 - Term2) 314 | 315 | 316 | # Primal heuristic 317 | if SumDelay < Model._BestObj: 318 | 319 | # Store the better solution 320 | Model._BestObj = SumDelay 321 | Model._BestY = model.cbGetSolution(YVar) 322 | Model._BestZ = model.cbGetSolution(ZVar) 323 | Model._BestTheta = [ThetaV[k] for k in Theta] 324 | 325 | # Pass the better solution to Gurobi 326 | if where == gp.GRB.callback.MIPNODE and \ 327 | model.cbGet(gp.GRB.callback.MIPNODE_STATUS) == gp.GRB.OPTIMAL and \ 328 | model.cbGet(gp.GRB.callback.MIPNODE_OBJBST) > Model._BestObj + EPS: 329 | 330 | # Pass the better solution to Gurobi 331 | Model.cbSetSolution(YVar, Model._BestY) 332 | Model.cbSetSolution(ZVar, Model._BestZ) 333 | Model.cbSetSolution(ThetaVar, Model._BestTheta) 334 | 335 | Model._TimeInCallback += time.time() - CallbackStartTime 336 | 337 | # Solve 338 | Model.setParam("LazyConstraints", 1) 339 | # Model.setParam("OutputFlag", 0) 340 | Model.optimize(Callback) 341 | Model._TotalTime = Model.Runtime + Model._InitialCutsTime \ 342 | + Model._HeuristicTime 343 | 344 | 345 | 346 | return Model, {t: round(Y[t].x) for t in Y} 347 | 348 | 349 | 350 | -------------------------------------------------------------------------------- /nursing/nursing_simulation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import time 3 | import heapq 4 | 5 | 6 | # A simulation engine with caching 7 | # and other efficiencies 8 | class Nursing_Simulation: 9 | def __init__(self, T, L, MinWorkers, MaxWorkers, Scenario): 10 | 11 | self.Simulations = {} 12 | self.CareStart = {} 13 | self.ReqsStart = {} 14 | self.TimeInSimulation = 0 15 | self.T = T 16 | self.L = L 17 | self.MinWorkers = MinWorkers 18 | self.MaxWorkers = MaxWorkers 19 | self.Scenario = Scenario 20 | 21 | # Initialize data structures 22 | for s in range(len(Scenario)): 23 | self.Simulate(s, None) 24 | 25 | def Simulate(self, s, LevelsList): 26 | SimStartTime = time.time() 27 | 28 | 29 | # Set up the worker levels 30 | Levels = [self.MaxWorkers for t in self.T] 31 | init = False 32 | if not LevelsList: 33 | init = True 34 | else: 35 | for (t, l) in LevelsList: 36 | Levels[t] = l 37 | 38 | # Check the cache 39 | TupleLevels = tuple(Levels) 40 | if (s, TupleLevels) in self.Simulations: 41 | return self.Simulations[s, TupleLevels] 42 | 43 | # Total delays 44 | Delay = {t: 0 for t in self.T} 45 | 46 | # Stopping criteria 47 | if init: 48 | tEnd = self.T[-1] + 1 49 | else: 50 | tEnd = LevelsList[-1][0] + 1 51 | 52 | # Initialise the simulation 53 | if init or LevelsList[0][0] == 0: 54 | tUpto = 0 55 | Agents = [] 56 | 57 | # Push each starting agent 58 | for i in range(Levels[0]): 59 | heapq.heappush(Agents, 0) 60 | 61 | # Get requests in this scenario 62 | Requests = self.Scenario[s] 63 | 64 | # If not initialising then 65 | # retrieve the starting state 66 | else: 67 | tUpto = LevelsList[0][0] 68 | Agents = [] 69 | for a in list(self.CareStart[s, tUpto]): 70 | heapq.heappush(Agents, a) 71 | for i in range(int(Levels[tUpto]), self.MaxWorkers): 72 | heapq.heappop(Agents) 73 | Requests = self.Scenario[s][self.ReqsStart[s, tUpto]:] 74 | 75 | # For when we get to the nightshift 76 | NightShift = False 77 | 78 | # Service the ith request 79 | for (i, k) in enumerate(Requests): 80 | # print(i, end = " ") 81 | # Stopping condition 82 | if k[0] > tEnd * self.L: 83 | break 84 | 85 | # Initialization 86 | if init: 87 | # If the next request doesn't arrive until the next 88 | if k[0] >= (tUpto + 1) * self.L: # time period, then move on 89 | tUpto += 1 90 | 91 | # Populate the data structures 92 | self.CareStart[s, tUpto] = tuple(Agents) 93 | self.ReqsStart[s, tUpto] = i 94 | 95 | else: 96 | # If the next time period isn't the night shift 97 | if round(tUpto + 1) in self.T: 98 | 99 | # If there are no workers available, or the next 100 | # request hasen't arrived yet, then move on 101 | while round(tUpto + 1) in self.T and \ 102 | max(k[0], Agents[0]) >= (tUpto + 1) * self.L: 103 | 104 | # Add care workers 105 | if Levels[tUpto + 1] > Levels[tUpto]: 106 | for _ in range(Levels[tUpto], Levels[tUpto + 1]): 107 | heapq.heappush(Agents, self.L*(tUpto + 1)) 108 | # Remove care workers 109 | if Levels[tUpto + 1] < Levels[tUpto]: 110 | for _ in range(Levels[tUpto + 1], Levels[tUpto]): 111 | heapq.heappop(Agents) 112 | # Move on 113 | tUpto += 1 114 | 115 | # Go to the night shift once the next job must 116 | # start during the night shift 117 | elif not NightShift: 118 | if int(max(k[0], min(Agents)) // self.L) not in self.T: 119 | NightShift = True 120 | # Drop down to night shift levels 121 | for _ in range(len(Agents) - self.MinWorkers): 122 | heapq.heappop(Agents) 123 | 124 | 125 | # Get the next care worker off the queue 126 | Agent = heapq.heappop(Agents) 127 | # Compute the delay 128 | ThisDelay = max(0, Agent - k[0]) 129 | # Update the objective value 130 | Delay[min(len(self.T) - 1, int(k[0] // self.L))] += ThisDelay 131 | # Get the next idle time of the care worker 132 | Agent = max(Agent, k[0]) + k[1] 133 | # Put the care worker back into the agent queue 134 | heapq.heappush(Agents, Agent) 135 | 136 | # Dont bother handling any requests in the last 137 | # time period if we are in the initialization step 138 | if init: 139 | for tUpto in self.T[tUpto + 1:]: 140 | self.CareStart[s, tUpto] = tuple(Agents) 141 | self.ReqsStart[s, tUpto] = len(self.Scenario[s]) 142 | 143 | # Cache and return the performance 144 | self.Simulations[s, TupleLevels] = Delay 145 | self.TimeInSimulation += time.time() - SimStartTime 146 | return Delay 147 | --------------------------------------------------------------------------------