diff --git a/runfixnights.py b/runfixnights.py index 33cc05d..32f137f 100644 --- a/runfixnights.py +++ b/runfixnights.py @@ -323,7 +323,7 @@ else: oop, not_available_to_work=leave_requests, work_requests=work_requests, - night_site=site_pref, + home_site=site_pref, previous_shifts=previous_shifts, pair=pair, shift_balance_extra=w["shift_balance_extra"], diff --git a/shifts.py b/shifts.py index c394329..f1aacb0 100644 --- a/shifts.py +++ b/shifts.py @@ -43,7 +43,23 @@ SHIFT_BOUNDS = { class SingleShift(object): - """Class to hold all details for a shift""" + """Class to hold all details for a shift + + + Valid constraints + + balance_across_groups (requires block assignment) + + postclear(2) / preclear(2) + + limit_grade_number: + options: {grade: max_number, grade2: max_number2} + + minimum_grade_number: + options: (grade, min_number) + + + """ def __init__( self, @@ -171,7 +187,6 @@ class RotaBuilder(object): self.max_weekend_frequency = max_weekend_frequency self.constraint_options = { - "limit_to_1_st2_on_nights": True, "ensure_1_st4_plus_on_nights": True, "ensure_derriford_reg_for_nights": True, "balance_nights": True, @@ -722,27 +737,6 @@ class RotaBuilder(object): # self.model.max_hours_constraint = Constraint( # [worker.id for worker in self.workers], rule=maxHoursRule) - # Limit to 1 ST2 (or below) on a night shift - def nightShiftMaxSTRule(model, week, shift): - """Limits to 1 ST2 (or below) per night""" - single_workers = [w for w in self.workers if w.grade < 3] - if not single_workers: - return Constraint.Skip - return ( - sum( - model.shift_week_worker_assigned[shift, week, w.id] - for w in single_workers - ) - <= 1 - ) - - if self.constraint_options["limit_to_1_st2_on_nights"]: - self.model.night_shifts_max_st_constraint = Constraint( - [week for week in self.weeks], - [shift.name for shift in self.get_shifts_with_constraint("night")], - rule=nightShiftMaxSTRule, - ) - # Limit grades def limitGradesRule(model, week, shift, grade, limit): workers = [w for w in self.workers if w.grade == grade] @@ -771,25 +765,34 @@ class RotaBuilder(object): #name=f"limit_grades_constraint_{shift.name}_{grade}" )) - - def nightShiftMinST4Rule(model, week, shift): - single_workers = [w for w in self.workers if w.grade >= 4] - if not single_workers: - return Constraint.Skip + def minimumGradesRule(model, week, shift, grade, limit): + workers = [w for w in self.workers if w.grade >= grade] + if len(workers) < limit: + #return Constraint.Skip + raise ValueError("minimum_grade_number: not enough valid workers") return ( sum( - model.shift_week_worker_assigned[shift, week, w.id] - for w in single_workers + model.shift_week_worker_assigned[shift.name, week, w.id] + for w in workers ) - >= 1 + >= limit ) - if self.constraint_options["ensure_1_st4_plus_on_nights"]: - self.model.night_shifts_min_st4_constraint = Constraint( + for shift in self.get_shifts_with_constraint("minimum_grade_number"): + if not shift.constraint_options["minimum_grade_number"]: + raise ValueError("Constraint option must be defined for 'minimum_grade_number'") + grade, min_required = shift.constraint_options["minimum_grade_number"] + #self.model.limit_grades_constraint = Constraint( + setattr(self.model, f"minimum_grade_number_{shift.name}", Constraint( [week for week in self.weeks], - [shift.name for shift in self.get_shifts_with_constraint("night")], - rule=nightShiftMinST4Rule, - ) + #[shift for shift in self.get_shifts_with_constraint("limit_grade_number")], + [shift], + [grade], + [min_required], + rule=minimumGradesRule, + #name=f"limit_grades_constraint_{shift.name}_{grade}" + )) + # def nightShiftDerrifordRule(model, week, shift): # derriford_workers = [w for w in self.workers if w.night_at_derriford >= 1] @@ -810,9 +813,9 @@ class RotaBuilder(object): # rule=nightShiftDerrifordRule, # ) - def presenceAtSiteOvernight(model, week, shift, required_site): + def presenceAtHomeSite(model, week, shift, required_site, required_number): required_site_workers = [ - w for w in self.workers if required_site == w.night_site + w for w in self.workers if required_site == w.home_site ] return ( @@ -820,18 +823,20 @@ class RotaBuilder(object): model.shift_week_worker_assigned[shift, week, w.id] for w in required_site_workers ) - >= 1 + >= required_number ) - if self.constraint_options["require_presence_at_site_overnight"]: - for site in self.constraint_options["require_presence_at_site_overnight"]: - self.model.require_presence_at_site_overnight_rule = Constraint( - [week for week in self.weeks], - [shift.name for shift in self.get_shifts_with_constraint("night")], - [site], - rule=presenceAtSiteOvernight, - ) + for shift in self.get_shifts_with_constraint("require_home_site_presence"): + site, required_number = shift.constraint_options["require_home_site_presence"] + #self.model.require_presence_at_site_overnight_rule = Constraint( + setattr(self.model, f"require_home_site_presence_{shift.name}", Constraint( + [week for week in self.weeks], + [shift.name], + [site], + [required_number], + rule=presenceAtHomeSite, + )) # # Count the number of workers from each site on each night shift # # As 1 or 0 is optimum we can simply subtract 1 from the number diff --git a/test_rota.py b/test_rota.py index 9bcbef2..456267e 100644 --- a/test_rota.py +++ b/test_rota.py @@ -23,8 +23,6 @@ class TestDemoRota: Rota.constraint_options["max_shifts_per_week"] = 5 Rota.constraint_options["max_shifts_per_month"] = 31 Rota.constraint_options["ensure_derriford_reg_for_nights"] = False - Rota.constraint_options["limit_to_1_st2_on_nights"] = False - Rota.constraint_options["ensure_1_st4_plus_on_nights"] = False Rota.constraint_options["constrain_time_off_after_nights"] = False Rota.constraint_options["balance_nights_across_site"] = False Rota.constraint_options["balance_shifts"] = True @@ -190,7 +188,6 @@ class TestDemoRotaNights: Rota.constraint_options["max_shifts_per_month"] = 31 Rota.constraint_options["ensure_derriford_reg_for_nights"] = False Rota.constraint_options["limit_to_1_st2_on_nights"] = False - Rota.constraint_options["ensure_1_st4_plus_on_nights"] = False Rota.constraint_options["constrain_time_off_after_nights"] = False Rota.constraint_options["balance_nights_across_site"] = False Rota.constraint_options["balance_shifts"] = True @@ -292,18 +289,8 @@ class TestDemoRotaClear: balance_offset_modifier=1, max_weekend_frequency=1, ) - #Rota.constraint_options["max_shifts_per_week"] = 5 Rota.constraint_options["max_shifts_per_month"] = 20 - #Rota.constraint_options["ensure_derriford_reg_for_nights"] = False - #Rota.constraint_options["limit_to_1_st2_on_nights"] = False - #Rota.constraint_options["ensure_1_st4_plus_on_nights"] = False - #Rota.constraint_options["constrain_time_off_after_nights"] = False - #Rota.constraint_options["balance_nights_across_site"] = False - #Rota.constraint_options["balance_shifts"] = True - #Rota.constraint_options["balance_shifts_over_workers"] = True - #Rota.constraint_options["balance_nights"] = False - #Rota.constraint_options["minimise_shift_diffs"] = False - #Rota.constraint_options["balance_blocks"] = False + worker1 = Worker(Rota, "worker1", "group1", 1) worker2 = Worker(Rota, "worker2", "group1", 1) @@ -549,12 +536,12 @@ class TestLimitConstraints: ) #Rota.constraint_options["max_shifts_per_week"] = 5 #Rota.constraint_options["max_shifts_per_month"] = 20 - worker1 = Worker(Rota, "worker1", "group1", 2) - worker2 = Worker(Rota, "worker2", "group1", 2) - worker3 = Worker(Rota, "worker3", "group1", 2) - worker4 = Worker(Rota, "worker4", "group3", 3) - worker5 = Worker(Rota, "worker5", "group3", 3) - worker6 = Worker(Rota, "worker6", "group3", 3) + worker1 = Worker(Rota, "worker1", "group1", 2, home_site="group1") + worker2 = Worker(Rota, "worker2", "group1", 2, home_site="group1") + worker3 = Worker(Rota, "worker3", "group1", 2, home_site="group1") + worker4 = Worker(Rota, "worker4", "group3", 3, home_site="group2") + worker5 = Worker(Rota, "worker5", "group3", 3, home_site="group2") + worker6 = Worker(Rota, "worker6", "group3", 3, home_site="group2") Rota.add_workers((worker1, worker2, worker3, worker4)) Rota.add_workers((worker5, worker6)) @@ -673,9 +660,160 @@ class TestLimitConstraints: self.Rota.constraint_options["balance_shifts_quadratic"] = True self.Rota.build_and_solve(options={"ratio": 0.1}) + assert self.Rota.results.solver.termination_condition == "infeasible" + + def test_constraint_minimum_grades(self): + self.Rota.shifts = [] + self.Rota.add_shifts( + SingleShift(("group1", "group2", "group3"), "weekday_night", 12.5, days, balance_offset=40, + workers_required=1, + force_as_block=True, + constraints=[("minimum_grade_number", (3, 1))] + ), + ) + + self.Rota.constraint_options["balance_shifts_quadratic"] = True + self.Rota.build_and_solve(options={"ratio": 0.1}) + self.Rota.export_rota_to_html("test9") - assert self.Rota.results.solver.termination_condition == "infeasible" + grade_workers = self.Rota.get_workers_by_grade() + for grade in grade_workers: + shift_patterns = [] + for w in grade_workers[grade]: + shift_patterns.append(self.Rota.get_worker_shift_list(w)) + + zipped_lists = list(zip(*shift_patterns)) + + limit = 0 + if grade > 2: + limit = 1 + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") == limit + + assert self.Rota.results.solver.status == "ok" + + def test_constraint_minimum_grades2(self): + self.Rota.shifts = [] + self.Rota.add_shifts( + SingleShift(("group1", "group2", "group3"), "weekday_night", 12.5, days, balance_offset=40, + workers_required=4, + force_as_block=True, + constraints=[("minimum_grade_number", (3, 3))] + ), + ) + + self.Rota.constraint_options["balance_shifts_quadratic"] = True + self.Rota.build_and_solve(options={"ratio": 0.1}) + + self.Rota.export_rota_to_html("test9") + + grade_workers = self.Rota.get_workers_by_grade() + for grade in grade_workers: + shift_patterns = [] + for w in grade_workers[grade]: + shift_patterns.append(self.Rota.get_worker_shift_list(w)) + + zipped_lists = list(zip(*shift_patterns)) + + limit = 1 + if grade > 2: + limit = 3 + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") == limit + + assert self.Rota.results.solver.status == "ok" + + def test_constraint_minimum_grades3(self): + self.Rota.shifts = [] + self.Rota.add_shifts( + SingleShift(("group1", "group2", "group3"), "weekday_night", 12.5, days, balance_offset=40, + workers_required=4, + force_as_block=True, + constraints=[("minimum_grade_number", (3, 0))] + ), + ) + + self.Rota.constraint_options["balance_shifts_quadratic"] = True + self.Rota.build_and_solve(options={"ratio": 0.1}) + + self.Rota.export_rota_to_html("test9") + + assert self.Rota.results.solver.status == "ok" + + def test_constraint_minimum_grades_no_valid_worker(self): + self.Rota.shifts = [] + self.Rota.add_shifts( + SingleShift(("group1", "group2", "group3"), "weekday_night", 12.5, days, balance_offset=40, + workers_required=4, + force_as_block=True, + constraints=[("minimum_grade_number", (4, 1))] + ), + ) + + self.Rota.constraint_options["balance_shifts_quadratic"] = True + with pytest.raises(ValueError): + self.Rota.build_and_solve(options={"ratio": 0.1}) + + + def test_constraint_require_home_site_presence(self): + self.Rota.shifts = [] + self.Rota.add_shifts( + SingleShift(("group1", "group2", "group3"), "weekday_night", 12.5, days, balance_offset=40, + workers_required=2, + force_as_block=True, + constraints=[("require_home_site_presence", ("group1", 2))] + ), + ) + self.Rota.build_and_solve(options={"ratio": 0.00}) + self.Rota.export_rota_to_html("test9") + + group_workers = self.Rota.get_workers_by_group() + for group in group_workers: + shift_patterns = [] + for w in group_workers[group]: + shift_patterns.append(self.Rota.get_worker_shift_list(w)) + + zipped_lists = list(zip(*shift_patterns)) + + limit = 2 + if group != "group1": + limit = 0 + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") == limit + + + assert self.Rota.results.solver.status == "ok" + assert self.Rota.results.solver.termination_condition == "optimal" + + def test_constraint_require_home_site_presence2(self): + self.Rota.shifts = [] + self.Rota.add_shifts( + SingleShift(("group1", "group2", "group3"), "weekday_night", 12.5, days, balance_offset=40, + workers_required=3, + force_as_block=True, + constraints=[("require_home_site_presence", ("group2", 2))] + ), + ) + self.Rota.build_and_solve(options={"ratio": 0.00}) + self.Rota.export_rota_to_html("test9") + + group_workers = self.Rota.get_workers_by_group() + for group in group_workers: + shift_patterns = [] + for w in group_workers[group]: + shift_patterns.append(self.Rota.get_worker_shift_list(w)) + + zipped_lists = list(zip(*shift_patterns)) + + if group == "group2": + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") >= 2 + + + assert self.Rota.results.solver.status == "ok" + assert self.Rota.results.solver.termination_condition == "optimal" + #class TestNoWorkerRota: # weeks_to_rota = 10 @@ -693,4 +831,4 @@ class TestLimitConstraints: if __name__ == "__main__": t = TestLimitConstraints() - t.test_constraint_limit_grades4() + t.test_constraint_require_home_site_presence() diff --git a/workers.py b/workers.py index c7ade52..e048922 100644 --- a/workers.py +++ b/workers.py @@ -20,7 +20,7 @@ class Worker: not_available_to_work=None, pref_not_to_work=None, work_requests=None, - night_site: str = "plymouth", # We set a default proc_site + home_site: str = "plymouth", # We set a default proc_site previous_shifts: dict = {}, shift_balance_extra: dict = {}, bank_holiday_extra: int = 0, @@ -44,8 +44,8 @@ class Worker: self.shift_balance_extra = shift_balance_extra self.bank_holiday_extra = bank_holiday_extra - self.proc_site = night_site - if night_site == "plymouth" or night_site == "": + self.home_site = home_site + if home_site == "plymouth" or home_site == "": self.night_at_derriford = 1 else: self.night_at_derriford = 0 @@ -172,7 +172,7 @@ class Worker: self.name, (self.site, self.grade, self.fte_adj), nwd, - self.proc_site, + self.home_site, self.night_at_derriford, )