feat: add exact shifts functionality to Worker class and update related logic in RotaBuilder
This commit is contained in:
+96
-67
@@ -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"
|
||||
|
||||
@@ -213,6 +213,8 @@ class Worker(BaseModel):
|
||||
|
||||
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
|
||||
avoid_shifts_on_dates: list[AvoidShiftOnDates] = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user