From 63dcc7ce7810f50e0307b910ef1716fc046822d7 Mon Sep 17 00:00:00 2001 From: Ross Date: Sat, 16 May 2020 19:01:35 +0100 Subject: [PATCH] night shift balancing now works! --- .~lock.trainees.csv# | 2 +- shifts.py | 130 +++++++++++++++++++++++++++++++------------ test.log | 118 --------------------------------------- test.py | 71 ++++++++++++----------- 4 files changed, 136 insertions(+), 185 deletions(-) delete mode 100644 test.log diff --git a/.~lock.trainees.csv# b/.~lock.trainees.csv# index bada531..09ebca7 100644 --- a/.~lock.trainees.csv# +++ b/.~lock.trainees.csv# @@ -1 +1 @@ -,ross,maverick.fritz.box,14.05.2020 23:29,file:///home/ross/.config/libreoffice/4; \ No newline at end of file +,ross,maverick.fritz.box,16.05.2020 17:19,file:///home/ross/.config/libreoffice/4; \ No newline at end of file diff --git a/shifts.py b/shifts.py index 5ecc6f6..222b09a 100644 --- a/shifts.py +++ b/shifts.py @@ -241,19 +241,25 @@ class RotaBuilder(object): initialize=0, ) - self.model.worker_weekend_assigned = Var( - ((worker.id, week) for worker in self.workers - for week in self.weeks), - domain=NonNegativeReals, - initialize=0, - ) + self.model.night_shift_count_w = Var( + ((worker.id) for worker in self.workers), + domain=NonNegativeReals, + initialize=0, + ) - self.model.works_weekend_count = Var( - ((worker.id, week) for worker in self.workers - for week in self.weeks), - domain=NonNegativeIntegers, - initialize=0, - ) + # self.model.worker_weekend_assigned = Var( + # ((worker.id, week) for worker in self.workers + # for week in self.weeks), + # domain=NonNegativeReals, + # initialize=0, + # ) + + # self.model.works_weekend_count = Var( + # ((worker.id, week) for worker in self.workers + # for week in self.weeks), + # domain=NonNegativeIntegers, + # initialize=0, + # ) # self.model.works_saturday = Var( # ((worker.id, week) for worker in self.workers # for week in self.weeks), @@ -274,18 +280,39 @@ class RotaBuilder(object): # initialize=0, # ) - self.model.works_weekend = Var( - ((worker.id, week) for worker in self.workers - for week in self.weeks), - domain=Binary, - initialize=0, - ) + if self.constraint_options["balance_weekends"]: - self.model.worker_weekend_count = Var( - ((worker.id) for worker in self.workers), - domain=NonNegativeIntegers, - initialize=0, - ) + self.model.works_weekend = Var( + ((worker.id, week) for worker in self.workers + for week in self.weeks), + domain=Binary, + initialize=0, + ) + + self.model.worker_weekend_count = Var( + ((worker.id) for worker in self.workers), + domain=NonNegativeIntegers, + initialize=0, + ) + + + self.model.weekend_shift_count_t1 = Var( + ((worker.id) for worker in self.workers), + domain=NonNegativeReals, + initialize=0, + ) + + self.model.weekend_shift_count_t2 = Var( + ((worker.id) for worker in self.workers), + domain=NonNegativeReals, + initialize=0, + ) + + self.model.weekend_shift_count_w = Var( + ((worker.id) for worker in self.workers), + domain=NonNegativeReals, + initialize=0, + ) # self.model.weekend_count_t1 = Var( # ((worker.id) for worker in self.workers), @@ -418,12 +445,12 @@ class RotaBuilder(object): ) def nightShiftMinST4Rule(model, week, shift): - single_workers = [w for w in self.workers if w.grade > 3] + single_workers = [w for w in self.workers if w.grade >= 4] if not single_workers: print(single_workers) return Constraint.Skip return sum(model.shift_week_worker_assigned[shift, week, w.id] - for w in single_workers) >= 2 + for w in single_workers) >= 1 if self.constraint_options["ensure_1_st4_plus_on_nights"]: self.model.night_shifts_min_st4_constraint = Constraint( @@ -592,6 +619,31 @@ class RotaBuilder(object): self.model.night_shift_count[worker.id] - night_shift_target_number) + xU = 6 + xL = 1 + self.model.constraints.add( + inequality( + xL, + self.model.night_shift_count_t1[worker.id] + + self.model.night_shift_count_t2[worker.id] + 1, + xU, + )) + + self.model.constraints.add( + self.model.night_shift_count_w[worker.id] >= xL * + (self.model.night_shift_count_t1[worker.id] + + self.model.night_shift_count_t2[worker.id] + 1) * 2 - + xL * xL) + + self.model.constraints.add( + self.model.night_shift_count_w[worker.id] >= xU * + (self.model.night_shift_count_t1[worker.id] + + self.model.night_shift_count_t2[worker.id] + 1) * 2 - + xU * xU) + + # self.model.constraints.add( + # self.model.night_shift_count_w[worker.id] >= 0) + # Ensure worker is not allocated shifts on non working days if worker.nwd: for week, day, shift in self.get_all_shiftclass_combinations(): @@ -775,11 +827,12 @@ class RotaBuilder(object): shift_balancing = 0 if self.constraint_options["balance_nights"]: - night_balance_modifier_constant = 2000 + night_balance_modifier_constant = 1 night_shift_balancing = sum( night_balance_modifier_constant * - (self.model.night_shift_count_t1[(worker.id)] + - self.model.night_shift_count_t2[(worker.id)]) + self.model.night_shift_count_w[(worker.id)] + # (self.model.night_shift_count_t1[(worker.id)] + + # self.model.night_shift_count_t2[(worker.id)]) for worker in self.workers) else: night_shift_balancing = 0 @@ -816,7 +869,7 @@ class RotaBuilder(object): return night_shift_balancing #return shift_balancing + preferences + blocks_balancing #return shift_balancing + preferences + nights_site_balancing + blocks_balancing - return shift_balancing + night_shift_balancing + preferences + nights_site_balancing + blocks_balancing + #return shift_balancing + night_shift_balancing + preferences + nights_site_balancing + blocks_balancing # add objective function to the model. rule (pass function) or expr (pass expression directly) self.model.obj = Objective(rule=obj_rule, sense=minimize) @@ -1267,9 +1320,16 @@ class RotaResults(object): def get_night_details(self): for worker in self.rota.workers: print( - worker.name, - self.rota.model.night_shift_count[(worker.id)].value, - self.rota.model.night_shift_count_t1[(worker.id)].value, - self.rota.model.night_shift_count_t2[(worker.id)].value, - worker.shift_target_number["night_weekday"] + - worker.shift_target_number["night_weekend"]) + "{:20}".format(worker.name), + "worked: {},".format( + self.rota.model.night_shift_count[(worker.id)].value), + "target: {},".format( + worker.shift_target_number["night_weekday"] + + worker.shift_target_number["night_weekend"]), + "target_diff: {},".format( + self.rota.model.night_shift_count_t1[(worker.id)].value + + self.rota.model.night_shift_count_t2[(worker.id)].value + + 1), + "balance: {},".format( + self.rota.model.night_shift_count_w[worker.id].value), + ) \ No newline at end of file diff --git a/test.log b/test.log deleted file mode 100644 index da9846b..0000000 --- a/test.log +++ /dev/null @@ -1,118 +0,0 @@ -Solver command line: ['/usr/bin/cbc', '-seconds', '1200', '-allow', '4000', '-printingOptions', 'all', '-import', '/tmp/tmpeun6jpa7.pyomo.lp', '-stat=1', '-solve', '-solu', '/tmp/tmpeun6jpa7.pyomo.soln'] - -Welcome to the CBC MILP Solver -Version: 2.9.9 -Build Date: Aug 7 2019 - -command line - /usr/bin/cbc -seconds 1200 -allow 4000 -printingOptions all -import /tmp/tmpeun6jpa7.pyomo.lp -stat=1 -solve -solu /tmp/tmpeun6jpa7.pyomo.soln (default strategy 1) -seconds was changed from 1e+100 to 1200 -allowableGap was changed from 1e-10 to 4000 -Option for printingOptions changed from normal to all -Presolve 57035 (-82138) rows, 23862 (-89251) columns and 226642 (-883011) elements -Statistics for presolved model -Original problem has 112272 integers (112112 of which binary) -Presolved problem has 23696 integers (23696 of which binary) -==== 23751 zero objective 2 different -23751 variables have objective of 0 -111 variables have objective of 2000 -==== absolute objective values 2 different -23751 variables have objective of 0 -111 variables have objective of 2000 -==== for integers 23696 zero objective 1 different -23696 variables have objective of 0 -==== for integers absolute objective values 1 different -23696 variables have objective of 0 -===== end objective counts - - -Problem has 57035 rows, 23862 columns (111 with objective) and 226642 elements -There are 111 singletons with objective 988 singletons with no objective -Column breakdown: -110 of type 0.0->inf, 56 of type 0.0->up, 0 of type lo->inf, -0 of type lo->up, 0 of type free, 0 of type fixed, -0 of type -inf->0.0, 0 of type -inf->up, 23696 of type 0.0->1.0 -Row breakdown: -2843 of type E 0.0, 676 of type E 1.0, 0 of type E -1.0, -289 of type E other, 0 of type G 0.0, 0 of type G 1.0, -231 of type G other, 2788 of type L 0.0, 48596 of type L 1.0, -1612 of type L other, 0 of type Range 0.0->1.0, 0 of type Range other, -0 of type Free -Continuous objective value is 497995 - 22.34 seconds -Cgl0002I 82402 variables fixed -Cgl0003I 0 fixed, 55 tightened bounds, 34225 strengthened rows, 50251 substitutions -Cgl0004I processed model has 17402 rows, 14104 columns (13939 integer (13939 of which binary)) and 89454 elements -Cbc0038I Initial state - 511 integers unsatisfied sum - 146.075 -Cbc0038I Pass 1: (33.84 seconds) suminf. 63.26415 (238) obj. 530335 iterations 2863 -Cbc0038I Pass 2: (33.87 seconds) suminf. 52.41969 (214) obj. 526335 iterations 226 -Cbc0038I Pass 3: (33.90 seconds) suminf. 51.56248 (211) obj. 526335 iterations 61 -Cbc0038I Pass 4: (33.93 seconds) suminf. 51.15178 (211) obj. 526335 iterations 79 -Cbc0038I Pass 5: (33.96 seconds) suminf. 45.85375 (210) obj. 529354 iterations 94 -Cbc0038I Pass 6: (33.99 seconds) suminf. 45.71180 (212) obj. 529354 iterations 41 -Cbc0038I Pass 7: (34.04 seconds) suminf. 35.87037 (187) obj. 529354 iterations 322 -Cbc0038I Pass 8: (34.08 seconds) suminf. 33.85554 (195) obj. 526761 iterations 264 -Cbc0038I Pass 9: (34.11 seconds) suminf. 33.14549 (176) obj. 526830 iterations 105 -Cbc0038I Pass 10: (34.14 seconds) suminf. 31.13224 (172) obj. 526830 iterations 115 -Cbc0038I Pass 11: (34.21 seconds) suminf. 30.55620 (160) obj. 529691 iterations 562 -Cbc0038I Pass 12: (34.25 seconds) suminf. 28.64178 (153) obj. 534012 iterations 205 -Cbc0038I Pass 13: (34.29 seconds) suminf. 29.16979 (146) obj. 527774 iterations 240 -Cbc0038I Pass 14: (34.31 seconds) suminf. 28.35432 (144) obj. 523774 iterations 4 -Cbc0038I Pass 15: (34.37 seconds) suminf. 25.43149 (140) obj. 525658 iterations 341 -Cbc0038I Pass 16: (34.39 seconds) suminf. 25.05606 (138) obj. 525658 iterations 13 -Cbc0038I Pass 17: (34.42 seconds) suminf. 25.05606 (133) obj. 525988 iterations 132 -Cbc0038I Pass 18: (34.45 seconds) suminf. 25.05606 (133) obj. 525988 iterations 6 -Cbc0038I Pass 19: (34.52 seconds) suminf. 25.01303 (143) obj. 526804 iterations 492 -Cbc0038I Pass 20: (34.55 seconds) suminf. 24.96852 (141) obj. 526288 iterations 33 -Cbc0038I Pass 21: (34.63 seconds) suminf. 22.73642 (133) obj. 527464 iterations 691 -Cbc0038I Pass 22: (34.64 seconds) suminf. 22.73642 (133) obj. 527464 iterations 0 -Cbc0038I Pass 23: (34.69 seconds) suminf. 22.27803 (129) obj. 527464 iterations 346 -Cbc0038I Pass 24: (34.72 seconds) suminf. 22.01961 (123) obj. 527464 iterations 76 -Cbc0038I Pass 25: (34.79 seconds) suminf. 22.04026 (123) obj. 526572 iterations 541 -Cbc0038I Pass 26: (34.82 seconds) suminf. 22.04026 (121) obj. 526572 iterations 47 -Cbc0038I Pass 27: (34.86 seconds) suminf. 22.15035 (111) obj. 527464 iterations 339 -Cbc0038I Pass 28: (34.89 seconds) suminf. 22.04689 (111) obj. 527464 iterations 91 -Cbc0038I Pass 29: (34.95 seconds) suminf. 22.01936 (123) obj. 526475 iterations 455 -Cbc0038I Pass 30: (34.98 seconds) suminf. 21.93681 (123) obj. 526475 iterations 40 -Cbc0038I No solution found this major pass -Cbc0038I Before mini branch and bound, 13026 integers at bound fixed and 50 continuous -Cbc0038I Full problem 17402 rows 14104 columns, reduced to 796 rows 583 columns -Cbc0038I Mini branch and bound did not improve solution (35.07 seconds) -Cbc0038I After 37.37 seconds - Feasibility pump exiting - took 3.92 seconds -Cbc0031I 166 added rows had average density of 73.783133 -Cbc0013I At root node, 166 cuts changed objective from 497995.24 to 497995.24 in 10 passes -Cbc0014I Cut generator 0 (Probing) - 298 row cuts average 8.2 elements, 0 column cuts (123 active) in 1.134 seconds - new frequency is 1 -Cbc0014I Cut generator 1 (Gomory) - 985 row cuts average 585.6 elements, 0 column cuts (0 active) in 0.785 seconds - new frequency is 1 -Cbc0014I Cut generator 2 (Knapsack) - 170 row cuts average 2.9 elements, 0 column cuts (0 active) in 0.100 seconds - new frequency is 1 -Cbc0014I Cut generator 3 (Clique) - 0 row cuts average 0.0 elements, 0 column cuts (0 active) in 0.012 seconds - new frequency is -100 -Cbc0014I Cut generator 4 (MixedIntegerRounding2) - 285 row cuts average 68.8 elements, 0 column cuts (0 active) in 0.137 seconds - new frequency is 1 -Cbc0014I Cut generator 5 (FlowCover) - 26 row cuts average 28.7 elements, 0 column cuts (0 active) in 0.019 seconds - new frequency is -100 -Cbc0014I Cut generator 6 (TwoMirCuts) - 723 row cuts average 138.8 elements, 0 column cuts (0 active) in 0.677 seconds - new frequency is -100 -Cbc0010I After 0 nodes, 1 on tree, 1e+50 best solution, best possible 497995.24 (43.62 seconds) -Cbc0010I After 100 nodes, 57 on tree, 1e+50 best solution, best possible 497995.24 (59.88 seconds) -Cbc0010I After 200 nodes, 109 on tree, 1e+50 best solution, best possible 497995.24 (64.07 seconds) -Cbc0010I After 300 nodes, 161 on tree, 1e+50 best solution, best possible 497995.24 (68.27 seconds) -Cbc0012I Integer solution of 497995.24 found by rounding after 15921 iterations and 379 nodes (71.58 seconds) -Cbc0038I Full problem 17402 rows 14104 columns, reduced to 0 rows 0 columns -Cbc0001I Search completed - best objective 497995.2442909057, took 15921 iterations and 379 nodes (71.87 seconds) -Cbc0032I Strong branching done 2148 times (28125 iterations), fathomed 0 nodes and fixed 0 variables -Cbc0035I Maximum depth 100, 0 variables fixed on reduced cost -Cuts at root node changed objective from 497995 to 497995 -Probing was tried 119 times and created 537 cuts of which 123 were active after adding rounds of cuts (1.734 seconds) -Gomory was tried 119 times and created 1156 cuts of which 0 were active after adding rounds of cuts (1.679 seconds) -Knapsack was tried 119 times and created 219 cuts of which 0 were active after adding rounds of cuts (1.038 seconds) -Clique was tried 10 times and created 0 cuts of which 0 were active after adding rounds of cuts (0.012 seconds) -MixedIntegerRounding2 was tried 119 times and created 374 cuts of which 0 were active after adding rounds of cuts (1.404 seconds) -FlowCover was tried 10 times and created 26 cuts of which 0 were active after adding rounds of cuts (0.019 seconds) -TwoMirCuts was tried 10 times and created 723 cuts of which 0 were active after adding rounds of cuts (0.677 seconds) -ImplicationCuts was tried 15 times and created 16 cuts of which 0 were active after adding rounds of cuts (0.023 seconds) - -Result - Optimal solution found - -Objective value: 497995.24429091 -Enumerated nodes: 379 -Total iterations: 15921 -Time (CPU seconds): 72.51 -Time (Wallclock seconds): 72.80 - -Total time (CPU seconds): 74.98 (Wallclock seconds): 75.41 - - diff --git a/test.py b/test.py index 7694312..a26b59d 100644 --- a/test.py +++ b/test.py @@ -77,31 +77,31 @@ Rota.add_shifts( # force_as_block=False, # rota_on_nwds=True, # constraints=["night"]), - SingleShift(("exeter", ), "exeter_twilight", 12.5, days[:5]), - SingleShift(("truro", ), "truro_twilight", 12.5, days[:5]), - SingleShift(("torquay", ), "torquay_twilight", 12.5, days[:5]), - SingleShift(("plymouth", ), "plymouth_twilight", 12.5, days[:5]), - SingleShift(("exeter", ), - "weekend_exeter", - 12.5, - days[5:], - assign_as_block=True), - SingleShift(("truro", ), - "weekend_truro", - 12.5, - days[5:], - assign_as_block=True), - SingleShift(("torquay", ), - "weekend_torquay", - 12.5, - days[5:], - assign_as_block=True), - SingleShift(("plymouth", ), - "weekend_plymouth", - 8, - days[5:], - workers_required=2, - assign_as_block=True), + # SingleShift(("exeter", ), "exeter_twilight", 12.5, days[:5]), + # SingleShift(("truro", ), "truro_twilight", 12.5, days[:5]), + # SingleShift(("torquay", ), "torquay_twilight", 12.5, days[:5]), + # SingleShift(("plymouth", ), "plymouth_twilight", 12.5, days[:5]), + # SingleShift(("exeter", ), + # "weekend_exeter", + # 12.5, + # days[5:], + # assign_as_block=True), + # SingleShift(("truro", ), + # "weekend_truro", + # 12.5, + # days[5:], + # assign_as_block=True), + # SingleShift(("torquay", ), + # "weekend_torquay", + # 12.5, + # days[5:], + # assign_as_block=True), + # SingleShift(("plymouth", ), + # "weekend_plymouth", + # 8, + # days[5:], + # workers_required=2, + # assign_as_block=True), SingleShift( (sites), "night_weekday", @@ -154,8 +154,13 @@ if use_test_workers: for i in range(9, 17) ]) Rota.add_workers([ - Worker(Rota, i, "Plym {}".format(i), "plymouth", 4, 80) - for i in range(17, 19) + Worker(Rota, + i, + "Plym {}".format(i), + "plymouth", + 4, + 80, + end_date="2020/10/05") for i in range(17, 19) ]) # Rota.add_workers([ # Worker( @@ -196,16 +201,20 @@ else: end_date = end_date if end_date else None oop = oop.split("-") if oop else None #print(nwds, end_date, oop) + # Rota.add_worker( + # Worker(Rota, n, name, site.lower(), int(grade[2]), 100, + # )) Rota.add_worker( Worker(Rota, n, name, site.lower(), int(grade[2]), int(fte), nwds, end_date, oop)) -Rota.constraint_options["limit_to_1_st1_on_nights"] = True +Rota.constraint_options["limit_to_1_st1_on_nights"] = False +Rota.constraint_options["ensure_1_st4_plus_on_nights"] = True Rota.constraint_options["balance_nights"] = True -Rota.constraint_options["constrain_time_off_after_nights"] = True +Rota.constraint_options["constrain_time_off_after_nights"] = False Rota.constraint_options["balance_nights_across_sites"] = False Rota.constraint_options["balance_blocks"] = False -Rota.constraint_options["balance_weekends"] = True +Rota.constraint_options["balance_weekends"] = False print(0) Rota.build_shifts_and_workers() @@ -226,7 +235,7 @@ else: tee=True, options={ "seconds": 1200, - "allow": 4000, + "allow": 15, }, logfile="test.log" ) # solve the model with the, options="seconds=60" selected solver