a lot of changes

This commit is contained in:
Ross
2025-06-21 11:45:58 +01:00
parent e15cc6ee51
commit 06a551bb68
4 changed files with 659 additions and 154 deletions
+250 -108
View File
@@ -82,6 +82,7 @@ class WorkerRequirement(BaseModel):
""" """
start date is inclusive, end date is not start date is inclusive, end date is not
""" """
# NOTE: if number is changed mid week block allocations may not work? # NOTE: if number is changed mid week block allocations may not work?
number: int = 1 number: int = 1
start_date: datetime.date | None = None start_date: datetime.date | None = None
@@ -306,7 +307,7 @@ class RotaBuilder(object):
"distribute_locum_shifts": True, "distribute_locum_shifts": True,
"balance_locum_shifts": False, "balance_locum_shifts": False,
"maximum_allowed_locum_shifts_per_worker": None, "maximum_allowed_locum_shifts_per_worker": None,
"minimum_allowed_locum_shifts_per_worker": None "minimum_allowed_locum_shifts_per_worker": None,
} }
self.terminate_on_warning = [ self.terminate_on_warning = [
@@ -316,7 +317,6 @@ class RotaBuilder(object):
"Shift/invalid start date", "Shift/invalid start date",
"Shift/invalid end date", "Shift/invalid end date",
"Locum/no locum availability", "Locum/no locum availability",
] ]
self.results = None self.results = None
@@ -558,22 +558,10 @@ class RotaBuilder(object):
initialize=0, initialize=0,
) )
self.model.worker_block_shift_split = Var(
(
(week, shift)
for week in self.weeks
for shift in self.shifts_to_assign_or_force_as_blocks()
),
within=Binary,
initialize=0,
)
self.model.blocks_worker_shift_assigned = Var( self.model.blocks_worker_shift_assigned = Var(
( (
(worker.id, week, shift) (worker.id, week, shift)
for worker in self.workers for worker, week, shift in self.worker_week_shifts_to_assign_as_blocks()
for week in self.weeks
for shift in self.shifts_to_assign_or_force_as_blocks()
), ),
within=Binary, within=Binary,
initialize=0, initialize=0,
@@ -582,8 +570,7 @@ class RotaBuilder(object):
self.model.blocks_assigned = Var( self.model.blocks_assigned = Var(
( (
(week, shift) (week, shift)
for week in self.weeks for week, shift in self.week_shifts_to_assign_as_blocks()
for shift in self.shifts_to_assign_or_force_as_blocks()
), ),
within=NonNegativeIntegers, within=NonNegativeIntegers,
initialize=0, initialize=0,
@@ -604,11 +591,15 @@ class RotaBuilder(object):
) )
self.model.multi_shift_together = Var( self.model.multi_shift_together = Var(
((worker.id, week, day, idx) (
(worker.id, week, day, idx)
for worker in self.workers for worker in self.workers
for week, day in self.get_week_day_combinations() for week, day in self.get_week_day_combinations()
for idx, allowed_set in enumerate(getattr(worker, "allowed_multi_shift_sets", [])) for idx, allowed_set in enumerate(
if allowed_set.issubset(self.get_shift_names_by_week_day(week, day))), getattr(worker, "allowed_multi_shift_sets", [])
)
if allowed_set.issubset(self.get_shift_names_by_week_day(week, day))
),
domain=Binary, domain=Binary,
initialize=0, initialize=0,
) )
@@ -897,7 +888,6 @@ class RotaBuilder(object):
f"Invalid locum shift request [shift(s): ${invalid_shifts}]" f"Invalid locum shift request [shift(s): ${invalid_shifts}]"
) )
def locum_request_init(model, wid, week, day, shift): def locum_request_init(model, wid, week, day, shift):
if (wid, week, day, shift) in self.locum_availability: if (wid, week, day, shift) in self.locum_availability:
self.locum_availability_map[(wid, week, day)] = shift self.locum_availability_map[(wid, week, day)] = shift
@@ -970,9 +960,9 @@ class RotaBuilder(object):
if self.get_workers_who_require_locums(): if self.get_workers_who_require_locums():
# We use this as a time check that their is some valid locum ability # We use this as a time check that their is some valid locum ability
if not self.get_all_locum_availability(): if not self.get_all_locum_availability():
self.add_warning("Locum/no locum availability", "No locum availability set") self.add_warning(
"Locum/no locum availability", "No locum availability set"
)
self.model.locum_required = Var( self.model.locum_required = Var(
( (
@@ -994,28 +984,19 @@ class RotaBuilder(object):
) )
self.model.locum_shifts_by_worker = Var( self.model.locum_shifts_by_worker = Var(
( (worker.id for worker in self.workers),
worker.id
for worker in self.workers
),
within=Integers, within=Integers,
initialize=0, initialize=0,
) )
self.model.locum_shifts_t1 = Var( self.model.locum_shifts_t1 = Var(
( (worker.id for worker in self.workers),
worker.id
for worker in self.workers
),
domain=NonNegativeReals, domain=NonNegativeReals,
initialize=0, initialize=0,
) )
self.model.locum_shifts_t2 = Var( self.model.locum_shifts_t2 = Var(
( (worker.id for worker in self.workers),
worker.id
for worker in self.workers
),
domain=NonNegativeReals, domain=NonNegativeReals,
initialize=0, initialize=0,
) )
@@ -1070,8 +1051,6 @@ class RotaBuilder(object):
) )
) )
# And it is not assigned if the worker is from the wrong site # And it is not assigned if the worker is from the wrong site
if [worker for worker in self.workers if worker.site not in site_required]: if [worker for worker in self.workers if worker.site not in site_required]:
self.model.constraints.add( self.model.constraints.add(
@@ -1305,11 +1284,14 @@ class RotaBuilder(object):
weeks_days = self.get_week_day_combinations() weeks_days = self.get_week_day_combinations()
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():
for week, shift_name in self.week_shifts_to_assign_as_blocks():
shift = self.get_shift_by_name(shift_name) shift = self.get_shift_by_name(shift_name)
for worker in self.get_workers_for_shift(shift): for worker in self.get_workers_for_shift(shift):
if (worker.id, week, shift.name) in self.model.blocks_worker_shift_assigned:
try: try:
self.model.constraints.add( self.model.constraints.add(
8 8
@@ -1330,6 +1312,7 @@ class RotaBuilder(object):
worker.id, week, shift_name worker.id, week, shift_name
] ]
for worker in self.workers for worker in self.workers
if (worker.id, week, shift.name) in self.model.blocks_worker_shift_assigned
) )
) )
@@ -1408,6 +1391,36 @@ class RotaBuilder(object):
) )
logging.debug(f"Generate worker constraints: {worker.name}") logging.debug(f"Generate worker constraints: {worker.name}")
# Add hard shift dependencies for this worker
if hasattr(worker, "hard_day_dependencies"):
for from_day, to_day in worker.hard_day_dependencies.items():
for week in self.weeks:
for shift in self.get_shift_names_by_week_day(week, from_day):
# Only apply if the shift also exists on the to_day
if shift in self.get_shift_names_by_week_day(week, to_day):
self.model.constraints.add(
self.model.works[worker.id, week, from_day, shift]
<= self.model.works[worker.id, week, to_day, shift]
)
# Add hard day exclusions for this worker
if hasattr(worker, "hard_day_exclusions"):
for day1, day2 in worker.hard_day_exclusions:
for week in self.weeks:
# For each week, prevent any shift being assigned on both days
self.model.constraints.add(
sum(
self.model.works[worker.id, week, day1, shift]
for shift in self.get_shift_names_by_week_day(week, day1)
)
+ sum(
self.model.works[worker.id, week, day2, shift]
for shift in self.get_shift_names_by_week_day(week, day2)
)
<= 1
)
for week, day in self.get_week_day_combinations(): for week, day in self.get_week_day_combinations():
shifts_today = self.get_shift_names_by_week_day(week, day) shifts_today = self.get_shift_names_by_week_day(week, day)
worker_allowed_sets = getattr(worker, "allowed_multi_shift_sets", []) worker_allowed_sets = getattr(worker, "allowed_multi_shift_sets", [])
@@ -1423,31 +1436,35 @@ class RotaBuilder(object):
var = self.model.multi_shift_together[worker.id, week, day, idx] var = self.model.multi_shift_together[worker.id, week, day, idx]
# var is 1 iff all shifts in allowed_set are assigned # var is 1 iff all shifts in allowed_set are assigned
for shift in allowed_set: for shift in allowed_set:
self.model.constraints.add(var <= self.model.works[worker.id, week, day, shift])
self.model.constraints.add( self.model.constraints.add(
var >= sum(self.model.works[worker.id, week, day, shift] for shift in allowed_set) - len(allowed_set) + 1 var <= self.model.works[worker.id, week, day, shift]
)
self.model.constraints.add(
var
>= sum(
self.model.works[worker.id, week, day, shift]
for shift in allowed_set
)
- len(allowed_set)
+ 1
) )
if self.get_workers_who_require_locums(): if self.get_workers_who_require_locums():
for week, day, shift in self.get_all_shiftclass_combinations(): for week, day, shift in self.get_all_shiftclass_combinations():
if not worker.locum: if not worker.locum:
self.model.constraints.add( self.model.constraints.add(
self.get_locum_availability(worker, week, day, shift) self.get_locum_availability(worker, week, day, shift)
>= >= self.model.locum_works[worker.id, week, day, shift.name]
self.model.locum_works[worker.id, week, day, shift.name]
) )
self.model.constraints.add( self.model.constraints.add(
self.model.locum_shifts_by_worker[worker.id] == self.model.locum_shifts_by_worker[worker.id]
sum( == sum(
self.model.locum_works[worker.id, week, day, shift] self.model.locum_works[worker.id, week, day, shift]
for week, day, shift in self.get_all_shiftname_combinations() for week, day, shift in self.get_all_shiftname_combinations()
) )
) )
self.model.constraints.add( self.model.constraints.add(
self.model.locum_shifts_t1[worker.id] self.model.locum_shifts_t1[worker.id]
- self.model.locum_shifts_t2[worker.id] - self.model.locum_shifts_t2[worker.id]
@@ -1457,40 +1474,62 @@ class RotaBuilder(object):
if worker.locum_max_shifts: if worker.locum_max_shifts:
self.model.constraints.add( self.model.constraints.add(
worker.locum_max_shifts worker.locum_max_shifts
>= >= sum(
sum(self.model.locum_works[worker.id, week, day, shiftname] for week, day, shiftname in self.get_all_shiftname_combinations()) self.model.locum_works[worker.id, week, day, shiftname]
for week, day, shiftname in self.get_all_shiftname_combinations()
)
) )
if worker.locum_max_shifts_per_week: if worker.locum_max_shifts_per_week:
for week_to_check in self.weeks: for week_to_check in self.weeks:
self.model.constraints.add( self.model.constraints.add(
worker.locum_max_shifts_per_week worker.locum_max_shifts_per_week
>= >= sum(
sum(self.model.locum_works[worker.id, week, day, shiftname] for week, day, shiftname in self.get_all_shiftname_combinations(week=week_to_check)) self.model.locum_works[worker.id, week, day, shiftname]
for week, day, shiftname in self.get_all_shiftname_combinations(
week=week_to_check
)
)
) )
if self.constraint_options["distribute_locum_shifts"]: if self.constraint_options["distribute_locum_shifts"]:
self.model.constraints.add( self.model.constraints.add(
sum(self.model.locum_required[week, day, shift] for week, day, shift in self.get_all_shiftname_combinations()) / (len(self.get_locum_workers())-1) sum(
self.model.locum_required[week, day, shift]
for week, day, shift in self.get_all_shiftname_combinations()
)
/ (len(self.get_locum_workers()) - 1)
>= self.model.locum_shifts_by_worker[worker.id] >= self.model.locum_shifts_by_worker[worker.id]
) )
if self.constraint_options["maximum_allowed_locum_shifts_per_worker"] is not None: if (
self.model.constraints.add(
self.constraint_options["maximum_allowed_locum_shifts_per_worker"] self.constraint_options["maximum_allowed_locum_shifts_per_worker"]
is not None
):
self.model.constraints.add(
self.constraint_options[
"maximum_allowed_locum_shifts_per_worker"
]
>= self.model.locum_shifts_by_worker[worker.id] >= self.model.locum_shifts_by_worker[worker.id]
) )
if worker.locum_availability: if worker.locum_availability:
if self.constraint_options["minimum_allowed_locum_shifts_per_worker"] is not None: if (
self.constraint_options[
"minimum_allowed_locum_shifts_per_worker"
]
is not None
):
self.model.constraints.add( self.model.constraints.add(
self.constraint_options["minimum_allowed_locum_shifts_per_worker"] self.constraint_options[
"minimum_allowed_locum_shifts_per_worker"
]
<= self.model.locum_shifts_by_worker[worker.id] <= self.model.locum_shifts_by_worker[worker.id]
) )
if (
if self.constraint_options["minimise_shift_diffs"] or self.constraint_options["maximum_allowed_shift_diff"] is not None: self.constraint_options["minimise_shift_diffs"]
or self.constraint_options["maximum_allowed_shift_diff"] is not None
):
self.model.constraints.add( self.model.constraints.add(
self.model.shift_count_diff_summed[worker.id] self.model.shift_count_diff_summed[worker.id]
== sum( == sum(
@@ -1609,16 +1648,17 @@ class RotaBuilder(object):
# total_shifts = ( # total_shifts = (
# self.shift_counts[shift.name] * shift.workers_required # self.shift_counts[shift.name] * shift.workers_required
# ) # )
total_shifts = ( total_shifts = self.shift_worker_counts[shift.name]
self.shift_worker_counts[shift.name]
)
full_time_equivalent_joined = sum( full_time_equivalent_joined = sum(
self.full_time_equivalent_sites[i][shift.name] for i in shift.sites self.full_time_equivalent_sites[i][shift.name]
for i in shift.sites
) )
target_shifts = ( target_shifts = (
total_shifts / full_time_equivalent_joined * worker.get_fte(shift=shift.name) total_shifts
/ full_time_equivalent_joined
* worker.get_fte(shift=shift.name)
) )
if self.use_previous_shifts: if self.use_previous_shifts:
@@ -2233,7 +2273,6 @@ class RotaBuilder(object):
# for shift in self.get_shifts_with_constraint("night"): # for shift in self.get_shifts_with_constraint("night"):
for shift in self.get_shifts(week=week): for shift in self.get_shifts(week=week):
if shift.force_as_block: if shift.force_as_block:
if shift.get_worker_requirement_by_date( if shift.get_worker_requirement_by_date(
self.get_week_start_date(week) self.get_week_start_date(week)
): ):
@@ -2446,38 +2485,56 @@ class RotaBuilder(object):
# single shift per day (unless multi-shift allowed) # single shift per day (unless multi-shift allowed)
# This is signifantly slower so only enable if required # This is signifantly slower so only enable if required
shifts_today = self.get_shift_names_by_week_day(week, day) shifts_today = self.get_shift_names_by_week_day(week, day)
assigned_vars = [self.model.works[worker.id, week, day, shift] for shift in shifts_today] assigned_vars = [
worker_allowed_sets = getattr(worker, "allowed_multi_shift_sets", []) self.model.works[worker.id, week, day, shift]
for shift in shifts_today
]
worker_allowed_sets = getattr(
worker, "allowed_multi_shift_sets", []
)
allowed_sets_today = [ allowed_sets_today = [
allowed_set for allowed_set in worker_allowed_sets allowed_set
for allowed_set in worker_allowed_sets
if allowed_set.issubset(shifts_today) if allowed_set.issubset(shifts_today)
] ]
if allowed_sets_today: if allowed_sets_today:
# Forbid any multi-shift assignment that includes shifts from different allowed sets # Forbid any multi-shift assignment that includes shifts from different allowed sets
# Build all possible multi-shift assignments # Build all possible multi-shift assignments
all_multi = [set(c) for i in range(2, len(shifts_today)+1) for c in itertools.combinations(shifts_today, i)] all_multi = [
set(c)
for i in range(2, len(shifts_today) + 1)
for c in itertools.combinations(shifts_today, i)
]
# Build all allowed multi-shift patterns (all non-empty subsets of each allowed set) # Build all allowed multi-shift patterns (all non-empty subsets of each allowed set)
allowed_patterns = [{shift} for shift in shifts_today] allowed_patterns = [{shift} for shift in shifts_today]
for allowed_set in allowed_sets_today: for allowed_set in allowed_sets_today:
allowed_patterns += [set(s) for i in range(2, len(allowed_set)+1) for s in itertools.combinations(allowed_set, i)] allowed_patterns += [
set(s)
for i in range(2, len(allowed_set) + 1)
for s in itertools.combinations(allowed_set, i)
]
# Forbid all other multi-shift combinations # Forbid all other multi-shift combinations
forbidden = [s for s in all_multi if s not in allowed_patterns] forbidden = [s for s in all_multi if s not in allowed_patterns]
for forbidden_set in forbidden: for forbidden_set in forbidden:
self.model.constraints.add( self.model.constraints.add(
sum(self.model.works[worker.id, week, day, shift] for shift in forbidden_set) sum(
self.model.works[worker.id, week, day, shift]
for shift in forbidden_set
)
<= len(forbidden_set) - 1 <= len(forbidden_set) - 1
) )
# Still, at most all shifts per day # Still, at most all shifts per day
self.model.constraints.add( self.model.constraints.add(
sum(self.model.works[worker.id, week, day, shift] for shift in shifts_today) sum(
self.model.works[worker.id, week, day, shift]
for shift in shifts_today
)
<= len(shifts_today) <= len(shifts_today)
) )
else: else:
# Only one shift per day if no allowed sets # Only one shift per day if no allowed sets
self.model.constraints.add( self.model.constraints.add(sum(assigned_vars) <= 1)
sum(assigned_vars) <= 1
)
# This applies to locums as well # This applies to locums as well
if self.get_workers_who_require_locums(): if self.get_workers_who_require_locums():
@@ -2491,7 +2548,8 @@ class RotaBuilder(object):
self.model.constraints.add( self.model.constraints.add(
1 1
>= sum( >= sum(
self.model.works[worker.id, week, day, shift] + self.model.locum_works[worker.id, week, day, shift] self.model.works[worker.id, week, day, shift]
+ self.model.locum_works[worker.id, week, day, shift]
for shift in self.get_shift_names_by_week_day(week, day) for shift in self.get_shift_names_by_week_day(week, day)
) )
) )
@@ -2662,11 +2720,13 @@ class RotaBuilder(object):
def obj_rule(m): def obj_rule(m):
# c = len(workers) # c = len(workers)
prefer_multi_shift_expr = sum( prefer_multi_shift_expr = sum(
getattr(worker, "prefer_multi_shift_together", 0) * getattr(worker, "prefer_multi_shift_together", 0)
self.model.multi_shift_together[worker.id, week, day, idx] * self.model.multi_shift_together[worker.id, week, day, idx]
for worker in self.workers for worker in self.workers
for week, day in self.get_week_day_combinations() for week, day in self.get_week_day_combinations()
for idx, allowed_set in enumerate(getattr(worker, "allowed_multi_shift_sets", [])) for idx, allowed_set in enumerate(
getattr(worker, "allowed_multi_shift_sets", [])
)
if allowed_set.issubset(self.get_shift_names_by_week_day(week, day)) if allowed_set.issubset(self.get_shift_names_by_week_day(week, day))
) )
@@ -2679,9 +2739,11 @@ class RotaBuilder(object):
if self.get_workers_who_require_locums(): if self.get_workers_who_require_locums():
if self.constraint_options["balance_locum_shifts"]: if self.constraint_options["balance_locum_shifts"]:
locum_shift_balancing = sum( locum_shift_balancing = sum(
locum_shift_balance_modifier_constant * locum_shift_balance_modifier_constant
(self.model.locum_shifts_t1[(worker.id)] * (
+ self.model.locum_shifts_t2[(worker.id)]) self.model.locum_shifts_t1[(worker.id)]
+ self.model.locum_shifts_t2[(worker.id)]
)
for worker in self.workers for worker in self.workers
) )
@@ -2732,7 +2794,6 @@ class RotaBuilder(object):
for worker in self.workers for worker in self.workers
) )
if self.constraint_options["minimise_shift_diffs"]: if self.constraint_options["minimise_shift_diffs"]:
shift_diff_modifier_constant = 10 shift_diff_modifier_constant = 10
shift_diff_balancing = sum( shift_diff_balancing = sum(
@@ -2832,13 +2893,27 @@ class RotaBuilder(object):
if self.constraint_options["balance_blocks"]: if self.constraint_options["balance_blocks"]:
blocks_balancing = sum( blocks_balancing = sum(
block_shift_balancing_constant * self.model.blocks_assigned[week, shift] block_shift_balancing_constant
* self.model.blocks_assigned[week, shift]
for week in self.weeks for week in self.weeks
for shift in self.shifts_to_assign_as_blocks() for shift in self.shifts_to_assign_as_blocks()
) )
else: else:
blocks_balancing = 0 blocks_balancing = 0
prefer_block_expr = sum(
worker.assign_as_block_preferences.get(shift.name, 0)
* self.model.blocks_worker_shift_assigned[worker.id, week, shift.name]
for worker in self.workers
for week in self.weeks
for shift in self.shifts
if hasattr(worker, "assign_as_block_preferences")
and shift.name in worker.assign_as_block_preferences
and (worker.id, week, shift.name)
in self.model.blocks_worker_shift_assigned
and worker.site in shift.sites
)
# Quadratic :( # Quadratic :(
# worker_pairs_balancing = 0 # worker_pairs_balancing = 0
# worker_pairs_constant = 100000 # worker_pairs_constant = 100000
@@ -2863,6 +2938,7 @@ class RotaBuilder(object):
+ locum_shift_balancing + locum_shift_balancing
+ true_quadratic_shift_balancing + true_quadratic_shift_balancing
- prefer_multi_shift_expr - prefer_multi_shift_expr
+ prefer_block_expr
) )
# add objective function to the model. rule (pass function) or expr (pass expression directly) # add objective function to the model. rule (pass function) or expr (pass expression directly)
@@ -2937,7 +3013,9 @@ class RotaBuilder(object):
self.workers = sorted(self.workers) self.workers = sorted(self.workers)
self.full_time_equivalent = sum(w.fte_adj for w in self.workers) # TODO: rewrite as per shift self.full_time_equivalent = sum(
w.fte_adj for w in self.workers
) # TODO: rewrite as per shift
self.full_time_equivalent_sites = {} self.full_time_equivalent_sites = {}
self.workers_at_sites = {} self.workers_at_sites = {}
@@ -3043,7 +3121,9 @@ class RotaBuilder(object):
raise ValueError(f"Must pair at least two shifts: {shifts}") raise ValueError(f"Must pair at least two shifts: {shifts}")
self.paired_shifts.append([*shifts]) self.paired_shifts.append([*shifts])
def get_date_range(self, start_date: datetime.date=None, end_date: datetime.date = None): def get_date_range(
self, start_date: datetime.date = None, end_date: datetime.date = None
):
"""Gets a range of dates """Gets a range of dates
If either start_date or end_date are not provided defaults to the rota dates""" If either start_date or end_date are not provided defaults to the rota dates"""
@@ -3060,7 +3140,7 @@ class RotaBuilder(object):
Allow the given set of shift names to be assigned together on a single day for all workers. Allow the given set of shift names to be assigned together on a single day for all workers.
""" """
for worker in self.workers: for worker in self.workers:
worker.allowed_multi_shift_sets.append(frozenset(shift_names)) worker.allow_shifts_together(*shift_names)
def build_shifts(self): def build_shifts(self):
""" """
@@ -3117,7 +3197,7 @@ class RotaBuilder(object):
elif worker_requirement.start_date > s.end_date: elif worker_requirement.start_date > s.end_date:
self.add_warning( self.add_warning(
"Shift/worker requirement start date", "Shift/worker requirement start date",
f"Worker requirement start date is after shift end date: {worker_requirement.start_date} > {s.end_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: if worker_requirement.end_date is None:
@@ -3125,10 +3205,9 @@ class RotaBuilder(object):
elif worker_requirement.end_date < s.start_date: elif worker_requirement.end_date < s.start_date:
self.add_warning( self.add_warning(
"Shift/worker requirement end date", "Shift/worker requirement end date",
f"Worker requirement end date is before shift start date: {worker_requirement.end_date} < {s.start_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.shifts_by_name[s.name] = s
self.shift_names.append(s.name) self.shift_names.append(s.name)
@@ -3164,14 +3243,18 @@ 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
self.shift_worker_counts[s.name] = self.shift_worker_counts[s.name] + workers_required self.shift_worker_counts[s.name] = (
self.shift_worker_counts[s.name] + workers_required
)
else: else:
if day in s.days: if day in s.days:
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)
self.shift_counts[s.name] = self.shift_counts[s.name] + 1 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.shift_worker_counts[s.name] = (
self.shift_worker_counts[s.name] + workers_required
)
self.max_pre = 1 self.max_pre = 1
self.max_post = 1 self.max_post = 1
@@ -3343,11 +3426,14 @@ class RotaBuilder(object):
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]:
(
"""Returns a list of all the registered shift names """Returns a list of all the registered shift names
Returns: Returns:
List[ShiftName]: List of names of all available shifts List[ShiftName]: List of names of all available shifts
""" """ """ """
""" """
)
return self.shift_names return self.shift_names
def get_week_block_iterator(self, block_length: int): def get_week_block_iterator(self, block_length: int):
@@ -3421,11 +3507,48 @@ class RotaBuilder(object):
s.extend(self.shifts_to_force_as_blocks()) s.extend(self.shifts_to_force_as_blocks())
return s return s
def worker_week_shifts_to_assign_as_blocks(self) -> List[Tuple[Worker, int, str]]:
"""Returns a list of tuples containing worker id, week and shift name for shifts to assign as blocks"""
return [
(worker, week, shift.name)
for worker in self.workers
for week in self.weeks
for shift in self.get_shifts()
if (
shift.assign_as_block
or shift.force_as_block
or shift.force_as_block_unless_nwd
or (
hasattr(worker, "assign_as_block_preferences")
and shift.name in getattr(worker, "assign_as_block_preferences", {})
and getattr(worker, "assign_as_block_preferences")[shift.name] != 0
)
)
]
def week_shifts_to_assign_as_blocks(self) -> List[Tuple[int, str]]:
"""Returns a list of tuples containing week and shift name for shifts to assign as blocks"""
return set([
(week, shift.name)
for worker in self.workers
for week in self.weeks
for shift in self.get_shifts()
if (
shift.assign_as_block
or shift.force_as_block
or shift.force_as_block_unless_nwd
or (
hasattr(worker, "assign_as_block_preferences")
and shift.name in getattr(worker, "assign_as_block_preferences", {})
and getattr(worker, "assign_as_block_preferences")[shift.name] != 0
)
)
])
def get_all_locum_availability(self): def get_all_locum_availability(self):
return self.locum_availability_map return self.locum_availability_map
def get_locum_availability(self, worker: Worker, week: int, day: DayStr, shift): def get_locum_availability(self, worker: Worker, week: int, day: DayStr, shift):
if (worker.id, week, day) in self.locum_availability_map: if (worker.id, week, day) in self.locum_availability_map:
if shift.name in self.locum_availability_map[(worker.id, week, day)]: if shift.name in self.locum_availability_map[(worker.id, week, day)]:
return 1 return 1
@@ -3502,7 +3625,11 @@ class RotaBuilder(object):
return [worker for worker in self.get_all_workers() if worker.locum] return [worker for worker in self.get_all_workers() if worker.locum]
def get_locum_workers(self): def get_locum_workers(self):
return [worker for worker in self.get_all_workers() if not worker.locum and worker.locum_availability] return [
worker
for worker in self.get_all_workers()
if not worker.locum and worker.locum_availability
]
def get_bank_holiday_week_days(self): def get_bank_holiday_week_days(self):
return [ return [
@@ -3583,7 +3710,6 @@ class RotaBuilder(object):
week_table[week][day][shift].append(worker.get_details()) week_table[week][day][shift].append(worker.get_details())
return week_table return week_table
def get_worker_timetable(self): def get_worker_timetable(self):
works = self.model.works works = self.model.works
timetable = { timetable = {
@@ -3600,7 +3726,6 @@ class RotaBuilder(object):
timetable[worker.name][week][day] = assigned_shifts timetable[worker.name][week][day] = assigned_shifts
return timetable return timetable
def get_worker_timetable_brief( def get_worker_timetable_brief(
self, show_prefs=False, marker_every=30, show_unavailable=False self, show_prefs=False, marker_every=30, show_unavailable=False
): ):
@@ -3618,7 +3743,9 @@ class RotaBuilder(object):
shift_names = [] shift_names = []
for shift in self.get_shift_names_by_week_day(week, day): for shift in self.get_shift_names_by_week_day(week, day):
if model.works[worker.id, week, day, shift].value > 0.5: if model.works[worker.id, week, day, shift].value > 0.5:
shift_names.append(self.get_shift_by_name(shift).get_display_char()) shift_names.append(
self.get_shift_by_name(shift).get_display_char()
)
if shift_names: if shift_names:
shift_display_char = "".join(shift_names) shift_display_char = "".join(shift_names)
shifts.extend(shift_names) shifts.extend(shift_names)
@@ -3745,7 +3872,12 @@ class RotaBuilder(object):
assigned_shift_names.append(shift) assigned_shift_names.append(shift)
locum = True locum = True
if shift_names: if shift_names:
shift_display_char = "<br/>".join(["<span class='multi-shift-shift'>"+name+"</span>" for name in shift_names]) shift_display_char = "<br/>".join(
[
"<span class='multi-shift-shift'>" + name + "</span>"
for name in shift_names
]
)
shifts.extend(assigned_shift_names) shifts.extend(assigned_shift_names)
shift_name = assigned_shift_names[0] if assigned_shift_names else "" shift_name = assigned_shift_names[0] if assigned_shift_names else ""
@@ -4002,9 +4134,9 @@ class RotaBuilder(object):
} }
for worker in self.workers: for worker in self.workers:
for shift in self.get_shift_names(): for shift in self.get_shift_names():
timetable[worker.name][shift] = round(self.model.shift_count[ timetable[worker.name][shift] = round(
worker.id, shift self.model.shift_count[worker.id, shift].value
].value) )
return timetable return timetable
@@ -4045,7 +4177,10 @@ class RotaBuilder(object):
for shift in self.get_shift_names_by_week_day(week, day): for shift in self.get_shift_names_by_week_day(week, day):
if self.model.works[worker.id, week, day, shift].value > 0.90: if self.model.works[worker.id, week, day, shift].value > 0.90:
temp = shift temp = shift
elif include_locums and self.model.locum_works[worker.id, week, day, shift].value > 0.90: elif (
include_locums
and self.model.locum_works[worker.id, week, day, shift].value > 0.90
):
temp = shift temp = shift
shifts.append(temp) shifts.append(temp)
@@ -4076,7 +4211,12 @@ class RotaBuilder(object):
shifts = self.get_worker_shift_list(worker) shifts = self.get_worker_shift_list(worker)
# Convert shift to a string representation # Convert shift to a string representation
return "".join([self.get_shift_by_name(i).get_display_char() if i != "" else "-" for i in shifts]) return "".join(
[
self.get_shift_by_name(i).get_display_char() if i != "" else "-"
for i in shifts
]
)
def get_shift_summary(self): def get_shift_summary(self):
works = self.model.works works = self.model.works
@@ -4108,7 +4248,9 @@ class RotaBuilder(object):
def get_shift_summary_html(self): def get_shift_summary_html(self):
works = self.model.works works = self.model.works
timetable = { timetable = {
worker.get_details(): {shift.get_display_char(): "" for shift in self.get_shifts()} worker.get_details(): {
shift.get_display_char(): "" for shift in self.get_shifts()
}
for worker in self.workers for worker in self.workers
} }
# timetable = { worker.get_details() : { shift : "" for shift in ["truro_twilight"] } for worker in workers } # timetable = { worker.get_details() : { shift : "" for shift in ["truro_twilight"] } for worker in workers }
+14
View File
@@ -114,6 +114,10 @@ class Worker(BaseModel):
allowed_multi_shift_sets: list[set] = [] # or list/set of frozensets allowed_multi_shift_sets: list[set] = [] # or list/set of frozensets
prefer_multi_shift_together: int = 0 # Default: no preference prefer_multi_shift_together: int = 0 # Default: no preference
# Set to a positive integer to prefer, negative to discourage, 0 for neutral # Set to a positive integer to prefer, negative to discourage, 0 for neutral
hard_day_dependencies: dict[str, str] = {} # e.g. {"Fri": "Sun"}
hard_day_exclusions: list[tuple[str, str]] = [] # e.g. [("Fri", "Sun")]
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
@@ -397,3 +401,13 @@ class Worker(BaseModel):
if not hasattr(self, "allowed_multi_shift_sets"): if not hasattr(self, "allowed_multi_shift_sets"):
self.allowed_multi_shift_sets = [] self.allowed_multi_shift_sets = []
self.allowed_multi_shift_sets.append(frozenset(shifts)) self.allowed_multi_shift_sets.append(frozenset(shifts))
def add_hard_day_dependency(self, from_day: str, to_day: str):
if not hasattr(self, "hard_day_dependencies"):
self.hard_day_dependencies = {}
self.hard_day_dependencies[from_day] = to_day
def add_hard_day_exclusion(self, day1: str, day2: str):
if not hasattr(self, "hard_day_exclusions"):
self.hard_day_exclusions = []
self.hard_day_exclusions.append((day1, day2))
+47
View File
@@ -227,3 +227,50 @@ def test_cons_rota2(cons_rota):
# Check that days worked is now more than oncall count # Check that days worked is now more than oncall count
days_worked = Rota.get_worker_days_worked(worker) days_worked = Rota.get_worker_days_worked(worker)
assert days_worked > oncall_count, f"{worker.name} worked {days_worked} days, more than oncall shifts {oncall_count}" assert days_worked > oncall_count, f"{worker.name} worked {days_worked} days, more than oncall shifts {oncall_count}"
def test_cons_rota3(cons_rota):
Rota = cons_rota
Rota.terminate_on_warning.remove("Worker/no valid shifts")
Rota.add_shifts(
#SingleShift(sites=["a"], name="oncall weekday", length=8, days=days[:4], workers_required=1,
# constraint=[
# {"name": "pre", "options": 2},
# {"name": "post", "options": 2},
# ],
# ),
##SingleShift(sites=["a"], name="oncall weekend", length=8, days=days[4:], workers_required=1),
#SingleShift(sites=["a", "b"], name="evening", length=8, days=days[:4], workers_required=1,
# constraint=[
# {"name": "pre", "options": 2},
# {"name": "post", "options": 2},
# ],
# ),
SingleShift(sites=["a"], name="help", length=8, days=days[4:], workers_required=1,
#assign_as_block=True,
#force_as_block=True
#constraint=[
# {"name": "pre", "options": 2},
# {"name": "post", "options": 2},
#],
) ,
SingleShift(sites=["b"], name="weekend b", length=8, days=days[5:], workers_required=1,
#constraint=[
# {"name": "pre", "options": 2},
# {"name": "post", "options": 2},
#],
),
)
#print("Adding shifts")
#for worker in Rota.get_workers_by_site("a"):
# print(worker.name)
## worker.allow_shifts_together("oncall weekday", "evening")
## #worker.allow_shifts_together("oncall weekend", "weekend")
## worker.prefer_multi_shift_together = 1
# worker.assign_as_block_preferences = {"help":10}
#for worker in Rota.get_workers_by_site("b"):
# worker.assign_as_block_preferences = {"weekend b":-10}
Rota.build_and_solve()
Rota.export_rota_to_html("test_cons_rota", folder="tests")
assert Rota.results.solver.status == "ok"
+302
View File
@@ -868,3 +868,305 @@ def test_locums():
Rota.export_rota_to_html("test_locums", timestamp_filename=False) Rota.export_rota_to_html("test_locums", timestamp_filename=False)
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_worker_assign_as_block_preference():
"""Test that a worker's assign_as_block preference is respected over the global setting."""
Rota = generate_basic_rota(workers=4, weeks_to_rota=12)
# Worker 1 prefers to have shift 'a' assigned as a block, worker 2 does not
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker4 = workers[3]
# Set a strong preference for worker1 to have 'a' as a block
worker1.assign_as_block_preferences = {"a":1}
worker2.assign_as_block_preferences = {"a":-1} # Worker 2 does not want 'a' as a block
# Add a shift 'a' that is NOT globally set as assign_as_block
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_assign_as_block_preference", folder="tests")
# Worker 1 should have their 'a' shifts grouped together (block), worker 2 may not
shift_list_1 = Rota.get_worker_shift_list_string(worker1)
assert shift_list_1.count("aaaa") == 3, "Worker 1 should have all 'a' shifts grouped together (block)"
shift_list_2 = Rota.get_worker_shift_list_string(worker2)
assert shift_list_2.count("a") == 12, "Worker 2 should have at 12 'a' shift, but may not be grouped together"
shift_list_3 = Rota.get_worker_shift_list_string(worker3)
assert shift_list_3.count("a") == 12, "Worker 3 should have at least 12 'a' shifts, but may not be grouped together"
shift_list_4 = Rota.get_worker_shift_list_string(worker4)
assert shift_list_4.count("a") == 12, "Worker 4 should have at least 12 'a' shifts, but may not be grouped together"
# Modify worker1's preference to prevent block 'a' shifts
worker1.assign_as_block_preferences = {"a":-1}
Rota.build_and_solve(options={"ratio": 0.0})
shift_list_1 = Rota.get_worker_shift_list_string(worker1)
assert shift_list_1.count("aaaa") == 0, "Worker 1 should have all 'a' shifts grouped together (block)"
worker1.assign_as_block_preferences = {"a":0}
a_shift = Rota.get_shift_by_name("a")
a_shift.assign_as_block = True # Set the shift 'a' to be assigned as a block globally
Rota.build_and_solve(options={"ratio": 0.0})
for worker in Rota.get_workers():
shift_list = Rota.get_worker_shift_list_string(worker)
# All workers should have 'a' shifts grouped together (block)
assert shift_list.count("aaaa") == 3, f"Worker {worker.name} should have all 'a' shifts grouped together (block)"
def test_worker_assign_as_block_preference_multiple_shifts():
"""Test that a worker's assign_as_block preference is respected over the global setting."""
Rota = generate_basic_rota(workers=4, weeks_to_rota=12)
# Worker 1 prefers to have shift 'a' assigned as a block, worker 2 does not
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker4 = workers[3]
# Set a strong preference for worker1 to have 'a' as a block
worker1.assign_as_block_preferences = {"a":100}
worker2.assign_as_block_preferences = {"b":-10000} # Worker 2 does not want 'a' as a block
# Add a shift 'a' that is NOT globally set as assign_as_block
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[4:],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
shift_list_1 = Rota.get_worker_shift_list_string(worker1)
assert shift_list_1.count("aaaa") == 3, "Worker 1 should have all 'a' shifts grouped together (block)"
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_assign_as_block_preference", folder="tests")
def test_worker_assign_as_block_preference_multiple_sites():
"""Test that a worker's assign_as_block preference is respected over the global setting."""
Rota = generate_basic_rota(workers=6, weeks_to_rota=12)
# Worker 1 prefers to have shift 'a' assigned as a block, worker 2 does not
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker4 = workers[3]
worker5 = workers[4]
worker6 = workers[5]
# Set a strong preference for worker1 to have 'a' as a block
worker1.assign_as_block_preferences = {"a":1}
worker4.site = "group2"
worker5.site = "group2"
worker6.site = "group2"
worker4.assign_as_block_preferences = {"b":-10000} # Worker 2 does not want 'a' as a block
# Add a shift 'a' that is NOT globally set as assign_as_block
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group2",),
name="b",
length=8,
days=days[4:],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_assign_as_block_preference", folder="tests")
def test_worker_hard_day_dependency():
Rota = generate_basic_rota(workers=6, weeks_to_rota=16)
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker1.add_hard_day_dependency("Mon", "Tue")
worker2.add_hard_day_dependency("Mon", "Wed")
worker3.add_hard_day_dependency("Mon", "Thu")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days,
workers_required=2,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_hard_day_depend", folder="tests")
for worker in Rota.get_workers():
shift_list = Rota.get_worker_shift_list_string(worker)
if worker.name == "worker1":
# Check that every time 'a' is assigned on Monday, it is also assigned on Tuesday
for week in range(12):
mon_idx = week * 7 + days.index("Mon")
tue_idx = week * 7 + days.index("Tue")
if shift_list[mon_idx] == "a":
assert shift_list[tue_idx] == "a", f"Worker1 assigned 'a' on Mon (week {week}) but not on Tue"
if worker.name == "worker2":
# Check that every time 'a' is assigned on Monday, it is also assigned on Wednesday
for week in range(12):
mon_idx = week * 7 + days.index("Mon")
wed_idx = week * 7 + days.index("Wed")
if shift_list[mon_idx] == "a":
assert shift_list[wed_idx] == "a", f"Worker2 assigned 'a' on Mon (week {week}) but not on Wed"
if worker.name == "worker3":
# Check that every time 'a' is assigned on Monday, it is also assigned on Thursday
for week in range(12):
mon_idx = week * 7 + days.index("Mon")
thu_idx = week * 7 + days.index("Thu")
if shift_list[mon_idx] == "a":
assert shift_list[thu_idx] == "a", f"Worker3 assigned 'a' on Mon (week {week}) but not on Thu"
def test_worker_hard_day_dependency_weekend():
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker4 = workers[3]
worker1.add_hard_day_dependency("Fri", "Sun")
worker2.add_hard_day_dependency("Fri", "Sun")
worker3.add_hard_day_dependency("Fri", "Sun")
worker4.add_hard_day_dependency("Fri", "Sun")
worker1.add_hard_day_exclusion("Fri", "Sat")
worker2.add_hard_day_exclusion("Fri", "Sat")
worker3.add_hard_day_exclusion("Fri", "Sat")
worker4.add_hard_day_exclusion("Fri", "Sat")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[4:],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_hard_day_depend", folder="tests")
for worker in Rota.get_workers():
shift_list = Rota.get_worker_shift_list_string(worker)
# Check that every time 'a' is assigned on Monday, it is also assigned on Tuesday
for week in range(16):
fri_idx = week * 7 + days.index("Fri")
sat_idx = week * 7 + days.index("Sat")
sun_idx = week * 7 + days.index("Sun")
if shift_list[fri_idx] == "a":
assert shift_list[sun_idx] == "a", f"Worker1 assigned 'a' on Fri (week {week}) but not on Sun"
assert shift_list[sat_idx] != "a", f"Worker1 assigned 'a' on Fri (week {week}) but also on Sat, which should not happen due to hard day exclusion"
def test_worker_hard_day_dependency_weekend2():
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker4 = workers[3]
worker1.add_hard_day_dependency("Fri", "Sun")
worker2.add_hard_day_dependency("Fri", "Sun")
worker3.add_hard_day_dependency("Fri", "Sun")
worker4.add_hard_day_dependency("Fri", "Sun")
worker4.add_hard_day_dependency("Fri", "Sat")
worker1.add_hard_day_exclusion("Fri", "Sat")
worker2.add_hard_day_exclusion("Fri", "Sat")
worker3.add_hard_day_exclusion("Fri", "Sat")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[4:],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_hard_day_depend", folder="tests")
for worker in Rota.get_workers():
shift_list = Rota.get_worker_shift_list_string(worker)
# Check that every time 'a' is assigned on Monday, it is also assigned on Tuesday
for week in range(16):
fri_idx = week * 7 + days.index("Fri")
sat_idx = week * 7 + days.index("Sat")
sun_idx = week * 7 + days.index("Sun")
if worker != worker4 and shift_list[fri_idx] == "a":
assert shift_list[sun_idx] == "a", f"Worker1 assigned 'a' on Fri (week {week}) but not on Sun"
assert shift_list[sat_idx] != "a", f"Worker1 assigned 'a' on Fri (week {week}) but also on Sat, which should not happen due to hard day exclusion"
if worker == worker4 and shift_list[fri_idx] == "a":
assert shift_list[sun_idx] == "a", f"Worker4 assigned 'a' on Fri (week {week}) but not on Sun"
assert shift_list[sat_idx] == "a", f"Worker4 assigned 'a' on Fri (week {week}) but not on Sun"