many improvements
This commit is contained in:
+319
-145
@@ -1,9 +1,9 @@
|
||||
from calendar import week
|
||||
import datetime
|
||||
import itertools
|
||||
from typing import Dict, Iterable, List, Sequence, Tuple, Set
|
||||
from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type
|
||||
|
||||
from pydantic import BaseModel, Extra
|
||||
from pydantic import BaseModel, Extra, constr
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -47,6 +47,11 @@ SHIFT_BOUNDS = {
|
||||
}
|
||||
|
||||
|
||||
class ShiftConstraint(BaseModel):
|
||||
name: str
|
||||
options: bool | int | Dict | tuple = False
|
||||
|
||||
|
||||
class SingleShift(BaseModel):
|
||||
"""Class to hold all details for a shift
|
||||
|
||||
@@ -70,66 +75,75 @@ class SingleShift(BaseModel):
|
||||
|
||||
|
||||
"""
|
||||
sites: Iterable[str]
|
||||
|
||||
sites: List[str]
|
||||
name: str
|
||||
length: float
|
||||
shift_days: Sequence[str]
|
||||
balance_offset: float | None = None,
|
||||
balance_weighting: float = 1,
|
||||
workers_required: float = 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: Iterable[str] = [],
|
||||
days: str | List[str]
|
||||
balance_offset: float | None = None
|
||||
balance_weighting: float = 1
|
||||
workers_required: float = 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] = []
|
||||
|
||||
class Config:
|
||||
extra = Extra.allow
|
||||
orm_mode = True
|
||||
|
||||
def __init__(self, **data: Any):
|
||||
super().__init__(**data)
|
||||
#self.site = sites
|
||||
#self.name = name
|
||||
#self.length = length
|
||||
#self.shift_days = shift_days
|
||||
# self.site = sites
|
||||
# self.name = name
|
||||
# self.length = length
|
||||
# self.days = days
|
||||
# self.balance_by_site = balance_by_site
|
||||
# balance_offset defines the max difference in allocated shifts
|
||||
# versus target shifts (hard constraint)
|
||||
# if 0 exactly equal shifts must be assigend (unlikely to be possible)
|
||||
|
||||
|
||||
|
||||
if self.balance_offset is None:
|
||||
self.balance_offset = len(self.shift_days)
|
||||
|
||||
self.balance_offset = len(self.days)
|
||||
|
||||
if self.force_as_block_unless_nwd:
|
||||
self.assign_as_block = True
|
||||
|
||||
#self.assign_as_block = assign_as_block
|
||||
# self.assign_as_block = assign_as_block
|
||||
|
||||
self.constraint_options = {}
|
||||
self.constraints = []
|
||||
for c in self.constraint:
|
||||
match c:
|
||||
case (constraint, options):
|
||||
self.constraints.append(constraint)
|
||||
self.constraint_options[constraint] = options
|
||||
case constraint:
|
||||
self.constraints.append(constraint)
|
||||
self.constraints.append(c.name)
|
||||
|
||||
if c.options:
|
||||
self.constraint_options[c.name] = c.options
|
||||
|
||||
# constraint = c.constraint
|
||||
# match c:
|
||||
# case (constraint, options):
|
||||
# self.constraint_options[constraint] = options
|
||||
# case constraint:
|
||||
# self.constraints.append(constraint)
|
||||
|
||||
if isinstance(self.days, str):
|
||||
self.days = [self.days]
|
||||
|
||||
def __str__(self):
|
||||
return f"name: {self.name}, site: {self.site}, length: {self.length}, balance weighting: {self.balance_weighting}, balance offset: {self.balance_offset}, hard_constrain: {self.hard_constrain_shift}, workers required : {self.workers_required}, rota on nwds {self.rota_on_nwds}, assign as block: {self.assign_as_block}, force as block: {self.force_as_block}, constraints: {self.constraints}, constraint_options: {self.constraint_options}" # , total shifts: {self.total_shifts}"
|
||||
return f"name: {self.name}, site: {self.sites}, length: {self.length}, balance weighting: {self.balance_weighting}, balance offset: {self.balance_offset}, hard_constrain: {self.hard_constrain_shift}, workers required : {self.workers_required}, rota on nwds {self.rota_on_nwds}, assign as block: {self.assign_as_block}, force as block: {self.force_as_block}, constraints: {self.constraints}, constraint_options: {self.constraint_options}" # , total shifts: {self.total_shifts}"
|
||||
|
||||
def get_shift_summary(self):
|
||||
"""
|
||||
Returns a text summary of the shift
|
||||
"""
|
||||
|
||||
return f"{self.name}: {self.workers_required} workers for sites ({', '.join(self.site)}) on days ({', '.join(self.shift_days)})"
|
||||
return f"{self.name}: {self.workers_required} workers for sites ({', '.join(self.site)}) on days ({', '.join(self.days)})"
|
||||
|
||||
def get_shift_number(self):
|
||||
return len(self.days)
|
||||
|
||||
|
||||
class RotaBuilder(object):
|
||||
@@ -137,16 +151,14 @@ class RotaBuilder(object):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
start_date: datetime.datetime = datetime.datetime.now(),
|
||||
start_date: datetime.date = datetime.date.today(),
|
||||
weeks_to_rota: int = 26,
|
||||
balance_offset_modifier: int = 1,
|
||||
ltft_balance_offset: int = 1,
|
||||
max_night_frequency: int = 2,
|
||||
max_weekend_frequency: int = 1, # Requires balance weekends
|
||||
use_previous_shifts: bool = False,
|
||||
use_shift_balance_extra: bool = False,
|
||||
use_bank_holiday_extra: bool = False,
|
||||
SHIFT_BOUNDS = SHIFT_BOUNDS
|
||||
SHIFT_BOUNDS=SHIFT_BOUNDS,
|
||||
):
|
||||
|
||||
print(f"Start time: {datetime.datetime.now()}")
|
||||
@@ -155,7 +167,7 @@ class RotaBuilder(object):
|
||||
print(f"Use previous shifts (balance_extra) {use_shift_balance_extra}")
|
||||
|
||||
self.SHIFT_BOUNDS = SHIFT_BOUNDS
|
||||
self.shifts = [] # type List[SingleShift]
|
||||
self.shifts: List[SingleShift] = [] # type List[SingleShift]
|
||||
|
||||
self.days = days
|
||||
|
||||
@@ -180,7 +192,7 @@ class RotaBuilder(object):
|
||||
self.unavailable_to_work_reason = {}
|
||||
self.pref_not_to_work = {}
|
||||
self.pref_not_to_work_reason = {}
|
||||
self.work_requests = set()
|
||||
self.work_requests: set[Tuple[str, WeekInt, DayStr, ShiftName]] = set()
|
||||
self.work_requests_map = {}
|
||||
|
||||
self.workers: list[Worker] = []
|
||||
@@ -193,9 +205,6 @@ class RotaBuilder(object):
|
||||
self.balance_offset_modifier = balance_offset_modifier
|
||||
self.ltft_balance_offset = ltft_balance_offset
|
||||
|
||||
# Don't assign multiple shifts every (n) weeks
|
||||
self.max_night_frequency = max_night_frequency
|
||||
self.max_weekend_frequency = max_weekend_frequency
|
||||
|
||||
self.constraint_options = {
|
||||
"balance_nights": True,
|
||||
@@ -204,21 +213,26 @@ class RotaBuilder(object):
|
||||
"balance_bank_holidays": True,
|
||||
"balance_blocks": True,
|
||||
"balance_shifts": True, # Does not use a quadratic function
|
||||
"balance_shifts_quadratic": False, # Will prevent spreading of spreading across different shifts
|
||||
"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,
|
||||
"max_weekends": 100,
|
||||
# Don't assign multiple shifts every (n) weeks
|
||||
"max_weekend_frequency": 1, # Requires balance weekends
|
||||
# Don't assign multiple shifts every (n) weeks
|
||||
"max_night_frequency": 4,
|
||||
"max_shifts_per_week": 7,
|
||||
"max_shifts_per_month": 40,
|
||||
# The following will cause an unsolvable problem if a shift
|
||||
# is forced as a block and spans the time peroid
|
||||
"prevent_monday_after_full_weekends": [],
|
||||
"prevent_monday_and_tuesday_after_full_weekends": [],
|
||||
"prevent_fridays_before_full_weekends": [],
|
||||
"prevent_fridays_before_full_weekends": [],
|
||||
"prevent_thursdays_before_full_weekends": [],
|
||||
"avoid_st2_first_month": False,
|
||||
"hard_constrain_pair_separation": False,
|
||||
"avoid_shifts_by_grades": [],
|
||||
}
|
||||
|
||||
# Generate a map for week, day combinations to a given date
|
||||
@@ -263,7 +277,9 @@ class RotaBuilder(object):
|
||||
if not results.solver.status:
|
||||
sys.exit(0)
|
||||
|
||||
def build_and_solve(self, options={"ratio": 0.1, "seconds": 1000, "threads": 10}, solve=True):
|
||||
def build_and_solve(
|
||||
self, options={"ratio": 0.1, "seconds": 1000, "threads": 10}, solve=True
|
||||
):
|
||||
self.build_shifts()
|
||||
self.build_workers()
|
||||
self.build_model()
|
||||
@@ -611,11 +627,22 @@ class RotaBuilder(object):
|
||||
initialize=availability_init,
|
||||
)
|
||||
|
||||
work_request_sets = set()
|
||||
for work_request in self.work_requests:
|
||||
if work_request[3] == "*":
|
||||
pass
|
||||
|
||||
else:
|
||||
work_request_sets.add(work_request[3])
|
||||
|
||||
|
||||
# Validate shifts are valid
|
||||
work_request_sets = set([i[3] for i in self.work_requests])
|
||||
#work_request_sets = set([i[3] for i in self.work_requests])
|
||||
invalid_shifts = work_request_sets - set(self.get_shift_names())
|
||||
if invalid_shifts:
|
||||
raise InvalidShift(f"Invalid worker shift request [shift(s): ${invalid_shifts}]")
|
||||
raise InvalidShift(
|
||||
f"Invalid worker shift request [shift(s): ${invalid_shifts}]"
|
||||
)
|
||||
|
||||
def work_request_init(model, wid, week, day, shift):
|
||||
if (wid, week, day, shift) in self.work_requests:
|
||||
@@ -692,7 +719,6 @@ class RotaBuilder(object):
|
||||
# self.model.works[worker.id, week, day, shift]
|
||||
# for worker in self.workers if worker.site not in site_required))
|
||||
|
||||
|
||||
# Constraint: total hours worked hours worked
|
||||
# for worker in self.workers:
|
||||
# self.model.constraints.add(
|
||||
@@ -735,8 +761,7 @@ class RotaBuilder(object):
|
||||
return Constraint.Skip
|
||||
return (
|
||||
sum(
|
||||
model.shift_week_worker_assigned[shift.name, week, w.id]
|
||||
for w in workers
|
||||
model.shift_week_worker_assigned[shift, week, w.id] for w in workers
|
||||
)
|
||||
<= limit
|
||||
)
|
||||
@@ -754,7 +779,7 @@ class RotaBuilder(object):
|
||||
Constraint(
|
||||
[week for week in self.weeks],
|
||||
# [shift for shift in self.get_shifts_with_constraint("limit_grade_number")],
|
||||
[shift],
|
||||
[shift.name],
|
||||
[grade],
|
||||
[shift.constraint_options["limit_grade_number"][grade]],
|
||||
rule=limitGradesRule,
|
||||
@@ -769,8 +794,7 @@ class RotaBuilder(object):
|
||||
raise ValueError("minimum_grade_number: not enough valid workers")
|
||||
return (
|
||||
sum(
|
||||
model.shift_week_worker_assigned[shift.name, week, w.id]
|
||||
for w in workers
|
||||
model.shift_week_worker_assigned[shift, week, w.id] for w in workers
|
||||
)
|
||||
>= limit
|
||||
)
|
||||
@@ -788,7 +812,7 @@ class RotaBuilder(object):
|
||||
Constraint(
|
||||
[week for week in self.weeks],
|
||||
# [shift for shift in self.get_shifts_with_constraint("limit_grade_number")],
|
||||
[shift],
|
||||
[shift.name],
|
||||
[grade],
|
||||
[min_required],
|
||||
rule=minimumGradesRule,
|
||||
@@ -796,7 +820,9 @@ class RotaBuilder(object):
|
||||
),
|
||||
)
|
||||
|
||||
def presenceAtRemoteSiteWeek(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
|
||||
]
|
||||
@@ -809,7 +835,9 @@ class RotaBuilder(object):
|
||||
>= required_number
|
||||
)
|
||||
|
||||
for shift in self.get_shifts_with_constraint("require_remote_site_presence_week"):
|
||||
for shift in self.get_shifts_with_constraint(
|
||||
"require_remote_site_presence_week"
|
||||
):
|
||||
site, required_number = shift.constraint_options[
|
||||
"require_remote_site_presence_week"
|
||||
]
|
||||
@@ -826,7 +854,9 @@ class RotaBuilder(object):
|
||||
),
|
||||
)
|
||||
|
||||
def presenceAtRemoteSite(model, day, week, shift, required_site, required_number):
|
||||
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
|
||||
]
|
||||
@@ -836,10 +866,7 @@ class RotaBuilder(object):
|
||||
return Constraint.Skip
|
||||
|
||||
return (
|
||||
sum(
|
||||
model.works[w.id, week, day, shift]
|
||||
for w in required_site_workers
|
||||
)
|
||||
sum(model.works[w.id, week, day, shift] for w in required_site_workers)
|
||||
>= required_number
|
||||
)
|
||||
|
||||
@@ -886,7 +913,7 @@ class RotaBuilder(object):
|
||||
# )
|
||||
for site in self.sites:
|
||||
for shift in self.get_shifts_with_constraint("balance_across_groups"):
|
||||
if site in shift.site:
|
||||
if site in shift.sites:
|
||||
block = shift.name
|
||||
for week in self.weeks:
|
||||
self.model.constraints.add(
|
||||
@@ -919,7 +946,7 @@ class RotaBuilder(object):
|
||||
]
|
||||
>= sum(
|
||||
self.model.works[worker.id, week, day, shift_name]
|
||||
for day in shift.shift_days
|
||||
for day in shift.days
|
||||
)
|
||||
)
|
||||
self.model.constraints.add(
|
||||
@@ -941,8 +968,12 @@ class RotaBuilder(object):
|
||||
for w in workers:
|
||||
if w.non_working_day_list:
|
||||
l = []
|
||||
for nwd, start_nwd_date, end_nwd_date in w.non_working_day_list:
|
||||
if nwd in shift.shift_days:
|
||||
for (
|
||||
nwd,
|
||||
start_nwd_date,
|
||||
end_nwd_date,
|
||||
) in w.non_working_day_list:
|
||||
if nwd in shift.days:
|
||||
if start_nwd_date > self.get_week_start_date(week):
|
||||
continue
|
||||
if end_nwd_date < self.get_week_start_date(week):
|
||||
@@ -957,8 +988,6 @@ class RotaBuilder(object):
|
||||
else:
|
||||
full_workers.append(w)
|
||||
|
||||
print(nwd_workers)
|
||||
|
||||
if not nwd_workers: # Just do the usual
|
||||
self.model.constraints.add(
|
||||
sum(
|
||||
@@ -1015,6 +1044,33 @@ class RotaBuilder(object):
|
||||
)
|
||||
)
|
||||
|
||||
for constraint_dict in self.constraint_options["avoid_shifts_by_grades"]:
|
||||
if invalid_shifts := set(constraint_dict["shifts"]).difference(
|
||||
set(self.get_shift_names())
|
||||
):
|
||||
raise ValueError(
|
||||
f"avoid shifts by grade constraint contains a non existent shift: {invalid_shifts}"
|
||||
)
|
||||
if invalid_grades := set(constraint_dict["grades"]).difference(
|
||||
set(self.get_worker_grades())
|
||||
):
|
||||
raise ValueError(
|
||||
f"avoid shifts by grade constraint contains a non existent worker grade: {invalid_grades}"
|
||||
)
|
||||
|
||||
if worker.grade in constraint_dict["grades"]:
|
||||
self.model.constraints.add(
|
||||
0
|
||||
== sum(
|
||||
self.model.works[worker.id, week, day, shift]
|
||||
for week, day, shift in self.get_all_shiftname_combinations()
|
||||
if week in constraint_dict["weeks"]
|
||||
and shift in constraint_dict["shifts"]
|
||||
)
|
||||
)
|
||||
|
||||
# sys.exit(0)
|
||||
|
||||
if self.constraint_options["avoid_st2_first_month"] and worker.grade == 2:
|
||||
# Avoid ST1s on the first month
|
||||
try:
|
||||
@@ -1049,15 +1105,17 @@ class RotaBuilder(object):
|
||||
# no solution will be possible
|
||||
for shift in self.get_shifts():
|
||||
if (
|
||||
worker.site in shift.site
|
||||
worker.site in shift.sites
|
||||
): # 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.shift_days) *
|
||||
total_shifts = self.shift_counts[shift] * shift.workers_required
|
||||
# total_shifts = (len(self.weeks) * len(shift.days) *
|
||||
total_shifts = (
|
||||
self.shift_counts[shift.name] * shift.workers_required
|
||||
)
|
||||
|
||||
full_time_equivalent_joined = sum(
|
||||
self.full_time_equivalent_sites[i] for i in shift.site
|
||||
self.full_time_equivalent_sites[i] for i in shift.sites
|
||||
)
|
||||
|
||||
target_shifts = (
|
||||
@@ -1111,16 +1169,18 @@ class RotaBuilder(object):
|
||||
)
|
||||
else:
|
||||
# Make sure shifts aren't assigned to those from other sites
|
||||
self.model.constraints.add(
|
||||
0
|
||||
== sum(
|
||||
self.model.works[worker.id, week, day, shift.name]
|
||||
for week, day in self.get_week_day_combinations_for_shift(
|
||||
shift
|
||||
# Not needed if there are no shifts
|
||||
if self.get_week_day_combinations_for_shift(shift):
|
||||
self.model.constraints.add(
|
||||
0
|
||||
== sum(
|
||||
self.model.works[worker.id, week, day, shift.name]
|
||||
for week, day in self.get_week_day_combinations_for_shift(
|
||||
shift
|
||||
)
|
||||
# for week, day in self.get_week_day_combinations()
|
||||
)
|
||||
# for week, day in self.get_week_day_combinations()
|
||||
)
|
||||
)
|
||||
|
||||
# model.shift_count stores the number of shifts that a worker is assigned to worker by shift
|
||||
# this is used (by the objective) to balance the total number of shifts worked
|
||||
@@ -1147,7 +1207,7 @@ class RotaBuilder(object):
|
||||
|
||||
if self.constraint_options["balance_shifts_quadratic"]:
|
||||
# This may need to be updated
|
||||
xU = 10
|
||||
xU = 25
|
||||
xL = 1
|
||||
self.model.constraints.add(
|
||||
inequality(
|
||||
@@ -1304,9 +1364,9 @@ class RotaBuilder(object):
|
||||
# We use a similar method to balance the number of weekends worked
|
||||
# This works as long as weekend shifts are assigned as blocks!
|
||||
weekend_shift_target_number = sum(
|
||||
worker.shift_target_number[shift.name] / len(shift.shift_days)
|
||||
worker.shift_target_number[shift.name] / len(shift.days)
|
||||
for shift in self.get_shifts()
|
||||
if "Sat" in shift.shift_days or "Sun" in shift.shift_days
|
||||
if "Sat" in shift.days or "Sun" in shift.days
|
||||
)
|
||||
|
||||
worker.weekend_shift_target_number = weekend_shift_target_number
|
||||
@@ -1374,7 +1434,7 @@ class RotaBuilder(object):
|
||||
|
||||
if self.constraint_options["balance_blocks"]:
|
||||
for week_blocks in self.get_week_block_iterator(
|
||||
self.max_night_frequency
|
||||
self.constraint_options["max_night_frequency"]
|
||||
):
|
||||
if self.get_shifts_with_constraint("night"):
|
||||
# Prevent nights more than once every n weeks
|
||||
@@ -1390,7 +1450,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
|
||||
# if self.constraint_options["balance_weekends"]:
|
||||
for week_blocks in self.get_week_block_iterator(self.max_weekend_frequency):
|
||||
for week_blocks in self.get_week_block_iterator(self.constraint_options["max_weekend_frequency"]):
|
||||
# Prevent weekend shifts more than once every n weeks
|
||||
self.model.constraints.add(
|
||||
1
|
||||
@@ -1400,12 +1460,55 @@ class RotaBuilder(object):
|
||||
)
|
||||
)
|
||||
|
||||
for week in self.weeks:
|
||||
for constraint_shift in self.get_shifts_with_constraints(
|
||||
"max_shifts_per_week_block"
|
||||
):
|
||||
constraint_options = constraint_shift.constraint_options[
|
||||
"max_shifts_per_week_block"
|
||||
]
|
||||
|
||||
# If not supplied, default to the number of days per shift
|
||||
shift_number = constraint_shift.get_shift_number()
|
||||
if "shift_number" in constraint_options:
|
||||
shift_number = constraint_options["shift_number"]
|
||||
|
||||
for week_blocks in self.get_week_block_iterator(
|
||||
constraint_options["weeks"]
|
||||
):
|
||||
# Prevent shifts more than once every n weeks
|
||||
self.model.constraints.add(
|
||||
shift_number
|
||||
>= sum(
|
||||
self.model.works[
|
||||
worker.id, week, day, constraint_shift.name
|
||||
]
|
||||
for week in week_blocks
|
||||
for day in constraint_shift.days
|
||||
)
|
||||
)
|
||||
|
||||
for week in self.weeks:
|
||||
for constraint_shift in self.get_shifts_with_constraints(
|
||||
"max_shifts_per_week"
|
||||
):
|
||||
self.model.constraints.add(
|
||||
constraint_shift.constraint_options["max_shifts_per_week"]
|
||||
>= sum(
|
||||
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(
|
||||
constraint_shift
|
||||
)
|
||||
if w == week
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
self.model.constraints.add(
|
||||
self.constraint_options["max_shifts_per_week"]
|
||||
>= sum(
|
||||
self.model.works[worker.id, week, day, shiftname]
|
||||
self.model.works[worker.id, w, day, shiftname]
|
||||
# for shiftname in self.get_shift_names_by_week_day(week, day)
|
||||
# for day in self.days
|
||||
for w, day, shiftname in self.get_all_shiftname_combinations(
|
||||
@@ -1438,7 +1541,7 @@ class RotaBuilder(object):
|
||||
+ sum(
|
||||
self.model.works[worker.id, week + 1, "Mon", shift]
|
||||
for shift in self.get_shift_names_by_week_day(
|
||||
week+1, "Mon"
|
||||
week + 1, "Mon"
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1485,21 +1588,15 @@ class RotaBuilder(object):
|
||||
full_weekend_count
|
||||
>= sum(
|
||||
self.model.works[worker.id, week, "Sat", shift.name]
|
||||
for shift in self.get_shift_names_by_week_day(
|
||||
week, "Sat"
|
||||
)
|
||||
for shift in self.get_shift_names_by_week_day(week, "Sat")
|
||||
)
|
||||
+ sum(
|
||||
self.model.works[worker.id, week, "Sun", shift.name]
|
||||
for shift in self.get_shift_names_by_week_day(
|
||||
week, "Sat"
|
||||
)
|
||||
for shift in self.get_shift_names_by_week_day(week, "Sat")
|
||||
)
|
||||
+ sum(
|
||||
self.model.works[worker.id, week, "Fri", shift.name]
|
||||
for shift in self.get_shift_names_by_week_day(
|
||||
week, "Sat"
|
||||
)
|
||||
for shift in self.get_shift_names_by_week_day(week, "Sat")
|
||||
)
|
||||
)
|
||||
if (
|
||||
@@ -1510,21 +1607,15 @@ class RotaBuilder(object):
|
||||
full_weekend_count
|
||||
>= sum(
|
||||
self.model.works[worker.id, week, "Sat", shift.name]
|
||||
for shift in self.get_shift_names_by_week_day(
|
||||
week, "Sat"
|
||||
)
|
||||
for shift in self.get_shift_names_by_week_day(week, "Sat")
|
||||
)
|
||||
+ sum(
|
||||
self.model.works[worker.id, week, "Sun", shift.name]
|
||||
for shift in self.get_shift_names_by_week_day(
|
||||
week, "Sun"
|
||||
)
|
||||
for shift in self.get_shift_names_by_week_day(week, "Sun")
|
||||
)
|
||||
+ sum(
|
||||
self.model.works[worker.id, week, "Thu", shift.name]
|
||||
for shift in self.get_shift_names_by_week_day(
|
||||
week, "Thu"
|
||||
)
|
||||
for shift in self.get_shift_names_by_week_day(week, "Thu")
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1576,6 +1667,27 @@ class RotaBuilder(object):
|
||||
# self.model.shift_week_worker_assigned[s.name, week, worker.id]
|
||||
# for s in self.get_shifts_with_constraint("night")))
|
||||
|
||||
#for shift in self.get_shifts_with_constraint("single_block_per_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, w, d, s]
|
||||
# for w, d, s in self.get_all_shiftname_combinations(week=week)
|
||||
# #for day in self.days
|
||||
# )
|
||||
# )
|
||||
# else:
|
||||
# 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():
|
||||
if shift.force_as_block:
|
||||
@@ -1588,10 +1700,10 @@ class RotaBuilder(object):
|
||||
self.model.shift_week_worker_assigned[
|
||||
shift.name, week, worker.id
|
||||
]
|
||||
* len(shift.shift_days)
|
||||
* len(shift.days)
|
||||
== sum(
|
||||
self.model.works[worker.id, week, day, shift.name]
|
||||
for day in shift.shift_days
|
||||
for day in shift.days
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1603,6 +1715,9 @@ class RotaBuilder(object):
|
||||
|
||||
p1 = 1
|
||||
p2 = 1
|
||||
p3 = 1
|
||||
p4 = 1
|
||||
p5 = 1
|
||||
if n > 0:
|
||||
pweek, pday = weeks_days[n - 1]
|
||||
else:
|
||||
@@ -1615,6 +1730,24 @@ class RotaBuilder(object):
|
||||
p2week, p2day = weeks_days[n]
|
||||
p2 = 0
|
||||
|
||||
try:
|
||||
p3week, p3day = weeks_days[n - 3]
|
||||
except IndexError:
|
||||
p3week, p3day = weeks_days[n]
|
||||
p3 = 0
|
||||
|
||||
try:
|
||||
p4week, p4day = weeks_days[n - 4]
|
||||
except IndexError:
|
||||
p4week, p4day = weeks_days[n]
|
||||
p4 = 0
|
||||
|
||||
try:
|
||||
p5week, p5day = weeks_days[n - 5]
|
||||
except IndexError:
|
||||
p5week, p5day = weeks_days[n]
|
||||
p5 = 0
|
||||
|
||||
n1 = 1
|
||||
n2 = 1
|
||||
try:
|
||||
@@ -1679,7 +1812,7 @@ class RotaBuilder(object):
|
||||
for constraint_shift in self.get_shifts_with_constraints(
|
||||
"preclear", "preclear2"
|
||||
):
|
||||
if day in constraint_shift.shift_days:
|
||||
if day in constraint_shift.days:
|
||||
self.model.constraints.add(
|
||||
1
|
||||
>= self.model.works[
|
||||
@@ -1696,7 +1829,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
|
||||
for constraint_shift in self.get_shifts_with_constraint("preclear2"):
|
||||
if day in constraint_shift.shift_days:
|
||||
if day in constraint_shift.days:
|
||||
self.model.constraints.add(
|
||||
1
|
||||
>= self.model.works[
|
||||
@@ -1715,7 +1848,7 @@ class RotaBuilder(object):
|
||||
for constraint_shift in self.get_shifts_with_constraints(
|
||||
"postclear", "postclear2"
|
||||
):
|
||||
if day in constraint_shift.shift_days:
|
||||
if day in constraint_shift.days:
|
||||
self.model.constraints.add(
|
||||
1
|
||||
>= self.model.works[
|
||||
@@ -1732,7 +1865,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
|
||||
for constraint_shift in self.get_shifts_with_constraints("postclear2"):
|
||||
if day in constraint_shift.shift_days:
|
||||
if day in constraint_shift.days:
|
||||
self.model.constraints.add(
|
||||
1
|
||||
>= self.model.works[
|
||||
@@ -1960,14 +2093,13 @@ class RotaBuilder(object):
|
||||
"""
|
||||
for worker in workers:
|
||||
self.add_worker(worker)
|
||||
#self.workers.extend(workers)
|
||||
# self.workers.extend(workers)
|
||||
|
||||
def build_workers(self) -> None:
|
||||
"""Process loaded shifts and workers
|
||||
|
||||
Must be called prior to attempting to solve
|
||||
"""
|
||||
# self.build_shifts()
|
||||
if not self.workers:
|
||||
raise NoWorkers("Workers must be added prior to calling build_workers")
|
||||
|
||||
@@ -1982,11 +2114,11 @@ class RotaBuilder(object):
|
||||
self.workers_id_map[wid] = worker
|
||||
|
||||
if worker.name in self.workers_name_map:
|
||||
raise ValueError(f"Worker with name '{worker.name}' has been added twice")
|
||||
raise ValueError(
|
||||
f"Worker with name '{worker.name}' has been added twice"
|
||||
)
|
||||
self.workers_name_map[worker.name] = worker
|
||||
|
||||
|
||||
|
||||
self.workers = sorted(self.workers)
|
||||
|
||||
self.full_time_equivalent = sum(w.fte_adj for w in self.workers)
|
||||
@@ -2007,7 +2139,14 @@ class RotaBuilder(object):
|
||||
for p in pairs:
|
||||
self.worker_pairs.append(tuple(pairs[p]))
|
||||
|
||||
def add_shift(self, shift) -> None:
|
||||
def add_grade_constraint_by_week(
|
||||
self, grades: Iterable[int], weeks: Iterable[int], shifts
|
||||
):
|
||||
self.constraint_options["avoid_shifts_by_grades"].append(
|
||||
{"grades": grades, "weeks": weeks, "shifts": shifts}
|
||||
)
|
||||
|
||||
def add_shift(self, shift: SingleShift) -> None:
|
||||
"""Add a shift to the collection
|
||||
|
||||
:param SingleShift shift: Shift object
|
||||
@@ -2023,7 +2162,7 @@ class RotaBuilder(object):
|
||||
"""
|
||||
self.shifts.extend(shifts)
|
||||
|
||||
def get_shift_names_by_week_day(self, week, day: DayStr) -> set():
|
||||
def get_shift_names_by_week_day(self, week, day: DayStr) -> set:
|
||||
"""Returns the shifts required for a specific day
|
||||
|
||||
Returns:
|
||||
@@ -2066,6 +2205,9 @@ class RotaBuilder(object):
|
||||
self.week_day_shift_product = []
|
||||
self.week_day_shiftclass_product = []
|
||||
|
||||
for s in self.shifts:
|
||||
pass
|
||||
|
||||
for s in self.shifts:
|
||||
if s.name in self.shift_names:
|
||||
raise InvalidShift(f"Duplicate shift: {s.name}")
|
||||
@@ -2073,10 +2215,10 @@ class RotaBuilder(object):
|
||||
self.shifts_by_name[s.name] = s
|
||||
self.shift_names.append(s.name)
|
||||
|
||||
# for day in s.shift_days:
|
||||
# for day in s.days:
|
||||
# self.week_day_shifts_dict[(week, day)].add(s.name)
|
||||
|
||||
for site in s.site:
|
||||
for site in list(s.sites):
|
||||
self.sites.add(site)
|
||||
|
||||
self.shift_counts = defaultdict(int)
|
||||
@@ -2085,16 +2227,17 @@ class RotaBuilder(object):
|
||||
for s in self.shifts:
|
||||
if s.bank_holidays_only:
|
||||
if self.week_day_date_map[(week, day)] in bank_holiday_map:
|
||||
print(s.name, self.week_day_date_map[(week, day)])
|
||||
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] = self.shift_counts[s] + 1
|
||||
self.shift_counts[s.name] = self.shift_counts[s.name] + 1
|
||||
else:
|
||||
if day in s.shift_days:
|
||||
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] = self.shift_counts[s] + 1
|
||||
self.shift_counts[s.name] = self.shift_counts[s.name] + 1
|
||||
# print(self.week_day_shift_product)
|
||||
|
||||
# # Todo replace with week_day.....
|
||||
@@ -2102,7 +2245,7 @@ class RotaBuilder(object):
|
||||
# self.day_shiftclass_product = []
|
||||
# for s in self.shifts:
|
||||
# for day in days:
|
||||
# if day in s.shift_days:
|
||||
# if day in s.days:
|
||||
# self.day_shift_product.append((day, s.name))
|
||||
# self.day_shiftclass_product.append((day, s))
|
||||
#
|
||||
@@ -2193,17 +2336,27 @@ class RotaBuilder(object):
|
||||
"""
|
||||
return self.shifts
|
||||
|
||||
def get_shifts_for_worker_site(self, worker_site):
|
||||
shifts = [shift for shift in self.shifts if worker_site in shift.sites]
|
||||
return shifts
|
||||
|
||||
def get_shifts_for_worker(self, worker_id):
|
||||
worker = self.get_worker_by_id(worker_id)
|
||||
shifts = [shift for shift in self.shifts if worker.site in shift.sites]
|
||||
return shifts
|
||||
|
||||
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) -> set[SingleShift]:
|
||||
shifts = set()
|
||||
def get_shifts_with_constraints(self, *constraints) -> List[SingleShift]:
|
||||
shift_names = set()
|
||||
|
||||
for constraint in constraints:
|
||||
shifts.update(
|
||||
[shift for shift in self.shifts if constraint in shift.constraints]
|
||||
shift_names.update(
|
||||
[shift.name for shift in self.shifts if constraint in shift.constraints]
|
||||
)
|
||||
return shifts
|
||||
|
||||
return [self.get_shift_by_name(s) for s in shift_names]
|
||||
|
||||
def get_shift_names(self) -> List[ShiftName]:
|
||||
"""Returns a list of all the registered shift names
|
||||
@@ -2239,12 +2392,12 @@ class RotaBuilder(object):
|
||||
"""Returns a list of all possible shifts, the workers required and site
|
||||
|
||||
Returns:
|
||||
list: list (week, day, shift name, workers required, shift site)
|
||||
list: list (week, day, shift name, workers required, shift.sites)
|
||||
"""
|
||||
l = []
|
||||
for week, day, shift in self.week_day_shiftclass_product:
|
||||
# if day in shift.shift_days:
|
||||
l.append((week, day, shift.name, shift.workers_required, shift.site))
|
||||
# if day in shift.days:
|
||||
l.append((week, day, shift.name, shift.workers_required, shift.sites))
|
||||
|
||||
return l
|
||||
|
||||
@@ -2265,17 +2418,24 @@ class RotaBuilder(object):
|
||||
|
||||
return l - set(self.get_all_shiftclass_combinations())
|
||||
|
||||
def shifts_to_assign_as_blocks(self):
|
||||
def shifts_to_assign_as_blocks(self) -> List[str]:
|
||||
return [shift.name for shift in self.get_shifts() if shift.assign_as_block]
|
||||
|
||||
def shifts_to_force_as_blocks(self):
|
||||
return [shift.name for shift in self.get_shifts() if (shift.force_as_block or shift.force_as_block_unless_nwd)]
|
||||
def shifts_to_force_as_blocks(self) -> List[str]:
|
||||
return [
|
||||
shift.name
|
||||
for shift in self.get_shifts()
|
||||
if (shift.force_as_block or shift.force_as_block_unless_nwd)
|
||||
]
|
||||
|
||||
def shifts_to_assign_or_force_as_blocks(self):
|
||||
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())
|
||||
return s
|
||||
|
||||
def get_worker_by_id(self, worker_id: str) -> Worker:
|
||||
return self.workers_id_map[worker_id]
|
||||
|
||||
def get_worker_by_name(self, name: str) -> Worker:
|
||||
return self.workers_name_map[name]
|
||||
|
||||
@@ -2301,7 +2461,7 @@ class RotaBuilder(object):
|
||||
return group_workers
|
||||
|
||||
def get_workers_for_shift(self, shift: SingleShift) -> List[Worker]:
|
||||
return [worker for worker in self.workers if worker.site in shift.site]
|
||||
return [worker for worker in self.workers if worker.site in shift.sites]
|
||||
|
||||
def get_workers_total_fte(self) -> float:
|
||||
return self.full_time_equivalent
|
||||
@@ -2322,6 +2482,9 @@ class RotaBuilder(object):
|
||||
def get_workers(self):
|
||||
return self.workers
|
||||
|
||||
def get_worker_grades(self) -> set[int]:
|
||||
return set([worker.grade for worker in self.workers])
|
||||
|
||||
def get_bank_holiday_week_days(self):
|
||||
return [
|
||||
(week, day)
|
||||
@@ -2385,7 +2548,6 @@ class RotaBuilder(object):
|
||||
week_table[week][day][shift].append(worker.get_details())
|
||||
return week_table
|
||||
|
||||
|
||||
def get_worker_timetable(self):
|
||||
works = self.model.works
|
||||
timetable = {
|
||||
@@ -2481,7 +2643,9 @@ class RotaBuilder(object):
|
||||
if d in bank_holiday_map:
|
||||
th_class = "bank-holiday"
|
||||
|
||||
date_row.append(f"<th title='{d}' class='{th_class}' data-date='{d}'>Week {week}: {day}</th>")
|
||||
date_row.append(
|
||||
f"<th title='{d}' class='{th_class}' data-date='{d}'>Week {week}: {day}</th>"
|
||||
)
|
||||
n = n + 1
|
||||
timetable.append(f"<tr class='data-row'>{''.join(date_row)}</tr>")
|
||||
|
||||
@@ -2499,10 +2663,10 @@ class RotaBuilder(object):
|
||||
|
||||
shifts = []
|
||||
nwds = json.dumps(worker.non_working_day_list, default=str)
|
||||
#if worker.nwd:
|
||||
# if worker.nwd:
|
||||
# # TODO: limit to dates
|
||||
# nwds = ", ".join([i[0] for i in worker.nwd])
|
||||
#else:
|
||||
# else:
|
||||
# nwds = None
|
||||
|
||||
worker_targets = json.dumps(worker.shift_target_number)
|
||||
@@ -2667,7 +2831,7 @@ class RotaBuilder(object):
|
||||
html = f"""
|
||||
<body>
|
||||
<div class="table-div" id="{table_name}">
|
||||
<table>{joined_timetable}</table>
|
||||
<table id="main-table">{joined_timetable}</table>
|
||||
</div>
|
||||
<details>
|
||||
<summary><h2>Rota settings</h2></summary>
|
||||
@@ -2688,7 +2852,14 @@ class RotaBuilder(object):
|
||||
<pre>
|
||||
{result_string}
|
||||
</pre>
|
||||
</details>
|
||||
<div>
|
||||
<details>
|
||||
<summary><h2>Export table</h2></summary>
|
||||
<div id="export-div">
|
||||
<div>
|
||||
</details>
|
||||
</div
|
||||
</body>
|
||||
"""
|
||||
|
||||
@@ -2730,7 +2901,7 @@ class RotaBuilder(object):
|
||||
temp = ""
|
||||
for shift in self.get_shift_names_by_week_day(week, day):
|
||||
if self.model.works[worker.id, week, day, shift].value > 0:
|
||||
shifts[d]=shift
|
||||
shifts[d] = shift
|
||||
|
||||
return shifts
|
||||
|
||||
@@ -2738,12 +2909,12 @@ class RotaBuilder(object):
|
||||
shifts = {}
|
||||
|
||||
for worker in self.workers:
|
||||
shifts[worker.name] = len([i for i in self.get_worker_shift_list(worker) if i != ""])
|
||||
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 = []
|
||||
|
||||
@@ -2822,14 +2993,17 @@ class RotaBuilder(object):
|
||||
|
||||
class NoActiveSites(Exception):
|
||||
"""Raised when there are no active sites"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NoWorkers(Exception):
|
||||
"""Raised when there are no active sites"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidShift(Exception):
|
||||
"""Raised when there are no active sites"""
|
||||
pass
|
||||
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user