From a54b5db5d2e7dce51c2511cbfbf8f42938a16959 Mon Sep 17 00:00:00 2001 From: Ross Date: Wed, 17 Dec 2025 12:24:26 +0000 Subject: [PATCH] Enhance weekend shift balancing by adding quadratic penalty and adjusting deviation handling --- gen_cons.py | 4 ++- rota_generator/shifts.py | 70 +++++++++++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/gen_cons.py b/gen_cons.py index cceca3e..d71652b 100644 --- a/gen_cons.py +++ b/gen_cons.py @@ -489,8 +489,10 @@ def main( max_shifts_per_week=6, max_days_per_week_block=[(4, 2), (4, 4)], balance_weekend_days=True, - #weekend_day_hard_max_deviation=2, + balance_weekend_days_quadratic=True, + weekend_day_hard_max_deviation=2, balance_weekend_days_use_previous_shifts=True, + balance_weekend_days_weight=500000 ), ) diff --git a/rota_generator/shifts.py b/rota_generator/shifts.py index 2c58079..281abc5 100644 --- a/rota_generator/shifts.py +++ b/rota_generator/shifts.py @@ -352,11 +352,11 @@ class RotaConstraintOptions(BaseModel): balance_weekend_days_weight: int = Field(5, description="Objective weight for balancing weekend days per day") balance_weekend_days_quadratic: bool = Field(False, description="Use quadratic penalty to balance weekend days per day") balance_weekend_days_use_previous_shifts: bool = Field(False, description="When true, adjust per-day weekend (Sat/Sun) targets using worker.previous_shifts (allocated-worked) distributed per shift day") - weekend_day_hard_max_deviation: Optional[int] = Field( + weekend_day_hard_max_deviation: Optional[float] = Field( None, description=( - "Hard maximum allowed absolute deviation (integer) per-worker from the per-day " - "weekend target (Sat/Sun). If set, adds constraints enforcing |assigned - target| <= value" + "Hard maximum allowed absolute deviation (number) per-worker from the per-day " + "weekend target (Sat/Sun). If set, adds constraints enforcing |assigned - target| <= value." ), ) max_weekend_frequency: int = Field(1, description="Don't assign multiple shifts every N weeks (requires balance_weekends)") @@ -1031,11 +1031,13 @@ class RotaBuilder(object): # Per-day weekend balancing (separate Sat / Sun counts) # Also create the bookkeeping variables when a hard deviation constraint is requested + logger.debug(f"Weekend day balancing options: {self.constraint_options_model.balance_weekend_days=}, {self.constraint_options_model.balance_weekend_days_quadratic=}, {self.constraint_options_model.weekend_day_hard_max_deviation=}") if ( self.constraint_options_model.balance_weekend_days or self.constraint_options_model.balance_weekend_days_quadratic or self.constraint_options_model.weekend_day_hard_max_deviation is not None ): + logger.debug("Creating per-day weekend balancing variables") self.model.weekend_day_shift_count = Var( ((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")), domain=NonNegativeReals, @@ -3586,19 +3588,63 @@ class RotaBuilder(object): if self.constraint_options_model.balance_weekend_days_quadratic: wd_weight = self.constraint_options_model.balance_weekend_days_weight # Quadratic penalty: strongly discourage deviations from per-day targets - weekend_day_term = sum( - wd_weight - * ( - self.model.weekend_day_shift_count[(worker.id, day_name)] - - sum( + weekend_day_term = 0 + for worker in self.workers: + for day_name in ("Sat", "Sun"): + # base target derived from shift targets distributed over shift days + base_target = sum( worker.shift_target_number[shift.name] / len(shift.days) for shift in self.get_shifts() if day_name in shift.days ) - ) ** 2 - for worker in self.workers - for day_name in ("Sat", "Sun") - ) + + # optionally include previous_shifts adjustments into the per-day target + prev_adj = 0.0 + if self.use_previous_shifts and self.constraint_options_model.balance_weekend_days_use_previous_shifts: + for shift in self.get_shifts(): + if day_name in shift.days and shift.name in getattr(worker, "previous_shifts", {}): + entry = worker.previous_shifts.get(shift.name) + if isinstance(entry, dict): + # prefer explicit per-day entry + if day_name in entry: + try: + worked = float(entry[day_name][0]) + allocated = float(entry[day_name][1]) + prev_adj += (allocated - worked) + except Exception: + continue + elif "__all__" in entry: + try: + worked = float(entry["__all__"][0]) + allocated = float(entry["__all__"][1]) + prev_adj += (allocated - worked) / max(1, len(shift.days)) + except Exception: + continue + else: + # sum any tuple/list values found in the dict (spread over days) + try: + total_worked = 0.0 + total_alloc = 0.0 + for v in entry.values(): + if isinstance(v, (list, tuple)): + total_worked += float(v[0]) + total_alloc += float(v[1]) + prev_adj += (total_alloc - total_worked) / max(1, len(shift.days)) + except Exception: + continue + else: + try: + worked = float(entry[0]) + allocated = float(entry[1]) + prev_adj += (allocated - worked) / max(1, len(shift.days)) + except Exception: + continue + + adjusted_target = base_target + prev_adj + + weekend_day_term += wd_weight * ( + self.model.weekend_day_shift_count[(worker.id, day_name)] - adjusted_target + ) ** 2 elif self.constraint_options_model.balance_weekend_days: weekend_day_modifier = self.constraint_options_model.balance_weekend_days_weight weekend_day_term = sum(