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
+96 -67
View File
@@ -2648,89 +2648,102 @@ class RotaBuilder(object):
):
if (
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.days) *
# TODO: check if shift_counts is still required?
# total_shifts = (
# self.shift_counts[shift.name] * shift.workers_required
# )
): # Each site specfies which sites self.workers can fullfill
total_shifts = self.shift_worker_counts[shift.name]
full_time_equivalent_joined = sum(
self.full_time_equivalent_sites[i][shift.name]
for i in shift.sites
)
# Find workers who have exact shifts for this shift type
exact_workers = [
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
# equivalent is available for the shift's sites the
# denominator may be zero (for example when all site
# FTEs are 0). In that case emit a warning and set the
# target to 0 rather than raising an exception.
if not full_time_equivalent_joined:
# avoid ZeroDivisionError
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
if exact_shifts_sum > total_shifts:
warning_msg = f"For shift {shift.name}, total exact shifts ({exact_shifts_sum}) exceeds total required shifts ({total_shifts})."
if not any(warn[1] == warning_msg for warn in self.warnings):
self.add_warning("Exact shifts exceed total shifts", warning_msg)
if worker in exact_workers:
target_shifts = worker.exact_shifts[shift.name]
else:
target_shifts = (
total_shifts
/ full_time_equivalent_joined
* worker.get_fte(shift=shift.name)
remaining_shifts = total_shifts - exact_shifts_sum
# Sum FTE only for workers who do not have exact shifts for this shift type
full_time_equivalent_joined = sum(
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 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
if not full_time_equivalent_joined:
# avoid ZeroDivisionError
if remaining_shifts > 0:
self.add_warning(
"Zero FTE for sites with remaining shifts",
f"full_time_equivalent sum is zero for shift {shift.name} but remaining_shifts = {remaining_shifts}; setting target_shifts=0 for worker {worker.name}",
)
target_shifts = 0
else:
target_shifts = (
max(0.0, float(remaining_shifts))
/ full_time_equivalent_joined
* 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:
# 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
worked, allocated = entry
target_shifts = (
target_shifts + float(allocated) - float(worked)
)
except Exception:
pass
else:
try:
worked, allocated = entry
target_shifts = (
target_shifts + float(allocated) - float(worked)
)
except Exception:
pass
if self.use_shift_balance_extra:
if shift.name in worker.shift_balance_extra:
extra = worker.shift_balance_extra[shift.name]
if self.use_shift_balance_extra:
if shift.name in worker.shift_balance_extra:
extra = worker.shift_balance_extra[shift.name]
# TODO look at how this affects allocation (how does it affect fte)
match extra:
case "double":
target_shifts = target_shifts * 2
case "half":
target_shifts = target_shifts / 2
case _:
target_shifts = target_shifts + extra
# TODO look at how this affects allocation (how does it affect fte)
match extra:
case "double":
target_shifts = target_shifts * 2
case "half":
target_shifts = target_shifts / 2
case _:
target_shifts = target_shifts + extra
# print(worker.name, 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
if worker.get_fte(shift=shift.name) < 100:
adjusted_balance_offset = (
@@ -4244,6 +4257,9 @@ class RotaBuilder(object):
Must be called prior to attempting to solve
"""
if not hasattr(self, "shifts_by_name"):
self.build_shifts()
if not self.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"):
print(worker.name)
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
if wid in self.workers_id_map:
message = f"Worker with id '{wid}' has been added twice"