diff --git a/output/timetable.js b/output/timetable.js index a3a1121..e1ef784 100644 --- a/output/timetable.js +++ b/output/timetable.js @@ -23,6 +23,9 @@ function generateExtra() { $(".auto-generated").remove(); global_shifts = new Set(); + total_summed_shift_diffs = 0; + total_summed_shift_diffs_abs = 0; + tables.each((n, table) => { rows = $(table).find(".worker-row"); @@ -114,7 +117,7 @@ function generateExtra() { (n, td_el) => { td_date = new Date(Date.parse(td_el.dataset.date)); - if (td_date >= start_date && td_date <= end_date) { + if (td_date >= start_date && td_date < end_date) { $(td_el).addClass("nwd"); } @@ -150,6 +153,9 @@ function generateExtra() { shift_diff = JSON.parse(worker_td.attr("data-shift-diff")); summed_shift_diff = Object.values(shift_diff).reduce((a, b) => a + b); + total_summed_shift_diffs = total_summed_shift_diffs + summed_shift_diff; + total_summed_shift_diffs_abs = total_summed_shift_diffs_abs + Math.abs(summed_shift_diff); + worker_td.after(`
Total shifts: ${total_shifts}, @@ -166,6 +172,7 @@ function generateExtra() { }) + sites = {}; sites_days_lost = {} for (const worker in workers) { @@ -216,6 +223,8 @@ function generateExtra() { }); + $(table).before($(`Total summed shift diff: ${total_summed_shift_diffs}
`)); + $(table).before($(`Total summed shift diff: ${total_summed_shift_diffs_abs}
`)); $(table).before(summary_button); @@ -363,7 +372,8 @@ function viewWorker(worker) { FTE: ${ds.fte} (${ds.fte_adj})
Start date: ${ds.start_date}
End date: ${ds.end_date}
- Non working days: ${ds.nwds} + Non working days: ${ds.nwds}
+ Remote site: ${ds.remoteSite}
`) $("body").append(dlg) diff --git a/requirements.txt b/requirements.txt index 53c81e4..ba80304 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ pyomo govuk_bank_holidays pytest -black \ No newline at end of file +black +rich +pydantic \ No newline at end of file diff --git a/rota/runfixnights.py b/rota/runfixnights.py index 3142f84..769a520 100644 --- a/rota/runfixnights.py +++ b/rota/runfixnights.py @@ -227,7 +227,7 @@ if use_test_workers: ] ) Rota.add_workers( - [Worker(Rota, i, "Plym {}".format(i), "plymouth", 4, 50) for i in range(9, 17)] + [Worker(i, "Plym {}".format(i), "plymouth", 4, 50) for i in range(9, 17)] ) Rota.add_workers( [ diff --git a/rota/shifts.py b/rota/shifts.py index 68cd06d..a523c5b 100644 --- a/rota/shifts.py +++ b/rota/shifts.py @@ -40,7 +40,7 @@ for bank_holiday in bank_holidays.get_holidays(division="england-and-wales"): SHIFT_BOUNDS = { "bank_holiday": (0, 9), "shift_count": (0, 400), - "night_shift_count": (0, 19), + "night_shift_count": (0, 100), "weekend_count": (0, 60), } @@ -55,7 +55,7 @@ class SingleShift(object): postclear(2) / preclear(2) - require_remote_site_presence: + require_remote_site_presence_week: options: (site, required_number) night @@ -185,6 +185,7 @@ class RotaBuilder(object): self.unavailable_to_work = set() self.unavailable_to_work_reason = {} self.pref_not_to_work = {} + self.pref_not_to_work_reason = {} self.work_requests = set() self.work_requests_map = {} @@ -203,14 +204,13 @@ class RotaBuilder(object): self.max_weekend_frequency = max_weekend_frequency self.constraint_options = { - "ensure_1_st4_plus_on_nights": True, "balance_nights": True, "constrain_time_off_after_nights": False, "balance_nights_across_sites": True, "balance_bank_holidays": True, "balance_blocks": True, "balance_shifts": True, # Does not use a quadratic function - "balance_shifts_quadratic": False, + "balance_shifts_quadratic": False, # Will prevent spreading of spreading across different shifts "balance_shifts_over_workers": True, "minimise_shift_diffs": False, # less sophisticated version of balance_shifts_over_workers "balance_weekends": True, @@ -223,8 +223,6 @@ class RotaBuilder(object): "prevent_monday_and_tuesday_after_full_weekends": [], "prevent_fridays_before_full_weekends": [], "prevent_thursdays_before_full_weekends": [], - # This may be better defined on the shift? - "require_presence_at_site_overnight": [], "avoid_st2_first_month": False, "hard_constrain_pair_separation": False, } @@ -804,7 +802,7 @@ class RotaBuilder(object): ), ) - def presenceAtRemoteSite(model, week, shift, required_site, required_number): + def presenceAtRemoteSiteWeek(model, week, shift, required_site, required_number): required_site_workers = [ w for w in self.workers if required_site == w.remote_site ] @@ -817,6 +815,40 @@ class RotaBuilder(object): >= required_number ) + for shift in self.get_shifts_with_constraint("require_remote_site_presence_week"): + site, required_number = shift.constraint_options[ + "require_remote_site_presence_week" + ] + # self.model.require_presence_at_site_overnight_rule = Constraint( + setattr( + self.model, + f"require_remote_site_presence_week_{shift.name}", + Constraint( + [week for week in self.weeks], + [shift.name], + [site], + [required_number], + rule=presenceAtRemoteSiteWeek, + ), + ) + + def presenceAtRemoteSite(model, day, week, shift, required_site, required_number): + required_site_workers = [ + w for w in self.workers if required_site == w.remote_site + ] + + for w in required_site_workers: + if (w.id, week, day, shift) not in model.works: + return Constraint.Skip + + return ( + sum( + model.works[w.id, week, day, shift] + for w in required_site_workers + ) + >= required_number + ) + for shift in self.get_shifts_with_constraint("require_remote_site_presence"): site, required_number = shift.constraint_options[ "require_remote_site_presence" @@ -826,6 +858,7 @@ class RotaBuilder(object): self.model, f"require_remote_site_presence_{shift.name}", Constraint( + [day for day in self.days], [week for week in self.weeks], [shift.name], [site], @@ -912,10 +945,9 @@ class RotaBuilder(object): full_workers = [] for w in workers: - if w.nwd: + if w.non_working_day_list: l = [] - # This should take into account dates! - for nwd, start_nwd_date, end_nwd_date in w.nwd: + for nwd, start_nwd_date, end_nwd_date in w.non_working_day_list: if nwd in shift.shift_days: if start_nwd_date > self.get_week_start_date(week): continue @@ -1121,7 +1153,7 @@ class RotaBuilder(object): if self.constraint_options["balance_shifts_quadratic"]: # This may need to be updated - xU = 25 + xU = 10 xL = 1 self.model.constraints.add( inequality( @@ -1333,9 +1365,9 @@ class RotaBuilder(object): ) # Ensure worker is not allocated shifts on non working days - if worker.nwd: + if worker.non_working_day_list: for week, day, shift in self.get_all_shiftclass_combinations(): - for n, start, end in worker.nwd: + for n, start, end in worker.non_working_day_list: if not shift.rota_on_nwds and day == n: # print(start, self.week_day_date_map[(week, day)], end) if start <= self.week_day_date_map[(week, day)] < end: @@ -1932,7 +1964,9 @@ class RotaBuilder(object): Args: workers (List(Worker)): """ - self.workers.extend(workers) + for worker in workers: + self.add_worker(worker) + #self.workers.extend(workers) def build_workers(self) -> None: """Process loaded shifts and workers @@ -1947,6 +1981,7 @@ class RotaBuilder(object): self.workers_name_map = {} for worker in self.workers: + 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") @@ -2251,16 +2286,23 @@ class RotaBuilder(object): return self.workers_name_map[name] def get_workers_by_group(self) -> Dict[str, Worker]: - group_workers = defaultdict(set) + group_workers = defaultdict(list) for worker in self.workers: - group_workers[worker.site].add(worker) + group_workers[worker.site].append(worker) + + return group_workers + + def get_workers_by_remote_group(self) -> Dict[str, Worker]: + group_workers = defaultdict(list) + for worker in self.workers: + group_workers[worker.remote_site].append(worker) return group_workers def get_workers_by_grade(self) -> Dict[str, Worker]: - group_workers = defaultdict(set) + group_workers = defaultdict(list) for worker in self.workers: - group_workers[worker.grade].add(worker) + group_workers[worker.grade].append(worker) return group_workers @@ -2462,7 +2504,7 @@ class RotaBuilder(object): current_site = worker.site shifts = [] - nwds = json.dumps(worker.nwd, default=str) + nwds = json.dumps(worker.non_working_day_list, default=str) #if worker.nwd: # # TODO: limit to dates # nwds = ", ".join([i[0] for i in worker.nwd]) @@ -2525,8 +2567,8 @@ class RotaBuilder(object): remote_site = "" if shift_name: shift = self.get_shift_by_name(shift_name) - if "require_remote_site_presence" in shift.constraints: - remote_site = f" data-shift-remote-site='{shift.constraint_options['require_remote_site_presence'][0]}'" + if "require_remote_site_presence_week" in shift.constraints: + remote_site = f" data-shift-remote-site='{shift.constraint_options['require_remote_site_presence_week'][0]}'" shift_tds.append( f"{a}" @@ -2564,8 +2606,8 @@ class RotaBuilder(object): name=worker.name, fte=worker.fte, fte_adj=worker.fte_adj, - start_date=worker.start_date, - end_date=worker.end_date, + start_date=worker.calculated_start_date, + end_date=worker.calculated_end_date, targets=worker_targets, worker_shift_counts=json.dumps(shift_count_dict), weekend_target=worker.weekend_shift_target_number, @@ -2698,6 +2740,15 @@ class RotaBuilder(object): return shifts + def get_workers_total_shifts(self) -> Dict[str, int]: + shifts = {} + + for worker in self.workers: + shifts[worker.name] = len([i for i in self.get_worker_shift_list(worker) if i != ""]) + + return shifts + + def get_worker_shift_list(self, worker: Worker) -> List: shifts = [] diff --git a/rota/workers.py b/rota/workers.py index 7bdc490..fb32173 100644 --- a/rota/workers.py +++ b/rota/workers.py @@ -1,6 +1,9 @@ import datetime from collections import defaultdict -from typing import Iterable, List +from typing import Iterable, List, Literal, Optional +from pydantic import BaseModel, Extra + +from rich.pretty import pprint # from .shifts import RotaBuilder, days, sites import uuid @@ -11,175 +14,204 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from rota.shifts import RotaBuilder +days = Literal["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] -class Worker: - def __init__( - self, - Rota: "RotaBuilder", # Aim to remove (workers should be independent of rotas) - name: str, - site: str, - grade: int, - id=None, - fte: int = 100, - nwd: list[str] | list[str, datetime.datetime, datetime.datetime] = [], - start_date: datetime.datetime | None = None, - end_date: datetime.datetime | None = None, - oop: list[(datetime.datetime, datetime.datetime, str)] | None = None, - not_available_to_work: list[(datetime.datetime, str)] | None = None, - pref_not_to_work: list[datetime.datetime] | None = None, - work_requests: list[(datetime.datetime, str)] | None = None, - remote_site: str = "plymouth", # We set a default proc_site - previous_shifts: dict = {}, - shift_balance_extra: dict = {}, - bank_holiday_extra: int = 0, - pair: int = 0, - ): +class NotAvailableToWork(BaseModel): + date: datetime.date + reason: str = "unknown" - # We can either have a user generated ID - if id is not None: - self.id = id - else: - self.id = uuid.uuid4() - self.name = name - self.site = site - # Grade are equivalent to roles, by keeping them integer defining model - # rules is easier. - self.grade = grade - self.fte = fte - self.nwd = [] +class NonWorkingDays(BaseModel): + day: days + start_date: datetime.date | None = None + end_date: datetime.date | None = None + +class WorkRequests(BaseModel): + date: datetime.date + shift: str = "" + +class PreferenceNotToWork(BaseModel): + date: datetime.date + reason: str = "" + +class OutOfProgramme(BaseModel): + start_date: datetime.date + end_date: datetime.date + reason: str = "" + +class Worker(BaseModel): + name: str + site: str + # Grade are equivalent to roles, by keeping them integer defining model + # rules is easier. + grade: int + # We can either have a user generated ID + id: Optional[int]=None + fte: int = 100 + nwds: list[NonWorkingDays] = [] + start_date: datetime.date | None = None + end_date: datetime.date | None = None + oop: list[OutOfProgramme] = [] + not_available_to_work: list[NotAvailableToWork] = [] + pref_not_to_work: list[PreferenceNotToWork] = [] + work_requests: list[WorkRequests] = [] + remote_site: str = "plymouth", # We set a default proc_site + previous_shifts: dict = {} + shift_balance_extra: dict = {} + bank_holiday_extra: int = 0 + pair: int = 0 + + class Config: + extra = Extra.allow + +# def __init__( +# self, +# #Rota: "RotaBuilder", # Aim to remove (workers should be independent of rotas) +# ): +# +# self.name = name +# self.site = site +# self.grade = grade +# self.fte = fte +# self.nwd = nwd +# self.pair = pair +# self.shift_balance_extra = shift_balance_extra +# self.bank_holiday_extra = bank_holiday_extra +# self.start_date +# +# self.remote_site = remote_site +# +# self.previous_shifts = previous_shifts +# +# +# # days_to_work = Rota.rota_days_length + + def load_rota(self, Rota): self.proportion_rota_to_work = 1 - self.pair = pair - self.shift_balance_extra = shift_balance_extra - self.bank_holiday_extra = bank_holiday_extra - - self.remote_site = remote_site - - self.previous_shifts = previous_shifts - self.shift_target_number = defaultdict(int) - # days_to_work = Rota.rota_days_length + if self.id is None: + self.id = uuid.uuid4() - if start_date is not None and end_date is not None: - if start_date >= end_date: + if self.start_date is not None and self.end_date is not None: + if self.start_date >= self.end_date: raise ValueError("End date must be after start date") # if no start date default to the start of the rota - if start_date is None: - self.start_date = Rota.start_date + if self.start_date is None: + self.calculated_start_date = Rota.start_date else: # ? test if start date is valid - self.start_date = start_date + self.calculated_start_date = self.start_date - if end_date is None: - self.end_date = Rota.rota_end_date + if self.end_date is None: + self.calculated_end_date = Rota.rota_end_date else: - self.end_date = end_date - # self.end_date = datetime.datetime.strptime(end_date, "%d/%m/%y").date() + self.calculated_end_date = self.end_date + # self.calculated_end_date = datetime.datetime.strptime(end_date, "%d/%m/%y").date() - if self.end_date > Rota.rota_end_date: - self.end_date = Rota.rota_end_date + if self.calculated_end_date > Rota.rota_end_date: + self.calculated_end_date = Rota.rota_end_date - days_to_work = (self.end_date - self.start_date).days + days_to_work = (self.calculated_end_date - self.calculated_start_date).days for week, day in Rota.weeks_days_product: date = Rota.week_day_date_map[(week, day)] - if date < self.start_date: + if date < self.calculated_start_date: Rota.unavailable_to_work.add((self.id, week, day)) Rota.unavailable_to_work_reason[ (self.id, week, day) - ] = f"START DATE: {self.start_date}" + ] = f"START DATE: {self.calculated_start_date}" - if date >= self.end_date: + if date >= self.calculated_end_date: Rota.unavailable_to_work.add((self.id, week, day)) Rota.unavailable_to_work_reason[ (self.id, week, day) - ] = f"END DATE: {self.end_date}" + ] = f"END DATE: {self.calculated_end_date}" - if oop is not None: - for start_oop, end_oop, oop_name in oop: - # start_oop, end_oop = oop - if isinstance(start_oop, datetime.date): - start_oop_date = start_oop + for item in self.oop: + start_oop = item.start_date + end_oop = item.end_date + oop_name = item.reason + # start_oop, end_oop = oop + if isinstance(start_oop, datetime.date): + start_oop_date = start_oop + else: + start_oop_date = datetime.datetime.strptime( + start_oop, "%d/%m/%y" + ).date() + + if isinstance(end_oop, datetime.date): + end_oop_date = end_oop + else: + end_oop_date = datetime.datetime.strptime( + end_oop, "%d/%m/%y" + ).date() + + if start_oop_date >= end_oop_date: + raise ValueError("End OOP date must be after start date") + + # ignore oops if they finish before the rota (or worker) start date + if end_oop_date > self.calculated_start_date: + if start_oop_date > self.calculated_end_date: + pass else: - start_oop_date = datetime.datetime.strptime( - start_oop, "%d/%m/%y" - ).date() + if end_oop_date > Rota.rota_end_date: + end_oop_date = Rota.rota_end_date - if isinstance(end_oop, datetime.date): - end_oop_date = end_oop - else: - end_oop_date = datetime.datetime.strptime( - end_oop, "%d/%m/%y" - ).date() + if start_oop_date < Rota.start_date: + start_oop_date = Rota.start_date - if start_oop_date >= end_oop_date: - raise ValueError("End OOP date must be after start date") + oop_length = (end_oop_date - start_oop_date).days + days_to_work = days_to_work - oop_length - # ignore oops if they finish before the rota (or worker) start date - if end_oop_date > self.start_date: - if start_oop_date > self.end_date: - pass - else: - if end_oop_date > Rota.rota_end_date: - end_oop_date = Rota.rota_end_date + days_until_oop = (start_oop_date - Rota.start_date).days - if start_oop_date < Rota.start_date: - start_oop_date = Rota.start_date + for weeks_days in Rota.weeks_days_product[ + days_until_oop : days_until_oop + oop_length + ]: + week, day = weeks_days + Rota.unavailable_to_work.add((self.id, week, day)) + Rota.unavailable_to_work_reason[ + (self.id, week, day) + ] = f"OOP ({oop_name})".format(self.oop) - oop_length = (end_oop_date - start_oop_date).days - days_to_work = days_to_work - oop_length + # loop throught dates converting to week / day combination + for item in self.pref_not_to_work: + days_from_start = ( + item.date + - Rota.start_date + ).days + week = days_from_start // 7 + 1 + day = Rota.days[(days_from_start % 7)] + # Weight the value to take into account the number of preferences + # 1 is added to the total number of requests to ensure they do not outweight + # a single (or fewer) request(s) + Rota.pref_not_to_work[(self.id, week, day)] = 1 / ( + len(self.pref_not_to_work) + 1 + ) + Rota.pref_not_to_work_reason[(self.id, week, day)] = item.reason - days_until_oop = (start_oop_date - Rota.start_date).days + # print(not_available_to_work) + # loop throught dates converting to week / day combination + for item in self.not_available_to_work: + days_from_start = ( + item.date + - Rota.start_date + ).days + week = days_from_start // 7 + 1 + day = Rota.days[(days_from_start % 7)] + Rota.unavailable_to_work.add((self.id, week, day)) + Rota.unavailable_to_work_reason[(self.id, week, day)] = item.reason - for weeks_days in Rota.weeks_days_product[ - days_until_oop : days_until_oop + oop_length - ]: - week, day = weeks_days - Rota.unavailable_to_work.add((self.id, week, day)) - Rota.unavailable_to_work_reason[ - (self.id, week, day) - ] = f"OOP ({oop_name})".format(oop) - - if pref_not_to_work is not None: - # loop throught dates converting to week / day combination - for date in pref_not_to_work: + for item in self.work_requests: days_from_start = ( - date + item.date - Rota.start_date ).days week = days_from_start // 7 + 1 day = Rota.days[(days_from_start % 7)] - # Weight the value to take into account the number of preferences - # 1 is added to the total number of requests to ensure they do not outweight - # a single (or fewer) request(s) - Rota.pref_not_to_work[(self.id, week, day)] = 1 / ( - len(pref_not_to_work) + 1 - ) - - if not_available_to_work is not None: - # print(not_available_to_work) - # loop throught dates converting to week / day combination - for date, reason in not_available_to_work: - days_from_start = ( - date - - Rota.start_date - ).days - week = days_from_start // 7 + 1 - day = Rota.days[(days_from_start % 7)] - Rota.unavailable_to_work.add((self.id, week, day)) - Rota.unavailable_to_work_reason[(self.id, week, day)] = reason - - if work_requests is not None: - for date, shift in work_requests: - days_from_start = ( - date - - Rota.start_date - ).days - week = days_from_start // 7 + 1 - day = Rota.days[(days_from_start % 7)] - Rota.work_requests.add((self.id, week, day, shift)) + Rota.work_requests.add((self.id, week, day, item.shift)) # Calculate the proportion of the rota that is being worked self.proportion_rota_to_work = days_to_work / Rota.rota_days_length @@ -189,15 +221,20 @@ class Worker: self.fte_adj = self.fte * self.proportion_rota_to_work if self.fte_adj > 100: + # Shouldn't happen ? bug if it does raise ValueError("{} : fte_ajd = {}".format(self.name, self.fte_adj)) - assert type(nwd) == list, f"nwd must be a list: {nwd=}" - for item in nwd: - match item: - case (nwd_day, start_date, end_date): - self.nwd.append((nwd_day, start_date, end_date)) - case nwd_day: - self.nwd.append((nwd_day, Rota.start_date, Rota.rota_end_date)) + # TODO: this has already been validated, consider moving + self.non_working_day_list = [] + for item 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 + + self.non_working_day_list.append((item.day, start_date, end_date)) def __lt__(self, other) -> bool: return (self.site, self.grade, self.fte_adj, self.name) < ( @@ -209,11 +246,11 @@ class Worker: def __str__(self) -> str: - nwd = ", ".join([str(i) for i in self.nwd]) if self.nwd is not None else "" + nwds = ", ".join([str(i) for i, start, end in self.non_working_day_list]) if self.non_working_day_list is not None else "" return "{} {} [{}] REMOTE SITE:{}".format( self.name, (self.site, self.grade, self.fte_adj), - nwd, + nwds, self.remote_site, ) diff --git a/run.py b/run.py index 43ee67f..022b8c3 100644 --- a/run.py +++ b/run.py @@ -232,7 +232,7 @@ if use_test_workers: ] ) Rota.add_workers( - [Worker(Rota, i, "Plym {}".format(i), "plymouth", 4, 50) for i in range(9, 17)] + [Worker(i, "Plym {}".format(i), "plymouth", 4, 50) for i in range(9, 17)] ) Rota.add_workers( [ diff --git a/test/test_general_balancing.py b/test/test_general_balancing.py index bcaad5d..2058d2f 100644 --- a/test/test_general_balancing.py +++ b/test/test_general_balancing.py @@ -16,12 +16,12 @@ def generate_basic_rota(weeks_to_rota=10): # Add a few workers Rota.add_workers([ - Worker(Rota, "worker1", "group1", 1), - Worker(Rota, "worker2", "group1", 1), - Worker(Rota, "worker3", "group2", 1), - #Worker(Rota, "worker4", "group2", 1), - #Worker(Rota, "worker5", "group2", 1), - #Worker(Rota, "worker6", "group2", 1, fte=50), + 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 @@ -201,7 +201,7 @@ class TestBalancing: Rota = generate_basic_rota(10) Rota.add_worker( - Worker(Rota, "worker4", "group3", 1), + Worker(name="worker4", site="group3", grade=1), ) Rota.add_shifts( @@ -242,7 +242,7 @@ class TestBalancing: Rota = generate_basic_rota(10) Rota.add_worker( - Worker(Rota, "worker4", "group3", 1), + Worker(name="worker4", site="group3", grade=1), ) Rota.add_shifts( diff --git a/test/test_nwds.py b/test/test_nwds.py index f102cb5..8e20fda 100644 --- a/test/test_nwds.py +++ b/test/test_nwds.py @@ -24,8 +24,8 @@ class TestDemoRota: ) # Add a few workers - worker1 = Worker(self.Rota, "worker1", "group1", 1, nwd=["Mon"]) - worker2 = Worker(self.Rota, "worker2", "group1", 1, nwd=["Fri"]) + worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}]) + worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Fri"}]) self.Rota.add_workers((worker1, worker2)) @@ -36,7 +36,7 @@ class TestDemoRota: "d", 12.5, days[:5], - balance_offset=10, + # balance_offset=10, workers_required=1, ), SingleShift( @@ -48,7 +48,7 @@ class TestDemoRota: ), ) - self.Rota.build_and_solve(options={"ratio": 0.00}) + self.Rota.build_and_solve(options={"ratio": 0.10}) self.Rota.export_rota_to_html("nwd") @@ -78,8 +78,8 @@ class TestDemoRota: ) # Add a few workers - worker1 = Worker(self.Rota, "worker1", "group1", 1, nwd=["Mon"]) - worker2 = Worker(self.Rota, "worker2", "group1", 1, nwd=["Mon"]) + 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)) @@ -114,10 +114,16 @@ class TestDemoRota: d = start_date + datetime.timedelta(weeks=5) # Add a few workers worker1 = Worker( - self.Rota, "worker1", "group1", 1, nwd=[("Mon", start_date, d)] + name="worker1", + site="group1", + grade=1, + nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}], ) worker2 = Worker( - self.Rota, "worker2", "group1", 1, nwd=[("Mon", d, self.Rota.rota_end_date)] + 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)) @@ -140,6 +146,21 @@ class TestDemoRota: 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]) + + 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 @@ -154,10 +175,16 @@ class TestDemoRota: d2 = start_date + datetime.timedelta(weeks=6) # Add a few workers worker1 = Worker( - self.Rota, "worker1", "group1", 1, nwd=[("Mon", start_date, d2)] + name="worker1", + site="group1", + grade=1, + nwds=[{"day":"Mon", "start_date":start_date, "end_date":d2}], ) worker2 = Worker( - self.Rota, "worker2", "group1", 1, nwd=[("Mon", d, self.Rota.rota_end_date)] + 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)) @@ -190,25 +217,16 @@ class TestDemoRota: d = start_date + datetime.timedelta(weeks=4) worker1 = Worker( - self.Rota, "worker1", "group1", 1, nwd=[("Mon", start_date, d)] + name="worker1", + site="group1", + grade=1, + nwds=[{"day": "Mon", "start_date":start_date, "end_date":d}], ) - worker2 = Worker( - self.Rota, - "worker2", - "group1", - 1, + worker2 = Worker(name="worker2", site="group1", grade=1, ) - worker3 = Worker( - self.Rota, - "worker3", - "group1", - 1, + worker3 = Worker(name="worker3", site="group1", grade=1, ) - worker4 = Worker( - self.Rota, - "worker4", - "group1", - 1, + worker4 = Worker(name="worker4", site="group1", grade=1, ) self.Rota.add_workers((worker1, worker2, worker3, worker4)) @@ -255,25 +273,16 @@ class TestDemoRota: d = start_date + datetime.timedelta(weeks=5) worker1 = Worker( - self.Rota, "worker1", "group1", 1, nwd=[("Mon", start_date, d)] + name="worker1", + site="group1", + grade=1, + nwds=[{"day": "Mon", "start_date":start_date, "end_date":d}], ) - worker2 = Worker( - self.Rota, - "worker2", - "group1", - 1, + worker2 = Worker(name="worker2", site="group1", grade=1, ) - worker3 = Worker( - self.Rota, - "worker3", - "group1", - 1, + worker3 = Worker(name="worker3", site="group1", grade=1, ) - worker4 = Worker( - self.Rota, - "worker4", - "group1", - 1, + worker4 = Worker(name="worker4", site="group1", grade=1, ) self.Rota.add_workers((worker1, worker2, worker3, worker4)) @@ -303,7 +312,7 @@ class TestDemoRota: ) self.Rota.build_and_solve(options={"ratio": 0.00}) - self.Rota.export_rota_to_html("nwd") + self.Rota.export_rota_to_html("nwd_block_force_split") assert self.Rota.results.solver.status == "ok" @@ -333,24 +342,16 @@ class TestDemoRota: ) d = start_date + datetime.timedelta(weeks=5) - worker1 = Worker( - self.Rota, - "worker1", - "group1", - 1, - nwd=[ - ("Mon", d, self.Rota.rota_end_date), - ("Tue", d, self.Rota.rota_end_date), - ("Wed", d, self.Rota.rota_end_date), - ("Thu", d, self.Rota.rota_end_date), - ("Fri", d, self.Rota.rota_end_date), + 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( - self.Rota, - "worker2", - "group1", - 1, + worker2 = Worker(name="worker2", site="group1", grade=1, ) self.Rota.add_workers((worker1, worker2)) @@ -396,8 +397,8 @@ class TestDemoRota: else: assert week == "dddddww" - for week in weeks_from_list(shifts_string[:7 * 5 ]): + for week in weeks_from_list(shifts_string[: 7 * 5]): if worker.name == "worker1": assert week == "dddddww" else: - assert week == "-----ww" \ No newline at end of file + assert week == "-----ww" diff --git a/test/test_remote.py b/test/test_remote.py new file mode 100644 index 0000000..83c4a5a --- /dev/null +++ b/test/test_remote.py @@ -0,0 +1,181 @@ + +import pytest +from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days + +import datetime +from rota.workers import Worker + +def setup_basic_rota(): + weeks_to_rota = 10 + start_date = datetime.date(2022, 3, 7) + + return RotaBuilder( + start_date, + weeks_to_rota=weeks_to_rota, + ) + +class TestRemoteRotas: + + def test_remote_fail(self): + Rota = setup_basic_rota() + + Rota.add_workers( + [Worker( + name="worker1", site="group1", grade=1, remote_site="group1" + ), + Worker( + name="worker2", site="group2", grade=1, remote_site="group2" + ),] + ) + + Rota.add_shifts( + SingleShift( + ("group1", "group2"), + "a", + 12.5, + days, + workers_required=1, + balance_offset=100, + force_as_block=False, + constraints=[("require_remote_site_presence_week", ("group2", 2))], + ), + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + + assert Rota.results.solver.status == "warning" + + def test_remote_pass_week(self): + Rota = setup_basic_rota() + Rota.constraint_options["balance_bank_holidays"] = False + Rota.constraint_options["balance_shifts"] = False + Rota.constraint_options["balance_shifts_quadratic"] = False + Rota.constraint_options["balance_shifts_over_workers"] =True + Rota.constraint_options["balance_blocks"] = False + Rota.constraint_options["balance_weekends"] = False + Rota.constraint_options["balance_nights"] = False + Rota.constraint_options["minimse_shift_diffs"] = False + + Rota.add_workers( + [Worker( + name="worker1", site="group2", grade=1, remote_site="group1" + ), + Worker( + name="worker2", site="group2", grade=1, remote_site="group2" + ),Worker( + name="worker3", site="group2", grade=1, remote_site="group2" + ),] + ) + + Rota.add_shifts( + SingleShift( + ("group1", "group2"), + "a", + 12.5, + days[:3], + workers_required=1, + balance_offset=10, + force_as_block=True, + constraints=[("require_remote_site_presence_week", ("group2", 1))], + ), + SingleShift( + ("group1", "group2"), + "b", + 12.5, + days[3:], + workers_required=1, + balance_offset=10, + force_as_block=False, + #constraints=[("require_remote_site_presence_week", ("group2", 1))], + #constraint=["balance_shifts_over_workers"] + ), + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + + Rota.export_rota_to_html("remote_pass_week") + + assert Rota.results.solver.status == "ok" + + for worker in Rota.workers: + shifts = Rota.get_worker_shift_list(worker) + + # Convert shift to a string representation + shifts_string = "".join([i if i != "" else "-" for i in shifts]) + + if worker.site == "group1": + assert "a" not in shifts_string + + total_shifts = Rota.get_workers_total_shifts() + + shift_range = max(total_shifts.values()) - min(total_shifts.values()) + assert shift_range <= 1 + + def test_remote_pass(self): + Rota = setup_basic_rota() + Rota.constraint_options["balance_bank_holidays"] = False + Rota.constraint_options["balance_shifts"] = False + Rota.constraint_options["balance_shifts_quadratic"] = False + Rota.constraint_options["balance_shifts_over_workers"] =True + Rota.constraint_options["balance_blocks"] = False + Rota.constraint_options["balance_weekends"] = False + Rota.constraint_options["balance_nights"] = False + Rota.constraint_options["minimse_shift_diffs"] = False + + Rota.add_workers( + [Worker( + name="worker1", site="group2", grade=1, remote_site="group1" + ), + Worker( + name="worker2", site="group2", grade=1, remote_site="group2" + ),Worker( + name="worker3", site="group2", grade=1, remote_site="group2" + ),] + ) + + Rota.add_shifts( + SingleShift( + ("group1", "group2"), + "a", + 12.5, + days[:3], + workers_required=1, + balance_offset=10, + force_as_block=False, + constraints=[("require_remote_site_presence", ("group2", 1))], + ), + SingleShift( + ("group1", "group2"), + "b", + 12.5, + days[3:], + workers_required=1, + balance_offset=10, + force_as_block=False, + #constraints=[("require_remote_site_presence_week", ("group2", 1))], + #constraint=["balance_shifts_over_workers"] + ), + ) + + Rota.build_and_solve(options={"ratio": 0.000}) + + Rota.export_rota_to_html("remote_pass") + + assert Rota.results.solver.status == "ok" + + for worker in Rota.workers: + shifts = Rota.get_worker_shift_list(worker) + + # Convert shift to a string representation + shifts_string = "".join([i if i != "" else "-" for i in shifts]) + + if worker.site == "group1": + assert "a" not in shifts_string + + total_shifts = Rota.get_workers_total_shifts() + + shift_range = max(total_shifts.values()) - min(total_shifts.values()) + assert shift_range <= 1 + + + diff --git a/test/test_rota.py b/test/test_rota.py index 6b63649..d10974b 100644 --- a/test/test_rota.py +++ b/test/test_rota.py @@ -24,20 +24,20 @@ class TestDemoRota: Rota.constraint_options["max_shifts_per_month"] = 31 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_quadratic"] = 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["balance_weekends"] = False + Rota.constraint_options["balance_bank_holidays"] = False # Add a few workers - worker1 = Worker(Rota, "worker1", "group1", 1) - worker2 = Worker(Rota, "worker2", "group1", 1) - worker3 = Worker(Rota, "worker3", "group1", 1) - worker4 = Worker(Rota, "worker4", "group1", 1) - worker5 = Worker(Rota, "worker5", "group2", 1, fte=60) - worker6 = Worker(Rota, "worker6", "group2", 1, fte=40) + 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)) @@ -207,14 +207,14 @@ class TestDemoRotaNights: # Rota.constraint_options["balance_weekends"] = False # Add a few workers - worker1 = Worker(Rota, "worker1", "group1", 1) - worker2 = Worker(Rota, "worker2", "group1", 1) - worker3 = Worker(Rota, "worker3", "group1", 1) - worker4 = Worker(Rota, "worker4", "group1", 1) - worker5 = Worker(Rota, "worker5", "group2", 1, fte=60) - worker6 = Worker(Rota, "worker6", "group2", 1, fte=40) - worker7 = Worker(Rota, "worker7", "group2", 1) - worker8 = Worker(Rota, "worker8", "group2", 1) + 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) @@ -307,14 +307,14 @@ class TestDemoRotaClear: ) Rota.constraint_options["max_shifts_per_month"] = 20 - worker1 = Worker(Rota, "worker1", "group1", 1) - worker2 = Worker(Rota, "worker2", "group1", 1) - worker3 = Worker(Rota, "worker3", "group1", 1) - worker4 = Worker(Rota, "worker4", "group1", 1) - worker5 = Worker(Rota, "worker5", "group2", 1, fte=60) - worker6 = Worker(Rota, "worker6", "group2", 1, fte=40) - worker7 = Worker(Rota, "worker7", "group2", 1) - worker8 = Worker(Rota, "worker8", "group2", 1) + 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) @@ -393,8 +393,8 @@ class TestDemoRotaShiftConstraints: ) # Rota.constraint_options["max_shifts_per_week"] = 5 # Rota.constraint_options["max_shifts_per_month"] = 20 - worker1 = Worker(Rota, "worker1", "group1", 1) - worker2 = Worker(Rota, "worker2", "group1", 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( @@ -409,7 +409,7 @@ class TestDemoRotaShiftConstraints: ) Rota3Workers = deepcopy(Rota) - worker3 = Worker(Rota3Workers, "worker3", "group1", 1) + worker3 = Worker(name="worker3", site="group1", grade=1) Rota3Workers.add_worker(worker3) @@ -449,7 +449,7 @@ class TestDemoRotaShiftConstraints: def test_max_shifts_per_week_extra_worker_pass(self): self.Rota3Workers.constraint_options["max_shifts_per_week"] = 3 - # worker3 = Worker(self.Rota, "worker3", "group1", 1) + # worker3 = Worker(name="worker3", site="group1", grade=1) # self.Rota.add_worker(worker3) self.Rota3Workers.build_and_solve() @@ -466,12 +466,12 @@ class TestDemoRotaBalanceShiftSites: ) # Rota.constraint_options["max_shifts_per_week"] = 5 # Rota.constraint_options["max_shifts_per_month"] = 20 - worker1 = Worker(Rota, "worker1", "group1", 1) - worker2 = Worker(Rota, "worker2", "group1", 1) - worker3 = Worker(Rota, "worker3", "group2", 1) - worker4 = Worker(Rota, "worker4", "group2", 1) - worker5 = Worker(Rota, "worker5", "group3", 1) - worker6 = Worker(Rota, "worker6", "group3", 1) + 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)) @@ -564,6 +564,8 @@ class TestDemoRotaBalanceShiftSites: 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 = [] @@ -589,12 +591,12 @@ class TestLimitConstraints: ) # Rota.constraint_options["max_shifts_per_week"] = 5 # Rota.constraint_options["max_shifts_per_month"] = 20 - worker1 = Worker(Rota, "worker1", "group1", 2, remote_site="group1") - worker2 = Worker(Rota, "worker2", "group1", 2, remote_site="group1") - worker3 = Worker(Rota, "worker3", "group1", 2, remote_site="group1") - worker4 = Worker(Rota, "worker4", "group3", 3, remote_site="group2") - worker5 = Worker(Rota, "worker5", "group3", 3, remote_site="group2") - worker6 = Worker(Rota, "worker6", "group3", 3, remote_site="group2") + 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)) @@ -847,18 +849,18 @@ class TestLimitConstraints: with pytest.raises(ValueError): self.Rota.build_and_solve(options={"ratio": 0.1}) - def test_constraint_require_remote_site_presence(self): + def test_constraint_require_remote_site_presence_week(self): self.Rota.shifts = [] self.Rota.add_shifts( SingleShift( ("group1", "group2", "group3"), - "weekday_night", + "night", 12.5, days, balance_offset=40, workers_required=2, force_as_block=True, - constraints=[("require_remote_site_presence", ("group1", 2))], + constraints=[("require_remote_site_presence_week", ("group1", 2))], ), ) self.Rota.build_and_solve(options={"ratio": 0.00}) @@ -876,29 +878,33 @@ class TestLimitConstraints: if group != "group1": limit = 0 for day_shifts in zipped_lists: - assert day_shifts.count("weekday_night") == limit + assert day_shifts.count("night") == limit assert self.Rota.results.solver.status == "ok" assert self.Rota.results.solver.termination_condition == "optimal" - def test_constraint_require_remote_site_presence2(self): + def test_constraint_require_remote_site_presence_week2(self): + self.Rota.constraint_options["minimise_shift_diffs"] = False + self.Rota.constraint_options["balance_shifts_quadratic"] = False + self.Rota.constraint_options["balance_shift"] = False + self.Rota.constraint_options["balance_nights_across_sites"] = True self.Rota.shifts = [] self.Rota.add_shifts( SingleShift( ("group1", "group2", "group3"), - "weekday_night", + "night", 12.5, days, - balance_offset=40, + balance_offset=20, workers_required=3, force_as_block=True, - constraints=[("require_remote_site_presence", ("group2", 2))], + constraints=[("require_remote_site_presence_week", ("group2", 2))], ), ) self.Rota.build_and_solve(options={"ratio": 0.00}) self.Rota.export_rota_to_html("remote2") - group_workers = self.Rota.get_workers_by_group() + group_workers = self.Rota.get_workers_by_remote_group() for group in group_workers: shift_patterns = [] for w in group_workers[group]: @@ -908,11 +914,42 @@ class TestLimitConstraints: if group == "group2": for day_shifts in zipped_lists: - assert day_shifts.count("weekday_night") >= 2 + assert day_shifts.count("night") >= 2 assert self.Rota.results.solver.status == "ok" assert self.Rota.results.solver.termination_condition == "optimal" + def test_constraint_require_remote_site_presence_week3(self): + self.Rota.shifts = [] + self.Rota.add_shifts( + SingleShift( + ("group1", "group2", "group3"), + "night", + 12.5, + days, + balance_offset=20, + workers_required=3, + force_as_block=True, + constraints=[("require_remote_site_presence_week", ("group2", 1))], + ), + ) + self.Rota.build_and_solve(options={"ratio": 0.00}) + self.Rota.export_rota_to_html("remote3") + + group_workers = self.Rota.get_workers_by_remote_group() + for group in group_workers: + shift_patterns = [] + for w in group_workers[group]: + shift_patterns.append(self.Rota.get_worker_shift_list(w)) + + zipped_lists = list(zip(*shift_patterns)) + + if group == "group2": + for day_shifts in zipped_lists: + assert day_shifts.count("night") >= 1 + + assert self.Rota.results.solver.status == "ok" + assert self.Rota.results.solver.termination_condition == "optimal" # class TestNoWorkerRota: # weeks_to_rota = 10 @@ -942,10 +979,10 @@ class TestNightUnavailable: # Add a few workers worker1 = Worker( - Rota, "worker1", "group1", 1, not_available_to_work=((datetime.datetime.strptime("15/03/22", "%d/%m/%y").date(), "****"),) + name="worker1", site="group1", grade=1, not_available_to_work=[{ "date": datetime.datetime.strptime("15/03/22", "%d/%m/%y").date(), "reason" : "****" },] ) worker2 = Worker( - Rota, "worker2", "group1", 1, not_available_to_work=((datetime.datetime.strptime("14/03/22", "%d/%m/%y").date(), "****"),) + 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)) @@ -1007,10 +1044,10 @@ class TestNightUnavailable: def test_assign_split(self): self.Rota.shifts = [] worker3 = Worker( - self.Rota, "worker3", "group1", 1, not_available_to_work=((datetime.datetime.strptime("13/03/22", "%d/%m/%y").date(), "****"),) + name="worker3", site="group1", grade=1, not_available_to_work=({"date":datetime.datetime.strptime("13/03/22", "%d/%m/%y").date()},) ) worker4 = Worker( - self.Rota, "worker4", "group1", 1, not_available_to_work=((datetime.datetime.strptime("12/03/22", "%d/%m/%y").date(), "****"),) + 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)) @@ -1033,10 +1070,10 @@ class TestNightUnavailable: # def test_assign_split_night_constraint(self): # self.Rota.shifts = [] # worker3 = Worker( -# self.Rota, "worker3", "group1", 1, not_available_to_work=((datetime.datetime.strptime("13/03/22", "%d/%m/%y").date(), "****"),) +# 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, "worker4", "group1", 1, not_available_to_work=((datetime.datetime.strptime("12/03/22", "%d/%m/%y").date(), "****"),) +# 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)) # @@ -1052,4 +1089,4 @@ class TestNightUnavailable: if __name__ == "__main__": t = TestLimitConstraints() - t.test_constraint_require_remote_site_presence() + t.test_constraint_require_remote_site_presence_week() diff --git a/test/test_shifts.py b/test/test_shifts.py index ccd8a14..1159e9c 100644 --- a/test/test_shifts.py +++ b/test/test_shifts.py @@ -18,8 +18,8 @@ def generate_basic_rota(weeks_to_rota=10): # Add a few workers Rota.add_workers( [ - Worker(Rota, "worker1", "group1", 1), - Worker(Rota, "worker2", "group1", 1), + Worker(name="worker1", site="group1", grade=1), + Worker(name="worker2", site="group1", grade=1), ] ) @@ -43,10 +43,7 @@ class TestShifts: Rota.add_worker( Worker( - Rota, - "worker3", - "group1", - 1, + name="worker3", site="group1", grade=1, ), ) diff --git a/test/test_weekend_padding.py b/test/test_weekend_padding.py index b430470..7108236 100644 --- a/test/test_weekend_padding.py +++ b/test/test_weekend_padding.py @@ -23,10 +23,10 @@ class TestDemoRota: self.Rota.constraint_options["balance_weekends"] = True # Add a few workers - worker1 = Worker(self.Rota, "worker1", "group1", 1) - worker2 = Worker(self.Rota, "worker2", "group1", 1) - worker3 = Worker(self.Rota, "worker3", "group1", 1) - worker4 = Worker(self.Rota, "worker4", "group1", 1) + 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) self.Rota.add_workers((worker1, worker2, worker3, worker4)) @@ -86,11 +86,11 @@ class TestDemoRota: self.Rota.constraint_options["balance_weekends"] = True # Add a few workers - worker1 = Worker(self.Rota, "worker1", "group1", 1) - worker2 = Worker(self.Rota, "worker2", "group1", 1) - worker3 = Worker(self.Rota, "worker3", "group1", 1) - worker4 = Worker(self.Rota, "worker4", "group1", 1) - worker5 = Worker(self.Rota, "worker5", "group1", 1) + 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="group1", grade=1) self.Rota.add_workers((worker1, worker2, worker3, worker4, worker5)) @@ -151,8 +151,8 @@ class TestDemoRota: self.Rota.constraint_options["prevent_monday_and_tuesday_after_full_weekends"] = ["group1",] # Add a few workers - worker1 = Worker(self.Rota, "worker1", "group1", 1) - worker2 = Worker(self.Rota, "worker2", "group1", 1) + worker1 = Worker(name="worker1", site="group1", grade=1) + worker2 = Worker(name="worker2", site="group1", grade=1) self.Rota.add_workers((worker1, worker2)) @@ -203,8 +203,8 @@ class TestDemoRota: self.Rota.constraint_options["prevent_monday_and_tuesday_after_full_weekends"] = ["group1",] # Add a few workers - worker1 = Worker(self.Rota, "worker1", "group1", 1) - worker2 = Worker(self.Rota, "worker2", "group1", 1) + worker1 = Worker(name="worker1", site="group1", grade=1) + worker2 = Worker(name="worker2", site="group1", grade=1) self.Rota.add_workers((worker1, worker2)) @@ -251,8 +251,8 @@ class TestDemoRota: self.Rota.constraint_options["prevent_monday_after_full_weekends"] = ["group1",] # Add a few workers - worker1 = Worker(self.Rota, "worker1", "group1", 1) - worker2 = Worker(self.Rota, "worker2", "group1", 1) + worker1 = Worker(name="worker1", site="group1", grade=1) + worker2 = Worker(name="worker2", site="group1", grade=1) self.Rota.add_workers((worker1, worker2)) @@ -310,8 +310,8 @@ class TestDemoRota: self.Rota.constraint_options["prevent_monday_after_full_weekends"] = ["group1",] # Add a few workers - worker1 = Worker(self.Rota, "worker1", "group1", 1) - worker2 = Worker(self.Rota, "worker2", "group1", 1) + worker1 = Worker(name="worker1", site="group1", grade=1) + worker2 = Worker(name="worker2", site="group1", grade=1) self.Rota.add_workers((worker1, worker2)) @@ -360,8 +360,8 @@ class TestDemoRota: self.Rota.constraint_options["prevent_monday_after_full_weekends"] = ["group1",] # Add a few workers - worker1 = Worker(self.Rota, "worker1", "group1", 1) - worker2 = Worker(self.Rota, "worker2", "group1", 1) + worker1 = Worker(name="worker1", site="group1", grade=1) + worker2 = Worker(name="worker2", site="group1", grade=1) self.Rota.add_workers((worker1, worker2)) diff --git a/test/test_worker_requests.py b/test/test_worker_requests.py index 9ab58c4..04ddeca 100644 --- a/test/test_worker_requests.py +++ b/test/test_worker_requests.py @@ -18,8 +18,8 @@ def generate_basic_rota(weeks_to_rota=10): # Add a few workers Rota.add_workers( [ - Worker(Rota, "worker1", "group1", 1), - Worker(Rota, "worker2", "group1", 1), + Worker(name="worker1", site="group1", grade=1), + Worker(name="worker2", site="group1", grade=1), ] ) @@ -43,11 +43,8 @@ class TestWorkerRequests: Rota.add_worker( Worker( - Rota, - "worker3", - "group1", - 1, - pref_not_to_work=(list(date_generator(datetime.date(2022, 3, 7), 5))), + name="worker3", site="group1", grade=1, + pref_not_to_work=[{"date": d} for d in date_generator(datetime.date(2022, 3, 7), 5)], ), ) @@ -86,11 +83,8 @@ class TestWorkerRequests: Rota.add_worker( Worker( - Rota, - "worker3", - "group1", - 1, - pref_not_to_work=(list(date_generator(datetime.date(2022, 3, 7), 55))), + name="worker3", site="group1", grade=1, + pref_not_to_work=[{"date": d} for d in date_generator(datetime.date(2022, 3, 7), 55)], ), ) @@ -127,11 +121,8 @@ class TestWorkerRequests: Rota.add_worker( Worker( - Rota, - "worker3", - "group1", - 1, - pref_not_to_work=(list(date_generator(datetime.date(2022, 3, 7), 70))), + name="worker3", site="group1", grade=1, + pref_not_to_work=[{"date": d} for d in date_generator(datetime.date(2022, 3, 7), 70)], ), ) @@ -170,13 +161,10 @@ class TestWorkerRequests: Rota.add_worker( Worker( - Rota, - "worker3", - "group1", - 1, + name="worker3", site="group1", grade=1, not_available_to_work=( [ - (i, "NOT AROUND") + {"date": i, "reason": "NOT AROUND"} for i in list(date_generator(datetime.date(2022, 3, 7), 23)) ] ), @@ -218,13 +206,10 @@ class TestWorkerRequests: Rota.add_worker( Worker( - Rota, - "worker3", - "group1", - 1, + name="worker3", site="group1", grade=1, not_available_to_work=( [ - (i, "NOT AROUND") + {"date": i, "reason": "NOT AROUND"} for i in list(date_generator(datetime.date(2022, 3, 7), 25)) ] ), @@ -257,17 +242,14 @@ class TestWorkerRequests: Rota.add_worker( Worker( - Rota, - "worker3", - "group1", - 1, + name="worker3", site="group1", grade=1, not_available_to_work=( [ - (i, "NOT AROUND") + {"date":i, "reason":"NOT AROUND"} for i in list(date_generator(datetime.date(2022, 3, 7), 23)) ] ), - pref_not_to_work=[datetime.date(2022, 5, 15)], + pref_not_to_work=[{"date":datetime.date(2022, 5, 15)}], ), ) @@ -309,25 +291,19 @@ class TestWorkerRequests: Rota.add_workers( [Worker( - Rota, - "worker3", - "group1", - 1, + name="worker3", site="group1", grade=1, not_available_to_work=( [ - (i, "NOT AROUND") + {"date": i, "reason": "NOT AROUND"} for i in list(date_generator(datetime.date(2022, 3, 7), 25)) ] ), ), Worker( - Rota, - "worker4", - "group1", - 1, + name="worker4", site="group1", grade=1, not_available_to_work=( [ - (i, "NOT AROUND") + {"date": i, "reason": "NOT AROUND"} for i in list(date_generator(datetime.date(2022, 3, 7), 25)) ] ), @@ -359,25 +335,19 @@ class TestWorkerRequests: Rota.add_workers( [Worker( - Rota, - "worker3", - "group1", - 1, + name="worker3", site="group1", grade=1, work_requests=( [ - (i, "a") + {"date": i, "shift": "a"} for i in list(date_generator(datetime.date(2022, 3, 7), 35)) ] ), ), Worker( - Rota, - "worker4", - "group1", - 1, + name="worker4", site="group1", grade=1, work_requests=( [ - (i, "a") + {"date": i, "shift": "a"} for i in list(date_generator(datetime.date(2022, 3, 7), 35)) ] ), @@ -419,13 +389,10 @@ class TestWorkerRequests: Rota.add_worker( Worker( - Rota, - "worker3", - "group1", - 1, + name="worker3", site="group1", grade=1, work_requests=( [ - (i, "b") + {"date": i, "shift": "b"} for i in list(date_generator(datetime.date(2022, 3, 7), 35)) ] ), @@ -482,13 +449,10 @@ class TestWorkerRequests: Rota.add_worker( Worker( - Rota, - "worker4", - "group1", - 1, + name="worker4", site="group1", grade=1, work_requests=( [ - (i, "d") + {"date": i, "shift": "d"} for i in list(date_generator(datetime.date(2022, 3, 7), 35)) ] ), diff --git a/test/test_workers.py b/test/test_workers.py index 0489f4a..72e9c54 100644 --- a/test/test_workers.py +++ b/test/test_workers.py @@ -2,7 +2,6 @@ import pytest from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days import datetime - from rota.workers import Worker @@ -41,10 +40,10 @@ class TestWorkers: Rota = setup_basic_rota() workers = [] - workers.append(Worker(Rota, "worker1", "group1", 1, fte=100)) - workers.append(Worker(Rota, "worker1", "group1", 1, fte=100)) - workers.append(Worker(Rota, "worker3", "group2", 1, fte=50)) - workers.append(Worker(Rota, "worker4", "group2", 1, fte=50)) + workers.append(Worker(name="worker1", site="group1", grade=1, fte=100)) + workers.append(Worker(name="worker1", site="group1", grade=1, fte=100)) + workers.append(Worker(name="worker3", site="group2", grade=1, fte=50)) + workers.append(Worker(name="worker4", site="group2", grade=1, fte=50)) Rota.add_workers(workers) @@ -81,36 +80,48 @@ class TestWorkers: workers = [] workers.append( - Worker(Rota, "worker1", "group1", 1, fte=100, end_date=worker1_end_date) - ) - workers.append( - Worker(Rota, "worker2", "group1", 1, fte=100, start_date=worker2_start_date) + Worker( + name="worker1", + site="group1", + grade=1, + fte=100, + end_date=worker1_end_date, + ) ) workers.append( Worker( - Rota, - "worker3", - "group2", - 1, + name="worker2", + site="group1", + grade=1, + fte=100, + start_date=worker2_start_date, + ) + ) + workers.append( + Worker( + name="worker3", + site="group2", + grade=1, fte=100, start_date=worker3_start_date, end_date=worker3_end_date, ) ) - workers.append(Worker(Rota, "worker4", "group2", 1, fte=50)) - workers.append(Worker(Rota, "worker5", "group2", 1, fte=50, nwd=["Mon", "Tue"])) + workers.append(Worker(name="worker4", site="group2", grade=1, fte=50)) + workers.append( + Worker(name="worker5", site="group2", grade=1, fte=50, nwd=["Mon", "Tue"]) + ) workers.append( Worker( - Rota, - "worker6", - "group3", - 1, + name="worker6", + site="group3", + grade=1, fte=100, start_date=start_date + datetime.timedelta(weeks=weeks_to_rota), ) ) workers.append( - Worker(Rota, "worker7", "group3", 1, fte=50, end_date=start_date) + Worker(name="worker7", site="group3", grade=1, fte=50, end_date=start_date) ) Rota.add_workers(workers) @@ -131,6 +142,7 @@ class TestWorkers: # All workers should have an adjusted fte of 50 % for worker in Rota.get_workers(): + # Except those in group3 if worker.site == "group3": assert worker.fte_adj == 0 assert Rota.get_worker_shift_list(worker).count("d") == 0 @@ -145,13 +157,20 @@ class TestWorkers: assert Rota.get_worker_shift_list(worker).count("d") == 10 if worker.name == "worker1": - assert Rota.get_worker_shift_list_string(worker).endswith("-"*(Rota.rota_days_length//2)) + assert Rota.get_worker_shift_list_string(worker).endswith( + "-" * (Rota.rota_days_length // 2) + ) if worker.name == "worker2": - assert Rota.get_worker_shift_list_string(worker).startswith("-"*(Rota.rota_days_length//2)) + assert Rota.get_worker_shift_list_string(worker).startswith( + "-" * (Rota.rota_days_length // 2) + ) if worker.name == "worker3": - assert Rota.get_worker_shift_list_string(worker).startswith("-"*(Rota.rota_days_length//4)) - assert Rota.get_worker_shift_list_string(worker).endswith("-"*(Rota.rota_days_length//4)) - + assert Rota.get_worker_shift_list_string(worker).startswith( + "-" * (Rota.rota_days_length // 4) + ) + assert Rota.get_worker_shift_list_string(worker).endswith( + "-" * (Rota.rota_days_length // 4) + ) assert Rota.results.solver.status == "ok" assert Rota.results.solver.termination_condition == "optimal" @@ -168,87 +187,96 @@ class TestWorkers: ) worker1_oop = [ - ( - start_date, - start_date + datetime.timedelta(weeks=weeks_to_rota / 2), - "First half OOP", - ) + { + "start_date": start_date, + "end_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2), + "reason": "First half OOP", + } ] worker2_oop = [ - ( - start_date + datetime.timedelta(weeks=weeks_to_rota / 2), - Rota.rota_end_date, - "First half OOP", - ) + { + "start_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2), + "end_date": Rota.rota_end_date, + "reason": "First half OOP", + } ] worker3_start_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 4) worker3_oop = [ - ( - start_date + datetime.timedelta(weeks=weeks_to_rota / 2), - start_date + datetime.timedelta(weeks=3 * weeks_to_rota / 4), - "Quarter oop", - ) + { + "start_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2), + "end_date": start_date + datetime.timedelta(weeks=3 * weeks_to_rota / 4), + "reason": "Quarter oop", + } ] worker4_oop = [ - ( - start_date, - start_date + datetime.timedelta(weeks=weeks_to_rota / 4), - "Quarter oop", - ), - ( - start_date + datetime.timedelta(weeks=weeks_to_rota / 2), - start_date + { + "start_date": start_date, + "end_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 4), + "reason": "Quarter oop", + }, + { + "start_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2), + "end_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2) + datetime.timedelta(weeks=weeks_to_rota / 4), - "Quarter oop 2", - ), + "reason": "Quarter oop 2", + }, ] workers = [] - workers.append(Worker(Rota, "worker1", "group1", 1, fte=100, oop=worker1_oop)) - workers.append(Worker(Rota, "worker2", "group1", 1, fte=100, oop=worker2_oop)) + workers.append( + Worker(name="worker1", site="group1", grade=1, fte=100, oop=worker1_oop) + ) + workers.append( + Worker(name="worker2", site="group1", grade=1, fte=100, oop=worker2_oop) + ) workers.append( Worker( - Rota, - "worker3", - "group2", - 1, + name="worker3", + site="group2", + grade=1, fte=100, start_date=worker3_start_date, oop=worker3_oop, ) ) - workers.append(Worker(Rota, "worker4", "group2", 1, oop=worker4_oop)) - workers.append(Worker(Rota, "worker5", "group2", 1, fte=50, nwd=["Mon", "Tue"])) + workers.append(Worker(name="worker4", site="group2", grade=1, oop=worker4_oop)) + workers.append( + Worker(name="worker5", site="group2", grade=1, fte=50, nwd=["Mon", "Tue"]) + ) worker6_oop = [ - ( - start_date, - start_date + datetime.timedelta(weeks=weeks_to_rota / 6), - "oop", - ), - ( - start_date + datetime.timedelta(weeks=weeks_to_rota / 2), - start_date + datetime.timedelta(weeks=weeks_to_rota / 2) + { + "start_date": start_date, + "end_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 6), + "reason": "oop", + }, + { + "start_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2), + "end_date": start_date + + datetime.timedelta(weeks=weeks_to_rota / 2) + datetime.timedelta(weeks=weeks_to_rota / 3), - " oop 2", - ), + "reason": " oop 2", + }, ] worker7_oop = [ - ( - start_date-datetime.timedelta(weeks=1), - Rota.rota_end_date, - "oop", - ), + { + "start_date": start_date - datetime.timedelta(weeks=1), + "end_date": Rota.rota_end_date, + "reason": "oop", + }, ] - - workers.append(Worker(Rota, "worker6", "group2", 1, fte=100, oop=worker6_oop)) - workers.append(Worker(Rota, "worker7", "group3", 1, fte=100, oop=worker7_oop)) + workers.append( + Worker(name="worker6", site="group2", grade=1, fte=100, oop=worker6_oop) + ) + workers.append( + Worker(name="worker7", site="group3", grade=1, fte=100, oop=worker7_oop) + ) Rota.add_workers(workers) @@ -274,8 +302,8 @@ class TestWorkers: assert worker.fte_adj == pytest.approx(50, 2) if worker.fte == 100: - assert worker.proportion_rota_to_work == pytest.approx(0.5, 0.2) - assert worker.days_to_work == pytest.approx(35, 1) + assert worker.proportion_rota_to_work == pytest.approx(0.5, 0.2) + assert worker.days_to_work == pytest.approx(35, 1) assert Rota.get_worker_shift_list(worker).count("d") in (8, 9)