add worker requirements

This commit is contained in:
Ross
2025-01-01 21:58:48 +00:00
parent 9af01a3415
commit 7ce4677921
5 changed files with 535 additions and 218 deletions
+171 -64
View File
@@ -4,7 +4,7 @@ import itertools
from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type, Literal
import time
from pydantic import BaseModel, ConfigDict, Extra, constr
from pydantic import BaseModel, ConfigDict, Extra, constr, model_validator, StrictInt
from pathlib import Path
@@ -78,6 +78,23 @@ class ShiftConstraint(BaseModel):
options: bool | int | Dict | tuple = False
class WorkerRequirement(BaseModel):
"""
start date is inclusive, end date is not
"""
# NOTE: if number is changed mid week block allocations may not work?
number: int = 1
start_date: datetime.date | None = None
end_date: datetime.date | None = None
@model_validator(mode="after")
def check_dates(self):
if self.start_date is not None and self.end_date is not None:
if self.start_date > self.end_date:
raise ValueError("Start date must be before end date")
return self
class SingleShift(BaseModel):
"""Class to hold all details for a shift
@@ -112,24 +129,24 @@ class SingleShift(BaseModel):
sites: List[str]
name: str
length: float
days: str | List[str]
days: str | list[str]
balance_offset: float | None = None
balance_weighting: float = 1
workers_required: float = 1
workers_required: StrictInt | list[WorkerRequirement] = 1
rota_on_nwds: bool = False
assign_as_block: bool = False
force_as_block: bool = False
force_as_block_unless_nwd: bool = False
hard_constrain_shift: bool = True
bank_holidays_only: bool = False
constraint: List[ShiftConstraint] = []
constraint: list[ShiftConstraint] = []
start_date: datetime.date | None = None
end_date: datetime.date | None = None
pair_proxy: bool = False
model_config = ConfigDict(
extra = "allow",
#orm_mode = True
extra="allow",
# orm_mode = True
)
def __init__(self, **data: Any):
@@ -182,6 +199,21 @@ class SingleShift(BaseModel):
def get_shift_number(self):
return len(self.days)
def get_worker_requirement_by_date(self, date: datetime.date):
"""Must be called after build_shifts
Returns the number of workers required for a given date
If none are required 0 is returned"""
if isinstance(self.workers_required, int):
return self.workers_required
for wr in self.workers_required:
if wr.start_date <= date < wr.end_date:
return wr.number
return 0
class RotaBuilder(object):
"""Class to hold and manipulate shifts"""
@@ -263,7 +295,7 @@ class RotaBuilder(object):
"Worker/duplicate name",
"Worker/no valid shifts",
"Shift/invalid start date",
"Shift/invalid end date"
"Shift/invalid end date",
]
self.results = None
@@ -370,8 +402,7 @@ class RotaBuilder(object):
if (
not results.solver.termination_condition == TerminationCondition.infeasible
#and not results.solver.status == SolverStatus.aborted
# and not results.solver.status == SolverStatus.aborted
):
self.model.solutions.load_from(results)
@@ -1119,12 +1150,17 @@ class RotaBuilder(object):
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)
print(
week,
self.get_week_start_date(week),
shift.start_date,
shift.end_date,
)
#if self.get_week_start_date(week) <= shift.start_date:
# if self.get_week_start_date(week) <= shift.start_date:
# continue
#if self.get_week_start_date(week) > shift.end_date:
# if self.get_week_start_date(week) > shift.end_date:
# continue
for worker in self.get_workers_for_shift(shift):
@@ -1151,6 +1187,10 @@ class RotaBuilder(object):
)
)
workers_required = shift.get_worker_requirement_by_date(
self.get_week_start_date(week)
)
if shift.force_as_block_unless_nwd:
workers = self.get_workers_for_shift(shift)
# Get workers who have a nwd on the shift
@@ -1188,7 +1228,7 @@ class RotaBuilder(object):
]
for worker in self.workers
)
<= shift.workers_required
<= workers_required
)
else:
@@ -1199,19 +1239,20 @@ class RotaBuilder(object):
]
for worker in full_workers
)
<= shift.workers_required + 1
<= workers_required + 1
)
elif shift.force_as_block:
self.model.constraints.add(
sum(
self.model.blocks_worker_shift_assigned[
worker.id, week, shift.name
]
for worker in self.workers
)
<= shift.workers_required
)
if workers_required:
self.model.constraints.add(
sum(
self.model.blocks_worker_shift_assigned[
worker.id, week, shift.name
]
for worker in self.workers
)
<= workers_required
)
# Most of our constraints apply per worker
worker: Worker
@@ -1246,7 +1287,7 @@ class RotaBuilder(object):
# TODO: test if this breaks (and we should check rathar than except)
self.add_warning(
"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
@@ -1326,8 +1367,12 @@ class RotaBuilder(object):
): # Each site specfies which sites self.workers can fullfill it
# calaculate the total number of shifts that need assigning over the rota
# total_shifts = (len(self.weeks) * len(shift.days) *
# TODO: check if shift_counts is still required?
#total_shifts = (
# self.shift_counts[shift.name] * shift.workers_required
#)
total_shifts = (
self.shift_counts[shift.name] * shift.workers_required
self.shift_worker_counts[shift.name]
)
full_time_equivalent_joined = sum(
@@ -1736,7 +1781,9 @@ class RotaBuilder(object):
self.model.constraints.add(
constraint_shift.constraint_options["max_shifts_per_week"]
>= sum(
self.model.works[worker.id, w, day, constraint_shift.name]
self.model.works[
worker.id, w, day, constraint_shift.name
]
# for shiftname in self.get_shift_names_by_week_day(week, day)
# for day in self.days
for w, day in self.get_week_day_combinations_for_shift(
@@ -1938,21 +1985,24 @@ class RotaBuilder(object):
# for shift in self.get_shifts_with_constraint("night"):
for shift in self.get_shifts(week=week):
if shift.force_as_block:
# 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)
# )
# Force night shifts to be assigned in blocks
self.model.constraints.add(
self.model.shift_week_worker_assigned[
shift.name, week, worker.id
]
* len(shift.days)
== sum(
self.model.works[worker.id, week, day, shift.name]
for day in shift.days
)
)
if shift.get_worker_requirement_by_date(
self.get_week_start_date(week)
):
# Force shifts to be assigned in blocks
try:
self.model.constraints.add(
self.model.shift_week_worker_assigned[
shift.name, week, worker.id
]
* len(shift.days)
== sum(
self.model.works[worker.id, week, day, shift.name]
for day in shift.days
)
)
except KeyError:
pass # blocks with workerd requirements
#
@@ -2167,13 +2217,13 @@ class RotaBuilder(object):
# shift spans 7 days you may get >7 allocations in a row
# as it only checks for a different shift allocation
for constraint_shift in self.get_shifts_with_constraints(
"pre"#, week=week
"pre" # , week=week
):
for n in range(0, constraint_shift.constraint_options["pre"]):
if day in constraint_shift.days:
#week_start_date = self.get_week_start_date(week)
#try:
#if constraint_shift.start_date <= week_start_date < constraint_shift.end_date:
# week_start_date = self.get_week_start_date(week)
# try:
# if constraint_shift.start_date <= week_start_date < constraint_shift.end_date:
# works = 1
try:
works = self.model.works[
@@ -2223,14 +2273,14 @@ class RotaBuilder(object):
# )
for constraint_shift in self.get_shifts_with_constraints(
"post"#, week=week
"post" # , week=week
):
for n in range(0, constraint_shift.constraint_options["post"]):
if day in constraint_shift.days:
#week_start_date = self.get_week_start_date(week)
#if constraint_shift.start_date <= week_start_date < constraint_shift.end_date:
#works = 1
#else:
# week_start_date = self.get_week_start_date(week)
# if constraint_shift.start_date <= week_start_date < constraint_shift.end_date:
# works = 1
# else:
try:
works = self.model.works[
worker.id, week, day, constraint_shift.name
@@ -2254,7 +2304,7 @@ class RotaBuilder(object):
for w in workers
)
)
#except KeyError:
# except KeyError:
# pass
# for constraint_shift in self.get_shifts_with_constraints(
@@ -2688,10 +2738,15 @@ class RotaBuilder(object):
"""
return self.shifts_by_name[name].length
def pair_shifts(self, shift1: SingleShift, shift2: SingleShift):
def get_date_by_week_day(self, week: WeekInt, day: DayStr) -> datetime.date:
return self.week_day_date_map[(week, day)]
def pair_shifts(self, *shifts: SingleShift):
# NOTE: currently designed to pair two shifts
# this could be extended...
self.paired_shifts.append([shift1, shift2])
if len(shifts) < 2:
raise ValueError(f"Must pair at least two shifts: {shifts}")
self.paired_shifts.append([*shifts])
def build_shifts(self):
"""
@@ -2711,11 +2766,6 @@ class RotaBuilder(object):
for s in self.shifts:
pass
## For paired shifts we create proxy shifts
#self.paired_shift_map = {}
#for shifts in self.paired_shifts:
for s in self.shifts:
if s.name in self.shift_names:
raise InvalidShift(f"Duplicate shift: {s.name}")
@@ -2738,6 +2788,33 @@ class RotaBuilder(object):
else:
s.end_date = self.rota_end_date
# Check worker requirements
if not isinstance(s.workers_required, int):
# Validate the worker requirements
print(s.workers_required)
for worker_requirement in s.workers_required:
if not isinstance(worker_requirement, WorkerRequirement):
raise InvalidShift(
f"Invalid worker requirement: {worker_requirement}"
)
if worker_requirement.start_date is None:
worker_requirement.start_date = s.start_date
elif worker_requirement.start_date > s.end_date:
self.add_warning(
"Shift/worker requirement start date",
f"Worker requirement start date is after shift end date: {worker_requirement.start_date} > {s.end_date}"
)
if worker_requirement.end_date is None:
worker_requirement.end_date = s.end_date
elif worker_requirement.end_date < s.start_date:
self.add_warning(
"Shift/worker requirement end date",
f"Worker requirement end date is before shift start date: {worker_requirement.end_date} < {s.start_date}"
)
self.shifts_by_name[s.name] = s
self.shift_names.append(s.name)
@@ -2748,27 +2825,39 @@ class RotaBuilder(object):
self.sites.add(site)
self.shift_counts = defaultdict(int)
self.shift_worker_counts = defaultdict(int)
for week, day in self.weeks_days_product:
self.week_day_shifts_dict[(week, day)] = set()
current_date = self.week_day_date_map[(week, day)]
current_date = self.get_date_by_week_day(week, day)
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
# Shift not required if there are no worker requirements
workers_required = s.get_worker_requirement_by_date(current_date)
if not workers_required:
self.add_warning(
"Shift/worker requirement is zero",
f"Shift '{s.name}' has no worker requirements on {current_date}",
)
continue
if s.bank_holidays_only:
if current_date in self.bank_holidays:
self.week_day_shift_product.append((week, day, s.name))
self.week_day_shiftclass_product.append((week, day, s))
self.week_day_shifts_dict[(week, day)].add(s.name)
self.shift_counts[s.name] = self.shift_counts[s.name] + 1
self.shift_worker_counts[s.name] = self.shift_worker_counts[s.name] + workers_required
else:
if day in s.days:
self.week_day_shift_product.append((week, day, s.name))
self.week_day_shiftclass_product.append((week, day, s))
self.week_day_shifts_dict[(week, day)].add(s.name)
self.shift_counts[s.name] = self.shift_counts[s.name] + 1
self.shift_worker_counts[s.name] = self.shift_worker_counts[s.name] + workers_required
self.max_pre = 1
self.max_post = 1
@@ -2778,6 +2867,19 @@ class RotaBuilder(object):
for shift in self.get_shifts_with_constraint("post"):
self.max_post = max(shift.constraint_options["post"], self.max_post)
## For paired shifts we create proxy shifts
# self.paired_shift_map = {}
# for shifts in self.paired_shifts:
# sites = shifts[0].sites
# for shift in shifts:
# self.paired_shift_map[shift] = shifts
#
# if shift.sites != sites:
# # This will lead to odd/broken behaviour
# self.add_warning(
# "Paired shifts/site mismatch",
# f"Shift '{shift.name}' has different sites to paired shifts",
# )
# # Todo replace with week_day.....
# self.day_shift_product = []
@@ -2867,7 +2969,7 @@ class RotaBuilder(object):
return week_day_shifts
def get_shifts(self, week: int | None=None) -> List[SingleShift]:
def get_shifts(self, week: int | None = None) -> List[SingleShift]:
"""Returns a list of all the registered shifts
Returns:
@@ -2880,7 +2982,8 @@ class RotaBuilder(object):
for shift in self.shifts:
if shift.start_date < date <= shift.end_date:
shifts.append(shift)
if shift.get_worker_requirement_by_date(date):
shifts.append(shift)
return shifts
@@ -2899,7 +3002,9 @@ class RotaBuilder(object):
def get_shifts_with_constraint(self, constraint) -> List[SingleShift]:
return [shift for shift in self.shifts if constraint in shift.constraints]
def get_shifts_with_constraints(self, *constraints, week : int | None =None) -> List[SingleShift]:
def get_shifts_with_constraints(
self, *constraints, week: int | None = None
) -> List[SingleShift]:
shift_names = set()
for constraint in constraints:
@@ -2911,7 +3016,6 @@ class RotaBuilder(object):
]
)
return [self.get_shift_by_name(s) for s in shift_names]
def get_shift_names(self) -> List[ShiftName]:
@@ -2952,8 +3056,11 @@ class RotaBuilder(object):
"""
l = []
for week, day, shift in self.week_day_shiftclass_product:
workers_required = shift.get_worker_requirement_by_date(
self.get_date_by_week_day(week, day)
)
# if day in shift.days:
l.append((week, day, shift.name, shift.workers_required, shift.sites))
l.append((week, day, shift.name, workers_required, shift.sites))
return l
@@ -2984,7 +3091,7 @@ class RotaBuilder(object):
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, week: int | None = None) -> List[str]:
def shifts_to_assign_or_force_as_blocks(self) -> List[str]:
s = self.shifts_to_assign_as_blocks()
s.extend(self.shifts_to_force_as_blocks())
+66 -25
View File
@@ -32,6 +32,27 @@ class NotAvailableToWork(BaseModel):
reason: str = "unknown"
def generate_not_available_to_works(
start_date: datetime.date,
end_date: datetime.date | None = None,
days: int | None = None,
reason: str = "unknown",
) -> List[NotAvailableToWork]:
if end_date is None and days is None:
raise ValueError("Either end_date or days must be provided")
if end_date is not None and days is not None:
raise ValueError("Only one of end_date or days must be provided")
if end_date is not None:
days = (end_date - start_date).days
not_available_to_works = []
for day in range(days):
not_available_to_works.append(NotAvailableToWork(date=start_date + datetime.timedelta(days=day), reason=reason))
return not_available_to_works
class NonWorkingDays(BaseModel):
day: days
start_date: datetime.date | None = None
@@ -68,7 +89,7 @@ class Worker(BaseModel):
# rules is easier.
grade: int
# We can either have a user generated ID
id: Optional[int| uuid.UUID| str] = None
id: Optional[int | uuid.UUID | str] = None
fte: int = 100
nwds: list[NonWorkingDays] = []
start_date: datetime.date | None = None
@@ -84,7 +105,7 @@ class Worker(BaseModel):
pair: int | str | None = None
model_config = ConfigDict(
extra = "allow",
extra="allow",
)
# def __init__(
@@ -111,14 +132,17 @@ class Worker(BaseModel):
def load_rota(self, Rota: "RotaBuilder"):
self.proportion_rota_to_work: float = 1
self.shift_target_number: dict[ str, int ] = defaultdict(int)
self.shift_target_number: dict[str, int] = defaultdict(int)
if self.id is None:
self.id = uuid.uuid4()
if self.start_date is not None and self.end_date is not None:
if self.start_date >= self.end_date:
Rota.add_warning("Worker/Early end date", f"{self.name} [{self.id}] end date is before rota start date: {self.end_date}")
Rota.add_warning(
"Worker/Early end date",
f"{self.name} [{self.id}] end date is before rota start date: {self.end_date}",
)
raise ValueError(f"End date must be after start date: {self.name}")
# if no start date default to the start of the rota
@@ -132,7 +156,10 @@ class Worker(BaseModel):
self.calculated_start_date = Rota.start_date
elif self.start_date >= Rota.rota_end_date:
# If it is after add a warning
Rota.add_warning("Worker/Late start date", f"{self.name} [{self.id}] start date is after rota end date: {self.start_date}")
Rota.add_warning(
"Worker/Late start date",
f"{self.name} [{self.id}] start date is after rota end date: {self.start_date}",
)
else:
pass
@@ -152,18 +179,21 @@ class Worker(BaseModel):
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.calculated_start_date}"
Rota.unavailable_to_work_reason[(self.id, week, day)] = (
f"START DATE: {self.calculated_start_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.calculated_end_date}"
Rota.unavailable_to_work_reason[(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)")
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
@@ -207,9 +237,9 @@ class Worker(BaseModel):
]:
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)
Rota.unavailable_to_work_reason[(self.id, week, day)] = (
f"OOP ({oop_name})".format(self.oop)
)
days_to_end = (self.calculated_end_date - Rota.start_date).days
@@ -239,10 +269,11 @@ class Worker(BaseModel):
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)] = unavalability.reason
Rota.unavailable_to_work_reason[(self.id, week, day)] = (
unavalability.reason
)
unavailable_set.add((week, day))
for request in self.work_requests:
days_from_start = (request.date - Rota.start_date).days
week = days_from_start // 7 + 1
@@ -262,7 +293,9 @@ class Worker(BaseModel):
if self.fte_adj > 100:
# Shouldn't happen ? bug if it does
Rota.add_warning("FTE > 100%", f"{self.name} [{self.id}] FTE = {self.fte_adj}")
Rota.add_warning(
"FTE > 100%", f"{self.name} [{self.id}] FTE = {self.fte_adj}"
)
raise ValueError("{} : fte_ajd = {}".format(self.name, self.fte_adj))
# TODO: this has already been validated, consider moving
@@ -275,14 +308,22 @@ class Worker(BaseModel):
if non_working_day.end_date is not None:
end_date = non_working_day.end_date
self.non_working_day_list.append((non_working_day.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
if days_to_work > 0 and (days_to_work - len(unavailable_set)) / days_to_work < 0.3:
if (
days_to_work > 0
and (days_to_work - len(unavailable_set)) / days_to_work < 0.3
):
console.print(f"{ self.name }: {days_to_work}, {Rota.rota_days_length}")
console.print(f"Warning {self.name} has excessive unavailability [{len(unavailable_set)}/{days_to_work}, adjusted fte={self.fte_adj}] (this may be infeasible)", style="red")
console.print(
f"Warning {self.name} has excessive unavailability [{len(unavailable_set)}/{days_to_work}, adjusted fte={self.fte_adj}] (this may be infeasible)",
style="red",
)
def __lt__(self, other) -> bool:
return (self.site, self.grade, self.fte_adj, self.name) < (
@@ -295,17 +336,17 @@ class Worker(BaseModel):
def __str__(self) -> str:
return f"{self.name}, {self.site}, {self.oop}"
#nwds = (
# 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(
# )
# return "{} {} [{}] REMOTE SITE:{}".format(
# self.name,
# (self.site, self.grade, self.fte_adj),
# nwds,
# self.remote_site,
#)
# )
def get_details(self) -> str:
return "{} {} {}".format(self.id, self.site[0], self.grade)