Add weekend day balancing options and summary counts for shifts

This commit is contained in:
Ross
2025-12-17 10:43:37 +00:00
parent 81b2c85d61
commit 83444ac402
3 changed files with 283 additions and 2 deletions
+2
View File
@@ -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,
),
)
+12
View File
@@ -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(`<div class='worker-summary auto-generated'>
<span>Total shifts: ${total_shifts}, </span>
<span>Weekends: ${weekends_worked}, </span>
<span>Sat: ${sat_count}, </span>
<span>Sun: ${sun_count}, </span>
<span>Nights: ${nights_worked}, </span>
<span>Bank holidays: ${bank_holidays_worked}, </span>
<span class='shift-diff-span'>Summed shift diff: ${summed_shift_diff.toPrecision(2)}, </span>
+269 -2
View File
@@ -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"<div class='worker-summary auto-generated' style='margin-top:4px; font-size:0.9em; color:#333;'>"
f"<span class=\'weekend-sat\'>Sat: <strong>{sat_count}</strong></span>"
f" &nbsp; <span class=\'weekend-sun\'>Sun: <strong>{sun_count}</strong></span>"
f"</div>"
)
worker_td = """<td title='Site: {site}' class='worker {site}'
data-worker-id='{worker_id}'
data-nwds='{nwds}' data-site='{site}' data-worker='{name}'
@@ -4676,10 +4920,12 @@ class RotaBuilder(object):
data-pair='{pair}'
data-shift-balance-extra='{shift_balance_extra}'
data-bank-holiday-extra='{bank_holiday_extra}'
data-weekend-sat='{weekend_sat}'
data-weekend-sun='{weekend_sun}'
data-shift-diff='{shift_diff}'
data-shift-diff-summed='{shift_diff_summed}'
>
<span class='name' title='{name} ({worker_id})'>{name}</span> ({grade}) [{fte}]</td>""".format(
<span class='name' title='{name} ({worker_id})'>{name}</span> ({grade}) [{fte}]{worker_summary_html}</td>""".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):