From 7ef0c191d7f5b301331ff14681b419027f186ac1 Mon Sep 17 00:00:00 2001 From: Ross Date: Wed, 27 Jul 2022 07:49:34 +0100 Subject: [PATCH] e --- gen.py | 46 +++---- rota/shifts.py | 63 ++++++---- rota/workers.py | 2 +- test/test_nights2.py | 278 ++++++++++++++++++++++++++++++++++++++++++- test/test_workers.py | 73 ++++++++++++ 5 files changed, 411 insertions(+), 51 deletions(-) diff --git a/gen.py b/gen.py index 5015b5d..ef05b01 100644 --- a/gen.py +++ b/gen.py @@ -25,22 +25,26 @@ from rota.workers import ( ) -suspend_on_finish = False +suspend_on_finish = True solve = True time_to_run = 258500 -time_to_run = 26600000 +#time_to_run = 2660000 +#time_to_run = 458500 # allow = 5000 -ratio = 0.01 +ratio = 0.001 #ratio = 0.9 start_date = datetime.date(2022, 9, 5) +#start_date = start_date + datetime.timedelta(weeks=13) # start_date = datetime.date(2022, 10, 10) Rota = RotaBuilder(start_date, weeks_to_rota=26) Rota.constraint_options["balance_weekends"] = True Rota.constraint_options["max_night_frequency"] = 3 -Rota.constraint_options["max_weekend_frequency"] = 3 +Rota.constraint_options["max_night_frequency_week_exclusions"] = [1,2,3,4,5,6] +Rota.constraint_options["max_weekend_frequency"] = 2 +Rota.constraint_options["hard_constrain_pair_separation"] = True # Rota.constraint_options["avoid_st2_first_month"] = True Rota.add_shifts( @@ -49,7 +53,7 @@ Rota.add_shifts( name="exeter_twilight", length=12.5, days=days[:5], - balance_offset=2, + balance_offset=4, constraint=[ { "name": "max_shifts_per_week", @@ -62,14 +66,14 @@ Rota.add_shifts( name="truro_twilight", length=12.5, days=days[:4], - balance_offset=2, + balance_offset=4, ), SingleShift( sites=("torbay", "torbay twilights"), name="torbay_twilight", length=12.5, days=days[:4], - balance_offset=2, + balance_offset=4, assign_as_block=True, ), SingleShift( @@ -77,7 +81,7 @@ Rota.add_shifts( name="plymouth_twilight", length=12.5, days=days[:5], - balance_offset=2, + balance_offset=4, workers_required=1, constraint=[{"name": "max_shifts_per_week", "options": 2}], ), @@ -86,7 +90,7 @@ Rota.add_shifts( name="weekend_exeter", length=12.5, days=days[5:], - balance_offset=2, + balance_offset=3, rota_on_nwds=True, force_as_block=True, constraint=[{"name": "post", "options": 2}, {"name": "pre", "options": 5}], @@ -119,7 +123,7 @@ Rota.add_shifts( name="weekend_plymouth1", length=8, days=days[5:], - balance_offset=2, + balance_offset=3, workers_required=1, rota_on_nwds=True, force_as_block=True, @@ -130,7 +134,7 @@ Rota.add_shifts( name="weekend_plymouth2", length=8, days=days[5:], - balance_offset=2, + balance_offset=3, workers_required=1, rota_on_nwds=True, force_as_block=True, @@ -141,7 +145,7 @@ Rota.add_shifts( name="plymouth_bank_holidays", length=8, days=days[5:], - balance_offset=2, + balance_offset=3, workers_required=1, rota_on_nwds=True, force_as_block=False, @@ -152,7 +156,7 @@ Rota.add_shifts( name="night_weekday", length=12.25, days=days[:4], - # balance_offset=4.9, + balance_offset=4.9, balance_weighting=1, # hard_constrain_shift=False, workers_required=3, @@ -160,8 +164,8 @@ Rota.add_shifts( rota_on_nwds=True, constraint=[ {"name": "night"}, - {"name": "pre", "options": 5}, - {"name": "post", "options": 5}, + {"name": "pre", "options": 2}, + {"name": "post", "options": 2}, {"name": "require_remote_site_presence_week", "options": ("plymouth", 1)}, {"name": "limit_grade_number", "options": {2: 1}}, {"name": "minimum_grade_number", "options": (4, 1)}, @@ -172,7 +176,7 @@ Rota.add_shifts( name="night_weekend", length=12.25, days=days[4:], - # balance_offset=3.9, + balance_offset=3.9, balance_weighting=1, # hard_constrain_shift=False, workers_required=3, @@ -180,8 +184,8 @@ Rota.add_shifts( rota_on_nwds=True, constraint=[ {"name": "night"}, - {"name": "pre", "options": 5}, - {"name": "post", "options": 5}, + {"name": "pre", "options": 2}, + {"name": "post", "options": 3}, {"name": "require_remote_site_presence_week", "options": ("plymouth", 1)}, {"name": "limit_grade_number", "options": {2: 1}}, {"name": "minimum_grade_number", "options": (4, 1)}, @@ -189,11 +193,11 @@ Rota.add_shifts( ), ) -Rota.add_grade_constraint_by_week([2], [1, 2, 3, 4], ["night_weekday", "night_weekend"]) +Rota.add_grade_constraint_by_week([2], [1, 2], ["night_weekday", "night_weekend"]) load_leave = True Rota.build_shifts() -Rota.add_grade_constraint_by_week([2], [1, 2], Rota.get_shift_names()) +Rota.add_grade_constraint_by_week([2], [1], Rota.get_shift_names()) if load_leave: import leave @@ -296,7 +300,7 @@ if load_leave: # Rota.build_workers() # Rota.build_model() -solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10} +solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 11} start_time = time.time() Rota.build_and_solve(solver_options) diff --git a/rota/shifts.py b/rota/shifts.py index 3de709c..3e51d1f 100644 --- a/rota/shifts.py +++ b/rota/shifts.py @@ -186,29 +186,10 @@ class RotaBuilder(object): self.SHIFT_BOUNDS = SHIFT_BOUNDS self.shifts: List[SingleShift] = [] # type List[SingleShift] - self.days = days - - self.weeks = [i for i in range(1, weeks_to_rota + 1)] - - self.weeks_days_product = list(itertools.product(self.weeks, days)) - self.use_previous_shifts = use_previous_shifts self.use_shift_balance_extra = use_shift_balance_extra self.use_bank_holiday_extra = use_bank_holiday_extra - if start_date.weekday() != 0: - raise ValueError( - f"Start date {start_date.isoformat()} must be a Mon (not a {days[start_date.weekday()]})" - ) - - self.start_date = start_date - self.rota_days_length = len(self.weeks) * 7 - self.rota_end_date = self.start_date + datetime.timedelta(self.rota_days_length) - - console.print( - f"Weeks to rota: {weeks_to_rota} ({self.start_date} - {self.rota_end_date})" - ) - self.unavailable_to_work = set() self.unavailable_to_work_reason = {} self.pref_not_to_work = {} @@ -217,7 +198,6 @@ class RotaBuilder(object): self.work_requests_map = {} self.workers: list[Worker] = [] - self.worker_pairs = [] # self.night_blocks = ["weekday", "weekend", "none"] @@ -242,6 +222,7 @@ class RotaBuilder(object): "max_weekend_frequency": 1, # Requires balance weekends # Don't assign multiple shifts every (n) weeks "max_night_frequency": 1, + "max_night_frequency_week_exclusions": [], "max_shifts_per_week": 7, "max_shifts_per_month": 40, # The following will cause an unsolvable problem if a shift @@ -255,6 +236,34 @@ class RotaBuilder(object): "avoid_shifts_by_grades": [], } + + self.results = None + + self.ignore_valid_shifts = False + + self.set_rota_dates(start_date, weeks_to_rota) + + def set_rota_dates(self, start_date, weeks_to_rota): + + self.days = days + + self.weeks = [i for i in range(1, weeks_to_rota + 1)] + + self.weeks_days_product = list(itertools.product(self.weeks, days)) + + if start_date.weekday() != 0: + raise ValueError( + f"Start date {start_date.isoformat()} must be a Mon (not a {days[start_date.weekday()]})" + ) + + self.start_date = start_date + self.rota_days_length = len(self.weeks) * 7 + self.rota_end_date = self.start_date + datetime.timedelta(self.rota_days_length) + + console.print( + f"Weeks to rota: {weeks_to_rota} ({self.start_date} - {self.rota_end_date})" + ) + # Generate a map for week, day combinations to a given date self.week_day_date_map = {} @@ -264,9 +273,6 @@ class RotaBuilder(object): self.week_day_date_map[(week, day)] = d n = n + 1 - self.results = None - - self.ignore_valid_shifts = False def solve_model( self, @@ -307,6 +313,7 @@ class RotaBuilder(object): if not results.solver.status: sys.exit(0) + def solve_shifts_individually(self, options, folder="individual_shifts"): shifts = self.shifts @@ -1500,6 +1507,11 @@ class RotaBuilder(object): for week_blocks in self.get_week_block_iterator( self.constraint_options["max_night_frequency"] ): + # Ignore excluded weeks + if set(week_blocks).intersection(set(self.constraint_options["max_night_frequency_week_exclusions"])): + continue + + if self.get_shifts_with_constraint("night"): # Prevent nights more than once every n weeks self.model.constraints.add( @@ -2388,9 +2400,10 @@ class RotaBuilder(object): w.fte_adj for w in self.workers if w.site == site ) + self.worker_pairs = [] pairs = defaultdict(list) for w in self.workers: - if w.pair > 0: + if w.pair is not None: pairs[w.pair].append(w) for p in pairs: @@ -2552,7 +2565,7 @@ class RotaBuilder(object): Returns: list: contains tuple of all possible week / day / shift combinations - """ + """ week_day_shifts = self.week_day_shift_product if week is not None: diff --git a/rota/workers.py b/rota/workers.py index e247442..689a680 100644 --- a/rota/workers.py +++ b/rota/workers.py @@ -81,7 +81,7 @@ class Worker(BaseModel): previous_shifts: dict = {} shift_balance_extra: dict = {} bank_holiday_extra: int = 0 - pair: int = 0 + pair: str | None = None class Config: extra = Extra.allow diff --git a/test/test_nights2.py b/test/test_nights2.py index 53bc295..a30c020 100644 --- a/test/test_nights2.py +++ b/test/test_nights2.py @@ -197,8 +197,10 @@ class TestNightShifts: length=12.5, days=days[:2], force_as_block=True, - constraint=[{"name": "night"}, - {"name": "pre", "options": 5},{"name": "post", "options": 5}, + constraint=[ + {"name": "night"}, + {"name": "pre", "options": 5}, + {"name": "post", "options": 5}, ], workers_required=3, ), @@ -227,10 +229,10 @@ class TestNightShifts: "date": Rota.rota_end_date - datetime.timedelta(days=1), "reason": "no wanna", }, - #{ + # { # "date": Rota.rota_end_date - datetime.timedelta(days=5), # "reason": "no wanna", - #}, + # }, ], ), ] @@ -240,3 +242,271 @@ class TestNightShifts: Rota.export_rota_to_html("night") assert Rota.results.solver.status == "ok" + + def test_nights_max_frequency(self): + Rota = generate_basic_rota() + + Rota.constraint_options["max_night_frequency"] = 1 + + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], + force_as_block=True, + constraint=[{"name": "night"}], + workers_required=2, + ), + # SingleShift( + # sites=("group1", "group2"), + # name="m", + # length=12.5, + # days=days[:5], + # force_as_block=True, + # # constraint=[{"name": "night"}, {"name": "pre", "options": 3}], + # # constraint=[{"name": "night"}], + # workers_required=2, + # ), + # SingleShift( + # sites=("group1", "group2"), name="b", length= 12.5, days=days[:3], + # force_as_block=False, + # #constraint=[{"name": "night"}], + # ), + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night") + + assert Rota.results.solver.status == "ok" + + def test_nights_max_frequency_fail(self): + Rota = generate_basic_rota() + + Rota.constraint_options["max_night_frequency"] = 2 + + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], + force_as_block=True, + constraint=[{"name": "night"}], + workers_required=2, + ), + # SingleShift( + # sites=("group1", "group2"), + # name="m", + # length=12.5, + # days=days[:5], + # force_as_block=True, + # # constraint=[{"name": "night"}, {"name": "pre", "options": 3}], + # # constraint=[{"name": "night"}], + # workers_required=2, + # ), + # SingleShift( + # sites=("group1", "group2"), name="b", length= 12.5, days=days[:3], + # force_as_block=False, + # #constraint=[{"name": "night"}], + # ), + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night") + + assert Rota.results.solver.status == "warning" + + def test_nights_max_frequency3(self): + Rota = generate_basic_rota() + + Rota.constraint_options["max_night_frequency"] = 2 + + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], + force_as_block=True, + constraint=[{"name": "night"}], + workers_required=1, + ), + SingleShift( + sites=("group1", "group2"), + name="m", + length=12.5, + days=days[:5], + force_as_block=True, + # constraint=[{"name": "night"}, {"name": "pre", "options": 3}], + constraint=[{"name": "night"}], + workers_required=1, + ), + # SingleShift( + # sites=("group1", "group2"), name="b", length= 12.5, days=days[:3], + # force_as_block=False, + # #constraint=[{"name": "night"}], + # ), + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night") + + assert Rota.results.solver.status == "warning" + + def test_nights_max_frequency4(self): + Rota = generate_basic_rota() + + Rota.constraint_options["max_night_frequency"] = 2 + + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], + force_as_block=True, + constraint=[{"name": "night"}], + workers_required=1, + ), + SingleShift( + sites=("group1", "group2"), + name="m", + length=12.5, + days=days[:5], + force_as_block=True, + # constraint=[{"name": "night"}, {"name": "pre", "options": 3}], + constraint=[{"name": "night"}], + workers_required=1, + ), + # SingleShift( + # sites=("group1", "group2"), name="b", length= 12.5, days=days[:3], + # force_as_block=False, + # #constraint=[{"name": "night"}], + # ), + ) + + Rota.add_workers( + [ + Worker( + name="worker3", + site="group1", + grade=1, + ), + Worker( + name="worker4", + site="group1", + grade=1, + ), + ] + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night") + + assert Rota.results.solver.status == "ok" + + def test_nights_max_frequency_exclusions(self): + Rota = generate_basic_rota() + + Rota.constraint_options["max_night_frequency"] = 2 + Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(10)) + + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], + force_as_block=True, + constraint=[{"name": "night"}], + workers_required=2, + ), + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night") + + assert Rota.results.solver.status == "ok" + + def test_nights_max_frequency_exclusions2(self): + Rota = generate_basic_rota(weeks_to_rota=8) + + Rota.constraint_options["max_night_frequency"] = 2 + Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(1,4)) + + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], + force_as_block=True, + constraint=[{"name": "night"}], + workers_required=2, + ), + ) + + Rota.add_workers( + [ + Worker( + name="worker3", + site="group1", + grade=1, + start_date=Rota.start_date + datetime.timedelta(weeks=4) + ), + Worker( + name="worker4", + site="group1", + grade=1, + start_date=Rota.start_date + datetime.timedelta(weeks=4) + ), + ] + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night") + + assert Rota.results.solver.status == "ok" + + def test_nights_max_frequency_exclusions3(self): + # TODO: find out the issues with odd rotas and exclusions + Rota = generate_basic_rota(weeks_to_rota=9) + + Rota.constraint_options["max_night_frequency"] = 2 + Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(1,5)) + + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], + force_as_block=True, + balance_offset=10, + constraint=[{"name": "night"}], + workers_required=2, + ), + ) + + Rota.add_workers( + [ + Worker( + name="worker3", + site="group1", + grade=1, + start_date=Rota.start_date + datetime.timedelta(weeks=4) + ), + Worker( + name="worker4", + site="group1", + grade=1, + start_date=Rota.start_date + datetime.timedelta(weeks=4) + ), + ] + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night") + + assert Rota.results.solver.status == "ok" \ No newline at end of file diff --git a/test/test_workers.py b/test/test_workers.py index d69ca36..07989c2 100644 --- a/test/test_workers.py +++ b/test/test_workers.py @@ -318,3 +318,76 @@ class TestWorkers: assert Rota.results.solver.termination_condition == "optimal" Rota.export_rota_to_html("worker_oops") + + def test_worker_pairs(self): + Rota = setup_basic_rota() + + Rota.add_shifts( + SingleShift( + sites=("group1",), + name="a", + length=12.5, + days=days[:5], + balance_offset=20, + workers_required=1, + ), + SingleShift( + sites=("group1",), + name="b", + length=12.5, + days=days[:5], + balance_offset=20, + workers_required=1, + ), + ) + + Rota.add_workers( + [ + Worker(name="worker1", site="group1", grade=1, fte=100, pair="1"), + Worker(name="worker2", site="group1", grade=1, fte=100, pair="1"), + ] + ) + + Rota.constraint_options["hard_constrain_pair_separation"] = True + + Rota.build_and_solve() + + # With two paired workers we shouldn't be able to solve + assert Rota.results.solver.status == "warning" + + worker3 = Worker(name="worker3", site="group1", grade=1, fte=100) + Rota.add_worker(worker3) + + # An additional worker can take half the shifts + Rota.build_and_solve() + + assert Rota.results.solver.status == "ok" + + assert Rota.get_workers_total_shifts()["worker3"] == 50 + assert ( + Rota.get_workers_total_shifts()["worker1"] + + Rota.get_workers_total_shifts()["worker2"] + == 50 + ) + + worker3.pair = "1" + + Rota.build_and_solve() + + assert Rota.results.solver.status == "warning" + + worker3.pair = 0 + + Rota.build_and_solve() + Rota.export_rota_to_html("worker_pairs") + + assert Rota.results.solver.status == "ok" + + worker4 = Worker(name="worker4", site="group1", grade=1, fte=100, pair="1") + Rota.add_worker(worker4) + + Rota.build_and_solve() + Rota.export_rota_to_html("worker_pairs") + + assert Rota.results.solver.status == "ok" + assert Rota.get_workers_total_shifts()["worker3"] == 50 \ No newline at end of file