Enhance weekend shift balancing by adding quadratic penalty and adjusting deviation handling
This commit is contained in:
+3
-1
@@ -489,8 +489,10 @@ def main(
|
|||||||
max_shifts_per_week=6,
|
max_shifts_per_week=6,
|
||||||
max_days_per_week_block=[(4, 2), (4, 4)],
|
max_days_per_week_block=[(4, 2), (4, 4)],
|
||||||
balance_weekend_days=True,
|
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_use_previous_shifts=True,
|
||||||
|
balance_weekend_days_weight=500000
|
||||||
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+57
-11
@@ -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_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_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")
|
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,
|
None,
|
||||||
description=(
|
description=(
|
||||||
"Hard maximum allowed absolute deviation (integer) per-worker from the per-day "
|
"Hard maximum allowed absolute deviation (number) per-worker from the per-day "
|
||||||
"weekend target (Sat/Sun). If set, adds constraints enforcing |assigned - target| <= value"
|
"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_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)
|
# Per-day weekend balancing (separate Sat / Sun counts)
|
||||||
# Also create the bookkeeping variables when a hard deviation constraint is requested
|
# 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 (
|
if (
|
||||||
self.constraint_options_model.balance_weekend_days
|
self.constraint_options_model.balance_weekend_days
|
||||||
or self.constraint_options_model.balance_weekend_days_quadratic
|
or self.constraint_options_model.balance_weekend_days_quadratic
|
||||||
or self.constraint_options_model.weekend_day_hard_max_deviation is not None
|
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(
|
self.model.weekend_day_shift_count = Var(
|
||||||
((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")),
|
((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")),
|
||||||
domain=NonNegativeReals,
|
domain=NonNegativeReals,
|
||||||
@@ -3586,19 +3588,63 @@ class RotaBuilder(object):
|
|||||||
if self.constraint_options_model.balance_weekend_days_quadratic:
|
if self.constraint_options_model.balance_weekend_days_quadratic:
|
||||||
wd_weight = self.constraint_options_model.balance_weekend_days_weight
|
wd_weight = self.constraint_options_model.balance_weekend_days_weight
|
||||||
# Quadratic penalty: strongly discourage deviations from per-day targets
|
# Quadratic penalty: strongly discourage deviations from per-day targets
|
||||||
weekend_day_term = sum(
|
weekend_day_term = 0
|
||||||
wd_weight
|
for worker in self.workers:
|
||||||
* (
|
for day_name in ("Sat", "Sun"):
|
||||||
self.model.weekend_day_shift_count[(worker.id, day_name)]
|
# base target derived from shift targets distributed over shift days
|
||||||
- sum(
|
base_target = sum(
|
||||||
worker.shift_target_number[shift.name] / len(shift.days)
|
worker.shift_target_number[shift.name] / len(shift.days)
|
||||||
for shift in self.get_shifts()
|
for shift in self.get_shifts()
|
||||||
if day_name in shift.days
|
if day_name in shift.days
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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
|
) ** 2
|
||||||
for worker in self.workers
|
|
||||||
for day_name in ("Sat", "Sun")
|
|
||||||
)
|
|
||||||
elif self.constraint_options_model.balance_weekend_days:
|
elif self.constraint_options_model.balance_weekend_days:
|
||||||
weekend_day_modifier = self.constraint_options_model.balance_weekend_days_weight
|
weekend_day_modifier = self.constraint_options_model.balance_weekend_days_weight
|
||||||
weekend_day_term = sum(
|
weekend_day_term = sum(
|
||||||
|
|||||||
Reference in New Issue
Block a user