From 8f8e5a2a28945c2ed1040b7e1194ea0cabc2ee47 Mon Sep 17 00:00:00 2001 From: Ross Date: Thu, 14 Jul 2022 12:12:50 +0100 Subject: [PATCH] more updates --- gen.py | 272 +++++++++--------- rota/console.py | 2 + rota/shifts.py | 87 ++++-- rota/workers.py | 40 ++- .../test_avoid_grades_by_week.py | 2 +- test/test_nights2.py | 130 +++++++-- 6 files changed, 338 insertions(+), 195 deletions(-) create mode 100644 rota/console.py diff --git a/gen.py b/gen.py index ceedb2a..b78194f 100644 --- a/gen.py +++ b/gen.py @@ -13,7 +13,6 @@ sites = ( "plymouth", "taunton", "proc", - "truro no proc", ) from rota.workers import ( @@ -29,154 +28,156 @@ from rota.workers import ( suspend_on_finish = False solve = True time_to_run = 258500 +time_to_run = 36000 +time_to_run = 200 # allow = 5000 -ratio = 0.5 +ratio = 0.05 start_date = datetime.date(2022, 9, 5) #start_date = datetime.date(2022, 10, 10) -Rota = RotaBuilder(start_date, weeks_to_rota=12) +Rota = RotaBuilder(start_date, weeks_to_rota=26) Rota.constraint_options["balance_weekends"] = True -Rota.constraint_options["max_night_frequency"] = 4 -Rota.constraint_options["max_weekend_frequency"] = 4 +Rota.constraint_options["max_night_frequency"] = 3 +Rota.constraint_options["max_weekend_frequency"] = 3 # Rota.constraint_options["avoid_st2_first_month"] = True Rota.add_shifts( - SingleShift( - sites=("exeter", "exeter twilights and weekends"), - name="exeter_twilight", - length=12.5, - days=days[:5], - balance_offset=3, - constraint=[{"name": "max_shifts_per_week", "options": 2, }], - ), - SingleShift( - sites=("truro", "truro twilights"), - name="truro_twilight", - length=12.5, - days=days[:4], - balance_offset=3, - ), - SingleShift( - sites=("torbay", "torbay twilights"), - name="torbay_twilight", - length=12.5, - days=days[:4], - balance_offset=5, - assign_as_block=True, - ), + #SingleShift( + # sites=("exeter", "exeter twilights and weekends"), + # name="exeter_twilight", + # length=12.5, + # days=days[:5], + # balance_offset=3, + # constraint=[{"name": "max_shifts_per_week", "options": 2, }], + #), + #SingleShift( + # sites=("truro", "truro twilights"), + # name="truro_twilight", + # length=12.5, + # days=days[:4], + # balance_offset=3, + #), + #SingleShift( + # sites=("torbay", "torbay twilights"), + # name="torbay_twilight", + # length=12.5, + # days=days[:4], + # balance_offset=5, + # assign_as_block=True, + #), SingleShift( sites=("plymouth", "plymouth twilights"), name="plymouth_twilight", length=12.5, days=days[:5], - balance_offset=3, + balance_offset=2, workers_required=1, constraint=[{"name": "max_shifts_per_week", "options": 2}], ), - SingleShift( - sites=("exeter", "exeter twilights and weekends"), - name="weekend_exeter", - length=12.5, - days=days[5:], - balance_offset=4, - rota_on_nwds=True, - force_as_block=True, - constraint=[{"name": "post", "options": 2}, {"name": "pre", "options": 5}], - #constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},], - ), - SingleShift( - sites=("truro",), - name="weekend_truro", - length=12.5, - days=days[4:], - balance_offset=4, - rota_on_nwds=True, - force_as_block=True, - constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], - # force_as_block_unless_nwd=True - ), - SingleShift( - sites=("torbay",), - name="weekend_torbay", - length=12.5, - days=days[4:], - balance_offset=4, - rota_on_nwds=True, - force_as_block=True, - constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], - # force_as_block_unless_nwd=True - ), - SingleShift( - sites=("plymouth",), - name="weekend_plymouth1", - length=8, - days=days[5:], - balance_offset=4, - workers_required=1, - rota_on_nwds=True, - force_as_block=True, - constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], - ), - SingleShift( - sites=("plymouth",), - name="weekend_plymouth2", - length=8, - days=days[5:], - balance_offset=4, - workers_required=1, - rota_on_nwds=True, - force_as_block=True, - constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], - ), - SingleShift( - sites=("plymouth", "plymouth twilights"), - name="plymouth_bank_holidays", - length=8, - days=days[5:], - balance_offset=2, - workers_required=1, - rota_on_nwds=True, - force_as_block=False, - bank_holidays_only=True, - ), - SingleShift( - sites=(sites[:-1]), - name="night_weekday", - length=12.25, - days=days[:4], - balance_offset=4.9, - balance_weighting=1, - # hard_constrain_shift=False, - workers_required=3, - force_as_block=True, - rota_on_nwds=True, - constraint=[ - {"name": "night"}, - {"name": "pre", "options": 5}, - {"name": "post", "options": 5}, - {"name": "require_remote_site_presence_week", "options": ("plymouth", 1)}, - ], - ), - SingleShift( - sites=(sites[:-1]), - name="night_weekend", - length=12.25, - days=days[4:], - balance_offset=3.9, - balance_weighting=1, - # hard_constrain_shift=False, - workers_required=3, - force_as_block=True, - rota_on_nwds=True, - constraint=[ - {"name": "night"}, - {"name": "pre", "options": 5}, - {"name": "post", "options": 5}, - {"name": "require_remote_site_presence_week", "options": ("plymouth", 1)}, - ], - ), + #SingleShift( + # sites=("exeter", "exeter twilights and weekends"), + # name="weekend_exeter", + # length=12.5, + # days=days[5:], + # balance_offset=4, + # rota_on_nwds=True, + # force_as_block=True, + # constraint=[{"name": "post", "options": 2}, {"name": "pre", "options": 5}], + # #constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},], + #), + #SingleShift( + # sites=("truro",), + # name="weekend_truro", + # length=12.5, + # days=days[4:], + # balance_offset=4, + # rota_on_nwds=True, + # force_as_block=True, + # constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], + # # force_as_block_unless_nwd=True + #), + #SingleShift( + # sites=("torbay",), + # name="weekend_torbay", + # length=12.5, + # days=days[4:], + # balance_offset=4, + # rota_on_nwds=True, + # force_as_block=True, + # constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], + # # force_as_block_unless_nwd=True + #), + #SingleShift( + # sites=("plymouth",), + # name="weekend_plymouth1", + # length=8, + # days=days[5:], + # balance_offset=4, + # workers_required=1, + # rota_on_nwds=True, + # force_as_block=True, + # constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], + #), + #SingleShift( + # sites=("plymouth",), + # name="weekend_plymouth2", + # length=8, + # days=days[5:], + # balance_offset=4, + # workers_required=1, + # rota_on_nwds=True, + # force_as_block=True, + # constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], + #), + #SingleShift( + # sites=("plymouth", "plymouth twilights"), + # name="plymouth_bank_holidays", + # length=8, + # days=days[5:], + # balance_offset=2, + # workers_required=1, + # rota_on_nwds=True, + # force_as_block=False, + # bank_holidays_only=True, + #), + #SingleShift( + # sites=(sites), + # name="night_weekday", + # length=12.25, + # days=days[:4], + # balance_offset=4.9, + # balance_weighting=1, + # # hard_constrain_shift=False, + # workers_required=3, + # force_as_block=True, + # rota_on_nwds=True, + # constraint=[ + # {"name": "night"}, + # {"name": "pre", "options": 5}, + # {"name": "post", "options": 5}, + # {"name": "require_remote_site_presence_week", "options": ("plymouth", 1)}, + # ], + #), + #SingleShift( + # sites=(sites), + # name="night_weekend", + # length=12.25, + # days=days[4:], + # balance_offset=3.9, + # balance_weighting=1, + # # hard_constrain_shift=False, + # workers_required=3, + # force_as_block=True, + # rota_on_nwds=True, + # constraint=[ + # {"name": "night"}, + # {"name": "pre", "options": 5}, + # {"name": "post", "options": 5}, + # {"name": "require_remote_site_presence_week", "options": ("plymouth", 1)}, + # ], + #), ) Rota.add_grade_constraint_by_week([2], [1, 2, 3, 4], ["night_weekday", "night_weekend"]) @@ -283,13 +284,14 @@ if load_leave: ) ) -Rota.build_workers() -Rota.build_model() +#Rota.build_workers() +#Rota.build_model() -solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 6} +solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10} start_time = time.time() -Rota.solve_model(options=solver_options) +Rota.solve_shifts_individually(solver_options) +#Rota.solve_model(options=solver_options) end_time = time.time() print(f"Time taken {end_time-start_time}") diff --git a/rota/console.py b/rota/console.py new file mode 100644 index 0000000..745d8c8 --- /dev/null +++ b/rota/console.py @@ -0,0 +1,2 @@ +from rich.console import Console +console = Console() \ No newline at end of file diff --git a/rota/shifts.py b/rota/shifts.py index 9d37dfc..c082c3f 100644 --- a/rota/shifts.py +++ b/rota/shifts.py @@ -23,6 +23,8 @@ from collections import defaultdict from io import StringIO import sys +from rich.pretty import pprint + ShiftName = str DayStr = str WeekInt = int @@ -248,6 +250,8 @@ class RotaBuilder(object): self.results = None + self.ignore_valid_shifts = False + def solve_model( self, solver: str = "cbc", use_neos: bool = False, options: dict = {} ): @@ -279,6 +283,25 @@ class RotaBuilder(object): if not results.solver.status: sys.exit(0) + def solve_shifts_individually(self, options, folder="individual_shifts"): + shifts = self.shifts + + outcomes = {} + + for shift in shifts: + self.ignore_valid_shifts = True + self.clear_shifts() + self.add_shift(shift) + self.build_and_solve(options) + self.export_rota_to_html(filename=shift.name, folder=folder) + + outcomes[shift.name] = (self.results.solver.status, self.results.solver.termination_condition) + pprint(outcomes) + + + pprint(outcomes) + + def build_and_solve( self, options={"ratio": 0.1, "seconds": 1000, "threads": 10}, solve=True ): @@ -640,7 +663,7 @@ class RotaBuilder(object): # Validate shifts are valid # work_request_sets = set([i[3] for i in self.work_requests]) invalid_shifts = work_request_sets - set(self.get_shift_names()) - if invalid_shifts: + if invalid_shifts and not self.ignore_valid_shifts: raise InvalidShift( f"Invalid worker shift request [shift(s): ${invalid_shifts}]" ) @@ -1036,22 +1059,30 @@ class RotaBuilder(object): for week_blocks in self.get_week_block_iterator(4): # Prevent more than n number shifts per 4 weeks - self.model.constraints.add( - self.constraint_options["max_shifts_per_month"] - >= sum( - self.model.works[worker.id, week, day, shiftname] - for week, day, shiftname in self.get_all_shiftname_combinations() - if week in week_blocks + try: + self.model.constraints.add( + self.constraint_options["max_shifts_per_month"] + >= sum( + self.model.works[worker.id, week, day, shiftname] + for week, day, shiftname in self.get_all_shiftname_combinations() + if week in week_blocks + ) ) - ) + except ValueError: + # Occurs if there are no shifts within a defined block + # TODO: test if this breaks (and we should check rathar than except) + pass for constraint_dict in self.constraint_options["avoid_shifts_by_grades"]: if invalid_shifts := set(constraint_dict["shifts"]).difference( set(self.get_shift_names()) ): - raise ValueError( - f"avoid shifts by grade constraint contains a non existent shift: {invalid_shifts}" - ) + if self.ignore_valid_shifts: + continue # Skip if we don't want to raise an error + else: + raise InvalidShift( + f"avoid shifts by grade constraint contains a non existent shift: {invalid_shifts}" + ) if invalid_grades := set(constraint_dict["grades"]).difference( set(self.get_worker_grades()) ): @@ -1509,17 +1540,20 @@ class RotaBuilder(object): ) ) - self.model.constraints.add( - self.constraint_options["max_shifts_per_week"] - >= sum( - self.model.works[worker.id, w, day, shiftname] - # for shiftname in self.get_shift_names_by_week_day(week, day) - # for day in self.days - for w, day, shiftname in self.get_all_shiftname_combinations( - week=week + try: + self.model.constraints.add( + self.constraint_options["max_shifts_per_week"] + >= sum( + self.model.works[worker.id, w, day, shiftname] + # for shiftname in self.get_shift_names_by_week_day(week, day) + # for day in self.days + for w, day, shiftname in self.get_all_shiftname_combinations( + week=week + ) ) ) - ) + except ValueError: + pass # TODO: consider excluding shifts that span relevant period if ( @@ -2350,6 +2384,9 @@ class RotaBuilder(object): """ self.shifts.extend(shifts) + def clear_shifts(self) -> None: + self.shifts = [] + def get_shift_names_by_week_day(self, week, day: DayStr) -> set: """Returns the shifts required for a specific day @@ -2701,8 +2738,14 @@ class RotaBuilder(object): return self.start_date + datetime.timedelta(weeks=week) # RESULTS - def export_rota_to_html(self, filename: str = "rota"): - output_file = Path("output", f"{filename}.html") + def export_rota_to_html(self, filename: str = "rota", folder = None): + if folder is not None: + output_file = Path("output", folder, f"{filename}.html") + else: + output_file = Path("output", f"{filename}.html") + + # Make sure path exits + output_file.parent.mkdir(parents=True, exist_ok=True) with open(output_file, "w") as f: f.write(self.get_worker_timetable_html(True)) diff --git a/rota/workers.py b/rota/workers.py index 3b52a62..e247442 100644 --- a/rota/workers.py +++ b/rota/workers.py @@ -4,6 +4,7 @@ from typing import Iterable, List, Literal, Optional from pydantic import BaseModel, Extra, validator from rich.pretty import pprint +from rota.console import console # from .shifts import RotaBuilder, days, sites import uuid @@ -199,27 +200,37 @@ class Worker(BaseModel): (self.id, week, day) ] = f"OOP ({oop_name})".format(self.oop) + days_to_end = (self.calculated_end_date - Rota.start_date).days + # loop throught dates converting to week / day combination for item in self.pref_not_to_work: days_from_start = (item.date - Rota.start_date).days - week = days_from_start // 7 + 1 - day = Rota.days[(days_from_start % 7)] - # Weight the value to take into account the number of preferences - # 1 is added to the total number of requests to ensure they do not outweight - # a single (or fewer) request(s) - Rota.pref_not_to_work[(self.id, week, day)] = 1 / ( - len(self.pref_not_to_work) + 1 - ) - Rota.pref_not_to_work_reason[(self.id, week, day)] = item.reason + # Ignore dates past the end of the rota (or end date) + if days_from_start < days_to_end: + week = days_from_start // 7 + 1 + day = Rota.days[(days_from_start % 7)] + # Weight the value to take into account the number of preferences + # 1 is added to the total number of requests to ensure they do not outweight + # a single (or fewer) request(s) + Rota.pref_not_to_work[(self.id, week, day)] = 1 / ( + len(self.pref_not_to_work) + 1 + ) + Rota.pref_not_to_work_reason[(self.id, week, day)] = item.reason # print(not_available_to_work) # loop throught dates converting to week / day combination + + unavailable_set = set() for item in self.not_available_to_work: days_from_start = (item.date - Rota.start_date).days - week = days_from_start // 7 + 1 - day = Rota.days[(days_from_start % 7)] - Rota.unavailable_to_work.add((self.id, week, day)) - Rota.unavailable_to_work_reason[(self.id, week, day)] = item.reason + # Ignore dates past the end of the rota (or end date) + if days_from_start < days_to_end: + week = days_from_start // 7 + 1 + day = Rota.days[(days_from_start % 7)] + Rota.unavailable_to_work.add((self.id, week, day)) + Rota.unavailable_to_work_reason[(self.id, week, day)] = item.reason + unavailable_set.add((week, day)) + for item in self.work_requests: days_from_start = (item.date - Rota.start_date).days @@ -257,6 +268,9 @@ class Worker(BaseModel): if days_to_work < 1: self.fte_adj = 0 + if days_to_work > 0 and (days_to_work - len(unavailable_set)) / days_to_work < 0.3: + console.print(f"Warning {self.name} has excessive unavailability [{len(unavailable_set)}/{days_to_work}, adjusted fte={self.fte_adj}] (this may be infeasible)", style="red") + def __lt__(self, other) -> bool: return (self.site, self.grade, self.fte_adj, self.name) < ( other.site, diff --git a/test/rota_constraints/test_avoid_grades_by_week.py b/test/rota_constraints/test_avoid_grades_by_week.py index 88e6e54..7e65e3d 100644 --- a/test/rota_constraints/test_avoid_grades_by_week.py +++ b/test/rota_constraints/test_avoid_grades_by_week.py @@ -85,7 +85,7 @@ class TestWorkerRequests: force_as_block=False, ), ) - with pytest.raises(ValueError): + with pytest.raises(InvalidShift): Rota.add_grade_constraint_by_week([1], [1,2,3], ["b"]) Rota.build_and_solve(options={"ratio": 0.000}) diff --git a/test/test_nights2.py b/test/test_nights2.py index a9e8760..53bc295 100644 --- a/test/test_nights2.py +++ b/test/test_nights2.py @@ -4,6 +4,7 @@ from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days from rota.workers import Worker + def generate_basic_rota(weeks_to_rota=10): start_date = datetime.date(2022, 3, 7) @@ -38,18 +39,23 @@ class TestNightShifts: def test_nights(self): Rota = generate_basic_rota() - Rota.add_shifts( SingleShift( - sites=("group1", "group2"), name="n", length= 12.5, days=days[5:], + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], force_as_block=True, constraint=[{"name": "night"}, {"name": "pre", "options": 2}], workers_required=2, ), SingleShift( - sites=("group1", "group2"), name="b", length= 12.5, days=days[:3], + sites=("group1", "group2"), + name="b", + length=12.5, + days=days[:3], force_as_block=False, - #constraint=[{"name": "night"}], + # constraint=[{"name": "night"}], ), ) @@ -62,18 +68,23 @@ class TestNightShifts: def test_nights_pre3(self): Rota = generate_basic_rota() - Rota.add_shifts( SingleShift( - sites=("group1", "group2"), name="n", length= 12.5, days=days[5:], + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], force_as_block=True, constraint=[{"name": "night"}, {"name": "pre", "options": 3}], workers_required=2, ), SingleShift( - sites=("group1", "group2"), name="b", length= 12.5, days=days[:2], + sites=("group1", "group2"), + name="b", + length=12.5, + days=days[:2], force_as_block=False, - #constraint=[{"name": "night"}], + # constraint=[{"name": "night"}], ), ) @@ -85,18 +96,23 @@ class TestNightShifts: def test_nights_pre3_2(self): Rota = generate_basic_rota() - Rota.add_shifts( SingleShift( - sites=("group1", "group2"), name="n", length= 12.5, days=days[5:], + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], force_as_block=True, constraint=[{"name": "night"}, {"name": "pre", "options": 3}], workers_required=1, ), SingleShift( - sites=("group1", "group2"), name="b", length= 12.5, days=days[:3], + sites=("group1", "group2"), + name="b", + length=12.5, + days=days[:3], force_as_block=False, - #constraint=[{"name": "night"}], + # constraint=[{"name": "night"}], ), ) @@ -108,18 +124,23 @@ class TestNightShifts: def test_nights_fail(self): Rota = generate_basic_rota() - Rota.add_shifts( SingleShift( - sites=("group1", "group2"), name="n", length= 12.5, days=days[5:], + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[5:], force_as_block=True, constraint=[{"name": "night"}, {"name": "pre", "options": 3}], workers_required=2, ), SingleShift( - sites=("group1", "group2"), name="b", length= 12.5, days=days[:3], + sites=("group1", "group2"), + name="b", + length=12.5, + days=days[:3], force_as_block=False, - #constraint=[{"name": "night"}], + # constraint=[{"name": "night"}], ), ) @@ -132,29 +153,90 @@ class TestNightShifts: Rota.constraint_options["max_night_frequency"] = 0 - Rota.add_shifts( SingleShift( - sites=("group1", "group2"), name="n", length= 12.5, days=days[5:], + 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], + 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"}], + # constraint=[{"name": "night"}, {"name": "pre", "options": 3}], + # constraint=[{"name": "night"}], workers_required=2, ), - #SingleShift( + # 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" \ No newline at end of file + assert Rota.results.solver.status == "ok" + + def test_nights_pre_wrap_around(self): + Rota = generate_basic_rota(weeks_to_rota=8) + + Rota.constraint_options["max_night_frequency"] = 0 + + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="n", + length=12.5, + days=days[:2], + force_as_block=True, + constraint=[{"name": "night"}, + {"name": "pre", "options": 5},{"name": "post", "options": 5}, + ], + workers_required=3, + ), + # 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.add_workers( + [ + Worker( + name="worker3", + site="group1", + grade=1, + not_available_to_work=[ + { + "date": Rota.rota_end_date - datetime.timedelta(days=1), + "reason": "no wanna", + }, + #{ + # "date": Rota.rota_end_date - datetime.timedelta(days=5), + # "reason": "no wanna", + #}, + ], + ), + ] + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night") + + assert Rota.results.solver.status == "ok"