work towards paring of shifts

This commit is contained in:
Ross
2024-12-24 10:41:46 +00:00
parent 70dc279a02
commit 1ed5f4fbcd
4 changed files with 238 additions and 37 deletions
+2 -1
View File
@@ -13,4 +13,5 @@ python-dotenv
requests requests
loguru loguru
numpy numpy
highspy highspy
typer
+99 -27
View File
@@ -4,7 +4,7 @@ import itertools
from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type, Literal from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type, Literal
import time import time
from pydantic import BaseModel, Extra, constr from pydantic import BaseModel, ConfigDict, Extra, constr
from pathlib import Path from pathlib import Path
@@ -81,6 +81,10 @@ class ShiftConstraint(BaseModel):
class SingleShift(BaseModel): class SingleShift(BaseModel):
"""Class to hold all details for a shift """Class to hold all details for a shift
start_date: The day that the shift starts on
end_date: The day that the shift ends on (the last day it should be rota'd for)
Due to week block assigments these (probably) need to be week ba
Valid constraints Valid constraints
@@ -100,6 +104,9 @@ class SingleShift(BaseModel):
options: (grade, min_number) options: (grade, min_number)
""" """
sites: List[str] sites: List[str]
@@ -116,10 +123,13 @@ class SingleShift(BaseModel):
hard_constrain_shift: bool = True hard_constrain_shift: bool = True
bank_holidays_only: bool = False bank_holidays_only: bool = False
constraint: List[ShiftConstraint] = [] constraint: List[ShiftConstraint] = []
start_date: datetime.date | None = None
end_date: datetime.date | None = None
class Config: model_config = ConfigDict(
extra = Extra.allow extra = "allow",
orm_mode = True #orm_mode = True
)
def __init__(self, **data: Any): def __init__(self, **data: Any):
super().__init__(**data) super().__init__(**data)
@@ -251,6 +261,8 @@ class RotaBuilder(object):
"Worker/duplicate id", "Worker/duplicate id",
"Worker/duplicate name", "Worker/duplicate name",
"Worker/no valid shifts", "Worker/no valid shifts",
"Shift/invalid start date",
"Shift/invalid end date"
] ]
self.results = None self.results = None
@@ -266,6 +278,8 @@ class RotaBuilder(object):
self.bank_holidays = bank_holidays self.bank_holidays = bank_holidays
self.paired_shifts = []
def set_rota_dates(self, start_date: datetime.date, weeks_to_rota: int): def set_rota_dates(self, start_date: datetime.date, weeks_to_rota: int):
self.weeks_to_rota = weeks_to_rota self.weeks_to_rota = weeks_to_rota
@@ -1100,6 +1114,16 @@ class RotaBuilder(object):
for week in track(self.weeks, description="Generating week constraints..."): for week in track(self.weeks, description="Generating week constraints..."):
for shift_name in self.shifts_to_assign_or_force_as_blocks(): for shift_name in self.shifts_to_assign_or_force_as_blocks():
shift = self.get_shift_by_name(shift_name) shift = self.get_shift_by_name(shift_name)
print(shift_name)
print(week, self.get_week_start_date(week), shift.start_date, shift.end_date)
if self.get_week_start_date(week) <= shift.start_date:
continue
if self.get_week_start_date(week) > shift.end_date:
continue
for worker in self.get_workers_for_shift(shift): for worker in self.get_workers_for_shift(shift):
self.model.constraints.add( self.model.constraints.add(
8 8
@@ -1215,7 +1239,7 @@ class RotaBuilder(object):
# Occurs if there are no shifts within a defined block # Occurs if there are no shifts within a defined block
# TODO: test if this breaks (and we should check rathar than except) # TODO: test if this breaks (and we should check rathar than except)
self.add_warning( self.add_warning(
"max shifts per month constraint" "max shifts per month constraint",
f"Failed to constrain max_shifts_per_month for worker: {worker.name}" f"Failed to constrain max_shifts_per_month for worker: {worker.name}"
) )
pass pass
@@ -1702,18 +1726,22 @@ class RotaBuilder(object):
for constraint_shift in self.get_shifts_with_constraints( for constraint_shift in self.get_shifts_with_constraints(
"max_shifts_per_week" "max_shifts_per_week"
): ):
self.model.constraints.add( try:
constraint_shift.constraint_options["max_shifts_per_week"] self.model.constraints.add(
>= sum( constraint_shift.constraint_options["max_shifts_per_week"]
self.model.works[worker.id, w, day, constraint_shift.name] >= sum(
# for shiftname in self.get_shift_names_by_week_day(week, day) self.model.works[worker.id, w, day, constraint_shift.name]
# for day in self.days # for shiftname in self.get_shift_names_by_week_day(week, day)
for w, day in self.get_week_day_combinations_for_shift( # for day in self.days
constraint_shift for w, day in self.get_week_day_combinations_for_shift(
constraint_shift
)
if w == week
) )
if w == week
) )
) except ValueError:
# This happens if a shift if not assigned on the week (should we test for this instead?)
pass
try: try:
self.model.constraints.add( self.model.constraints.add(
@@ -1902,7 +1930,7 @@ class RotaBuilder(object):
# raise ValueError(f"Requires {shift.name} to have force_as_block") # raise ValueError(f"Requires {shift.name} to have force_as_block")
# for shift in self.get_shifts_with_constraint("night"): # for shift in self.get_shifts_with_constraint("night"):
for shift in self.get_shifts(): for shift in self.get_shifts(week=week):
if shift.force_as_block: if shift.force_as_block:
# Force nights to be assigned in blocks # Force nights to be assigned in blocks
# self.model.constraints.add(8* self.model.shift_week_worker_assigned[shift.name, week, worker.id] >= sum(self.model.works[worker.id, week, day, shift.name] for day in self.days) # self.model.constraints.add(8* self.model.shift_week_worker_assigned[shift.name, week, worker.id] >= sum(self.model.works[worker.id, week, day, shift.name] for day in self.days)
@@ -2133,7 +2161,7 @@ class RotaBuilder(object):
# shift spans 7 days you may get >7 allocations in a row # shift spans 7 days you may get >7 allocations in a row
# as it only checks for a different shift allocation # as it only checks for a different shift allocation
for constraint_shift in self.get_shifts_with_constraints( for constraint_shift in self.get_shifts_with_constraints(
"pre", "pre", week=week
): ):
for n in range(0, constraint_shift.constraint_options["pre"]): for n in range(0, constraint_shift.constraint_options["pre"]):
if day in constraint_shift.days: if day in constraint_shift.days:
@@ -2181,7 +2209,7 @@ class RotaBuilder(object):
# ) # )
for constraint_shift in self.get_shifts_with_constraints( for constraint_shift in self.get_shifts_with_constraints(
"post", "post", week=week
): ):
for n in range(0, constraint_shift.constraint_options["post"]): for n in range(0, constraint_shift.constraint_options["post"]):
if day in constraint_shift.days: if day in constraint_shift.days:
@@ -2633,6 +2661,11 @@ class RotaBuilder(object):
""" """
return self.shifts_by_name[name].length return self.shifts_by_name[name].length
def pair_shifts(self, shift1: SingleShift, shift2: SingleShift):
# NOTE: currently designed to pair two shifts
# this could be extended...
self.pair_shifts.append(set(shift1, shift2))
def build_shifts(self): def build_shifts(self):
""" """
Process the added shifts Process the added shifts
@@ -2655,6 +2688,24 @@ class RotaBuilder(object):
if s.name in self.shift_names: if s.name in self.shift_names:
raise InvalidShift(f"Duplicate shift: {s.name}") raise InvalidShift(f"Duplicate shift: {s.name}")
if s.start_date is not None:
if s.start_date > self.rota_end_date or s.start_date < self.start_date:
self.add_warning(
"Shift/invalid start date",
f"Shift '{s.name}' has a start date outside of the rota limits ({s.start_date} vs [{self.start_date}---{self.rota_end_date}])",
)
else:
s.start_date = self.start_date
if s.end_date is not None:
if s.end_date > self.rota_end_date or s.end_date < self.start_date:
self.add_warning(
"Shift/invalid end date",
f"Shift '{s.name}' has an end date outside of the rota limits ({s.end_date} vs [{self.start_date}---{self.rota_end_date}])",
)
else:
s.end_date = self.rota_end_date
self.shifts_by_name[s.name] = s self.shifts_by_name[s.name] = s
self.shift_names.append(s.name) self.shift_names.append(s.name)
@@ -2667,10 +2718,15 @@ class RotaBuilder(object):
self.shift_counts = defaultdict(int) self.shift_counts = defaultdict(int)
for week, day in self.weeks_days_product: for week, day in self.weeks_days_product:
self.week_day_shifts_dict[(week, day)] = set() self.week_day_shifts_dict[(week, day)] = set()
current_date = self.week_day_date_map[(week, day)]
for s in self.shifts: for s in self.shifts:
# Check if the shift has started or ended
if current_date < s.start_date or current_date > s.end_date:
continue
if s.bank_holidays_only: if s.bank_holidays_only:
if self.week_day_date_map[(week, day)] in self.bank_holidays: if current_date in self.bank_holidays:
print(s.name, self.week_day_date_map[(week, day)])
self.week_day_shift_product.append((week, day, s.name)) self.week_day_shift_product.append((week, day, s.name))
self.week_day_shiftclass_product.append((week, day, s)) self.week_day_shiftclass_product.append((week, day, s))
self.week_day_shifts_dict[(week, day)].add(s.name) self.week_day_shifts_dict[(week, day)].add(s.name)
@@ -2681,7 +2737,6 @@ class RotaBuilder(object):
self.week_day_shiftclass_product.append((week, day, s)) self.week_day_shiftclass_product.append((week, day, s))
self.week_day_shifts_dict[(week, day)].add(s.name) self.week_day_shifts_dict[(week, day)].add(s.name)
self.shift_counts[s.name] = self.shift_counts[s.name] + 1 self.shift_counts[s.name] = self.shift_counts[s.name] + 1
# print(self.week_day_shift_product)
self.max_pre = 1 self.max_pre = 1
self.max_post = 1 self.max_post = 1
@@ -2761,7 +2816,7 @@ class RotaBuilder(object):
""" """
return self.weeks_days_product return self.weeks_days_product
def get_week_day_combinations_for_shift(self, shift) -> list: def get_week_day_combinations_for_shift(self, shift: SingleShift) -> list:
return [ return [
(week, day) (week, day)
for week, day in self.get_week_day_combinations() for week, day in self.get_week_day_combinations()
@@ -2779,13 +2834,25 @@ class RotaBuilder(object):
return week_day_shifts return week_day_shifts
def get_shifts(self) -> List[SingleShift]: def get_shifts(self, week: int | None=None) -> List[SingleShift]:
"""Returns a list of all the registered shifts """Returns a list of all the registered shifts
Returns: Returns:
List[SingleShift]: list of registered shifts (as SingleShift) List[SingleShift]: list of registered shifts (as SingleShift)
""" """
return self.shifts if week is not None:
date = self.get_week_start_date(week)
shifts = []
for shift in self.shifts:
if shift.start_date < date <= shift.end_date:
shifts.append(shift)
return shifts
else:
return self.shifts
def get_shifts_for_worker_site(self, worker_site): def get_shifts_for_worker_site(self, worker_site):
shifts = [shift for shift in self.shifts if worker_site in shift.sites] shifts = [shift for shift in self.shifts if worker_site in shift.sites]
@@ -2799,14 +2866,19 @@ class RotaBuilder(object):
def get_shifts_with_constraint(self, constraint) -> List[SingleShift]: def get_shifts_with_constraint(self, constraint) -> List[SingleShift]:
return [shift for shift in self.shifts if constraint in shift.constraints] return [shift for shift in self.shifts if constraint in shift.constraints]
def get_shifts_with_constraints(self, *constraints) -> List[SingleShift]: def get_shifts_with_constraints(self, *constraints, week : int | None =None) -> List[SingleShift]:
shift_names = set() shift_names = set()
for constraint in constraints: for constraint in constraints:
shift_names.update( shift_names.update(
[shift.name for shift in self.shifts if constraint in shift.constraints] [
shift.name
for shift in self.get_shifts(week)
if constraint in shift.constraints
]
) )
return [self.get_shift_by_name(s) for s in shift_names] return [self.get_shift_by_name(s) for s in shift_names]
def get_shift_names(self) -> List[ShiftName]: def get_shift_names(self) -> List[ShiftName]:
@@ -2879,6 +2951,7 @@ class RotaBuilder(object):
if (shift.force_as_block or shift.force_as_block_unless_nwd) if (shift.force_as_block or shift.force_as_block_unless_nwd)
] ]
#def shifts_to_assign_or_force_as_blocks(self, week: int | None = None) -> List[str]:
def shifts_to_assign_or_force_as_blocks(self) -> List[str]: def shifts_to_assign_or_force_as_blocks(self) -> List[str]:
s = self.shifts_to_assign_as_blocks() s = self.shifts_to_assign_as_blocks()
s.extend(self.shifts_to_force_as_blocks()) s.extend(self.shifts_to_force_as_blocks())
@@ -2923,7 +2996,6 @@ class RotaBuilder(object):
def get_worker_details(self): def get_worker_details(self):
w = defaultdict(list) w = defaultdict(list)
for worker in self.workers: for worker in self.workers:
# print(worker)
w[worker.site].append(worker) w[worker.site].append(worker)
l = [] l = []
+5 -4
View File
@@ -1,7 +1,7 @@
import datetime import datetime
from collections import defaultdict from collections import defaultdict
from typing import Iterable, List, Literal, Optional from typing import Iterable, List, Literal, Optional
from pydantic import BaseModel, Extra, validator from pydantic import BaseModel, ConfigDict, field_validator
from rich.pretty import pprint from rich.pretty import pprint
from rota.console import console from rota.console import console
@@ -37,7 +37,7 @@ class NonWorkingDays(BaseModel):
start_date: datetime.date | None = None start_date: datetime.date | None = None
end_date: datetime.date | None = None end_date: datetime.date | None = None
@validator("day") @field_validator("day")
def day_in(cls, day): def day_in(cls, day):
for whole_day in whole_days: for whole_day in whole_days:
if day.lower() in whole_day: if day.lower() in whole_day:
@@ -83,8 +83,9 @@ class Worker(BaseModel):
bank_holiday_extra: int = 0 bank_holiday_extra: int = 0
pair: int | str | None = None pair: int | str | None = None
class Config: model_config = ConfigDict(
extra = Extra.allow extra = "allow",
)
# def __init__( # def __init__(
# self, # self,
+132 -5
View File
@@ -1,13 +1,13 @@
import datetime import datetime
import pytest import pytest
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, WarningTermination, days
from rota.workers import Worker from rota.workers import Worker
import itertools import itertools
def generate_basic_rota(weeks_to_rota=10): def generate_basic_rota(weeks_to_rota=10, workers=2):
start_date = datetime.date(2022, 3, 7) start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder( Rota = RotaBuilder(
@@ -18,8 +18,8 @@ def generate_basic_rota(weeks_to_rota=10):
# Add a few workers # Add a few workers
Rota.add_workers( Rota.add_workers(
[ [
Worker(name="worker1", site="group1", grade=1), Worker(name=f"worker{i}", site="group1", grade=1) for i in range(1, workers+1)
Worker(name="worker2", site="group1", grade=1), #Worker(name="worker2", site="group1", grade=1),
] ]
) )
@@ -311,4 +311,131 @@ class TestShiftConstraints:
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("post") Rota.export_rota_to_html("post")
assert Rota.results.solver.status in ("warning", "error") assert Rota.results.solver.status in ("warning", "error")
class TestShiftDates:
def test_shift_start_date(self):
Rota = generate_basic_rota(workers=2)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week", "options": 4}],
start_date=(Rota.start_date + datetime.timedelta(days=7)),
),
)
Rota.build_and_solve(options={"ratio": 0.000})
#Rota.export_rota_to_html("test_shift_start_date")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
assert Rota.get_workers_total_shifts()["worker1"] in (31, 32)
assert Rota.get_workers_total_shifts()["worker2"] in (31, 32)
def test_shift_start_date_end_date(self):
Rota = generate_basic_rota(workers=2)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week", "options": 4}],
start_date=(Rota.start_date + datetime.timedelta(days=7)),
end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
),
)
Rota.build_and_solve(options={"ratio": 0.000})
#Rota.export_rota_to_html("test_shift_start_date")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
assert Rota.get_workers_total_shifts()["worker1"] in (17, 18)
assert Rota.get_workers_total_shifts()["worker2"] in (17, 18)
for worker in Rota.get_workers():
assert Rota.get_worker_shift_list_string(worker).startswith("-"*7)
assert Rota.get_worker_shift_list_string(worker).endswith("-"*28)
def test_shift_invalid_start_date(self):
Rota = generate_basic_rota(workers=2)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week", "options": 4}],
start_date=(Rota.start_date - datetime.timedelta(days=6)),
),
)
with pytest.raises(WarningTermination):
Rota.build_and_solve(options={"ratio": 0.000})
def test_shift_invalid_start_date2(self):
Rota = generate_basic_rota(workers=2)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week", "options": 4}],
start_date=(Rota.rota_end_date + datetime.timedelta(days=1)),
),
)
with pytest.raises(WarningTermination):
Rota.build_and_solve(options={"ratio": 0.000})
def test_shift_invalid_end_date(self):
Rota = generate_basic_rota(workers=2)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week", "options": 4}],
end_date=(Rota.rota_end_date + datetime.timedelta(days=1)),
),
)
with pytest.raises(WarningTermination):
Rota.build_and_solve(options={"ratio": 0.000})
def test_shift_invalid_end_date2(self):
Rota = generate_basic_rota(workers=2)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week", "options": 4}],
end_date=(Rota.start_date - datetime.timedelta(days=1)),
),
)
with pytest.raises(WarningTermination):
Rota.build_and_solve(options={"ratio": 0.000})
def test_shift_start_date_end_date_block(self):
Rota = generate_basic_rota(workers=2)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
assign_as_block=True,
constraint=[{"name": "max_shifts_per_week", "options": 4}],
start_date=(Rota.start_date + datetime.timedelta(days=7)),
end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
),
)
Rota.build_and_solve(options={"ratio": 0.000})
#Rota.export_rota_to_html("test_shift_start_date")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
assert Rota.get_workers_total_shifts()["worker1"] in (17, 18)
assert Rota.get_workers_total_shifts()["worker2"] in (17, 18)
for worker in Rota.get_workers():
assert Rota.get_worker_shift_list_string(worker).startswith("-"*7)
assert Rota.get_worker_shift_list_string(worker).endswith("-"*28)