From 83444ac402fb7b207276c2f45fd91d40f2d0c58f Mon Sep 17 00:00:00 2001 From: Ross Date: Wed, 17 Dec 2025 10:43:37 +0000 Subject: [PATCH] Add weekend day balancing options and summary counts for shifts --- gen_cons.py | 2 + output/timetable.js | 12 ++ rota_generator/shifts.py | 271 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 283 insertions(+), 2 deletions(-) diff --git a/gen_cons.py b/gen_cons.py index 4f4843d..157c77c 100644 --- a/gen_cons.py +++ b/gen_cons.py @@ -342,6 +342,8 @@ def main( balance_weekends=True, max_shifts_per_week=6, max_days_per_week_block=[(4, 2), (4, 3)], + balance_weekend_days=True, + #weekend_day_hard_max_deviation=2, ), ) diff --git a/output/timetable.js b/output/timetable.js index 299b49a..e7e36e4 100644 --- a/output/timetable.js +++ b/output/timetable.js @@ -173,9 +173,21 @@ function generateExtra() { worker_td.get(0).dataset.shiftDiffSummed = summed_shift_diff.toPrecision(2); + // compute separate Sat / Sun counts for this worker + let sat_count = 0; + let sun_count = 0; + for (let i = 1; i <= weeks; i++) { + let sat = $(tr).find(`[data-week='${i}'][data-day='Sat']`).attr("data-shift"); + let sun = $(tr).find(`[data-week='${i}'][data-day='Sun']`).attr("data-shift"); + if (sat && sat !== "") sat_count += 1; + if (sun && sun !== "") sun_count += 1; + } + worker_td.after(`
Total shifts: ${total_shifts}, Weekends: ${weekends_worked}, + Sat: ${sat_count}, + Sun: ${sun_count}, Nights: ${nights_worked}, Bank holidays: ${bank_holidays_worked}, Summed shift diff: ${summed_shift_diff.toPrecision(2)}, diff --git a/rota_generator/shifts.py b/rota_generator/shifts.py index 8b917fa..459bd62 100644 --- a/rota_generator/shifts.py +++ b/rota_generator/shifts.py @@ -345,6 +345,19 @@ class RotaConstraintOptions(BaseModel): maximum_allowed_shift_diff: Optional[int] = Field(None, description="Maximum allowed per-worker shift difference (None = no limit)") balance_weekends: bool = Field(True, description="Balance weekend allocations") max_weekends: int = Field(100, description="Maximum number of weekends considered") + # If true, try to balance number of weekend days worked per worker separately + # for Saturday and Sunday (i.e. equivalent workers should have similar Sat counts + # and similar Sun counts). + balance_weekend_days: bool = Field(False, description="Balance weekend days (Sat/Sun) separately") + 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") + weekend_day_hard_max_deviation: Optional[int] = 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" + ), + ) max_weekend_frequency: int = Field(1, description="Don't assign multiple shifts every N weeks (requires balance_weekends)") max_night_frequency: int = Field(1, description="Don't assign multiple night shifts every N weeks") max_night_frequency_week_exclusions: List[int] = Field(default_factory=list, description="Weeks to exclude from night frequency checks") @@ -421,7 +434,6 @@ class RotaBuilder(object): # None (use defaults), a dict (raw values), or an existing model instance. # Store the model instance in `self.constraint_options_model` and also # expose it as `self.constraint_options` for minimal code changes. - constraint_options = None if constraint_options is None: opts_model = RotaConstraintOptions() elif isinstance(constraint_options, RotaConstraintOptions): @@ -433,6 +445,8 @@ class RotaBuilder(object): self.constraint_options_model: RotaConstraintOptions = opts_model + logger.debug(f"Rota constraint options: {self.constraint_options_model.model_dump()}") + self.terminate_on_warning = [ "Worker/duplicate id", "Worker/duplicate name", @@ -1014,6 +1028,37 @@ class RotaBuilder(object): initialize=0, ) + # Per-day weekend balancing (separate Sat / Sun counts) + # Also create the bookkeeping variables when a hard deviation constraint is requested + 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 + ): + self.model.weekend_day_shift_count = Var( + ((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")), + domain=NonNegativeReals, + initialize=0, + ) + + self.model.weekend_day_shift_count_t1 = Var( + ((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")), + domain=NonNegativeReals, + initialize=0, + ) + + self.model.weekend_day_shift_count_t2 = Var( + ((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")), + domain=NonNegativeReals, + initialize=0, + ) + + self.model.weekend_day_shift_count_w = Var( + ((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")), + domain=NonNegativeReals, + initialize=0, + ) + # self.model.weekend_count_t1 = Var( # ((worker.id) for worker in self.workers), # domain=NonNegativeReals, @@ -2459,6 +2504,99 @@ class RotaBuilder(object): - xU * xU ) + # Per-day weekend balancing (Sat and Sun separately) + logger.debug(f"Adding per-day weekend balancing constraints for worker {worker.name} ({worker.id})") + logger.debug(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(f"Worker {worker.name}: per-day weekend block active (hard_dev={self.constraint_options_model.weekend_day_hard_max_deviation})") + console.print(f"Worker {worker.name}: per-day weekend block active (hard_dev={self.constraint_options_model.weekend_day_hard_max_deviation})") + hard_weekend_constraints_added = 0 + for day_name in ("Sat", "Sun"): + # count assignments on this specific weekend day + self.model.constraints.add( + self.model.weekend_day_shift_count[worker.id, day_name] + == sum( + self.model.works[worker.id, w, d, shift] + for w, d, shift in self.get_all_shiftname_combinations() + if d == day_name + ) + ) + + # target number for this day (derived from shift targets) + weekend_day_shift_target_number = sum( + worker.shift_target_number[shift.name] / len(shift.days) + for shift in self.get_shifts() + if day_name in shift.days + ) + + self.model.constraints.add( + self.model.weekend_day_shift_count_t1[worker.id, day_name] + - self.model.weekend_day_shift_count_t2[worker.id, day_name] + == self.model.weekend_day_shift_count[worker.id, day_name] + - weekend_day_shift_target_number + ) + + xU_day = self.constraint_options_model.max_weekends + xL_day = 1 + self.model.constraints.add( + inequality( + xL_day, + self.model.weekend_day_shift_count_t1[worker.id, day_name] + + self.model.weekend_day_shift_count_t2[worker.id, day_name] + + 1, + xU_day, + ) + ) + + self.model.constraints.add( + self.model.weekend_day_shift_count_w[worker.id, day_name] + >= xL_day + * ( + self.model.weekend_day_shift_count_t1[worker.id, day_name] + + self.model.weekend_day_shift_count_t2[worker.id, day_name] + + 1 + ) + * 2 + - xL_day * xL_day + ) + + self.model.constraints.add( + self.model.weekend_day_shift_count_w[worker.id, day_name] + >= xU_day + * ( + self.model.weekend_day_shift_count_t1[worker.id, day_name] + + self.model.weekend_day_shift_count_t2[worker.id, day_name] + + 1 + ) + * 2 + - xU_day * xU_day + ) + + # Optional hard constraint: limit absolute deviation from per-day target + if self.constraint_options_model.weekend_day_hard_max_deviation is not None: + d = self.constraint_options_model.weekend_day_hard_max_deviation + console.print(f"Adding hard weekend-day deviation constraint: worker={worker.name} ({worker.id}), day={day_name}, target={weekend_day_shift_target_number:.3f}, d={d}") + # assigned - target <= d + self.model.constraints.add( + self.model.weekend_day_shift_count[worker.id, day_name] + - weekend_day_shift_target_number + <= d + ) + # target - assigned <= d + self.model.constraints.add( + weekend_day_shift_target_number + - self.model.weekend_day_shift_count[worker.id, day_name] + <= d + ) + hard_weekend_constraints_added += 2 + + if hard_weekend_constraints_added: + console.print(f"Worker {worker.name}: added {hard_weekend_constraints_added} hard weekend-day constraints") + # Ensure worker is not allocated shifts on non working days if worker.non_working_day_list: for week, day, shift in self.get_all_shiftclass_combinations(): @@ -3380,6 +3518,33 @@ class RotaBuilder(object): else: weekend_shift_balancing = 0 + # Balance each weekend day (Sat / Sun) separately if requested. + # Support either a linear term or a stronger quadratic penalty. + weekend_day_term = 0 + 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( + 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") + ) + elif self.constraint_options_model.balance_weekend_days: + weekend_day_modifier = self.constraint_options_model.balance_weekend_days_weight + weekend_day_term = sum( + weekend_day_modifier * self.model.weekend_day_shift_count_w[(worker.id, day_name)] + for worker in self.workers + for day_name in ("Sat", "Sun") + ) + preference_constant = 10 # Preferences (not to work) preferences = sum( @@ -3479,6 +3644,7 @@ class RotaBuilder(object): + true_quadratic_shift_balancing - prefer_multi_shift_expr + prefer_block_expr + + weekend_day_term ) # add objective function to the model. rule (pass function) or expr (pass expression directly) @@ -3566,6 +3732,47 @@ class RotaBuilder(object): self.workers = sorted(self.workers) + # Pairwise hard constraints to limit per-day weekend skew among equivalent workers + # Group workers by (site, grade) and enforce for each pair and each day: + # assigned_i_day - assigned_j_day <= d + # assigned_j_day - assigned_i_day <= d + # Use `weekend_day_hard_max_deviation` as `d` when set. + if self.constraint_options_model.weekend_day_hard_max_deviation is not None: + d = self.constraint_options_model.weekend_day_hard_max_deviation + groups = defaultdict(list) + for w in self.workers: + groups[(w.site, w.grade)].append(w) + + pairwise_added = 0 + for (site, grade), workers_group in groups.items(): + if len(workers_group) < 2: + continue + # iterate unique unordered pairs + for i in range(len(workers_group)): + for j in range(i + 1, len(workers_group)): + wi = workers_group[i] + wj = workers_group[j] + for day_name in ("Sat", "Sun"): + try: + # wi - wj <= d + self.model.constraints.add( + self.model.weekend_day_shift_count[wi.id, day_name] + - self.model.weekend_day_shift_count[wj.id, day_name] + <= d + ) + # wj - wi <= d + self.model.constraints.add( + self.model.weekend_day_shift_count[wj.id, day_name] + - self.model.weekend_day_shift_count[wi.id, day_name] + <= d + ) + pairwise_added += 2 + except Exception: + # If variables aren't present for some worker/day, skip + continue + + console.print(f"Added {pairwise_added} pairwise weekend-day hard constraints (d={d})") + self.full_time_equivalent = sum( w.fte_adj for w in self.workers ) # TODO: rewrite as per shift @@ -4438,6 +4645,21 @@ class RotaBuilder(object): shift_count + f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#" ) + + # compute assigned Saturday / Sunday counts for this worker + sat_count = 0 + sun_count = 0 + for w, d in self.get_week_day_combinations(): + if d == "Sat": + for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True): + if model.works[worker.id, w, d, s].value > 0.5: + sat_count += 1 + break + if d == "Sun": + for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True): + if model.works[worker.id, w, d, s].value > 0.5: + sun_count += 1 + break timetable.append(f"{''.join(w)} {shift_count}") if show_prefs: @@ -4661,6 +4883,28 @@ class RotaBuilder(object): diff = model.shift_count_diff[worker.id, shift.name].value shift_diff_dict[shift.name] = diff + # compute assigned Saturday / Sunday counts for this worker (for HTML data attributes) + sat_count = 0 + sun_count = 0 + for w, d in self.get_week_day_combinations(): + if d == "Sat": + for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True): + if model.works[worker.id, w, d, s].value > 0.5: + sat_count += 1 + break + if d == "Sun": + for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True): + if model.works[worker.id, w, d, s].value > 0.5: + sun_count += 1 + break + # Small HTML summary to be inserted inside the worker td (friendly display) + worker_summary_html = ( + f"
" + f"Sat: {sat_count}" + f"   Sun: {sun_count}" + f"
" + ) + worker_td = """ - {name} ({grade}) [{fte}]""".format( + {name} ({grade}) [{fte}]{worker_summary_html}""".format( site=worker.site, nwds=nwds, worker_id=worker.id, @@ -4695,6 +4941,9 @@ class RotaBuilder(object): weekend_target=worker.weekend_shift_target_number, remote_site=worker.remote_site, grade=worker.grade, + weekend_sat=sat_count, + weekend_sun=sun_count, + worker_summary_html=worker_summary_html, pair=worker.pair, shift_balance_extra=json.dumps(worker.shift_balance_extra), shift_diff=json.dumps(shift_diff_dict), @@ -4788,6 +5037,24 @@ class RotaBuilder(object): f"Unavailable: {''}", f"Shift targets: {json.dumps(getattr(worker, 'shift_target_number', {}))}", ] + # compute Sat / Sun assigned counts for this worker + sat_count = 0 + sun_count = 0 + for w, d in self.get_week_day_combinations(): + if d == "Sat": + for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True): + if self.model.works[worker.id, w, d, s].value > 0.5: + sat_count += 1 + break + if d == "Sun": + for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True): + if self.model.works[worker.id, w, d, s].value > 0.5: + sun_count += 1 + break + details.insert( + -1, + f"Sat assigned: {sat_count}, Sun assigned: {sun_count}", + ) # Build unavailability list for this worker unavail_list = [] for entry in sorted(self.unavailable_to_work):