diff --git a/rota/shifts.py b/rota/shifts.py index 07b4951..b9f5c13 100644 --- a/rota/shifts.py +++ b/rota/shifts.py @@ -1,6 +1,5 @@ from calendar import week import datetime -from distutils.log import debug import itertools from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type, Literal import time @@ -9,10 +8,12 @@ from pydantic import BaseModel, Extra, constr from pathlib import Path +import uuid + import datetime from pyomo.environ import * -from pyomo.opt import SolverFactory +from pyomo.opt import SolverFactory, TerminationCondition, SolverStatus import urllib.parse from rota.workers import Worker @@ -211,7 +212,7 @@ class RotaBuilder(object): # self.night_blocks = ["weekday", "weekend", "none"] - self.sites = None + self.sites: set = set() self.balance_offset_modifier = balance_offset_modifier self.ltft_balance_offset = ltft_balance_offset @@ -246,6 +247,12 @@ class RotaBuilder(object): "avoid_shifts_by_worker_names": [], } + self.terminate_on_warning = [ + "Worker/duplicate id", + "Worker/duplicate name", + "Worker/no valid shifts", + ] + self.results = None self.warnings: list[tuple[str, str]] = [] @@ -294,13 +301,14 @@ class RotaBuilder(object): self.unavailable_to_work_reason = {} self.pref_not_to_work = {} self.pref_not_to_work_reason = {} - self.work_requests: set[Tuple[str, WeekInt, DayStr, ShiftName]] = set() + self.work_requests: set[ + Tuple[str | uuid.UUID | int, WeekInt, DayStr, ShiftName] + ] = set() self.work_requests_map = {} def solve_model( self, - solver: str = "cbc", - use_neos: bool = False, + solver: str = "appsi_highs", options: dict = {}, debug_if_fail: bool = False, ): @@ -314,41 +322,46 @@ class RotaBuilder(object): if "threads" in options: options.pop("threads") self.opt = SolverFactory(solver, executable="scip") + elif solver == "appsi_highs": + self.opt = SolverFactory(solver) + if "seconds" in options: + options["time_limit"] = options.pop("seconds") + if "ratio" in options: + options["mip_rel_gap"] = options.pop("ratio") else: self.opt = SolverFactory(solver) try: console.print("Solving") + console.print(f"Options: {options}") log_file = f"logs/{self.name}_{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}.log" - if use_neos: - solver_manager = SolverManagerFactory("neos") # Solve using neos server - # results = solver_manager.solve(Rota.model, opt=opt, logfile="test.log") - results = solver_manager.solve( - self.model, - keepfiles=True, - tee=True, - opt=self.opt, - logfile=log_file, - ) - else: - results = self.opt.solve( - self.model, - tee=True, - options=options, - # options={ - # "threads": 10, - # }, - logfile=log_file, - keepfiles=True, - ) + results = self.opt.solve( + self.model, + tee=True, + options=options, + # options={ + # "threads": 10, + # }, + # logfile=log_file, + keepfiles=True, + load_solutions=False, + ) except KeyboardInterrupt: return pass + if ( + not results.solver.termination_condition == TerminationCondition.infeasible + + #and not results.solver.status == SolverStatus.aborted + ): + self.model.solutions.load_from(results) + self.results = results console.print(f"Complete - outcome: {results.solver.status}") + console.print(f"Termination condition: {results.solver.termination_condition}") if results.solver.status != "ok" and debug_if_fail: console.print(f"Attempting each shift individually") @@ -417,7 +430,7 @@ class RotaBuilder(object): solve=True, debug_if_fail: bool = False, export=False, - solver="cbc", + solver="appsi_highs", ): self.run_start_time = datetime.datetime.now() @@ -1305,7 +1318,15 @@ class RotaBuilder(object): if self.use_shift_balance_extra: if shift.name in worker.shift_balance_extra: extra = worker.shift_balance_extra[shift.name] - target_shifts = target_shifts + extra + + # TODO look at how this affects allocation (how does it affect fte) + match extra: + case "double": + target_shifts = target_shifts * 2 + case "half": + target_shifts = target_shifts / 2 + case _: + target_shifts = target_shifts + extra # print(worker.name, shift.name, target_shifts) worker.shift_target_number[shift.name] = target_shifts @@ -2461,6 +2482,15 @@ class RotaBuilder(object): print(f"[bold red]WARNING:[/bold red] {warning_type} - {message}") self.warnings.append((warning_type, message)) + if warning_type in self.terminate_on_warning: + raise WarningTermination(warning_type) + + def get_warnings(self, warning_type: None | str = None): + if warning_type is None: + return self.warnings + else: + return [warning for warning in self.warnings if warning[0] == warning_type] + def add_worker(self, worker: Worker) -> None: """Add a worker to the rota @@ -2491,22 +2521,23 @@ class RotaBuilder(object): self.workers_name_map: dict[str, Worker] = {} for worker in track(self.workers, description="Building workers"): + print(worker.name) worker.load_rota(self) wid = worker.id if wid in self.workers_id_map: - raise ValueError(f"Worker with id '{wid}' has been added twice") + message = f"Worker with id '{wid}' has been added twice" + self.add_warning("Worker/duplicate id", message) + self.workers_id_map[wid] = worker if worker.name in self.workers_name_map: - raise ValueError( - f"Worker with name '{worker.name}' has been added twice" - ) + message = f"Worker with name '{worker.name}' has been added twice" + self.add_warning("Worker/duplicate name", message) self.workers_name_map[worker.name] = worker if worker.site not in self.sites: - raise ValueError( - f"Worker with name '{worker.name}' ({worker.id}) has no valid shifts (site: {worker.site})" - ) + message = f"Worker with name '{worker.name}' ({worker.id}) has no valid shifts (site: {worker.site})" + self.add_warning("Worker/no valid shifts", message) self.workers = sorted(self.workers) @@ -3128,7 +3159,7 @@ class RotaBuilder(object): # Loop through all the days possible shifts and see # if the worker has been assigned for shift in self.get_shift_names_by_week_day(week, day): - if model.works[worker.id, week, day, shift].value > 0: + if model.works[worker.id, week, day, shift].value > 0.5: shifts.append(shift) a = shift[0] shift_name = shift @@ -3187,6 +3218,7 @@ class RotaBuilder(object): shift_diff_dict[shift.name] = diff worker_td = """{name} ({grade}) [{fte}]""".format( site=worker.site, nwds=nwds, + worker_id=worker.id, name=worker.name, fte=worker.fte, fte_adj=worker.fte_adj, @@ -3465,3 +3498,9 @@ class InvalidShift(Exception): """Raised when there are no active sites""" pass + + +class WarningTermination(Exception): + """Raised when a warning in the termination group is raised""" + + pass diff --git a/rota/workers.py b/rota/workers.py index 3bda9ff..bd9b431 100644 --- a/rota/workers.py +++ b/rota/workers.py @@ -68,7 +68,7 @@ class Worker(BaseModel): # rules is easier. grade: int # We can either have a user generated ID - id: Optional[int| uuid.UUID] = None + id: Optional[int| uuid.UUID| str] = None fte: int = 100 nwds: list[NonWorkingDays] = [] start_date: datetime.date | None = None @@ -81,7 +81,7 @@ class Worker(BaseModel): previous_shifts: dict = {} shift_balance_extra: dict = {} bank_holiday_extra: int = 0 - pair: str | None = None + pair: int | str | None = None class Config: extra = Extra.allow @@ -161,6 +161,9 @@ class Worker(BaseModel): (self.id, week, day) ] = f"END DATE: {self.calculated_end_date}" + if not self.not_available_to_work: + Rota.add_warning("Worker/No unavailability", f"{self.name} [{self.id}] has no unavailabilities (leave)") + for item in self.oop: start_oop = item.start_date end_oop = item.end_date @@ -210,8 +213,8 @@ class Worker(BaseModel): days_to_end = (self.calculated_end_date - Rota.start_date).days # loop throught dates converting to week / day combination - for item in self.pref_not_to_work: - days_from_start = (item.date - Rota.start_date).days + for preference in self.pref_not_to_work: + days_from_start = (preference.date - Rota.start_date).days # Ignore dates past the end of the rota (or end date) if days_from_start < days_to_end: week = days_from_start // 7 + 1 @@ -222,32 +225,32 @@ class Worker(BaseModel): Rota.pref_not_to_work[(self.id, week, day)] = 1 / ( len(self.pref_not_to_work) + 1 ) - Rota.pref_not_to_work_reason[(self.id, week, day)] = item.reason + Rota.pref_not_to_work_reason[(self.id, week, day)] = preference.reason # print(not_available_to_work) # loop throught dates converting to week / day combination unavailable_set = set() - for item in self.not_available_to_work: - days_from_start = (item.date - Rota.start_date).days + for unavalability in self.not_available_to_work: + days_from_start = (unavalability.date - Rota.start_date).days # Ignore dates past the end of the rota (or end date) if days_from_start < days_to_end: week = days_from_start // 7 + 1 day = Rota.days[(days_from_start % 7)] Rota.unavailable_to_work.add((self.id, week, day)) - Rota.unavailable_to_work_reason[(self.id, week, day)] = item.reason + Rota.unavailable_to_work_reason[(self.id, week, day)] = unavalability.reason unavailable_set.add((week, day)) - for item in self.work_requests: - days_from_start = (item.date - Rota.start_date).days + for request in self.work_requests: + days_from_start = (request.date - Rota.start_date).days week = days_from_start // 7 + 1 day = Rota.days[(days_from_start % 7)] - if item.shift == "*": + if request.shift == "*": for shift in Rota.get_shifts_for_worker_site(self.site): Rota.work_requests.add((self.id, week, day, shift.name)) else: - Rota.work_requests.add((self.id, week, day, item.shift)) + Rota.work_requests.add((self.id, week, day, request.shift)) # Calculate the proportion of the rota that is being worked self.proportion_rota_to_work = days_to_work / Rota.rota_days_length @@ -263,15 +266,15 @@ class Worker(BaseModel): # TODO: this has already been validated, consider moving self.non_working_day_list = [] - for item in self.nwds: + for non_working_day in self.nwds: start_date = Rota.start_date end_date = Rota.rota_end_date - if item.start_date is not None: - start_date = item.start_date - if item.end_date is not None: - end_date = item.end_date + if non_working_day.start_date is not None: + start_date = non_working_day.start_date + if non_working_day.end_date is not None: + end_date = non_working_day.end_date - self.non_working_day_list.append((item.day, start_date, end_date)) + self.non_working_day_list.append((non_working_day.day, start_date, end_date)) if days_to_work < 1: self.fte_adj = 0 diff --git a/test/rota_constraints/test_avoid_grades_by_week.py b/test/rota_constraints/test_avoid_grades_by_week.py index 7e65e3d..0cc9973 100644 --- a/test/rota_constraints/test_avoid_grades_by_week.py +++ b/test/rota_constraints/test_avoid_grades_by_week.py @@ -145,7 +145,7 @@ class TestWorkerRequests: Rota.add_grade_constraint_by_week([3], [1,2,3], ["a"]) Rota.build_and_solve(options={"ratio": 0.000}) - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") def test_avoid_grade_constraint_multiple_shifts(self): Rota = generate_basic_rota() @@ -192,4 +192,4 @@ class TestWorkerRequests: Rota.add_grade_constraint_by_week([1], [7,8,9], ["a"]) Rota.build_and_solve(options={"ratio": 0.000}) - assert Rota.results.solver.status == "warning" \ No newline at end of file + assert Rota.results.solver.status in ("warning", "error") \ No newline at end of file diff --git a/test/rota_constraints/test_avoid_workers_by_week.py b/test/rota_constraints/test_avoid_workers_by_week.py index b857b2e..2f98dec 100644 --- a/test/rota_constraints/test_avoid_workers_by_week.py +++ b/test/rota_constraints/test_avoid_workers_by_week.py @@ -146,7 +146,7 @@ class TestWorkerRequests: Rota.add_worker_name_constraint_by_week(["worker3"], [1,2,3], ["a"]) Rota.build_and_solve(options={"ratio": 0.000}) - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") def test_avoid_worker_constraint_multiple_shifts(self): Rota = generate_basic_rota() @@ -193,4 +193,4 @@ class TestWorkerRequests: Rota.add_worker_name_constraint_by_week(["worker1"], [7,8,9], ["a"]) Rota.build_and_solve(options={"ratio": 0.000}) - assert Rota.results.solver.status == "warning" \ No newline at end of file + assert Rota.results.solver.status in ("warning", "error") \ No newline at end of file diff --git a/test/test_general_balancing.py b/test/test_general_balancing.py index 47b515b..b9c53d9 100644 --- a/test/test_general_balancing.py +++ b/test/test_general_balancing.py @@ -75,7 +75,7 @@ class TestBalancing: name="a", length=12.5, days=days, - balance_weighting=5, + balance_weighting=10, workers_required=1, force_as_block=False, constraint=[{"name": "pre","options": "2"}, {"name": "post","options": "2"}], @@ -98,7 +98,7 @@ class TestBalancing: ), ) - Rota.build_and_solve(options={"ratio": 0.001}) + Rota.build_and_solve(options={"ratio": 0.0001}) Rota.export_rota_to_html("basic_balancing_weighted_shifts") shift_summary = Rota.get_shift_summary_dict() @@ -198,7 +198,7 @@ class TestBalancing: worker = Rota.get_worker_by_name(worker_name) # assert worker_shifts["a"] in (46,47,48) - assert worker_shifts["c"] in (7, 8) + assert worker_shifts["c"] in (approx(7), approx(8)) #assert worker_shifts["b"] in (53, 54) def test_weighted_shift_balancing4(self): diff --git a/test/test_nights2.py b/test/test_nights2.py index a30c020..94ff614 100644 --- a/test/test_nights2.py +++ b/test/test_nights2.py @@ -146,7 +146,7 @@ class TestNightShifts: Rota.build_and_solve(options={"ratio": 0.000}) - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") def test_nights2(self): Rota = generate_basic_rota() @@ -315,7 +315,7 @@ class TestNightShifts: Rota.build_and_solve(options={"ratio": 0.000}) Rota.export_rota_to_html("night") - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") def test_nights_max_frequency3(self): Rota = generate_basic_rota() @@ -352,7 +352,7 @@ class TestNightShifts: Rota.build_and_solve(options={"ratio": 0.000}) Rota.export_rota_to_html("night") - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") def test_nights_max_frequency4(self): Rota = generate_basic_rota() diff --git a/test/test_nwds.py b/test/test_nwds.py index fe343ba..e27b1e8 100644 --- a/test/test_nwds.py +++ b/test/test_nwds.py @@ -90,7 +90,7 @@ class TestDemoRota: self.Rota.export_rota_to_html("nwd") - assert self.Rota.results.solver.status == "warning" + assert self.Rota.results.solver.status in ("warning", "error") def test_nwd_partial_rota(self): # Set up rota @@ -188,7 +188,7 @@ class TestDemoRota: self.Rota.build_and_solve(options={"ratio": 0.00}) - assert self.Rota.results.solver.status == "warning" + assert self.Rota.results.solver.status in ("warning", "error") def test_nwd_force_as_block(self): # Set up rota @@ -273,21 +273,21 @@ class TestDemoRota: self.Rota.add_shifts( SingleShift( sites=("group1",), name="d", length= 12.5, days=days[:5], - balance_offset=10, + #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=10, + #balance_offset=50, workers_required=4, force_as_block_unless_nwd=True, assign_as_block=True, ), ) - self.Rota.build_and_solve(options={"ratio": 0.00}) + self.Rota.build_and_solve() self.Rota.export_rota_to_html("nwd_block_force_split") assert self.Rota.results.solver.status == "ok" diff --git a/test/test_remote.py b/test/test_remote.py index f8f3553..9fe240a 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -40,7 +40,7 @@ class TestRemoteRotas: Rota.build_and_solve(options={"ratio": 0.000}) - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") def test_remote_pass_week(self): Rota = setup_basic_rota() diff --git a/test/test_rota.py b/test/test_rota.py index 23976dc..8ba921c 100644 --- a/test/test_rota.py +++ b/test/test_rota.py @@ -2,7 +2,8 @@ from copy import deepcopy from re import S from black import main import pytest -from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days +from pytest import approx +from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WarningTermination, days import datetime @@ -285,14 +286,14 @@ class TestDemoRotaNights: worker = self.Rota.get_worker_by_name(worker_name) if worker.fte == 40: - assert worker_shifts["night_weekday"] in (0, 4) - assert worker_shifts["night_weekend"] in (0, 3) + 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 (0, 4, 8) - assert worker_shifts["night_weekend"] in (0, 3, 6) + 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 (4, 8) - assert worker_shifts["night_weekend"] in (3, 6) + assert worker_shifts["night_weekday"] in (approx(4), approx(8)) + assert worker_shifts["night_weekend"] in (approx(3), approx(6)) class TestDemoRotaClear: @@ -326,7 +327,7 @@ class TestDemoRotaClear: name="a", length=12.5, days=days[:5], - balance_offset=40, + #balance_offset=40, workers_required=2, constraint=[{"name": "pre", "options": 1}], ), @@ -335,7 +336,7 @@ class TestDemoRotaClear: name="b", length=12.5, days=days[:5], - balance_offset=40, + #balance_offset=40, workers_required=2, ), SingleShift( @@ -343,7 +344,7 @@ class TestDemoRotaClear: name="c", length=12.5, days=days[3], - balance_offset=40, + #balance_offset=40, workers_required=1, constraint=[{"name": "pre", "options": 1}, {"name": "post", "options": 1}], ), @@ -481,7 +482,7 @@ class TestDemoRotaShiftConstraints: 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 == "warning" + assert self.Rota.results.solver.status in ("warning", "error") assert self.Rota.results.solver.termination_condition == "infeasible" def test_max_shifts_extra_worker(self): @@ -495,7 +496,7 @@ class TestDemoRotaShiftConstraints: self.Rota.constraint_options["max_shifts_per_week"] = 3 self.Rota.build_and_solve() - assert self.Rota.results.solver.status == "warning" + 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): @@ -550,7 +551,17 @@ class TestDemoRotaBalanceShiftSites: 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()) @@ -586,6 +597,8 @@ class TestDemoRotaBalanceShiftSites: 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.terminate_on_warning.remove("Worker/no valid shifts") self.Rota.build_and_solve(options={"ratio": 0.1}) group_workers = self.Rota.get_workers_by_group() @@ -1151,7 +1164,7 @@ class TestNightUnavailable: self.Rota.build_and_solve(options={"ratio": 0.00}) self.Rota.export_rota_to_html("testnight_unavail") - assert self.Rota.results.solver.status == "warning" + 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): @@ -1252,7 +1265,7 @@ class TestNightUnavailable: # # self.Rota.build_and_solve(options={"ratio": 0.00}) # -# assert self.Rota.results.solver.status == "warning" +# assert self.Rota.results.solver.status in ("warning", "error") if __name__ == "__main__": t = TestLimitConstraints() diff --git a/test/test_shifts.py b/test/test_shifts.py index 46fe6d2..3ff1ff8 100644 --- a/test/test_shifts.py +++ b/test/test_shifts.py @@ -142,7 +142,7 @@ class TestShiftConstraints: Rota.build_and_solve(options={"ratio": 0.000}) Rota.export_rota_to_html("max_shifts_per_week_block") - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") def test_max_shifts_per_week_block_shift_number(self): Rota = generate_basic_rota() @@ -172,7 +172,7 @@ class TestShiftConstraints: Rota.build_and_solve(options={"ratio": 0.000}) Rota.export_rota_to_html("max_shifts_per_week_block") - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") def test_pre_shift_constraint(self): Rota = generate_basic_rota() @@ -226,7 +226,7 @@ class TestShiftConstraints: Rota.build_and_solve(options={"ratio": 0.000}) - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") def test_pre_shift_constraint2(self): Rota = generate_basic_rota() @@ -269,7 +269,7 @@ class TestShiftConstraints: Rota.build_and_solve(options={"ratio": 0.000}) Rota.export_rota_to_html("pre") - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") def test_post_shift_constraint(self): Rota = generate_basic_rota() @@ -311,4 +311,4 @@ class TestShiftConstraints: Rota.build_and_solve(options={"ratio": 0.000}) Rota.export_rota_to_html("post") - assert Rota.results.solver.status == "warning" \ No newline at end of file + assert Rota.results.solver.status in ("warning", "error") \ No newline at end of file diff --git a/test/test_weekend_padding.py b/test/test_weekend_padding.py index 9235c8b..3b5d816 100644 --- a/test/test_weekend_padding.py +++ b/test/test_weekend_padding.py @@ -210,7 +210,7 @@ class TestDemoRota: self.Rota.build_and_solve(options={"ratio": 0.00}) - assert self.Rota.results.solver.status == "warning" + assert self.Rota.results.solver.status in ("warning", "error") def test_post_mon(self): # Set up rota @@ -343,4 +343,4 @@ class TestDemoRota: self.Rota.export_rota_to_html("weekend_padding") - assert self.Rota.results.solver.status == "warning" + assert self.Rota.results.solver.status in ("warning", "error") diff --git a/test/test_worker_requests.py b/test/test_worker_requests.py index 3e54e73..d5ef441 100644 --- a/test/test_worker_requests.py +++ b/test/test_worker_requests.py @@ -1,5 +1,6 @@ import datetime import pytest +from pytest import approx from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days from rota.workers import Worker @@ -179,7 +180,7 @@ class TestWorkerRequests: worker = Rota.get_worker_by_name(worker_name) - assert worker_shifts["a"] in (46, 47) + assert worker_shifts["a"] in (approx(46), approx(47)) # shift_string = Rota.get_worker_shift_list_string(worker) @@ -216,7 +217,7 @@ class TestWorkerRequests: Rota.build_and_solve(options={"ratio": 0.000}) Rota.export_rota_to_html("unavailable_to_work2") - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") assert Rota.results.solver.termination_condition == "infeasible" def test_unavailable_to_work3(self): @@ -304,7 +305,7 @@ class TestWorkerRequests: Rota.build_and_solve(options={"ratio": 0.000}) Rota.export_rota_to_html("unavailable_to_work4") - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") assert Rota.results.solver.termination_condition == "infeasible" def test_work_requests(self): @@ -523,7 +524,7 @@ class TestWorkerRequests: worker = Rota.get_worker_by_name(worker_name) assert worker_shifts["a"] in (26, 27) - assert worker_shifts["b"] == 10 + assert worker_shifts["b"] == approx(10) shift_string = Rota.get_worker_shift_list_string(worker) diff --git a/test/test_workers.py b/test/test_workers.py index 07989c2..ca4be0d 100644 --- a/test/test_workers.py +++ b/test/test_workers.py @@ -1,5 +1,5 @@ import pytest -from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days +from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WarningTermination, days import datetime from rota.workers import Worker @@ -64,7 +64,7 @@ class TestWorkers: ), ) - with pytest.raises(ValueError): + with pytest.raises(WarningTermination): Rota.build_and_solve(solve=False) def test_worker_ftes(self): @@ -143,6 +143,10 @@ class TestWorkers: ), ) + with pytest.raises(WarningTermination): + Rota.build_and_solve() + + Rota.terminate_on_warning.remove("Worker/no valid shifts") Rota.build_and_solve() # All workers should have an adjusted fte of 50 % @@ -298,6 +302,7 @@ class TestWorkers: ), ) + Rota.terminate_on_warning.remove("Worker/no valid shifts") Rota.build_and_solve() # All workers should have an adjusted fte of 50 % @@ -353,7 +358,7 @@ class TestWorkers: Rota.build_and_solve() # With two paired workers we shouldn't be able to solve - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") worker3 = Worker(name="worker3", site="group1", grade=1, fte=100) Rota.add_worker(worker3) @@ -374,7 +379,7 @@ class TestWorkers: Rota.build_and_solve() - assert Rota.results.solver.status == "warning" + assert Rota.results.solver.status in ("warning", "error") worker3.pair = 0