From a647416720abc90cac76bbacb273d0cc15763086 Mon Sep 17 00:00:00 2001 From: Ross Date: Thu, 5 Jun 2025 23:40:28 +0100 Subject: [PATCH] major test refactoring --- output/tests/timetable.css | 1 + output/tests/timetable.js | 1 + rota/shifts.py | 9 +- test/test_balance_shift_sites.py | 139 ++++ test/test_general_balancing.py | 459 +++++------ test/test_limit_constraints.py | 325 ++++++++ test/test_night_unavailable.py | 146 ++++ test/test_nights2.py | 791 ++++++++---------- test/test_nwds.py | 548 +++++-------- test/test_rota.py | 1298 +++--------------------------- test/test_rota_clear.py | 85 ++ test/test_rota_nights.py | 99 +++ test/test_shift_constraints.py | 71 ++ 13 files changed, 1690 insertions(+), 2282 deletions(-) create mode 120000 output/tests/timetable.css create mode 120000 output/tests/timetable.js create mode 100644 test/test_balance_shift_sites.py create mode 100644 test/test_limit_constraints.py create mode 100644 test/test_night_unavailable.py create mode 100644 test/test_rota_clear.py create mode 100644 test/test_rota_nights.py create mode 100644 test/test_shift_constraints.py diff --git a/output/tests/timetable.css b/output/tests/timetable.css new file mode 120000 index 0000000..6694d8b --- /dev/null +++ b/output/tests/timetable.css @@ -0,0 +1 @@ +/home/ross/proc_rota/output/timetable.css \ No newline at end of file diff --git a/output/tests/timetable.js b/output/tests/timetable.js new file mode 120000 index 0000000..b7adfdf --- /dev/null +++ b/output/tests/timetable.js @@ -0,0 +1 @@ +/home/ross/proc_rota/output/timetable.js \ No newline at end of file diff --git a/rota/shifts.py b/rota/shifts.py index 3449332..06d0f38 100644 --- a/rota/shifts.py +++ b/rota/shifts.py @@ -392,6 +392,7 @@ class RotaBuilder(object): debug_if_fail: bool = False, ): console.print("Setting up solver") + console.print(f"{options=}") if solver == "scip": if "seconds" in options: @@ -516,6 +517,9 @@ class RotaBuilder(object): self.build_workers() self.build_model() + console.print("Building model") + console.print(f"{options=}") + if solve: self.solve_model( options=options, debug_if_fail=debug_if_fail, solver=solver @@ -2203,7 +2207,6 @@ class RotaBuilder(object): self.get_week_start_date(week) ): # Force shifts to be assigned in blocks - try: self.model.constraints.add( self.model.shift_week_worker_assigned[ shift.name, week, worker.id @@ -2214,8 +2217,6 @@ class RotaBuilder(object): for day in shift.days ) ) - except KeyError: - pass # blocks with workerd requirements # @@ -3213,7 +3214,7 @@ class RotaBuilder(object): shifts = [] for shift in self.shifts: - if shift.start_date < date <= shift.end_date: + if shift.start_date <= date < shift.end_date: if shift.get_worker_requirement_by_date(date): shifts.append(shift) diff --git a/test/test_balance_shift_sites.py b/test/test_balance_shift_sites.py new file mode 100644 index 0000000..a91427b --- /dev/null +++ b/test/test_balance_shift_sites.py @@ -0,0 +1,139 @@ +import pytest +import datetime +from rota.shifts import RotaBuilder, SingleShift, days +from rota.workers import Worker + +@pytest.fixture +def demo_rota_balance_sites(): + weeks_to_rota = 8 + start_date = datetime.date(2022, 3, 7) + Rota = RotaBuilder( + start_date, + weeks_to_rota=weeks_to_rota, + ) + worker1 = Worker(name="worker1", site="group1", grade=1) + worker2 = Worker(name="worker2", site="group1", grade=1) + worker3 = Worker(name="worker3", site="group2", grade=1) + worker4 = Worker(name="worker4", site="group2", grade=1) + worker5 = Worker(name="worker5", site="group3", grade=1) + worker6 = Worker(name="worker6", site="group3", grade=1) + Rota.add_workers((worker1, worker2, worker3, worker4, worker5, worker6)) + return Rota + +def test_balance_blocks_across_2_groups(demo_rota_balance_sites): + Rota = demo_rota_balance_sites + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="weekday_night", + length=12.5, + days=days[:4], + balance_offset=40, + workers_required=2, + force_as_block=True, + constraint=[{"name": "balance_across_groups"}], + ), + ) + Rota.constraint_options["balance_nights_across_sites"] = False + Rota.constraint_options["balance_nights"] = False + Rota.constraint_options["constrain_time_off_after_nights"] = False + + # Worker 5 and 6 don't have any valid shifts so this should fail + #with pytest.raises(WarningTermination): + # Rota.build_and_solve(options={"ratio": 0.0}) + + # Unless we remove the warning + Rota.terminate_on_warning.remove("Worker/no valid shifts") + Rota.build_and_solve(options={"ratio": 0.0}) + + assert len(Rota.get_warnings("Worker/no valid shifts")) == 2 + + Rota.export_rota_to_html("test5") + + group_workers = Rota.get_workers_by_group() + for group in group_workers: + shift_patterns = [] + for w in group_workers[group]: + shift_patterns.append(Rota.get_worker_shift_list(w)) + + zipped_lists = list(zip(*shift_patterns)) + + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") <= 1 + + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + +def test_balance_blocks_across_2_groups_unbalanced(demo_rota_balance_sites): + Rota = demo_rota_balance_sites + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="weekday_night", + length=12.5, + days=days[:4], + balance_offset=40, + workers_required=3, + force_as_block=True, + constraint=[{"name": "balance_across_groups"}], + ), + ) + Rota.constraint_options["balance_nights_across_sites"] = False + Rota.constraint_options["balance_nights"] = False + Rota.constraint_options["constrain_time_off_after_nights"] = False + + try: + Rota.terminate_on_warning.remove("Worker/no valid shifts") + except ValueError: + # Seems to happen with sequential test running + pass + Rota.build_and_solve(options={"ratio": 0.1}) + + group_workers = Rota.get_workers_by_group() + for group in group_workers: + shift_patterns = [] + for w in group_workers[group]: + shift_patterns.append(Rota.get_worker_shift_list(w)) + + zipped_lists = list(zip(*shift_patterns)) + + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") <= 2 + + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + +def test_balance_blocks_across_3_groups(demo_rota_balance_sites): + Rota = demo_rota_balance_sites + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="weekday_night", + length=12.5, + days=days[:4], + balance_offset=40, + workers_required=3, + force_as_block=True, + constraint=[{"name": "balance_across_groups"}], + ), + ) + Rota.constraint_options["balance_nights_across_sites"] = False + Rota.constraint_options["balance_nights"] = False + Rota.constraint_options["constrain_time_off_after_nights"] = False + Rota.build_and_solve(options={"ratio": 0.1}) + + group_workers = Rota.get_workers_by_group() + for group in group_workers: + shift_patterns = [] + for w in group_workers[group]: + shift_patterns.append(Rota.get_worker_shift_list(w)) + + zipped_lists = list(zip(*shift_patterns)) + + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") <= 1 + + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" \ No newline at end of file diff --git a/test/test_general_balancing.py b/test/test_general_balancing.py index b9c53d9..6bb64b3 100644 --- a/test/test_general_balancing.py +++ b/test/test_general_balancing.py @@ -1,284 +1,227 @@ import datetime import pytest from pytest import approx -from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days - +from rota.shifts import RotaBuilder, SingleShift, days from rota.workers import Worker - def generate_basic_rota(weeks_to_rota=10): start_date = datetime.date(2022, 3, 7) - Rota = RotaBuilder( start_date, weeks_to_rota=weeks_to_rota, ) Rota.constraint_options["balance_weekends"] = True - - # Add a few workers Rota.add_workers( [ Worker(name="worker1", site="group1", grade=1), Worker(name="worker2", site="group1", grade=1), Worker(name="worker3", site="group2", grade=1), - # Worker(name="worker4", site="group2", grade=1), - # Worker(name="worker5", site="group2", grade=1), - # Worker(name="worker6", site="group2", grade=1, fte=50), ] ) - return Rota +def test_basic_balancing(): + Rota = generate_basic_rota() + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="a", + length=12.5, + days=days, + balance_offset=2, + workers_required=1, + force_as_block=False, + ), + SingleShift( + sites=("group1", "group2"), + name="b", + length=12.5, + days=days, + balance_offset=2, + workers_required=1, + force_as_block=False, + ), + ) + Rota.build_and_solve(options={"ratio": 0.001}) + Rota.export_rota_to_html("basic_balancing") + shift_summary = Rota.get_shift_summary_dict() + for worker_name in shift_summary: + worker_shifts = shift_summary[worker_name] + assert worker_shifts["a"] in (approx(22), approx(23), approx(24), approx(25)) + assert worker_shifts["b"] in (approx(22), approx(23), approx(24), approx(25)) -class TestBalancing: - def test_basic_balancing(self): - Rota = generate_basic_rota() +def test_weighted_shift_balancing(): + Rota = generate_basic_rota(20) + Rota.add_shifts( + SingleShift( + sites=["group1", "group2"], + name="a", + length=12.5, + days=days, + balance_weighting=10, + workers_required=1, + force_as_block=False, + constraint=[{"name": "pre","options": "2"}, {"name": "post","options": "2"}], + ), + SingleShift( + sites=("group1", "group2"), + name="b", + length=12.5, + days=days, + workers_required=1, + force_as_block=False, + ), + SingleShift( + sites=("group1", "group2"), + name="c", + length=12.5, + days=days[0], + workers_required=1, + force_as_block=False, + ), + ) + Rota.build_and_solve(options={"ratio": 0.0001}) + Rota.export_rota_to_html("basic_balancing_weighted_shifts") + shift_summary = Rota.get_shift_summary_dict() + for worker_name in shift_summary: + worker_shifts = shift_summary[worker_name] + assert worker_shifts["a"] in (approx(46), approx(47), approx(48)) - Rota.add_shifts( - SingleShift( - sites=("group1", "group2"), - name="a", - length=12.5, - days=days, - balance_offset=2, - workers_required=1, - force_as_block=False, - ), - SingleShift( - sites=("group1", "group2"), - name="b", - length=12.5, - days=days, - balance_offset=2, - workers_required=1, - force_as_block=False, - ), - ) +def test_weighted_shift_balancing2(): + Rota = generate_basic_rota(23) + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="a", + length=12.5, + days=days, + workers_required=1, + force_as_block=False, + ), + SingleShift( + sites=("group1", "group2"), + name="b", + length=12.5, + days=days, + balance_weighting=8, + workers_required=1, + force_as_block=False, + ), + SingleShift( + sites=("group1", "group2"), + name="c", + length=12.5, + days=days[0], + workers_required=1, + force_as_block=False, + ), + ) + Rota.build_and_solve(options={"ratio": 0.0001}) + Rota.export_rota_to_html("basic_balancing_weighted_shifts2") + shift_summary = Rota.get_shift_summary_dict() + for worker_name in shift_summary: + worker_shifts = shift_summary[worker_name] + assert worker_shifts["b"] in (approx(53), approx(54), approx(55)) - Rota.build_and_solve(options={"ratio": 0.001}) - Rota.export_rota_to_html("basic_balancing") +def test_weighted_shift_balancing3(): + Rota = generate_basic_rota(23) + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="a", + length=12.5, + days=days, + workers_required=1, + force_as_block=False, + ), + SingleShift( + sites=("group1", "group2"), + name="b", + length=12.5, + days=days, + balance_weighting=1, + workers_required=1, + force_as_block=False, + ), + SingleShift( + sites=("group1", "group2"), + name="c", + length=12.5, + days=days[0], + balance_weighting=8, + workers_required=1, + force_as_block=False, + ), + ) + Rota.build_and_solve(options={"ratio": 0.0001}) + Rota.export_rota_to_html("basic_balancing_weighted_shifts3") + shift_summary = Rota.get_shift_summary_dict() + for worker_name in shift_summary: + worker_shifts = shift_summary[worker_name] + assert worker_shifts["c"] in (approx(7), approx(8)) - shift_summary = Rota.get_shift_summary_dict() - for worker_name in shift_summary: - worker_shifts = shift_summary[worker_name] +def test_weighted_shift_balancing4(): + Rota = generate_basic_rota(10) + Rota.add_worker( + Worker(name="worker4", site="group3", grade=1), + ) + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="a", + length=12.5, + days=days, + balance_offset=99, + balance_weighting=8, + workers_required=2, + force_as_block=False, + ), + SingleShift( + sites=("group2", "group3"), + name="b", + length=12.5, + days=days, + balance_offset=99, + workers_required=1, + force_as_block=False, + ), + ) + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("basic_balancing_weighted_shifts4") + shift_summary = Rota.get_shift_summary_dict() + for worker_name in shift_summary: + worker_shifts = shift_summary[worker_name] + assert worker_shifts["a"] in (0, 46, 47, 48) - #worker = Rota.get_worker_by_name(worker_name) - assert worker_shifts["a"] in (approx(22), approx(23), approx(24), approx(25)) - assert worker_shifts["b"] in (approx(22), approx(23), approx(24), approx(25)) - - def test_weighted_shift_balancing(self): - Rota = generate_basic_rota(20) - - Rota.add_shifts( - SingleShift( - sites=["group1", "group2"], - name="a", - length=12.5, - days=days, - balance_weighting=10, - workers_required=1, - force_as_block=False, - constraint=[{"name": "pre","options": "2"}, {"name": "post","options": "2"}], - ), - SingleShift( - sites=("group1", "group2"), - name="b", - length=12.5, - days=days, - workers_required=1, - force_as_block=False, - ), - SingleShift( - sites=("group1", "group2"), - name="c", - length=12.5, - days=days[0], - workers_required=1, - force_as_block=False, - ), - ) - - Rota.build_and_solve(options={"ratio": 0.0001}) - Rota.export_rota_to_html("basic_balancing_weighted_shifts") - - shift_summary = Rota.get_shift_summary_dict() - for worker_name in shift_summary: - worker_shifts = shift_summary[worker_name] - - worker = Rota.get_worker_by_name(worker_name) - assert worker_shifts["a"] in (approx(46), approx(47), approx(48)) - #assert worker_shifts["b"] in (46, 47, 48) - # assert worker_shifts["b"] in (22, 23, 24, 25) - - def test_weighted_shift_balancing2(self): - Rota = generate_basic_rota(23) - - Rota.add_shifts( - SingleShift( - sites=("group1", "group2"), - name="a", - length=12.5, - days=days, - # balance_weighting=4, - workers_required=1, - force_as_block=False, - # constraint=["preclear2", "postclear2"], - ), - SingleShift( - sites=("group1", "group2"), - name="b", - length=12.5, - days=days, - balance_weighting=8, - workers_required=1, - force_as_block=False, - ), - SingleShift( - sites=("group1", "group2"), - name="c", - length=12.5, - days=days[0], - workers_required=1, - force_as_block=False, - ), - ) - - Rota.build_and_solve(options={"ratio": 0.0001}) - Rota.export_rota_to_html("basic_balancing_weighted_shifts2") - - shift_summary = Rota.get_shift_summary_dict() - for worker_name in shift_summary: - worker_shifts = shift_summary[worker_name] - - worker = Rota.get_worker_by_name(worker_name) - # assert worker_shifts["a"] in (46,47,48) - # assert worker_shifts["c"] in (46,47,48) - assert worker_shifts["b"] in (approx(53), approx(54), approx(55)) - - def test_weighted_shift_balancing3(self): - Rota = generate_basic_rota(23) - - Rota.add_shifts( - SingleShift( - sites=("group1", "group2"), - name="a", - length=12.5, - days=days, - # balance_weighting=4, - workers_required=1, - force_as_block=False, - # constraint=["preclear2", "postclear2"], - ), - SingleShift( - sites=("group1", "group2"), - name="b", - length=12.5, - days=days, - balance_weighting=1, - workers_required=1, - force_as_block=False, - ), - SingleShift( - sites=("group1", "group2"), - name="c", - length=12.5, - days=days[0], - balance_weighting=8, - workers_required=1, - force_as_block=False, - ), - ) - - Rota.build_and_solve(options={"ratio": 0.0001}) - Rota.export_rota_to_html("basic_balancing_weighted_shifts3") - - shift_summary = Rota.get_shift_summary_dict() - for worker_name in shift_summary: - worker_shifts = shift_summary[worker_name] - - worker = Rota.get_worker_by_name(worker_name) - # assert worker_shifts["a"] in (46,47,48) - assert worker_shifts["c"] in (approx(7), approx(8)) - #assert worker_shifts["b"] in (53, 54) - - def test_weighted_shift_balancing4(self): - Rota = generate_basic_rota(10) - - Rota.add_worker( - Worker(name="worker4", site="group3", grade=1), - ) - - Rota.add_shifts( - SingleShift( - sites=("group1", "group2"), - name="a", - length=12.5, - days=days, - balance_offset=99, - balance_weighting=8, - workers_required=2, - force_as_block=False, - # constraint=["preclear2", "postclear2"], - ), - SingleShift( - sites=("group2", "group3"), - name="b", - length=12.5, - days=days, - balance_offset=99, - # balance_weighting=4, - workers_required=1, - force_as_block=False, - ), - ) - - Rota.build_and_solve(options={"ratio": 0.000}) - Rota.export_rota_to_html("basic_balancing_weighted_shifts4") - - shift_summary = Rota.get_shift_summary_dict() - for worker_name in shift_summary: - worker_shifts = shift_summary[worker_name] - - worker = Rota.get_worker_by_name(worker_name) - assert worker_shifts["a"] in (0, 46, 47, 48) - - def test_weighted_shift_balancing5(self): - Rota = generate_basic_rota(10) - - Rota.add_worker( - Worker(name="worker4", site="group3", grade=1), - ) - - Rota.add_shifts( - SingleShift( - sites=("group1", "group2"), - name="a", - length=12.5, - days=days, - balance_offset=99, - # balance_weighting=8, - workers_required=2, - force_as_block=False, - # constraint=["preclear2", "postclear2"], - ), - SingleShift( - sites=("group2", "group3"), - name="b", - length=12.5, - days=days, - balance_offset=99, - balance_weighting=4, - workers_required=1, - force_as_block=False, - ), - ) - - Rota.build_and_solve(options={"ratio": 0.000}) - Rota.export_rota_to_html("basic_balancing_weighted_shifts5") - - shift_summary = Rota.get_shift_summary_dict() - for worker_name in shift_summary: - worker_shifts = shift_summary[worker_name] - - worker = Rota.get_worker_by_name(worker_name) - assert worker_shifts["b"] in (0, 35) +def test_weighted_shift_balancing5(): + Rota = generate_basic_rota(10) + Rota.add_worker( + Worker(name="worker4", site="group3", grade=1), + ) + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="a", + length=12.5, + days=days, + balance_offset=99, + workers_required=2, + force_as_block=False, + ), + SingleShift( + sites=("group2", "group3"), + name="b", + length=12.5, + days=days, + balance_offset=99, + balance_weighting=4, + workers_required=1, + force_as_block=False, + ), + ) + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("basic_balancing_weighted_shifts5") + shift_summary = Rota.get_shift_summary_dict() + for worker_name in shift_summary: + worker_shifts = shift_summary[worker_name] + assert worker_shifts["b"] in (0, 35) \ No newline at end of file diff --git a/test/test_limit_constraints.py b/test/test_limit_constraints.py new file mode 100644 index 0000000..7736f49 --- /dev/null +++ b/test/test_limit_constraints.py @@ -0,0 +1,325 @@ +import pytest +import datetime +from rota.shifts import RotaBuilder, SingleShift, days +from rota.workers import Worker + +@pytest.fixture +def limit_constraint_rota(): + weeks_to_rota = 8 + start_date = datetime.date(2022, 3, 7) + Rota = RotaBuilder( + start_date, + weeks_to_rota=weeks_to_rota, + ) + worker1 = Worker(name="worker1", site="group1", grade=2, remote_site="group1") + worker2 = Worker(name="worker2", site="group1", grade=2, remote_site="group1") + worker3 = Worker(name="worker3", site="group1", grade=2, remote_site="group1") + worker4 = Worker(name="worker4", site="group3", grade=3, remote_site="group2") + worker5 = Worker(name="worker5", site="group3", grade=3, remote_site="group2") + worker6 = Worker(name="worker6", site="group3", grade=3, remote_site="group2") + Rota.add_workers((worker1, worker2, worker3, worker4, worker5, worker6)) + Rota.shifts = [] + return Rota + +def test_constraint_limit_grades(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="weekday_night", + length=12.5, + days=days, + balance_offset=60, + workers_required=4, + force_as_block=True, + constraint=[{"name": "limit_grade_number", "options": {2: 1}}], + ), + ) + Rota.build_and_solve(options={"ratio": 0.01}) + Rota.export_rota_to_html("constraint_limit_grades", folder="tests") + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + grade_workers = Rota.get_workers_by_grade() + for grade in grade_workers: + shift_patterns = [Rota.get_worker_shift_list(w) for w in grade_workers[grade]] + zipped_lists = list(zip(*shift_patterns)) + limit = 1 if grade == 2 else 3 + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") == limit + +def test_constraint_limit_grades2(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="weekday_night", + length=12.5, + days=days, + balance_offset=40, + workers_required=4, + force_as_block=True, + constraint=[{"name": "limit_grade_number", "options": {3: 2}}], + ), + ) + Rota.constraint_options["balance_shifts_quadratic"] = True + Rota.build_and_solve(options={"ratio": 0.1}) + Rota.export_rota_to_html("test9") + grade_workers = Rota.get_workers_by_grade() + for grade in grade_workers: + shift_patterns = [Rota.get_worker_shift_list(w) for w in grade_workers[grade]] + zipped_lists = list(zip(*shift_patterns)) + limit = 2 if grade == 3 else 2 + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") == limit + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + +def test_constraint_limit_grades3(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.workers = [] + worker1 = Worker(name="worker1", site="group1", grade=2, remote_site="group1") + worker2 = Worker(name="worker2", site="group1", grade=2, remote_site="group1") + worker3 = Worker(name="worker3", site="group1", grade=2, remote_site="group1") + worker4 = Worker(name="worker4", site="group3", grade=3, remote_site="group2") + worker5 = Worker(name="worker5", site="group3", grade=3, remote_site="group2") + worker6 = Worker(name="worker6", site="group3", grade=3, remote_site="group2") + worker7 = Worker(name="worker7", site="group3", grade=2, remote_site="group2") + Rota.add_workers((worker1, worker2, worker3, worker4, worker5, worker6, worker7)) + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="weekday_night", + length=12.5, + days=days, + balance_offset=60, + workers_required=4, + force_as_block=True, + constraint=[{"name": "limit_grade_number", "options": {2: 3, 3: 1}}], + ), + ) + Rota.constraint_options["balance_shifts_quadratic"] = True + Rota.build_and_solve(options={"ratio": 0.01}) + Rota.export_rota_to_html("test9") + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + grade_workers = Rota.get_workers_by_grade() + for grade in grade_workers: + shift_patterns = [Rota.get_worker_shift_list(w) for w in grade_workers[grade]] + zipped_lists = list(zip(*shift_patterns)) + limit = 3 if grade == 2 else 1 + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") == limit + +def test_constraint_limit_grades4(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="weekday_night", + length=12.5, + days=days, + balance_offset=40, + workers_required=4, + force_as_block=True, + constraint=[{"name": "limit_grade_number", "options": {2: 4, 3: 0}}], + ), + ) + Rota.constraint_options["balance_shifts_quadratic"] = True + Rota.build_and_solve(options={"ratio": 0.1}) + assert Rota.results.solver.termination_condition == "infeasible" + +def test_constraint_minimum_grades(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="weekday_night", + length=12.5, + days=days, + balance_offset=40, + workers_required=1, + force_as_block=True, + constraint=[{"name": "minimum_grade_number", "options": (3, 1)}], + ), + ) + Rota.constraint_options["balance_shifts_quadratic"] = True + Rota.build_and_solve(options={"ratio": 0.1}) + Rota.export_rota_to_html("test9") + assert Rota.results.solver.status == "ok" + grade_workers = Rota.get_workers_by_grade() + for grade in grade_workers: + shift_patterns = [Rota.get_worker_shift_list(w) for w in grade_workers[grade]] + zipped_lists = list(zip(*shift_patterns)) + limit = 1 if grade > 2 else 0 + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") == limit + +def test_constraint_minimum_grades2(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="weekday_night", + length=12.5, + days=days, + balance_offset=40, + workers_required=4, + force_as_block=True, + constraint=[{"name": "minimum_grade_number", "options": (3, 3)}], + ), + ) + Rota.constraint_options["balance_shifts_quadratic"] = True + Rota.build_and_solve(options={"ratio": 0.1}) + Rota.export_rota_to_html("test9") + grade_workers = Rota.get_workers_by_grade() + for grade in grade_workers: + shift_patterns = [Rota.get_worker_shift_list(w) for w in grade_workers[grade]] + zipped_lists = list(zip(*shift_patterns)) + limit = 3 if grade > 2 else 1 + for day_shifts in zipped_lists: + assert day_shifts.count("weekday_night") == limit + assert Rota.results.solver.status == "ok" + +def test_constraint_minimum_grades3(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="weekday_night", + length=12.5, + days=days, + balance_offset=40, + workers_required=4, + force_as_block=True, + constraint=[{"name": "minimum_grade_number", "options": (3, 0)}], + ), + ) + Rota.constraint_options["balance_shifts_quadratic"] = True + Rota.build_and_solve(options={"ratio": 0.1}) + Rota.export_rota_to_html("test9") + assert Rota.results.solver.status == "ok" + +def test_constraint_minimum_grades_no_valid_worker(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="weekday_night", + length=12.5, + days=days, + balance_offset=40, + workers_required=4, + force_as_block=True, + constraint=[{"name": "minimum_grade_number", "options": (4, 1)}], + ), + ) + Rota.constraint_options["balance_shifts_quadratic"] = True + with pytest.raises(ValueError): + Rota.build_and_solve(options={"ratio": 0.1}) + +def test_constraint_require_remote_site_presence_week(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="night", + length=12.5, + days=days, + balance_offset=40, + workers_required=2, + force_as_block=True, + constraint=[ + { + "name": "require_remote_site_presence_week", + "options": ("group1", 2), + } + ], + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("remote1") + group_workers = Rota.get_workers_by_group() + for group in group_workers: + shift_patterns = [Rota.get_worker_shift_list(w) for w in group_workers[group]] + zipped_lists = list(zip(*shift_patterns)) + limit = 2 if group == "group1" else 0 + for day_shifts in zipped_lists: + assert day_shifts.count("night") == limit + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + +def test_constraint_require_remote_site_presence_week2(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.constraint_options["minimise_shift_diffs"] = False + Rota.constraint_options["balance_shifts_quadratic"] = False + Rota.constraint_options["balance_shift"] = False + Rota.constraint_options["balance_nights_across_sites"] = True + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="night", + length=12.5, + days=days, + balance_offset=20, + workers_required=3, + force_as_block=True, + constraint=[ + { + "name": "require_remote_site_presence_week", + "options": ("group1", 2), + } + ], + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("remote2") + group_workers = Rota.get_workers_by_remote_group() + for group in group_workers: + shift_patterns = [Rota.get_worker_shift_list(w) for w in group_workers[group]] + zipped_lists = list(zip(*shift_patterns)) + if group == "group1": + for day_shifts in zipped_lists: + assert day_shifts.count("night") >= 2 + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + +def test_constraint_require_remote_site_presence_week3(limit_constraint_rota): + Rota = limit_constraint_rota + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1", "group2", "group3"), + name="night", + length=12.5, + days=days, + balance_offset=20, + workers_required=3, + force_as_block=True, + constraint=[ + { + "name": "require_remote_site_presence_week", + "options": ("group2", 1), + } + ], + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("remote3") + group_workers = Rota.get_workers_by_remote_group() + for group in group_workers: + shift_patterns = [Rota.get_worker_shift_list(w) for w in group_workers[group]] + zipped_lists = list(zip(*shift_patterns)) + if group == "group2": + for day_shifts in zipped_lists: + assert day_shifts.count("night") >= 1 + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" \ No newline at end of file diff --git a/test/test_night_unavailable.py b/test/test_night_unavailable.py new file mode 100644 index 0000000..9304884 --- /dev/null +++ b/test/test_night_unavailable.py @@ -0,0 +1,146 @@ +import pytest +import datetime +from rota.shifts import RotaBuilder, SingleShift, days +from rota.workers import Worker + +@pytest.fixture +def demo_rota_night_unavailable(): + weeks_to_rota = 10 + start_date = datetime.date(2022, 3, 7) + Rota = RotaBuilder( + start_date, + weeks_to_rota=weeks_to_rota, + ) + worker1 = Worker( + name="worker1", + site="group1", + grade=1, + not_available_to_work=[ + { + "date": datetime.datetime.strptime("15/03/22", "%d/%m/%y").date(), + "reason": "****", + }, + ], + ) + worker2 = Worker( + name="worker2", + site="group1", + grade=1, + not_available_to_work=[ + { + "date": datetime.datetime.strptime("14/03/22", "%d/%m/%y").date(), + "reason": "****", + } + ], + ) + Rota.add_workers((worker1, worker2)) + return Rota + +def test_basic_assignment(demo_rota_night_unavailable): + Rota = demo_rota_night_unavailable + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1",), + name="night_weekend", + length=12.5, + days=days[5:], + constraint=[{"name": "night"}], + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("testnight") + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + summary = Rota.get_shift_summary_dict() + for worker in summary: + assert summary[worker]["night_weekend"] == 10 + +def test_assign_night_prior_to_unavailablity(demo_rota_night_unavailable): + Rota = demo_rota_night_unavailable + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1",), + name="night_weekend", + length=12.5, + days=days[5:], + workers_required=2, + constraint=[{"name": "night"}], + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("testnight_unavail") + assert Rota.results.solver.status in ("warning", "error") + assert Rota.results.solver.termination_condition == "infeasible" + +def test_assign_non_night_prior_to_unavailablity(demo_rota_night_unavailable): + Rota = demo_rota_night_unavailable + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1",), + name="night_weekend", + length=12.5, + days=days[5:], + workers_required=2, + #constraint=[{"name": "night"}], + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("testnight_unavail") + assert Rota.results.solver.status == "ok" + +def test_assign_prior_to_unavailablity_non_night(demo_rota_night_unavailable): + Rota = demo_rota_night_unavailable + Rota.shifts = [] + Rota.add_shifts( + SingleShift( + sites=("group1",), + name="night_weekend", + length=12.5, + days=days[5:], + workers_required=2, + constraint=[], + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("testnight") + assert Rota.results.solver.status == "ok" + +def test_assign_split(demo_rota_night_unavailable): + Rota = demo_rota_night_unavailable + Rota.shifts = [] + worker3 = Worker( + name="worker3", + site="group1", + grade=1, + not_available_to_work=( + {"date": datetime.datetime.strptime("13/03/22", "%d/%m/%y").date()}, + ), + ) + worker4 = Worker( + name="worker4", + site="group1", + grade=1, + not_available_to_work=( + {"date": datetime.datetime.strptime("12/03/22", "%d/%m/%y").date()}, + ), + ) + Rota.add_workers((worker3, worker4)) + Rota.add_shifts( + SingleShift( + sites=("group1",), + name="night_weekend", + length=12.5, + days=days[5:], + workers_required=3, + constraint=[], + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("testnight") + assert Rota.results.solver.status == "ok" + summary = Rota.get_shift_summary_dict() + for worker in summary: + assert summary[worker]["night_weekend"] == 15 \ No newline at end of file diff --git a/test/test_nights2.py b/test/test_nights2.py index 94ff614..b3a3014 100644 --- a/test/test_nights2.py +++ b/test/test_nights2.py @@ -1,512 +1,371 @@ import datetime import pytest 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) - Rota = RotaBuilder( start_date, weeks_to_rota=weeks_to_rota, ) - - # Add a few workers Rota.add_workers( [ Worker(name="worker1", site="group1", grade=1), Worker(name="worker2", site="group1", grade=1), ] ) - return Rota - def date_generator(from_date, days): n = 0 while True: yield from_date - n = n + 1 if n >= days: break from_date = from_date + datetime.timedelta(days=1) +def test_nights(): + Rota = generate_basic_rota() + Rota.add_shifts( + SingleShift( + 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], + force_as_block=False, + ), + ) + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night", folder="tests") + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" -class TestNightShifts: - def test_nights(self): - Rota = generate_basic_rota() +def test_nights_pre3(): + Rota = generate_basic_rota() + Rota.add_shifts( + SingleShift( + 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], + force_as_block=False, + ), + ) + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night_pre3", folder="tests") + assert Rota.results.solver.status == "ok" - Rota.add_shifts( - SingleShift( - 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], - force_as_block=False, - # constraint=[{"name": "night"}], - ), - ) +def test_nights_pre3_2(): + Rota = generate_basic_rota() + Rota.add_shifts( + SingleShift( + 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], + force_as_block=False, + ), + ) + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night_pre3", folder="tests") + assert Rota.results.solver.status == "ok" - Rota.build_and_solve(options={"ratio": 0.000}) - Rota.export_rota_to_html("night") +def test_nights_fail(): + Rota = generate_basic_rota() + Rota.add_shifts( + SingleShift( + 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], + force_as_block=False, + ), + ) + Rota.build_and_solve(options={"ratio": 0.000}) + assert Rota.results.solver.status in ("warning", "error") - assert Rota.results.solver.status == "ok" - assert Rota.results.solver.termination_condition == "optimal" +def test_nights2(): + Rota = generate_basic_rota() + Rota.constraint_options["max_night_frequency"] = 0 + 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, + workers_required=2, + ), + ) + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night", folder="tests") + assert Rota.results.solver.status == "ok" - def test_nights_pre3(self): - Rota = generate_basic_rota() - - Rota.add_shifts( - SingleShift( - 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], - force_as_block=False, - # constraint=[{"name": "night"}], - ), - ) - - Rota.build_and_solve(options={"ratio": 0.000}) - Rota.export_rota_to_html("night_pre3") - - assert Rota.results.solver.status == "ok" - - 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:], - 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], - force_as_block=False, - # constraint=[{"name": "night"}], - ), - ) - - Rota.build_and_solve(options={"ratio": 0.000}) - Rota.export_rota_to_html("night_pre3") - - assert Rota.results.solver.status == "ok" - - def test_nights_fail(self): - Rota = generate_basic_rota() - - Rota.add_shifts( - SingleShift( - 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], - force_as_block=False, - # constraint=[{"name": "night"}], - ), - ) - - Rota.build_and_solve(options={"ratio": 0.000}) - - assert Rota.results.solver.status in ("warning", "error") - - def test_nights2(self): - Rota = generate_basic_rota() - - Rota.constraint_options["max_night_frequency"] = 0 - - 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_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}, +def test_nights_pre_wrap_around(): + 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, + ), + ) + 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", + }, ], - 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.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night pre wrap around", folder="tests") + assert Rota.results.solver.status == "ok" - 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", - # }, - ], - ), - ] - ) +def test_nights_max_frequency(): + 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, + ), + ) + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night max frequency", folder="tests") + assert Rota.results.solver.status == "ok" - Rota.build_and_solve(options={"ratio": 0.000}) - Rota.export_rota_to_html("night") +def test_nights_max_frequency_fail(): + 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, + ), + ) + Rota.build_and_solve(options={"ratio": 0.000}) + assert Rota.results.solver.status in ("warning", "error") - assert Rota.results.solver.status == "ok" +def test_nights_max_frequency3(): + 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"}], + workers_required=1, + ), + ) + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night max frequency 3", folder="tests") + assert Rota.results.solver.status in ("warning", "error") - def test_nights_max_frequency(self): - Rota = generate_basic_rota() +def test_nights_max_frequency4(): + 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"}], + workers_required=1, + ), + ) + 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 max frequency 4", folder="tests") + assert Rota.results.solver.status == "ok" - Rota.constraint_options["max_night_frequency"] = 1 +def test_nights_max_frequency_exclusions(): + 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 max frequency exclusions", folder="tests") + assert Rota.results.solver.status == "ok" - 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, +def test_nights_max_frequency_exclusions2(): + 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) ), - # 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, + Worker( + name="worker4", + site="group1", + grade=1, + start_date=Rota.start_date + datetime.timedelta(weeks=4) ), - # 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 max frequency exclusions 2", folder="tests") + assert Rota.results.solver.status == "ok" - Rota.build_and_solve(options={"ratio": 0.000}) - Rota.export_rota_to_html("night") - - assert Rota.results.solver.status in ("warning", "error") - - 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, +def test_nights_max_frequency_exclusions3(): + 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) ), - 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, + Worker( + name="worker4", + site="group1", + grade=1, + start_date=Rota.start_date + datetime.timedelta(weeks=4) ), - # 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 in ("warning", "error") - - 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 + ] + ) + Rota.build_and_solve(options={"ratio": 0.000}) + Rota.export_rota_to_html("night max frequency exclusions 3", folder="tests") + assert Rota.results.solver.status == "ok" \ No newline at end of file diff --git a/test/test_nwds.py b/test/test_nwds.py index e27b1e8..3a8ca51 100644 --- a/test/test_nwds.py +++ b/test/test_nwds.py @@ -1,374 +1,216 @@ import pytest from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days - import datetime - from rota.workers import Worker - def weeks_from_list(lst): """Yield successive n-sized chunks from a lst.""" for i in range(0, len(lst), 7): yield lst[i : i + 7] - -class TestDemoRota: - def test_nwd(self): - # Set up rota - weeks_to_rota = 10 +def setup_rota(weeks_to_rota=10, start_date=None): + if start_date is None: start_date = datetime.date(2022, 3, 7) + return RotaBuilder(start_date, weeks_to_rota=weeks_to_rota) - self.Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - ) +def test_nwd(): + Rota = setup_rota() + worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}]) + worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Fri"}]) + Rota.add_workers((worker1, worker2)) + Rota.add_shifts( + SingleShift( + sites=("group1",), name="d", length=12.5, days=days[:5], workers_required=1, + ), + SingleShift( + sites=("group1",), name="w", length=12.5, days=days[5:], workers_required=2, + ), + ) + Rota.build_and_solve(options={"ratio": 0.10}) + Rota.export_rota_to_html("nwd1", folder="tests") + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + for worker in Rota.workers: + shifts = Rota.get_worker_shift_list(worker) + shifts_string = "".join([i if i != "" else "-" for i in shifts]) + if worker.name == "worker1": + assert "wwd" not in shifts_string + if worker.name == "worker2": + assert "dww" not in shifts_string - # Add a few workers - worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}]) - worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Fri"}]) +def test_nwd_simple_fail(): + Rota = setup_rota() + worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}]) + worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Mon"}]) + Rota.add_workers((worker1, worker2)) + Rota.add_shifts( + SingleShift( + sites=("group1",), name="d", length=12.5, days=days[:5], balance_offset=10, workers_required=1, + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("nwd", folder="tests") + assert Rota.results.solver.status in ("warning", "error") - self.Rota.add_workers((worker1, worker2)) +def test_nwd_partial_rota(): + Rota = setup_rota() + start_date = Rota.start_date + d = start_date + datetime.timedelta(weeks=5) + worker1 = Worker( + name="worker1", site="group1", grade=1, + nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}], + ) + worker2 = Worker( + name="worker2", site="group1", grade=1, + nwds=[{"day": "Mon", "start_date": d, "end_date": Rota.rota_end_date}], + ) + Rota.add_workers((worker1, worker2)) + Rota.add_shifts( + SingleShift( + sites=("group1",), name="d", length=12.5, days=days[:5], balance_offset=10, workers_required=1, + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("nwd", folder="tests") + assert Rota.results.solver.status == "ok" + for worker in Rota.workers: + shifts = Rota.get_worker_shift_list(worker) + shifts_string = "".join([i if i != "" else "-" for i in shifts]) + if worker.name == "worker1": + for n in range(0, 4): + assert shifts_string[0 + 7 * n] == "-" + if worker.name == "worker2": + for n in range(5, 9): + assert shifts_string[0 + 7 * n] == "-" - # Add a weekday and weekend shift - self.Rota.add_shifts( - SingleShift( - sites=("group1",), name="d", length= 12.5, days=days[:5], - # balance_offset=10, - workers_required=1, - ), - SingleShift( - sites=("group1",), name="w", length= 12.5, days=days[5:], - workers_required=2, - ), - ) +def test_nwd_partial_overlay(): + Rota = setup_rota() + start_date = Rota.start_date + d = start_date + datetime.timedelta(weeks=5) + d2 = start_date + datetime.timedelta(weeks=6) + worker1 = Worker( + name="worker1", site="group1", grade=1, + nwds=[{"day": "Mon", "start_date": start_date, "end_date": d2}], + ) + worker2 = Worker( + name="worker2", site="group1", grade=1, + nwds=[{"day": "Mon", "start_date": d, "end_date": Rota.rota_end_date}], + ) + Rota.add_workers((worker1, worker2)) + Rota.add_shifts( + SingleShift( + sites=("group1",), name="d", length=12.5, days=days[:5], balance_offset=10, workers_required=1, + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + assert Rota.results.solver.status in ("warning", "error") - self.Rota.build_and_solve(options={"ratio": 0.10}) +def test_nwd_force_as_block(): + Rota = setup_rota() + start_date = Rota.start_date + d = start_date + datetime.timedelta(weeks=4) + worker1 = Worker( + name="worker1", site="group1", grade=1, + nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}], + ) + worker2 = Worker(name="worker2", site="group1", grade=1) + worker3 = Worker(name="worker3", site="group1", grade=1) + worker4 = Worker(name="worker4", site="group1", grade=1) + Rota.add_workers((worker1, worker2, worker3, worker4)) + Rota.add_shifts( + SingleShift( + sites=("group1",), name="d", length=12.5, days=days[:5], + balance_offset=10, workers_required=2, + force_as_block_unless_nwd=True, assign_as_block=True, + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("nwd", folder="tests") + assert Rota.results.solver.status == "ok" + for worker in Rota.workers: + shifts = Rota.get_worker_shift_list(worker) + shifts_string = "".join([i if i != "" else "-" for i in shifts]) + for week in weeks_from_list(shifts_string): + assert week in ("-------", "ddddd--") + assert shifts_string.count("d") == 25 - self.Rota.export_rota_to_html("nwd1") +def test_nwd_force_as_block_force_split(): + Rota = setup_rota() + start_date = Rota.start_date + d = start_date + datetime.timedelta(weeks=5) + worker1 = Worker( + name="worker1", site="group1", grade=1, + nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}], + ) + worker2 = Worker(name="worker2", site="group1", grade=1) + worker3 = Worker(name="worker3", site="group1", grade=1) + worker4 = Worker(name="worker4", site="group1", grade=1) + Rota.add_workers((worker1, worker2, worker3, worker4)) + Rota.add_shifts( + SingleShift( + sites=("group1",), name="d", length=12.5, days=days[:5], + workers_required=2, force_as_block_unless_nwd=True, assign_as_block=True, + ), + SingleShift( + sites=("group1",), name="w", length=12.5, days=days[5:], + workers_required=4, force_as_block_unless_nwd=True, assign_as_block=True, + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("nwd_block_force_split", folder="tests") - assert self.Rota.results.solver.status == "ok" - assert self.Rota.results.solver.termination_condition == "optimal" - - for worker in self.Rota.workers: - shifts = self.Rota.get_worker_shift_list(worker) - - # Convert shift to a string representation - shifts_string = "".join([i if i != "" else "-" for i in shifts]) + assert Rota.results.solver.status == "ok" + for worker in Rota.workers: + shifts = Rota.get_worker_shift_list(worker) + shifts_string = "".join([i if i != "" else "-" for i in shifts]) + for week in weeks_from_list(shifts_string[7 * 5 :]): + assert week in ("-----ww", "dddddww") + if worker.name == "worker1": + assert shifts_string[: 7 * 5].count("ww-") == 4 + assert shifts_string.count("d") == 25 + assert shifts_string.count("w") == 20 +def test_nwd_testing(): + Rota = setup_rota() + start_date = Rota.start_date + d = start_date + datetime.timedelta(weeks=5) + worker1 = Worker( + name="worker1", site="group1", grade=1, + nwds=[ + {"day": "Mon", "start_date": d, "end_date": Rota.rota_end_date}, + {"day": "Tue", "start_date": d, "end_date": Rota.rota_end_date}, + {"day": "Wed", "start_date": d, "end_date": Rota.rota_end_date}, + {"day": "Thu", "start_date": d, "end_date": Rota.rota_end_date}, + {"day": "Fri", "start_date": d, "end_date": Rota.rota_end_date}, + ], + ) + worker2 = Worker(name="worker2", site="group1", grade=1) + Rota.add_workers((worker1, worker2)) + Rota.add_shifts( + SingleShift( + sites=("group1",), name="d", length=12.5, days=days[:5], + balance_offset=10, workers_required=1, + force_as_block_unless_nwd=True, assign_as_block=True, + ), + SingleShift( + sites=("group1",), name="w", length=12.5, days=days[5:], + balance_offset=10, workers_required=2, + force_as_block_unless_nwd=True, assign_as_block=True, + ), + ) + Rota.build_and_solve(options={"ratio": 0.00}) + Rota.export_rota_to_html("nwd", folder="tests") + assert Rota.results.solver.status == "ok" + for worker in Rota.workers: + shifts = Rota.get_worker_shift_list(worker) + shifts_string = "".join([i if i != "" else "-" for i in shifts]) + for week in weeks_from_list(shifts_string[7 * 5 :]): if worker.name == "worker1": - assert "wwd" not in shifts_string - - if worker.name == "worker2": - assert "dww" not in shifts_string - - def test_nwd_simple_fail(self): - # Set up rota - weeks_to_rota = 10 - start_date = datetime.date(2022, 3, 7) - - self.Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - ) - - # Add a few workers - worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}]) - worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Mon"}]) - - self.Rota.add_workers((worker1, worker2)) - - # Add a weekday and weekend shift - self.Rota.add_shifts( - SingleShift( - sites=("group1",), name="d", length= 12.5, days=days[:5], - balance_offset=10, - workers_required=1, - ), - ) - - self.Rota.build_and_solve(options={"ratio": 0.00}) - - self.Rota.export_rota_to_html("nwd") - - assert self.Rota.results.solver.status in ("warning", "error") - - def test_nwd_partial_rota(self): - # Set up rota - weeks_to_rota = 10 - start_date = datetime.date(2022, 3, 7) - - self.Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - ) - - d = start_date + datetime.timedelta(weeks=5) - # Add a few workers - worker1 = Worker( - name="worker1", - site="group1", - grade=1, - nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}], - ) - worker2 = Worker( - name="worker2", - site="group1", - grade=1, - nwds=[{"day": "Mon", "start_date": d, "end_date": self.Rota.rota_end_date}], - ) - - self.Rota.add_workers((worker1, worker2)) - - # Add a weekday and weekend shift - self.Rota.add_shifts( - SingleShift( - sites=("group1",), name="d", length= 12.5, days=days[:5], - balance_offset=10, - workers_required=1, - ), - ) - - self.Rota.build_and_solve(options={"ratio": 0.00}) - - self.Rota.export_rota_to_html("nwd") - - assert self.Rota.results.solver.status == "ok" - - for worker in self.Rota.workers: - shifts = self.Rota.get_worker_shift_list(worker) - - # Convert shift to a string representation - shifts_string = "".join([i if i != "" else "-" for i in shifts]) - + assert week == "-----ww" + else: + assert week == "dddddww" + for week in weeks_from_list(shifts_string[: 7 * 5]): if worker.name == "worker1": - for n in range(0,4): - assert shifts_string[0 + 7*n] == "-" - - if worker.name == "worker2": - for n in range(5,9): - assert shifts_string[0 + 7*n] == "-" - - - def test_nwd_partial_overlay(self): - # Set up rota - weeks_to_rota = 10 - start_date = datetime.date(2022, 3, 7) - - self.Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - ) - - d = start_date + datetime.timedelta(weeks=5) - d2 = start_date + datetime.timedelta(weeks=6) - # Add a few workers - worker1 = Worker( - name="worker1", - site="group1", - grade=1, - nwds=[{"day":"Mon", "start_date":start_date, "end_date":d2}], - ) - worker2 = Worker( - name="worker2", - site="group1", - grade=1, - nwds=[{"day":"Mon", "start_date":d, "end_date":self.Rota.rota_end_date}], - ) - - self.Rota.add_workers((worker1, worker2)) - - # Add a weekday and weekend shift - self.Rota.add_shifts( - SingleShift( - sites=("group1",), name="d", length= 12.5, days=days[:5], - balance_offset=10, - workers_required=1, - ), - ) - - self.Rota.build_and_solve(options={"ratio": 0.00}) - - assert self.Rota.results.solver.status in ("warning", "error") - - def test_nwd_force_as_block(self): - # Set up rota - weeks_to_rota = 10 - start_date = datetime.date(2022, 3, 7) - - self.Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - ) - - d = start_date + datetime.timedelta(weeks=4) - worker1 = Worker( - name="worker1", - site="group1", - grade=1, - nwds=[{"day": "Mon", "start_date":start_date, "end_date":d}], - ) - worker2 = Worker(name="worker2", site="group1", grade=1, - ) - worker3 = Worker(name="worker3", site="group1", grade=1, - ) - worker4 = Worker(name="worker4", site="group1", grade=1, - ) - - self.Rota.add_workers((worker1, worker2, worker3, worker4)) - - # Add a weekday and weekend shift - self.Rota.add_shifts( - SingleShift( - sites=("group1",), name="d", length= 12.5, days=days[:5], - balance_offset=10, - workers_required=2, - force_as_block_unless_nwd=True, - assign_as_block=True, - ), - ) - - self.Rota.build_and_solve(options={"ratio": 0.00}) - self.Rota.export_rota_to_html("nwd") - - assert self.Rota.results.solver.status == "ok" - - for worker in self.Rota.workers: - shifts = self.Rota.get_worker_shift_list(worker) - - # Convert shift to a string representation - shifts_string = "".join([i if i != "" else "-" for i in shifts]) - - for week in weeks_from_list(shifts_string): - assert week in ("-------", "ddddd--") - - assert shifts_string.count("d") == 25 - - def test_nwd_force_as_block_force_split(self): - # Set up rota - weeks_to_rota = 10 - start_date = datetime.date(2022, 3, 7) - - self.Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - ) - - d = start_date + datetime.timedelta(weeks=5) - worker1 = Worker( - name="worker1", - site="group1", - grade=1, - nwds=[{"day": "Mon", "start_date":start_date, "end_date":d}], - ) - worker2 = Worker(name="worker2", site="group1", grade=1, - ) - worker3 = Worker(name="worker3", site="group1", grade=1, - ) - worker4 = Worker(name="worker4", site="group1", grade=1, - ) - - self.Rota.add_workers((worker1, worker2, worker3, worker4)) - - # Add a weekday and weekend shift - self.Rota.add_shifts( - SingleShift( - sites=("group1",), name="d", length= 12.5, days=days[:5], - #balance_offset=50, - workers_required=2, - force_as_block_unless_nwd=True, - assign_as_block=True, - ), - SingleShift( - sites=("group1",), name="w", length= 12.5, days=days[5:], - #balance_offset=50, - workers_required=4, - force_as_block_unless_nwd=True, - assign_as_block=True, - ), - ) - - self.Rota.build_and_solve() - self.Rota.export_rota_to_html("nwd_block_force_split") - - assert self.Rota.results.solver.status == "ok" - - for worker in self.Rota.workers: - shifts = self.Rota.get_worker_shift_list(worker) - - # Convert shift to a string representation - shifts_string = "".join([i if i != "" else "-" for i in shifts]) - - for week in weeks_from_list(shifts_string[7 * 5 :]): - assert week in ("-----ww", "dddddww") - - if worker.name == "worker1": - assert shifts_string[: 7 * 5].count("ww-") == 4 - - assert shifts_string.count("d") == 25 - assert shifts_string.count("w") == 20 - - def test_nwd_testing(self): - # Set up rota - weeks_to_rota = 10 - start_date = datetime.date(2022, 3, 7) - - self.Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - ) - - d = start_date + datetime.timedelta(weeks=5) - worker1 = Worker(name="worker1", site="group1", grade=1, - nwds=[ - {"day": "Mon", "start_date":d, "end_date":self.Rota.rota_end_date}, - {"day": "Tue", "start_date":d, "end_date":self.Rota.rota_end_date}, - {"day": "Wed", "start_date":d, "end_date":self.Rota.rota_end_date}, - {"day": "Thu", "start_date":d, "end_date":self.Rota.rota_end_date}, - {"day": "Fri", "start_date":d, "end_date":self.Rota.rota_end_date}, - ], - ) - worker2 = Worker(name="worker2", site="group1", grade=1, - ) - - self.Rota.add_workers((worker1, worker2)) - - # Add a weekday and weekend shift - self.Rota.add_shifts( - SingleShift( - sites=("group1",), name="d", length= 12.5, days=days[:5], - balance_offset=10, - workers_required=1, - force_as_block_unless_nwd=True, - assign_as_block=True, - ), - SingleShift( - sites=("group1",), name="w", length= 12.5, days=days[5:], - balance_offset=10, - workers_required=2, - force_as_block_unless_nwd=True, - assign_as_block=True, - ), - ) - - self.Rota.build_and_solve(options={"ratio": 0.00}) - self.Rota.export_rota_to_html("nwd") - - assert self.Rota.results.solver.status == "ok" - - for worker in self.Rota.workers: - shifts = self.Rota.get_worker_shift_list(worker) - - # Convert shift to a string representation - shifts_string = "".join([i if i != "" else "-" for i in shifts]) - - for week in weeks_from_list(shifts_string[7 * 5 :]): - if worker.name == "worker1": - assert week == "-----ww" - else: - assert week == "dddddww" - - for week in weeks_from_list(shifts_string[: 7 * 5]): - if worker.name == "worker1": - assert week == "dddddww" - else: - assert week == "-----ww" + assert week == "dddddww" + else: + assert week == "-----ww" \ No newline at end of file diff --git a/test/test_rota.py b/test/test_rota.py index b0764ec..c28b156 100644 --- a/test/test_rota.py +++ b/test/test_rota.py @@ -10,17 +10,15 @@ import datetime from rota.workers import Worker -class TestDemoRota: - # Set up rota +@pytest.fixture +def demo_rota(): weeks_to_rota = 10 start_date = datetime.date(2022, 3, 7) - Rota = RotaBuilder( start_date, weeks_to_rota=weeks_to_rota, balance_offset_modifier=1, ) - Rota.constraint_options["max_shifts_per_week"] = 5 Rota.constraint_options["max_shifts_per_month"] = 31 Rota.constraint_options["constrain_time_off_after_nights"] = False @@ -33,14 +31,15 @@ class TestDemoRota: Rota.constraint_options["balance_bank_holidays"] = False # Add a few workers - worker1 = Worker(name="worker1", site="group1", grade=1) - worker2 = Worker(name="worker2", site="group1", grade=1) - worker3 = Worker(name="worker3", site="group1", grade=1) - worker4 = Worker(name="worker4", site="group1", grade=1) - worker5 = Worker(name="worker5", site="group2", grade=1, fte=60) - worker6 = Worker(name="worker6", site="group2", grade=1, fte=40) - - Rota.add_workers((worker1, worker2, worker3, worker4, worker5, worker6)) + workers = [ + Worker(name="worker1", site="group1", grade=1), + Worker(name="worker2", site="group1", grade=1), + Worker(name="worker3", site="group1", grade=1), + Worker(name="worker4", site="group1", grade=1), + Worker(name="worker5", site="group2", grade=1, fte=60), + Worker(name="worker6", site="group2", grade=1, fte=40), + ] + Rota.add_workers(workers) # Add a weekday and weekend shift Rota.add_shifts( @@ -51,9 +50,6 @@ class TestDemoRota: days=days[:5], balance_offset=1, workers_required=1, - # We use a different balance weighting for each shift as otherwise - # the shifts can be treated equally (and no target diff) - # balance_weighting=0.6, ), SingleShift( sites=("group1",), @@ -61,7 +57,6 @@ class TestDemoRota: length=12.5, days=days[5:], balance_offset=4, - # balance_weighting=0.5 ), ) @@ -69,1008 +64,97 @@ class TestDemoRota: Rota.build_workers() Rota.build_model() - print(Rota.get_worker_details()) - - for w in Rota.get_workers(): - print(w) - print(w.get_shift_targets()) - - solver_options = {"ratio": 0, "seconds": 1000, "threads": 10} - + solver_options = {"ratio": 0.1, "seconds": 1000, "threads": 10} Rota.solve_model(options=solver_options) - - # print(Rota.get_worker_details()) - - # optimizer = SolverFactory('cbc') - # result = optimizer.solve(prob,tee=True) - # result.Solver.Status = SolverStatus.warning - # prob.solutions.load_from(result) - - # worker_timetable_brief = Rota.get_worker_timetable_brief( - # show_prefs=False, show_unavailable=False - # ) - - # print(worker_timetable_brief) - # print(Rota.get_shift_summary_dict()) Rota.export_rota_to_html("test2") + return Rota, start_date, weeks_to_rota + +def test_optimal_solution(demo_rota): + Rota, _, _ = demo_rota + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + +def test_start_date(demo_rota): + Rota, start_date, _ = demo_rota + assert Rota.start_date == start_date + +def test_end_date(demo_rota): + Rota, start_date, weeks_to_rota = demo_rota + assert Rota.rota_end_date == start_date + datetime.timedelta(weeks=weeks_to_rota) + +def test_rota_workers_number(demo_rota): + Rota, _, _ = demo_rota + assert len(Rota.workers) == 6 + +def test_rota_workers_total_fte(demo_rota): + Rota, _, _ = demo_rota + assert Rota.get_workers_total_fte() == 500 + +def test_weekday_workers(demo_rota): + Rota, _, _ = demo_rota + weekday_shift_workers = Rota.get_workers_for_shift(Rota.get_shift_by_name("weekday")) + for worker in weekday_shift_workers: + assert worker.proportion_rota_to_work == 1 + assert len(weekday_shift_workers) == 6 + +def test_weekend_workers(demo_rota): + Rota, _, _ = demo_rota + weekend_shift_workers = Rota.get_workers_for_shift(Rota.get_shift_by_name("weekend")) + for worker in weekend_shift_workers: + assert worker.proportion_rota_to_work == 1 + assert len(weekend_shift_workers) == 4 + +def test_worker_shift_targets(demo_rota): + Rota, _, _ = demo_rota + for worker in Rota.workers: + match worker.site: + case "group1": + assert worker.proportion_rota_to_work == 1 + assert worker.get_shift_targets() == {"weekday": 10, "weekend": 5} + case "group2": + assert worker.proportion_rota_to_work == 1 + match worker.fte: + case 60: + assert worker.get_shift_targets() == {"weekday": 6, "weekend": 0} + case 40: + assert worker.get_shift_targets() == {"weekday": 4, "weekend": 0} + case _: + assert 1 == 0 + case _: + assert 1 == 0 + +def test_worker_shift_targets2(demo_rota): + Rota, _, _ = demo_rota + shift_number = Rota.get_shift_summary_dict() + for worker in Rota.workers: + match worker.site: + case "group1": + assert shift_number[worker.name]["weekday"] == 10 + assert shift_number[worker.name]["weekend"] == 5 + case "group2": + assert shift_number[worker.name]["weekend"] == 0 + match worker.fte: + case 60: + assert shift_number[worker.name]["weekday"] == 6 + case 40: + assert shift_number[worker.name]["weekday"] == 4 + case _: + assert 1 == 0 + case _: + assert 1 == 0 + +def test_rota_sites(demo_rota): + Rota, _, _ = demo_rota + assert Rota.sites == {"group1", "group2"} + + + + + + - def test_optimal_solution(self): - assert self.Rota.results.solver.status == "ok" - assert self.Rota.results.solver.termination_condition == "optimal" - def test_start_date(self): - assert self.Rota.start_date == self.start_date - def test_end_date(self): - assert self.Rota.rota_end_date == self.start_date + datetime.timedelta( - weeks=self.weeks_to_rota - ) - - def test_rota_workers_number(self): - assert len(self.Rota.workers) == 6 - - def test_rota_workers_total_fte(self): - assert self.Rota.get_workers_total_fte() == 500 - - def test_weekday_workers(self): - weekday_shift_workers = self.Rota.get_workers_for_shift( - self.Rota.get_shift_by_name("weekday") - ) - - for worker in weekday_shift_workers: - assert worker.proportion_rota_to_work == 1 - - assert len(weekday_shift_workers) == 6 - - def test_weekend_workers(self): - weekend_shift_workers = self.Rota.get_workers_for_shift( - self.Rota.get_shift_by_name("weekend") - ) - - for worker in weekend_shift_workers: - assert worker.proportion_rota_to_work == 1 - - # assert worker.get_shift_targets() == {} - - assert len(weekend_shift_workers) == 4 - - def test_worker_shift_targets(self): - for worker in self.Rota.workers: - match worker.site: - case "group1": - assert worker.proportion_rota_to_work == 1 - assert worker.get_shift_targets() == {"weekday": 10, "weekend": 5} - case "group2": - assert worker.proportion_rota_to_work == 1 - - match worker.fte: - case 60: - assert worker.get_shift_targets() == { - "weekday": 6, - "weekend": 0, - } - case 40: - assert worker.get_shift_targets() == { - "weekday": 4, - "weekend": 0, - } - case _: - assert 1 == 0 - case _: - assert 1 == 0 - - def test_worker_shift_targets2(self): - shift_number = self.Rota.get_shift_summary_dict() - - for worker in self.Rota.workers: - match worker.site: - case "group1": - assert shift_number[worker.name]["weekday"] == 10 - assert shift_number[worker.name]["weekend"] == 5 - case "group2": - assert shift_number[worker.name]["weekend"] == 0 - match worker.fte: - case 60: - assert shift_number[worker.name]["weekday"] == 6 - case 40: - assert shift_number[worker.name]["weekday"] == 4 - case _: - assert 1 == 0 - case _: - assert 1 == 0 - - def test_rota_sites(self): - assert self.Rota.sites == {"group1", "group2"} - - -class TestDemoRotaNights: - # Set up rota - weeks_to_rota = 12 - start_date = datetime.date(2022, 3, 14) - - Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - balance_offset_modifier=1, - ) - - Rota.constraint_options["max_shifts_per_week"] = 5 - Rota.constraint_options["max_shifts_per_month"] = 31 - Rota.constraint_options["limit_to_1_st2_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 - Rota.constraint_options["max_night_frequency"] = 0 - # Rota.constraint_options["balance_weekends"] = False - - # Add a few workers - worker1 = Worker(name="worker1", site="group1", grade=1) - worker2 = Worker(name="worker2", site="group1", grade=1) - worker3 = Worker(name="worker3", site="group1", grade=1) - worker4 = Worker(name="worker4", site="group1", grade=1) - worker5 = Worker(name="worker5", site="group2", grade=1, fte=60) - worker6 = Worker(name="worker6", site="group2", grade=1, fte=40) - worker7 = Worker(name="worker7", site="group2", grade=1) - worker8 = Worker(name="worker8", site="group2", grade=1) - - Rota.add_workers( - (worker1, worker2, worker3, worker4, worker5, worker6, worker7, worker8) - ) - - # Add a weekday and weekend shift - Rota.add_shifts( - SingleShift( - sites=("group1", "group2"), - name="night_weekday", - length=12.5, - days=days[:4], - workers_required=1, - constraint=[{"name": "pre", "options": 1}, {"name": "post", "options": 2}], - # constraint=["night"], - force_as_block=True - # We use a different balance weighting for each shift as otherwise - # the shifts can be treated equally (and no target diff) - # balance_weighting=0.6, - ), - SingleShift( - sites=("group1", "group2"), - name="night_weekend", - length=12.5, - days=days[4:], - # balance_weighting=0.5 - constraint=[{"name": "pre", "options": 1}, {"name": "post", "options": 2}], - # constraint=["night"], - force_as_block=True, - ), - SingleShift( - sites=("group1", "group2"), - name="twilight", - length=12.5, - days=days[:5], - # balance_weighting=0.5 - workers_required=2, - # constraint=["preclear2", "postclear2"], - ), - # SingleShift(("group1","group2"), "atwilight", 12.5, days[:5], balance_offset=40, - ##balance_weighting=0.5 - # workers_required=2, - # #constraint=["postclear2"], - # ), - ) - - Rota.build_shifts() - Rota.build_workers() - Rota.build_model() - - solver_options = {"ratio": 0.00, "seconds": 1000, "threads": 10} - - Rota.solve_model(options=solver_options) - - Rota.export_rota_to_html("test3") - - def test_start_date(self): - assert self.Rota.start_date == self.start_date - - def test_night_assignment(self): - assert self.Rota.results.solver.status == "ok" - - shift_summary = self.Rota.get_shift_summary_dict() - - for worker_name in shift_summary: - worker_shifts = shift_summary[worker_name] - - worker = self.Rota.get_worker_by_name(worker_name) - - if worker.fte == 40: - assert worker_shifts["night_weekday"] in (approx(0), approx(4)) - assert worker_shifts["night_weekend"] in (approx(0), approx(3)) - elif worker.fte == 60: - assert worker_shifts["night_weekday"] in (approx(0), approx(4), approx(8)) - assert worker_shifts["night_weekend"] in (approx(0), approx(3), approx(6)) - else: - assert worker_shifts["night_weekday"] in (approx(4), approx(8)) - assert worker_shifts["night_weekend"] in (approx(3), approx(6)) - - -class TestDemoRotaClear: - weeks_to_rota = 10 - start_date = datetime.date(2022, 3, 7) - - Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - balance_offset_modifier=1, - ) - Rota.constraint_options["max_shifts_per_month"] = 20 - - worker1 = Worker(name="worker1", site="group1", grade=1) - worker2 = Worker(name="worker2", site="group1", grade=1) - worker3 = Worker(name="worker3", site="group1", grade=1) - worker4 = Worker(name="worker4", site="group1", grade=1) - worker5 = Worker(name="worker5", site="group2", grade=1, fte=60) - worker6 = Worker(name="worker6", site="group2", grade=1, fte=40) - worker7 = Worker(name="worker7", site="group2", grade=1) - worker8 = Worker(name="worker8", site="group2", grade=1) - - Rota.add_workers( - (worker1, worker2, worker3, worker4, worker5, worker6, worker7, worker8) - ) - - def test_pre(self): - self.Rota.add_shifts( - SingleShift( - sites=("group1", "group2"), - name="a", - length=12.5, - days=days[:5], - #balance_offset=40, - workers_required=2, - constraint=[{"name": "pre", "options": 1}], - ), - SingleShift( - sites=("group1", "group2"), - name="b", - length=12.5, - days=days[:5], - #balance_offset=40, - workers_required=2, - ), - SingleShift( - sites=("group1", "group2"), - name="c", - length=12.5, - days=days[3], - #balance_offset=40, - workers_required=1, - constraint=[{"name": "pre", "options": 1}, {"name": "post", "options": 1}], - ), - ) - - self.Rota.build_shifts() - self.Rota.build_workers() - self.Rota.build_model() - - solver_options = {"seconds": 1000, "threads": 10} - - self.Rota.solve_model(options=solver_options) - self.Rota.export_rota_to_html("test4") - - assert self.Rota.results.solver.status == "ok" - assert self.Rota.results.solver.termination_condition == "optimal" - - for worker in self.Rota.workers: - shifts = self.Rota.get_worker_shift_list(worker) - - # Convert shift to a string representation - shifts_string = "".join([i if i != "" else "-" for i in shifts]) - - # check for patterns that should not occur - assert "ba" not in shifts_string - assert "ca" not in shifts_string - assert "ac" not in shifts_string - assert "ca" not in shifts_string - #assert "a-c" not in shifts_string - #assert "c-a" not in shifts_string - assert "bc" not in shifts_string - assert "cb" not in shifts_string - #assert "b-c" not in shifts_string - #assert "c-b" not in shifts_string - - #def test_preclear(self): - # self.Rota.add_shifts( - # SingleShift( - # sites=("group1", "group2"), - # name="a", - # length=12.5, - # days=days[:5], - # balance_offset=40, - # workers_required=2, - # constraint=[{"name": "preclear"}], - # ), - # SingleShift( - # sites=("group1", "group2"), - # name="b", - # length=12.5, - # days=days[:5], - # balance_offset=40, - # workers_required=2, - # ), - # SingleShift( - # sites=("group1", "group2"), - # name="c", - # length=12.5, - # days=days[3], - # balance_offset=40, - # workers_required=1, - # constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], - # ), - # ) - - # self.Rota.build_shifts() - # self.Rota.build_workers() - # self.Rota.build_model() - - # solver_options = {"ratio": 0.1, "seconds": 1000, "threads": 10} - - # self.Rota.solve_model(options=solver_options) - # self.Rota.export_rota_to_html("test4") - - # assert self.Rota.results.solver.status == "ok" - # assert self.Rota.results.solver.termination_condition == "optimal" - - # for worker in self.Rota.workers: - # shifts = self.Rota.get_worker_shift_list(worker) - - # # Convert shift to a string representation - # shifts_string = "".join([i if i != "" else "-" for i in shifts]) - - # # check for patterns that should not occur - # assert "ba" not in shifts_string - # assert "ca" not in shifts_string - # assert "ac" not in shifts_string - # assert "ca" not in shifts_string - # assert "a-c" not in shifts_string - # assert "c-a" not in shifts_string - # assert "bc" not in shifts_string - # assert "cb" not in shifts_string - # assert "b-c" not in shifts_string - # assert "c-b" not in shifts_string - - -class TestDemoRotaShiftConstraints: - weeks_to_rota = 10 - start_date = datetime.date(2022, 3, 7) - - Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - balance_offset_modifier=1, - ) - # Rota.constraint_options["max_shifts_per_week"] = 5 - # Rota.constraint_options["max_shifts_per_month"] = 20 - worker1 = Worker(name="worker1", site="group1", grade=1) - worker2 = Worker(name="worker2", site="group1", grade=1) - - Rota.add_workers((worker1, worker2)) - Rota.add_shifts( - SingleShift( - sites=("group1",), - name="a", - length=12.5, - days=days, - balance_offset=40, - workers_required=1, - ), - ) - - Rota3Workers = deepcopy(Rota) - worker3 = Worker(name="worker3", site="group1", grade=1) - - Rota3Workers.add_worker(worker3) - - def test_max_shifts(self): - self.Rota.constraint_options["max_shifts_per_month"] = 14 - self.Rota.build_and_solve() - - assert self.Rota.results.solver.status == "ok" - assert self.Rota.results.solver.termination_condition == "optimal" - - def test_max_shifts_fail(self): - self.Rota.constraint_options["max_shifts_per_month"] = 13 - self.Rota.build_and_solve() - assert self.Rota.results.solver.status in ("warning", "error") - assert self.Rota.results.solver.termination_condition == "infeasible" - - def test_max_shifts_extra_worker(self): - self.Rota3Workers.constraint_options["max_shifts_per_month"] = 12 - self.Rota3Workers.build_and_solve() - - assert self.Rota3Workers.results.solver.status == "ok" - assert self.Rota3Workers.results.solver.termination_condition == "optimal" - - def test_max_shifts_per_week_fail(self): - self.Rota.constraint_options["max_shifts_per_week"] = 3 - self.Rota.build_and_solve() - - assert self.Rota.results.solver.status in ("warning", "error") - assert self.Rota.results.solver.termination_condition == "infeasible" - - def test_max_shifts_per_week_pass(self): - self.Rota.constraint_options["max_shifts_per_month"] = 14 - self.Rota.constraint_options["max_shifts_per_week"] = 4 - self.Rota.build_and_solve() - - assert self.Rota.results.solver.status == "ok" - - def test_max_shifts_per_week_extra_worker_pass(self): - self.Rota3Workers.constraint_options["max_shifts_per_week"] = 3 - # worker3 = Worker(name="worker3", site="group1", grade=1) - # self.Rota.add_worker(worker3) - self.Rota3Workers.build_and_solve() - - assert self.Rota3Workers.results.solver.status == "ok" - - -class TestDemoRotaBalanceShiftSites: - weeks_to_rota = 8 - start_date = datetime.date(2022, 3, 7) - - Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - ) - # Rota.constraint_options["max_shifts_per_week"] = 5 - # Rota.constraint_options["max_shifts_per_month"] = 20 - worker1 = Worker(name="worker1", site="group1", grade=1) - worker2 = Worker(name="worker2", site="group1", grade=1) - worker3 = Worker(name="worker3", site="group2", grade=1) - worker4 = Worker(name="worker4", site="group2", grade=1) - worker5 = Worker(name="worker5", site="group3", grade=1) - worker6 = Worker(name="worker6", site="group3", grade=1) - - Rota.add_workers((worker1, worker2, worker3, worker4)) - Rota.add_workers((worker5, worker6)) - - def test_balance_blocks_across_2_groups(self): - self.Rota.add_shifts( - SingleShift( - sites=("group1", "group2"), - name="weekday_night", - length=12.5, - days=days[:4], - balance_offset=40, - workers_required=2, - force_as_block=True, - constraint=[{"name": "balance_across_groups"}], - ), - ) - self.Rota.constraint_options["balance_nights_across_sites"] = False - self.Rota.constraint_options["balance_nights"] = False - self.Rota.constraint_options["constrain_time_off_after_nights"] = False - - # Worker 5 and 6 don't have any valid shifts so this should fail - #with pytest.raises(WarningTermination): - # self.Rota.build_and_solve(options={"ratio": 0.0}) - - # Unless we remove the warning - self.Rota.terminate_on_warning.remove("Worker/no valid shifts") - self.Rota.build_and_solve(options={"ratio": 0.0}) - - assert len(self.Rota.get_warnings("Worker/no valid shifts")) == 2 - - self.Rota.export_rota_to_html("test5") - - print(self.Rota.get_workers_by_group()) - - 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)) - - for day_shifts in zipped_lists: - assert day_shifts.count("weekday_night") <= 1 - - assert self.Rota.results.solver.status == "ok" - assert self.Rota.results.solver.termination_condition == "optimal" - - def test_balance_blocks_across_2_groups_unbalanced(self): - self.Rota.shifts = [] - self.Rota.add_shifts( - SingleShift( - sites=("group1", "group2"), - name="weekday_night", - length=12.5, - days=days[:4], - balance_offset=40, - workers_required=3, - force_as_block=True, - constraint=[{"name": "balance_across_groups"}], - ), - ) - self.Rota.constraint_options["balance_nights_across_sites"] = False - self.Rota.constraint_options["balance_nights"] = False - self.Rota.constraint_options["constrain_time_off_after_nights"] = False - - try: - self.Rota.terminate_on_warning.remove("Worker/no valid shifts") - except ValueError: - # Seems to happen with sequential test running - pass - self.Rota.build_and_solve(options={"ratio": 0.1}) - - 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)) - - 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" - - def test_balance_blocks_across_3_groups(self): - self.Rota.shifts = [] - - self.Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="weekday_night", - length=12.5, - days=days[:4], - balance_offset=40, - workers_required=3, - force_as_block=True, - constraint=[{"name": "balance_across_groups"}], - ), - ) - self.Rota.constraint_options["balance_nights_across_sites"] = False - self.Rota.constraint_options["balance_nights"] = False - self.Rota.constraint_options["constrain_time_off_after_nights"] = False - self.Rota.build_and_solve(options={"ratio": 0.1}) - - print("HELLO") - - 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)) - - for day_shifts in zipped_lists: - assert day_shifts.count("weekday_night") <= 1 - - assert self.Rota.results.solver.status == "ok" - assert self.Rota.results.solver.termination_condition == "optimal" - -def generate_limit_constraint_rota(): - weeks_to_rota = 8 - start_date = datetime.date(2022, 3, 7) - - Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - ) - # Rota.constraint_options["max_shifts_per_week"] = 5 - # Rota.constraint_options["max_shifts_per_month"] = 20 - worker1 = Worker(name="worker1", site="group1", grade=2, remote_site="group1") - worker2 = Worker(name="worker2", site="group1", grade=2, remote_site="group1") - worker3 = Worker(name="worker3", site="group1", grade=2, remote_site="group1") - worker4 = Worker(name="worker4", site="group3", grade=3, remote_site="group2") - worker5 = Worker(name="worker5", site="group3", grade=3, remote_site="group2") - worker6 = Worker(name="worker6", site="group3", grade=3, remote_site="group2") - - Rota.add_workers((worker1, worker2, worker3, worker4)) - Rota.add_workers((worker5, worker6)) - - Rota.shifts = [] - - return Rota - -class TestLimitConstraints: - - def test_constraint_limit_grades(self): - Rota = generate_limit_constraint_rota() - Rota.shifts = [] - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="weekday_night", - length=12.5, - days=days, - balance_offset=60, - workers_required=4, - force_as_block=True, - constraint=[{"name": "limit_grade_number", "options": {2: 1}}], - ), - ) - # Rota.constraint_options["balance_nights_across_sites"] = False - # Rota.constraint_options["balance_shifts_over_workers"] = False - #Rota.constraint_options["balance_shifts_quadratic"] = True - Rota.build_and_solve(options={}) - - Rota.export_rota_to_html("constraint_limit_grades") - - assert Rota.results.solver.status == "ok" - assert Rota.results.solver.termination_condition == "optimal" - - grade_workers = Rota.get_workers_by_grade() - - for grade in grade_workers: - shift_patterns = [] - for w in grade_workers[grade]: - shift_patterns.append(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 - - def test_constraint_limit_grades2(self): - Rota = generate_limit_constraint_rota() - Rota.shifts = [] - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="weekday_night", - length=12.5, - days=days, - balance_offset=40, - workers_required=4, - force_as_block=True, - constraint=[{"name": "limit_grade_number", "options": {3: 2}}], - ), - ) - # Rota.constraint_options["balance_nights_across_sites"] = False - # Rota.constraint_options["balance_shifts_over_workers"] = False - Rota.constraint_options["balance_shifts_quadratic"] = True - Rota.build_and_solve(options={"ratio": 0.1}) - - Rota.export_rota_to_html("test9") - - grade_workers = Rota.get_workers_by_grade() - for grade in grade_workers: - shift_patterns = [] - for w in grade_workers[grade]: - shift_patterns.append(Rota.get_worker_shift_list(w)) - - zipped_lists = list(zip(*shift_patterns)) - - limit = 2 - if grade > 2: - limit = 2 - for day_shifts in zipped_lists: - assert day_shifts.count("weekday_night") == limit - - assert Rota.results.solver.status == "ok" - assert Rota.results.solver.termination_condition == "optimal" - - def test_constraint_limit_grades3(self): - Rota = generate_limit_constraint_rota() - - Rota.workers = [] - - worker1 = Worker(name="worker1", site="group1", grade=2, remote_site="group1") - worker2 = Worker(name="worker2", site="group1", grade=2, remote_site="group1") - worker3 = Worker(name="worker3", site="group1", grade=2, remote_site="group1") - worker4 = Worker(name="worker4", site="group3", grade=3, remote_site="group2") - worker5 = Worker(name="worker5", site="group3", grade=3, remote_site="group2") - worker6 = Worker(name="worker6", site="group3", grade=3, remote_site="group2") - worker7 = Worker(name="worker7", site="group3", grade=2, remote_site="group2") - - Rota.add_workers((worker1, worker2, worker3, worker4)) - Rota.add_workers((worker5, worker6)) - - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="weekday_night", - length=12.5, - days=days, - balance_offset=60, - workers_required=4, - force_as_block=True, - constraint=[{"name": "limit_grade_number", "options": {2: 3, 3: 1}},] - ), - ) - # Rota.constraint_options["balance_nights_across_sites"] = False - # Rota.constraint_options["balance_shifts_over_workers"] = False - Rota.constraint_options["balance_shifts_quadratic"] = True - Rota.build_and_solve(options={"ratio": 0.01}) - - Rota.export_rota_to_html("test9") - assert Rota.results.solver.status == "ok" - assert Rota.results.solver.termination_condition == "optimal" - - grade_workers = Rota.get_workers_by_grade() - for grade in grade_workers: - shift_patterns = [] - for w in grade_workers[grade]: - shift_patterns.append(Rota.get_worker_shift_list(w)) - - zipped_lists = list(zip(*shift_patterns)) - - limit = 3 - if grade > 2: - limit = 1 - for day_shifts in zipped_lists: - assert day_shifts.count("weekday_night") == limit - - - def test_constraint_limit_grades4(self): - Rota = generate_limit_constraint_rota() - Rota.shifts = [] - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="weekday_night", - length=12.5, - days=days, - balance_offset=40, - workers_required=4, - force_as_block=True, - constraint=[{"name": "limit_grade_number", "options": {2: 4, 3: 0}}], - ), - ) - # Rota.constraint_options["balance_nights_across_sites"] = False - # Rota.constraint_options["balance_shifts_over_workers"] = False - Rota.constraint_options["balance_shifts_quadratic"] = True - Rota.build_and_solve(options={"ratio": 0.1}) - - assert Rota.results.solver.termination_condition == "infeasible" - - def test_constraint_minimum_grades(self): - Rota = generate_limit_constraint_rota() - Rota.shifts = [] - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="weekday_night", - length=12.5, - days=days, - balance_offset=40, - workers_required=1, - force_as_block=True, - constraint=[{"name": "minimum_grade_number", "options": (3, 1)}], - ), - ) - - Rota.constraint_options["balance_shifts_quadratic"] = True - Rota.build_and_solve(options={"ratio": 0.1}) - - Rota.export_rota_to_html("test9") - - assert Rota.results.solver.status == "ok" - - grade_workers = Rota.get_workers_by_grade() - for grade in grade_workers: - shift_patterns = [] - for w in grade_workers[grade]: - shift_patterns.append(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 - - - def test_constraint_minimum_grades2(self): - Rota = generate_limit_constraint_rota() - Rota.shifts = [] - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="weekday_night", - length=12.5, - days=days, - balance_offset=40, - workers_required=4, - force_as_block=True, - constraint=[{"name": "minimum_grade_number", "options": (3, 3)}], - ), - ) - - Rota.constraint_options["balance_shifts_quadratic"] = True - Rota.build_and_solve(options={"ratio": 0.1}) - - Rota.export_rota_to_html("test9") - - grade_workers = Rota.get_workers_by_grade() - for grade in grade_workers: - shift_patterns = [] - for w in grade_workers[grade]: - shift_patterns.append(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 Rota.results.solver.status == "ok" - - def test_constraint_minimum_grades3(self): - Rota = generate_limit_constraint_rota() - Rota.shifts = [] - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="weekday_night", - length=12.5, - days=days, - balance_offset=40, - workers_required=4, - force_as_block=True, - constraint=[{"name": "minimum_grade_number", "options": (3, 0)}], - ), - ) - - Rota.constraint_options["balance_shifts_quadratic"] = True - Rota.build_and_solve(options={"ratio": 0.1}) - - Rota.export_rota_to_html("test9") - - assert Rota.results.solver.status == "ok" - - def test_constraint_minimum_grades_no_valid_worker(self): - Rota = generate_limit_constraint_rota() - Rota.shifts = [] - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="weekday_night", - length=12.5, - days=days, - balance_offset=40, - workers_required=4, - force_as_block=True, - constraint=[{"name": "minimum_grade_number", "options": (4, 1)}], - ), - ) - - Rota.constraint_options["balance_shifts_quadratic"] = True - with pytest.raises(ValueError): - Rota.build_and_solve(options={"ratio": 0.1}) - - def test_constraint_require_remote_site_presence_week(self): - Rota = generate_limit_constraint_rota() - Rota.shifts = [] - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="night", - length=12.5, - days=days, - balance_offset=40, - workers_required=2, - force_as_block=True, - constraint=[ - { - "name": "require_remote_site_presence_week", - "options": ("group1", 2), - } - ], - ), - ) - Rota.build_and_solve(options={"ratio": 0.00}) - Rota.export_rota_to_html("remote1") - - group_workers = Rota.get_workers_by_group() - for group in group_workers: - shift_patterns = [] - for w in group_workers[group]: - shift_patterns.append(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("night") == limit - - assert Rota.results.solver.status == "ok" - assert Rota.results.solver.termination_condition == "optimal" - - def test_constraint_require_remote_site_presence_week2(self): - Rota = generate_limit_constraint_rota() - Rota.constraint_options["minimise_shift_diffs"] = False - Rota.constraint_options["balance_shifts_quadratic"] = False - Rota.constraint_options["balance_shift"] = False - Rota.constraint_options["balance_nights_across_sites"] = True - Rota.shifts = [] - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="night", - length=12.5, - days=days, - balance_offset=20, - workers_required=3, - force_as_block=True, - constraint=[ - { - "name": "require_remote_site_presence_week", - "options": ("group1", 2), - } - ], - ), - ) - Rota.build_and_solve(options={"ratio": 0.00}) - Rota.export_rota_to_html("remote2") - - group_workers = Rota.get_workers_by_remote_group() - for group in group_workers: - shift_patterns = [] - for w in group_workers[group]: - shift_patterns.append(Rota.get_worker_shift_list(w)) - - zipped_lists = list(zip(*shift_patterns)) - - if group == "group1": - for day_shifts in zipped_lists: - assert day_shifts.count("night") >= 2 - - assert Rota.results.solver.status == "ok" - assert Rota.results.solver.termination_condition == "optimal" - - def test_constraint_require_remote_site_presence_week3(self): - Rota = generate_limit_constraint_rota() - Rota.shifts = [] - Rota.add_shifts( - SingleShift( - sites=("group1", "group2", "group3"), - name="night", - length=12.5, - days=days, - balance_offset=20, - workers_required=3, - force_as_block=True, - constraint=[ - { - "name": "require_remote_site_presence_week", - "options": ("group2", 1), - } - ], - ), - ) - Rota.build_and_solve(options={"ratio": 0.00}) - Rota.export_rota_to_html("remote3") - - group_workers = Rota.get_workers_by_remote_group() - for group in group_workers: - shift_patterns = [] - for w in group_workers[group]: - shift_patterns.append(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("night") >= 1 - - assert Rota.results.solver.status == "ok" - assert Rota.results.solver.termination_condition == "optimal" # class TestNoWorkerRota: @@ -1086,191 +170,3 @@ class TestLimitConstraints: # with pytest.raises(NoWorkers): # self.Rota.build_workers() - -class TestNightUnavailable: - # Set up rota - weeks_to_rota = 10 - start_date = datetime.date(2022, 3, 7) - - Rota = RotaBuilder( - start_date, - weeks_to_rota=weeks_to_rota, - ) - - # Rota.constraint_options["balance_weekends"] = False - - # Add a few workers - worker1 = Worker( - name="worker1", - site="group1", - grade=1, - not_available_to_work=[ - { - "date": datetime.datetime.strptime("15/03/22", "%d/%m/%y").date(), - "reason": "****", - }, - ], - ) - worker2 = Worker( - name="worker2", - site="group1", - grade=1, - not_available_to_work=[ - { - "date": datetime.datetime.strptime("14/03/22", "%d/%m/%y").date(), - "reason": "****", - } - ], - ) - - Rota.add_workers((worker1, worker2)) - - def test_basic_assignment(self): - self.Rota.shifts = [] - - # Add a weekday and weekend shift - self.Rota.add_shifts( - SingleShift( - sites =("group1",), - name = "night_weekend", - length = 12.5, - days = days[5:], - constraint=[{"name": "night"}], - ), - ) - - self.Rota.build_and_solve(options={"ratio": 0.00}) - self.Rota.export_rota_to_html("testnight") - - assert self.Rota.results.solver.status == "ok" - assert self.Rota.results.solver.termination_condition == "optimal" - - self.Rota.export_rota_to_html("testnight") - summary = self.Rota.get_shift_summary_dict() - - for worker in summary: - assert summary[worker]["night_weekend"] == 10 - - def test_assign_night_prior_to_unavailablity(self): - self.Rota.shifts = [] - - self.Rota.add_shifts( - SingleShift( - sites =("group1",), - name = "night_weekend", - length = 12.5, - days = days[5:], - workers_required=2, - constraint=[{"name": "night"}], - ), - ) - - self.Rota.build_and_solve(options={"ratio": 0.00}) - self.Rota.export_rota_to_html("testnight_unavail") - - assert self.Rota.results.solver.status in ("warning", "error") - assert self.Rota.results.solver.termination_condition == "infeasible" - - def test_assign_non_night_prior_to_unavailablity(self): - self.Rota.shifts = [] - - self.Rota.add_shifts( - SingleShift( - sites =("group1",), - name = "night_weekend", - length = 12.5, - days = days[5:], - workers_required=2, - #constraint=[{"name": "night"}], - ), - ) - - self.Rota.build_and_solve(options={"ratio": 0.00}) - self.Rota.export_rota_to_html("testnight_unavail") - - assert self.Rota.results.solver.status == "ok" - - def test_assign_prior_to_unavailablity_non_night(self): - self.Rota.shifts = [] - - self.Rota.add_shifts( - SingleShift( - sites = ("group1",), - name = "night_weekend", - length = 12.5, - days = days[5:], - workers_required=2, - constraint=[], - ), - ) - - self.Rota.build_and_solve(options={"ratio": 0.00}) - self.Rota.export_rota_to_html("testnight") - - assert self.Rota.results.solver.status == "ok" - - def test_assign_split(self): - self.Rota.shifts = [] - worker3 = Worker( - name="worker3", - site="group1", - grade=1, - not_available_to_work=( - {"date": datetime.datetime.strptime("13/03/22", "%d/%m/%y").date()}, - ), - ) - worker4 = Worker( - name="worker4", - site="group1", - grade=1, - not_available_to_work=( - {"date": datetime.datetime.strptime("12/03/22", "%d/%m/%y").date()}, - ), - ) - self.Rota.add_workers((worker3, worker4)) - - self.Rota.add_shifts( - SingleShift( - sites=("group1",), - name="night_weekend", - length=12.5, - days=days[5:], - workers_required=3, - constraint=[], - ), - ) - - self.Rota.build_and_solve(options={"ratio": 0.00}) - self.Rota.export_rota_to_html("testnight") - - assert self.Rota.results.solver.status == "ok" - - summary = self.Rota.get_shift_summary_dict() - - for worker in summary: - assert summary[worker]["night_weekend"] == 15 - - -# def test_assign_split_night_constraint(self): -# self.Rota.shifts = [] -# worker3 = Worker( -# self.Rota, name="worker3", site="group1", grade=1, not_available_to_work=((datetime.datetime.strptime("13/03/22", "%d/%m/%y").date(), "****"),) -# ) -# worker4 = Worker( -# self.Rota, name="worker4", site="group1", grade=1, not_available_to_work=((datetime.datetime.strptime("12/03/22", "%d/%m/%y").date(), "****"),) -# ) -# self.Rota.add_workers((worker3, worker4)) -# -# self.Rota.add_shifts( -# SingleShift( -# ("group1",), "night_weekend", 12.5, days[5:], workers_required=3, constraint=["night"] -# ), -# ) -# -# self.Rota.build_and_solve(options={"ratio": 0.00}) -# -# assert self.Rota.results.solver.status in ("warning", "error") - -if __name__ == "__main__": - t = TestLimitConstraints() - t.test_constraint_require_remote_site_presence_week() diff --git a/test/test_rota_clear.py b/test/test_rota_clear.py new file mode 100644 index 0000000..1f616cc --- /dev/null +++ b/test/test_rota_clear.py @@ -0,0 +1,85 @@ +import pytest +from rota.shifts import RotaBuilder, SingleShift, days +from rota.workers import Worker +import datetime + +@pytest.fixture +def demo_rota_clear(): + weeks_to_rota = 10 + start_date = datetime.date(2022, 3, 7) + + Rota = RotaBuilder( + start_date, + weeks_to_rota=weeks_to_rota, + balance_offset_modifier=1, + ) + Rota.constraint_options["max_shifts_per_month"] = 20 + + workers = [ + Worker(name="worker1", site="group1", grade=1), + Worker(name="worker2", site="group1", grade=1), + Worker(name="worker3", site="group1", grade=1), + Worker(name="worker4", site="group1", grade=1), + Worker(name="worker5", site="group2", grade=1, fte=60), + Worker(name="worker6", site="group2", grade=1, fte=40), + Worker(name="worker7", site="group2", grade=1), + Worker(name="worker8", site="group2", grade=1), + ] + Rota.add_workers(workers) + return Rota + +def test_pre(demo_rota_clear): + Rota = demo_rota_clear + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="a", + length=12.5, + days=days[:5], + workers_required=2, + constraint=[{"name": "pre", "options": 1}], + ), + SingleShift( + sites=("group1", "group2"), + name="b", + length=12.5, + days=days[:5], + workers_required=2, + ), + SingleShift( + sites=("group1", "group2"), + name="c", + length=12.5, + days=days[3], + workers_required=1, + constraint=[{"name": "pre", "options": 1}, {"name": "post", "options": 1}], + ), + ) + + Rota.build_shifts() + Rota.build_workers() + Rota.build_model() + + solver_options = {"seconds": 1000, "threads": 10} + Rota.solve_model(options=solver_options) + Rota.export_rota_to_html("test4") + + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + + for worker in Rota.workers: + shifts = Rota.get_worker_shift_list(worker) + shifts_string = "".join([i if i != "" else "-" for i in shifts]) + + # check for patterns that should not occur + assert "ba" not in shifts_string + assert "ca" not in shifts_string + assert "ac" not in shifts_string + assert "ca" not in shifts_string + assert "bc" not in shifts_string + assert "cb" not in shifts_string + # Uncomment if you want to check for these as well: + # assert "a-c" not in shifts_string + # assert "c-a" not in shifts_string + # assert "b-c" not in shifts_string + # assert "c-b" not in shifts_string \ No newline at end of file diff --git a/test/test_rota_nights.py b/test/test_rota_nights.py new file mode 100644 index 0000000..71d4e45 --- /dev/null +++ b/test/test_rota_nights.py @@ -0,0 +1,99 @@ +import datetime +import pytest +from pytest import approx +from rota.shifts import RotaBuilder, SingleShift, days +from rota.workers import Worker + +@pytest.fixture +def demo_rota_nights(): + weeks_to_rota = 12 + start_date = datetime.date(2022, 3, 14) + + Rota = RotaBuilder( + start_date, + weeks_to_rota=weeks_to_rota, + balance_offset_modifier=1, + ) + + Rota.constraint_options["max_shifts_per_week"] = 5 + Rota.constraint_options["max_shifts_per_month"] = 31 + Rota.constraint_options["limit_to_1_st2_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 + Rota.constraint_options["max_night_frequency"] = 0 + + # Add workers + workers = [ + Worker(name="worker1", site="group1", grade=1), + Worker(name="worker2", site="group1", grade=1), + Worker(name="worker3", site="group1", grade=1), + Worker(name="worker4", site="group1", grade=1), + Worker(name="worker5", site="group2", grade=1, fte=60), + Worker(name="worker6", site="group2", grade=1, fte=40), + Worker(name="worker7", site="group2", grade=1), + Worker(name="worker8", site="group2", grade=1), + ] + Rota.add_workers(workers) + + # Add shifts + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="night_weekday", + length=12.5, + days=days[:4], + workers_required=1, + constraint=[{"name": "pre", "options": 1}, {"name": "post", "options": 2}], + force_as_block=True + ), + SingleShift( + sites=("group1", "group2"), + name="night_weekend", + length=12.5, + days=days[4:], + constraint=[{"name": "pre", "options": 1}, {"name": "post", "options": 2}], + force_as_block=True, + ), + SingleShift( + sites=("group1", "group2"), + name="twilight", + length=12.5, + days=days[:5], + workers_required=2, + ), + ) + + Rota.build_shifts() + Rota.build_workers() + Rota.build_model() + + solver_options = {"ratio": 0.01, "seconds": 1000, "threads": 10} + Rota.solve_model(options=solver_options) + Rota.export_rota_to_html("test3") + + return Rota + +def test_night_assignment(demo_rota_nights): + Rota = demo_rota_nights + assert Rota.results.solver.status == "ok" + + shift_summary = Rota.get_shift_summary_dict() + + for worker_name in shift_summary: + worker_shifts = shift_summary[worker_name] + worker = Rota.get_worker_by_name(worker_name) + + if worker.fte == 40: + assert worker_shifts["night_weekday"] in (approx(0), approx(4)) + assert worker_shifts["night_weekend"] in (approx(0), approx(3)) + elif worker.fte == 60: + assert worker_shifts["night_weekday"] in (approx(0), approx(4), approx(8)) + assert worker_shifts["night_weekend"] in (approx(0), approx(3), approx(6)) + else: + assert worker_shifts["night_weekday"] in (approx(4), approx(8)) + assert worker_shifts["night_weekend"] in (approx(3), approx(6)) \ No newline at end of file diff --git a/test/test_shift_constraints.py b/test/test_shift_constraints.py new file mode 100644 index 0000000..666ac6f --- /dev/null +++ b/test/test_shift_constraints.py @@ -0,0 +1,71 @@ +import pytest +import datetime +from copy import deepcopy +from rota.shifts import RotaBuilder, SingleShift, days +from rota.workers import Worker + +@pytest.fixture +def rota_2workers(): + weeks_to_rota = 10 + start_date = datetime.date(2022, 3, 7) + Rota = RotaBuilder( + start_date, + weeks_to_rota=weeks_to_rota, + balance_offset_modifier=1, + ) + worker1 = Worker(name="worker1", site="group1", grade=1) + worker2 = Worker(name="worker2", site="group1", grade=1) + Rota.add_workers((worker1, worker2)) + Rota.add_shifts( + SingleShift( + sites=("group1",), + name="a", + length=12.5, + days=days, + balance_offset=40, + workers_required=1, + ), + ) + return Rota + +@pytest.fixture +def rota_3workers(rota_2workers): + Rota3 = deepcopy(rota_2workers) + worker3 = Worker(name="worker3", site="group1", grade=1) + Rota3.add_worker(worker3) + return Rota3 + +def test_max_shifts(rota_2workers): + rota_2workers.constraint_options["max_shifts_per_month"] = 14 + rota_2workers.build_and_solve() + assert rota_2workers.results.solver.status == "ok" + assert rota_2workers.results.solver.termination_condition == "optimal" + +def test_max_shifts_fail(rota_2workers): + rota_2workers.constraint_options["max_shifts_per_month"] = 13 + rota_2workers.build_and_solve() + assert rota_2workers.results.solver.status in ("warning", "error") + assert rota_2workers.results.solver.termination_condition == "infeasible" + +def test_max_shifts_extra_worker(rota_3workers): + rota_3workers.constraint_options["max_shifts_per_month"] = 12 + rota_3workers.build_and_solve() + assert rota_3workers.results.solver.status == "ok" + assert rota_3workers.results.solver.termination_condition == "optimal" + +def test_max_shifts_per_week_fail(rota_2workers): + rota_2workers.constraint_options["max_shifts_per_week"] = 3 + rota_2workers.build_and_solve() + assert rota_2workers.results.solver.status in ("warning", "error") + assert rota_2workers.results.solver.termination_condition == "infeasible" + +def test_max_shifts_per_week_pass(rota_2workers): + rota_2workers.constraint_options["max_shifts_per_month"] = 14 + rota_2workers.constraint_options["max_shifts_per_week"] = 4 + rota_2workers.build_and_solve() + assert rota_2workers.results.solver.status == "ok" + +def test_max_shifts_per_week_extra_worker_pass(rota_3workers): + rota_3workers.constraint_options["max_shifts_per_week"] = 3 + rota_3workers.build_and_solve() + assert rota_3workers.results.solver.status == "ok" \ No newline at end of file