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):