Add weekend day balancing options and summary counts for shifts
This commit is contained in:
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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" <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):
|
||||
|
||||
Reference in New Issue
Block a user