feat: add exact shifts functionality to Worker class and update related logic in RotaBuilder

This commit is contained in:
Ross
2026-07-08 22:33:33 +01:00
parent 02598cb665
commit 0f31dfe1a9
7 changed files with 398 additions and 102 deletions
+3
View File
@@ -45,6 +45,7 @@ class WorkerForm(forms.ModelForm):
# Complex structured options edited as JSON # Complex structured options edited as JSON
assign_as_block_preferences = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->weight", label="Block preferences (JSON)") assign_as_block_preferences = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->weight", label="Block preferences (JSON)")
shift_fte_overrides = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->fte", label="Shift FTE overrides (JSON)") shift_fte_overrides = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->fte", label="Shift FTE overrides (JSON)")
exact_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->exact_count", label="Exact shifts (JSON)")
previous_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="Free JSON structure for previous shifts", label="Previous shifts (JSON)") previous_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="Free JSON structure for previous shifts", label="Previous shifts (JSON)")
shift_balance_extra = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON structure for extra shift-balance penalties", label="Shift balance extra (JSON)") shift_balance_extra = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON structure for extra shift-balance penalties", label="Shift balance extra (JSON)")
allowed_multi_shift_sets = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of sets (e.g. [[\"a\",\"b\"], ...])", label="Allowed multi-shift sets (JSON)") allowed_multi_shift_sets = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of sets (e.g. [[\"a\",\"b\"], ...])", label="Allowed multi-shift sets (JSON)")
@@ -94,6 +95,7 @@ class WorkerForm(forms.ModelForm):
for json_fld in [ for json_fld in [
"assign_as_block_preferences", "assign_as_block_preferences",
"shift_fte_overrides", "shift_fte_overrides",
"exact_shifts",
"previous_shifts", "previous_shifts",
"shift_balance_extra", "shift_balance_extra",
"allowed_multi_shift_sets", "allowed_multi_shift_sets",
@@ -146,6 +148,7 @@ class WorkerForm(forms.ModelForm):
json_fields = [ json_fields = [
"assign_as_block_preferences", "assign_as_block_preferences",
"shift_fte_overrides", "shift_fte_overrides",
"exact_shifts",
"previous_shifts", "previous_shifts",
"shift_balance_extra", "shift_balance_extra",
"allowed_multi_shift_sets", "allowed_multi_shift_sets",
+1
View File
@@ -229,6 +229,7 @@ class Worker(models.Model):
"max_shifts_per_week_by_shift_name", "max_shifts_per_week_by_shift_name",
"assign_as_block_preferences", "assign_as_block_preferences",
"shift_fte_overrides", "shift_fte_overrides",
"exact_shifts",
] ]
list_keys = [ list_keys = [
"oop", "oop",
+72 -34
View File
@@ -30,6 +30,10 @@ sites = (
"plymouth", "plymouth",
"taunton", "taunton",
"proc", "proc",
"plymouth ir",
"truro ir",
"exeter ir",
"torbay ir",
) )
from rota_generator.workers import ( from rota_generator.workers import (
@@ -49,7 +53,7 @@ def main(
time_to_run: int = 60 * 60 * 10, time_to_run: int = 60 * 60 * 10,
ratio: float = 0.1, ratio: float = 0.1,
start_date: datetime.datetime = "2026-09-07", start_date: datetime.datetime = "2026-09-07",
weeks: int = 26, weeks: int = 12,
bom: float = 1, bom: float = 1,
): ):
rota_start_date = start_date.date() rota_start_date = start_date.date()
@@ -65,13 +69,13 @@ def main(
Rota.constraint_options["balance_shifts"] = False Rota.constraint_options["balance_shifts"] = False
Rota.constraint_options["balance_shifts_quadratic"] = True Rota.constraint_options["balance_shifts_quadratic"] = True
Rota.constraint_options["balance_weekends"] = True Rota.constraint_options["balance_weekends"] = True
#Rota.constraint_options["max_night_frequency"] = 4 # 3 # Rota.constraint_options["max_night_frequency"] = 4 # 3
Rota.constraint_options["max_night_frequency_week_exclusions"] = [] Rota.constraint_options["max_night_frequency_week_exclusions"] = []
#Rota.constraint_options["max_weekend_frequency"] = 3 # Rota.constraint_options["max_weekend_frequency"] = 3
#Rota.constraint_options["max_days_per_week_block"] = [(8,4)] # Rota.constraint_options["max_days_per_week_block"] = [(8,4)]
#wr = [ # wr = [
# WorkerRequirement( # WorkerRequirement(
# end_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(), # end_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
# number=3, # number=3,
@@ -80,7 +84,7 @@ def main(
# start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(), # start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
# number=4, # number=4,
# ), # ),
#] # ]
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
@@ -89,6 +93,7 @@ def main(
"exeter twilights and weekends", "exeter twilights and weekends",
"exeter no nights", "exeter no nights",
"exeter twilights", "exeter twilights",
"exeter ir",
), ),
name="exeter_twilight", name="exeter_twilight",
length=12.5, length=12.5,
@@ -104,6 +109,7 @@ def main(
"truro twilights and weekends", "truro twilights and weekends",
"truro twilights and weekend nights", "truro twilights and weekend nights",
"truro twilights, weekends and weekend nights", "truro twilights, weekends and weekend nights",
"truro ir",
), ),
name="truro_twilight", name="truro_twilight",
length=12.5, length=12.5,
@@ -112,7 +118,12 @@ def main(
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)], constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
), ),
SingleShift( SingleShift(
sites=("torbay", "torbay twilights", "torbay twilights and weekends"), sites=(
"torbay",
"torbay twilights",
"torbay twilights and weekends",
"torbay ir",
),
name="torbay_twilight", name="torbay_twilight",
length=12.5, length=12.5,
days=days[:4], days=days[:4],
@@ -125,6 +136,7 @@ def main(
"plymouth twilights", "plymouth twilights",
"plymouth twilights and weekends", "plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights", "plymouth twilights, weekends and weekend nights",
"plymouth ir",
), ),
name="plymouth_twilight", name="plymouth_twilight",
length=12.5, length=12.5,
@@ -139,12 +151,12 @@ def main(
"exeter twilights and weekends", "exeter twilights and weekends",
"exeter no nights", "exeter no nights",
"exeter weekends", "exeter weekends",
#"exeter twilights", # "exeter twilights",
), ),
name="weekend_exeter", name="weekend_exeter",
length=12.5, length=12.5,
days=days[5:], days=days[5:],
#balance_offset=2, # balance_offset=2,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)], constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)],
@@ -161,10 +173,10 @@ def main(
name="weekend_truro", name="weekend_truro",
length=12.5, length=12.5,
days=days[4:], days=days[4:],
#balance_offset=3, # balance_offset=3,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
#assign_as_block=True, # assign_as_block=True,
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)], constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
# force_as_block_unless_nwd=True # force_as_block_unless_nwd=True
), ),
@@ -173,7 +185,7 @@ def main(
name="weekend_torbay", name="weekend_torbay",
length=12.5, length=12.5,
days=days[4:], days=days[4:],
#balance_offset=2, # balance_offset=2,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)], constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
@@ -189,7 +201,7 @@ def main(
name="weekend_plymouth1", name="weekend_plymouth1",
length=8, length=8,
days=days[5:], days=days[5:],
#balance_offset=2.9, # balance_offset=2.9,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
@@ -204,7 +216,7 @@ def main(
name="weekend_plymouth2", name="weekend_plymouth2",
length=8, length=8,
days=days[5:], days=days[5:],
#balance_offset=2.9, # balance_offset=2.9,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
@@ -216,6 +228,7 @@ def main(
"plymouth twilights", "plymouth twilights",
"plymouth twilights and weekends", "plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights", "plymouth twilights, weekends and weekend nights",
"plymouth ir",
), ),
name="plymouth_bank_holidays", name="plymouth_bank_holidays",
length=8, length=8,
@@ -227,11 +240,13 @@ def main(
bank_holidays_only=True, bank_holidays_only=True,
), ),
SingleShift( SingleShift(
sites=sites, sites=[
*sites,
],
name="night_weekday", name="night_weekday",
length=12.25, length=12.25,
days=days[:4], days=days[:4],
#balance_offset=3.9, # balance_offset=3.9,
balance_weighting=1, balance_weighting=1,
# hard_constrain_shift=False, # hard_constrain_shift=False,
workers_required=4, workers_required=4,
@@ -245,7 +260,7 @@ def main(
LimitGradeNumberConstraint(grade=2, max_number=2), LimitGradeNumberConstraint(grade=2, max_number=2),
MinimumGradeNumberConstraint(grade=4, min_number=1), MinimumGradeNumberConstraint(grade=4, min_number=1),
], ],
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(), # end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
), ),
SingleShift( SingleShift(
sites=[ sites=[
@@ -258,7 +273,7 @@ def main(
name="night_weekend", name="night_weekend",
length=12.25, length=12.25,
days=days[4:], days=days[4:],
#balance_offset=2.9, # balance_offset=2.9,
balance_weighting=1, balance_weighting=1,
# hard_constrain_shift=False, # hard_constrain_shift=False,
workers_required=4, workers_required=4,
@@ -272,15 +287,19 @@ def main(
LimitGradeNumberConstraint(grade=2, max_number=2), LimitGradeNumberConstraint(grade=2, max_number=2),
MinimumGradeNumberConstraint(grade=4, min_number=1), MinimumGradeNumberConstraint(grade=4, min_number=1),
], ],
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(), # end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
), ),
) )
# Prevent shifts for ST2s for 2 weeks # Prevent shifts for ST2s for 2 weeks
#Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts]) # Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6) Rota.add_min_summed_grade_by_shifts_per_day_constraint(
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6) ["weekend_plymouth1", "weekend_plymouth2"], 6
)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
["plymouth_twilight", "plymouth_bank_holidays"], 6
)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekday"], 12) Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekday"], 12)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekend"], 12) Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekend"], 12)
@@ -327,7 +346,7 @@ def main(
after_count = len(filtered) after_count = len(filtered)
if after_count != before_count: if after_count != before_count:
print( print(
f"Removed {before_count-after_count} leave entries for '{name}' between {_from} and {_to}" f"Removed {before_count - after_count} leave entries for '{name}' between {_from} and {_to}"
) )
w["leave"] = filtered w["leave"] = filtered
@@ -375,9 +394,9 @@ def main(
nwd_end_date = Rota.rota_end_date nwd_end_date = Rota.rota_end_date
if "[" in i: if "[" in i:
#print("Split nwd", worker_name, i) # print("Split nwd", worker_name, i)
a, b = i.split("[")[1][:-1].split("-") a, b = i.split("[")[1][:-1].split("-")
#print(f"{a=} {b=}") # print(f"{a=} {b=}")
nwd_start_date = datetime.datetime.strptime( nwd_start_date = datetime.datetime.strptime(
a, "%d/%m/%Y" a, "%d/%m/%Y"
).date() ).date()
@@ -406,7 +425,9 @@ def main(
elif "to" in raw: elif "to" in raw:
s, e = raw.split("to", 1) s, e = raw.split("to", 1)
else: else:
raise ValueError(f"Cannot parse OOP dates: '{oop}' for {worker}") raise ValueError(
f"Cannot parse OOP dates: '{oop}' for {worker}"
)
s = s.strip().strip("()[]\"' ") s = s.strip().strip("()[]\"' ")
e = e.strip().strip("()[]\"' ") e = e.strip().strip("()[]\"' ")
@@ -416,7 +437,9 @@ def main(
try: try:
start_dt = datetime.datetime.strptime(s, fmt).date() start_dt = datetime.datetime.strptime(s, fmt).date()
end_dt = datetime.datetime.strptime(e, fmt).date() end_dt = datetime.datetime.strptime(e, fmt).date()
formatted_oops.append({"start_date": start_dt, "end_date": end_dt}) formatted_oops.append(
{"start_date": start_dt, "end_date": end_dt}
)
parsed = True parsed = True
break break
except ValueError: except ValueError:
@@ -425,13 +448,15 @@ def main(
if not parsed: if not parsed:
print("WORKER", worker) print("WORKER", worker)
print("DATES", s, e) print("DATES", s, e)
raise ValueError(f"Cannot parse OOP dates: '{oop}' for {worker}") raise ValueError(
f"Cannot parse OOP dates: '{oop}' for {worker}"
)
oop = formatted_oops oop = formatted_oops
else: else:
oop = [] oop = []
#print(f"{worker} OOP {oop}") # print(f"{worker} OOP {oop}")
previous_shifts = {} previous_shifts = {}
# if twi: # if twi:
@@ -465,16 +490,29 @@ def main(
shift_fte_overrides = {} shift_fte_overrides = {}
#if worker_name == "Hadi Mohamed": exact_shifts = {}
if worker in ["hannah.lewis29@nhs.net", "shruti.bodapati@nhs.net", "a.abouelatta@nnhs.net"]:
exact_shifts = {
"night_weekday": 4,
"night_weekend": 3,
}
elif worker in ["g.abdelhalim@nhs.net", ""]:
exact_shifts = {
"night_weekend": 3,
"night_weekday": 0,
}
# if worker_name == "Hadi Mohamed":
# shift_fte_overrides = { # shift_fte_overrides = {
# "weekend_exeter": 50, # "weekend_exeter": 50,
# } # }
#elif worker_name == "Joel Lim": # elif worker_name == "Joel Lim":
# shift_fte_overrides = { # shift_fte_overrides = {
# "plymouth_twilight": 100, # "plymouth_twilight": 100,
# "weekend_exeter": 50, # "weekend_exeter": 50,
# } # }
#if worker_name == "Nang Thiriphoo": # if worker_name == "Nang Thiriphoo":
# shift_fte_overrides = { # shift_fte_overrides = {
# "weekend_exeter": 40, # "weekend_exeter": 40,
# } # }
@@ -497,9 +535,10 @@ def main(
shift_balance_extra=w["shift_balance_extra"], shift_balance_extra=w["shift_balance_extra"],
bank_holiday_extra=w["bank_holiday_extra"], bank_holiday_extra=w["bank_holiday_extra"],
shift_fte_overrides=shift_fte_overrides, shift_fte_overrides=shift_fte_overrides,
exact_shifts=exact_shifts,
) )
#print(w) # print(w)
Rota.add_worker(w) Rota.add_worker(w)
leave.load_academy(Rota) leave.load_academy(Rota)
@@ -507,7 +546,6 @@ def main(
# Rota.build_workers() # Rota.build_workers()
# Rota.build_model() # Rota.build_model()
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10} solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
# solver_options = {"seconds": time_to_run, "threads": 10} # solver_options = {"seconds": time_to_run, "threads": 10}
+15
View File
@@ -219,6 +219,21 @@ def load_leave(Rota):
#elif row_title == "Academy": #elif row_title == "Academy":
# pass # pass
#elif "night specific" in row_title:
# if lower_item:
# for shift in lower_item.split(","):
# shift = shift.strip()
# if shift == "nights":
# shift = "night_weekday"
# elif shift == "nights only":
# if date.weekday() < 5:
# shift = "night_weekday"
# else:
# shift = "night_weekend"
# worker["single_nights"].append(
# WorkRequests(date=date, shift=shift)
# )
elif re.match(date_re, row_date) is not None: elif re.match(date_re, row_date) is not None:
if lower_item != "": if lower_item != "":
+96 -67
View File
@@ -2648,89 +2648,102 @@ class RotaBuilder(object):
): ):
if ( if (
worker.site in shift.sites worker.site in shift.sites
): # Each site specfies which sites self.workers can fullfill it ): # Each site specfies which sites self.workers can fullfill
# 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_worker_counts[shift.name] total_shifts = self.shift_worker_counts[shift.name]
full_time_equivalent_joined = sum( # Find workers who have exact shifts for this shift type
self.full_time_equivalent_sites[i][shift.name] exact_workers = [
for i in shift.sites w for w in self.workers
) if w.site in shift.sites and shift.name in getattr(w, "exact_shifts", {})
]
exact_shifts_sum = sum(w.exact_shifts[shift.name] for w in exact_workers)
# Guard against division by zero: if no full-time if exact_shifts_sum > total_shifts:
# equivalent is available for the shift's sites the warning_msg = f"For shift {shift.name}, total exact shifts ({exact_shifts_sum}) exceeds total required shifts ({total_shifts})."
# denominator may be zero (for example when all site if not any(warn[1] == warning_msg for warn in self.warnings):
# FTEs are 0). In that case emit a warning and set the self.add_warning("Exact shifts exceed total shifts", warning_msg)
# target to 0 rather than raising an exception.
if not full_time_equivalent_joined: if worker in exact_workers:
# avoid ZeroDivisionError target_shifts = worker.exact_shifts[shift.name]
self.add_warning(
"Zero FTE for sites",
f"full_time_equivalent sum is zero for shift {shift.name}; setting target_shifts=0 for worker {worker.name}",
)
target_shifts = 0
else: else:
target_shifts = ( remaining_shifts = total_shifts - exact_shifts_sum
total_shifts # Sum FTE only for workers who do not have exact shifts for this shift type
/ full_time_equivalent_joined full_time_equivalent_joined = sum(
* worker.get_fte(shift=shift.name) w.get_fte(shift=shift.name)
for w in self.workers
if w.site in shift.sites and w not in exact_workers
) )
if self.use_previous_shifts: if not full_time_equivalent_joined:
if shift.name in worker.previous_shifts: # avoid ZeroDivisionError
entry = worker.previous_shifts[shift.name] if remaining_shifts > 0:
# entry may be a tuple (worked, allocated) or a dict of per-day tuples self.add_warning(
if isinstance(entry, dict): "Zero FTE for sites with remaining shifts",
# prefer overall tuple if present f"full_time_equivalent sum is zero for shift {shift.name} but remaining_shifts = {remaining_shifts}; setting target_shifts=0 for worker {worker.name}",
if "__all__" in entry: )
try: target_shifts = 0
worked = float(entry["__all__"][0]) else:
allocated = float(entry["__all__"][1]) target_shifts = (
target_shifts = target_shifts + allocated - worked max(0.0, float(remaining_shifts))
except Exception: / full_time_equivalent_joined
pass * worker.get_fte(shift=shift.name)
)
if worker not in exact_workers:
if self.use_previous_shifts:
if shift.name in worker.previous_shifts:
entry = worker.previous_shifts[shift.name]
# entry may be a tuple (worked, allocated) or a dict of per-day tuples
if isinstance(entry, dict):
# prefer overall tuple if present
if "__all__" in entry:
try:
worked = float(entry["__all__"][0])
allocated = float(entry["__all__"][1])
target_shifts = target_shifts + allocated - worked
except Exception:
pass
else:
# sum any per-day entries
try:
total_worked = 0.0
total_alloc = 0.0
for v in entry.values():
total_worked += float(v[0])
total_alloc += float(v[1])
target_shifts = target_shifts + total_alloc - total_worked
except Exception:
pass
else: else:
# sum any per-day entries
try: try:
total_worked = 0.0 worked, allocated = entry
total_alloc = 0.0 target_shifts = (
for v in entry.values(): target_shifts + float(allocated) - float(worked)
total_worked += float(v[0]) )
total_alloc += float(v[1])
target_shifts = target_shifts + total_alloc - total_worked
except Exception: except Exception:
pass pass
else:
try:
worked, allocated = entry
target_shifts = (
target_shifts + float(allocated) - float(worked)
)
except Exception:
pass
if self.use_shift_balance_extra: if self.use_shift_balance_extra:
if shift.name in worker.shift_balance_extra: if shift.name in worker.shift_balance_extra:
extra = worker.shift_balance_extra[shift.name] extra = worker.shift_balance_extra[shift.name]
# TODO look at how this affects allocation (how does it affect fte) # TODO look at how this affects allocation (how does it affect fte)
match extra: match extra:
case "double": case "double":
target_shifts = target_shifts * 2 target_shifts = target_shifts * 2
case "half": case "half":
target_shifts = target_shifts / 2 target_shifts = target_shifts / 2
case _: case _:
target_shifts = target_shifts + extra target_shifts = target_shifts + extra
# print(worker.name, shift.name, target_shifts) # print(worker.name, shift.name, target_shifts)
worker.shift_target_number[shift.name] = target_shifts worker.shift_target_number[shift.name] = target_shifts
if shift.hard_constrain_shift: if worker in exact_workers:
self.model.constraints.add(
self.model.shift_count[worker.id, shift.name] == target_shifts
)
elif shift.hard_constrain_shift:
adjusted_balance_offset = shift.balance_offset adjusted_balance_offset = shift.balance_offset
if worker.get_fte(shift=shift.name) < 100: if worker.get_fte(shift=shift.name) < 100:
adjusted_balance_offset = ( adjusted_balance_offset = (
@@ -4244,6 +4257,9 @@ class RotaBuilder(object):
Must be called prior to attempting to solve Must be called prior to attempting to solve
""" """
if not hasattr(self, "shifts_by_name"):
self.build_shifts()
if not self.workers: if not self.workers:
raise NoWorkers("Workers must be added prior to calling build_workers") raise NoWorkers("Workers must be added prior to calling build_workers")
@@ -4253,6 +4269,19 @@ class RotaBuilder(object):
for worker in track(self.workers, description="Building workers"): for worker in track(self.workers, description="Building workers"):
print(worker.name) print(worker.name)
worker.load_rota(self) worker.load_rota(self)
for s_name in getattr(worker, "exact_shifts", {}):
try:
s = self.get_shift_by_name(s_name)
if worker.site not in s.sites:
self.add_warning(
"Invalid exact shift site",
f"Worker {worker.name} requested exact shifts for shift {s_name} but their site {worker.site} is not in the shift sites {s.sites}",
)
except KeyError:
self.add_warning(
"Invalid exact shift",
f"Worker {worker.name} requested exact shifts for non-existent shift {s_name}",
)
wid = worker.id wid = worker.id
if wid in self.workers_id_map: if wid in self.workers_id_map:
message = f"Worker with id '{wid}' has been added twice" message = f"Worker with id '{wid}' has been added twice"
+2
View File
@@ -213,6 +213,8 @@ class Worker(BaseModel):
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
exact_shifts: dict[str, int] = {} # Map shift_name to exact number of shifts
weekend_shift_target_number: float = 0.0 weekend_shift_target_number: float = 0.0
avoid_shifts_on_dates: list[AvoidShiftOnDates] = [] avoid_shifts_on_dates: list[AvoidShiftOnDates] = []
+209 -1
View File
@@ -1624,4 +1624,212 @@ def test_worker_hard_day_exclusion2():
for week in range(16): for week in range(16):
sat_idx = week * 7 + days.index("Sat") sat_idx = week * 7 + days.index("Sat")
sun_idx = week * 7 + days.index("Sun") sun_idx = week * 7 + days.index("Sun")
assert not (shift_list[sat_idx] == "a" and shift_list[sun_idx] == "a"), f"Worker {worker.name} has 'a' on both Mon and Tue in week {week}, which should not happen due to hard day exclusion" assert not (shift_list[sat_idx] == "a" and shift_list[sun_idx] == "a"), f"Worker {worker.name} has 'a' on both Mon and Tue in week {week}, which should not happen due to hard day exclusion"
def test_exact_shifts_distribution():
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
workers = [
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
Worker(name="worker2", site="group1", grade=1, fte=100),
Worker(name="worker3", site="group1", grade=1, fte=50),
]
Rota.add_workers(workers)
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
hard_constrain_shift=True,
balance_offset=0,
),
)
Rota.build_and_solve()
assert Rota.workers_name_map["worker1"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker2"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker3"].shift_target_number["a"] == 2
# Verify actual assigned counts are exactly correct
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
for week, day, shiftname in Rota.get_all_shiftname_combinations():
for worker in Rota.get_workers():
val = Rota.model.works[worker.id, week, day, shiftname].value
if val is not None and val > 0.5:
if shiftname == "a":
worker_shift_counts[worker.name] += 1
assert worker_shift_counts["worker1"] == 4
assert worker_shift_counts["worker2"] == 4
assert worker_shift_counts["worker3"] == 2
def test_exact_shifts_distribution_unbalanced():
weeks_to_rota = 16
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
workers = [
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
Worker(name="worker2", site="group1", grade=1, fte=100),
Worker(name="worker3", site="group1", grade=1, fte=50),
]
Rota.add_workers(workers)
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
hard_constrain_shift=True,
balance_offset=0,
),
SingleShift(
sites=("group1",),
name="b",
length=12.5,
days=("Tue",),
workers_required=1,
hard_constrain_shift=True,
balance_offset=0.8,
),
)
Rota.build_and_solve()
Rota.export_rota_to_html("test_exact_shifts_distribution_unbalanced", folder="tests")
assert Rota.results.solver.status == "ok"
assert Rota.workers_name_map["worker1"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker2"].shift_target_number["a"] == 8
assert Rota.workers_name_map["worker3"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker1"].shift_target_number["b"] == 6.4
assert Rota.workers_name_map["worker2"].shift_target_number["b"] == 6.4
assert Rota.workers_name_map["worker3"].shift_target_number["b"] == 3.2
# Verify actual assigned counts are exactly correct
worker_shift_counts_a = {w.name: 0 for w in Rota.get_workers()}
worker_shift_counts_b = {w.name: 0 for w in Rota.get_workers()}
for week, day, shiftname in Rota.get_all_shiftname_combinations():
for worker in Rota.get_workers():
val = Rota.model.works[worker.id, week, day, shiftname].value
if val is not None and val > 0.5:
if shiftname == "a":
worker_shift_counts_a[worker.name] += 1
elif shiftname == "b":
worker_shift_counts_b[worker.name] += 1
assert worker_shift_counts_a["worker1"] == 4
assert worker_shift_counts_a["worker2"] == 8
assert worker_shift_counts_a["worker3"] == 4
assert worker_shift_counts_b["worker1"] in (6, 7) # Allowing for rounding differences
assert worker_shift_counts_b["worker2"] in (6, 7) # Allowing for rounding differences
assert worker_shift_counts_b["worker3"] in (3, 4) # Allowing for rounding differences
def test_exact_shifts_invalid_site_warning():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group2", grade=1, fte=100, exact_shifts={"a": 4}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
SingleShift(
sites=("group2",),
name="b",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_workers()
warnings = Rota.get_warnings("Invalid exact shift site")
assert len(warnings) > 0
assert "group2 is not in the shift sites" in warnings[0][1]
def test_exact_shifts_invalid_shift_warning():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"nonexistent": 4}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_workers()
warnings = Rota.get_warnings("Invalid exact shift")
assert len(warnings) > 0
assert "non-existent shift nonexistent" in warnings[0][1]
def test_exact_shifts_exceed_total_warning():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_workers()
Rota.build_model()
warnings = Rota.get_warnings("Exact shifts exceed total shifts")
assert len(warnings) > 0
assert "total exact shifts (4) exceeds total required shifts" in warnings[0][1]