Add constraints for maximum days and unique shifts per week block in worker model
This commit is contained in:
+3
-1
@@ -15,7 +15,7 @@ from rota_generator.workers import (
|
||||
NonWorkingDays,
|
||||
WorkRequests,
|
||||
PreferenceNotToWork,
|
||||
OutOfProgramme,
|
||||
OutOfProgramme, MaxUniqueShiftsPerWeekBlockConstraint,
|
||||
)
|
||||
|
||||
from loguru import logger
|
||||
@@ -465,6 +465,8 @@ def load_workers():
|
||||
else:
|
||||
w.add_hard_day_dependency({"Fri", "Sun"})
|
||||
w.add_hard_day_exclusion({"Sat", "Sun"})
|
||||
else:
|
||||
w.max_unique_shifts_per_week_block = [MaxUniqueShiftsPerWeekBlockConstraint(week_block=1, max_unique_shifts=1)]
|
||||
|
||||
|
||||
|
||||
|
||||
+10
-2
@@ -419,8 +419,15 @@ function renderWorkerList() {
|
||||
if (!workers) return;
|
||||
workers.forEach(w => {
|
||||
const name = (w.name || '') + '';
|
||||
const displayName = (w.display_name || '') + '';
|
||||
const site = (w.site || '') + '';
|
||||
if (filter && name.toLowerCase().indexOf(filter) === -1 && site.toLowerCase().indexOf(filter) === -1) return;
|
||||
const searchName = (displayName || name).toLowerCase();
|
||||
if (
|
||||
filter
|
||||
&& searchName.indexOf(filter) === -1
|
||||
&& name.toLowerCase().indexOf(filter) === -1
|
||||
&& site.toLowerCase().indexOf(filter) === -1
|
||||
) return;
|
||||
// if shiftFilter, ensure worker has target or assigned for that shift
|
||||
// attempt to read per-worker targets and counts from DOM if not present in JSON
|
||||
let targets = w.shift_target_number || {};
|
||||
@@ -443,7 +450,8 @@ function renderWorkerList() {
|
||||
if (!hasTarget && !hasAssigned) return;
|
||||
}
|
||||
const fte = w.fte || '';
|
||||
const card = $(`<div class='worker-card' style='border:1px solid #ccc;padding:8px;margin-bottom:6px;'><strong>${name}</strong> <span style='color:#666'>${site}</span><div>FTE: ${fte}</div></div>`);
|
||||
const labelName = displayName || name;
|
||||
const card = $(`<div class='worker-card' style='border:1px solid #ccc;padding:8px;margin-bottom:6px;'><strong>${labelName}</strong> <span style='color:#666'>${site}</span><div>FTE: ${fte}</div></div>`);
|
||||
// show shift targets summary
|
||||
if (w.shift_target_number) {
|
||||
const tbl = $("<table style='margin-top:6px; width:100%;'></table>");
|
||||
|
||||
@@ -1055,6 +1055,35 @@ class RotaBuilder(object):
|
||||
initialize=0,
|
||||
)
|
||||
|
||||
# Helper binary variables for per-worker unique-shift block limits.
|
||||
# Index is (worker_id, block_length, block_start_week, shift_name).
|
||||
unique_shift_block_indices = []
|
||||
for worker in self.workers:
|
||||
for constraint in worker.max_unique_shifts_per_week_block:
|
||||
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
||||
if not week_blocks:
|
||||
continue
|
||||
block_start_week = week_blocks[0]
|
||||
for shift_name in self.get_shift_names():
|
||||
if any(
|
||||
shift_name in self.get_shift_names_by_week_day(
|
||||
week,
|
||||
day,
|
||||
return_empty_if_week_day_not_found=True,
|
||||
)
|
||||
for week in week_blocks
|
||||
for day in days
|
||||
):
|
||||
unique_shift_block_indices.append(
|
||||
(worker.id, constraint.week_block, block_start_week, shift_name)
|
||||
)
|
||||
|
||||
self.model.worker_shift_used_in_week_block = Var(
|
||||
unique_shift_block_indices,
|
||||
within=Binary,
|
||||
initialize=0,
|
||||
)
|
||||
|
||||
if self.constraint_options_model.balance_bank_holidays:
|
||||
# Bank holidays
|
||||
self.model.bank_holiday_count = Var(
|
||||
@@ -2374,6 +2403,55 @@ class RotaBuilder(object):
|
||||
<= max_days
|
||||
)
|
||||
|
||||
for constraint in worker.max_days_per_week_block:
|
||||
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
||||
self.model.constraints.add(
|
||||
sum(
|
||||
self.model.works_day[worker.id, week, day]
|
||||
for week, day in self.get_week_day_combinations()
|
||||
if week in week_blocks
|
||||
)
|
||||
<= constraint.max_days
|
||||
)
|
||||
|
||||
for constraint in worker.max_unique_shifts_per_week_block:
|
||||
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
||||
if not week_blocks:
|
||||
continue
|
||||
|
||||
block_start_week = week_blocks[0]
|
||||
used_shift_vars = []
|
||||
for shift_name in self.get_shift_names():
|
||||
shift_assignments = [
|
||||
self.model.works[worker.id, week, day, shift_name]
|
||||
for week, day in self.get_week_day_combinations()
|
||||
if week in week_blocks
|
||||
and shift_name in self.get_shift_names_by_week_day(
|
||||
week,
|
||||
day,
|
||||
return_empty_if_week_day_not_found=True,
|
||||
)
|
||||
]
|
||||
if not shift_assignments:
|
||||
continue
|
||||
|
||||
used_shift_var = self.model.worker_shift_used_in_week_block[
|
||||
worker.id,
|
||||
constraint.week_block,
|
||||
block_start_week,
|
||||
shift_name,
|
||||
]
|
||||
used_shift_vars.append(used_shift_var)
|
||||
self.model.constraints.add(
|
||||
sum(shift_assignments)
|
||||
<= len(shift_assignments) * used_shift_var
|
||||
)
|
||||
|
||||
if used_shift_vars:
|
||||
self.model.constraints.add(
|
||||
sum(used_shift_vars) <= constraint.max_unique_shifts
|
||||
)
|
||||
|
||||
for week_blocks in self.get_week_block_iterator(4):
|
||||
# Prevent more than n number shifts per 4 weeks
|
||||
try:
|
||||
|
||||
@@ -135,6 +135,32 @@ class AvoidShiftOnDates(BaseModel):
|
||||
raise ValueError(f"Cannot parse date: {v}")
|
||||
|
||||
|
||||
class MaxDaysPerWeekBlockConstraint(BaseModel):
|
||||
max_days: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Maximum number of worked days allowed inside each sliding week block.",
|
||||
)
|
||||
week_block: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||
)
|
||||
|
||||
|
||||
class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel):
|
||||
max_unique_shifts: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Maximum number of distinct shift names allowed inside each sliding week block.",
|
||||
)
|
||||
week_block: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||
)
|
||||
|
||||
|
||||
class Worker(BaseModel):
|
||||
name: str
|
||||
site: str
|
||||
@@ -171,6 +197,8 @@ class Worker(BaseModel):
|
||||
forced_assignments: List[tuple[int, str, str]] = [] # (week, day, shift_name)
|
||||
forced_assignments_by_date: List[tuple[datetime.date, str]] = [] # (date, shift_name)
|
||||
max_days_worked_per_week: int | None= None # Default: no limit, can be set to a specific number
|
||||
max_days_per_week_block: list[MaxDaysPerWeekBlockConstraint] = []
|
||||
max_unique_shifts_per_week_block: list[MaxUniqueShiftsPerWeekBlockConstraint] = []
|
||||
|
||||
|
||||
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
|
||||
|
||||
+73
-1
@@ -1,11 +1,19 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from rota_generator.shifts import (
|
||||
InvalidShift, MaxShiftsPerWeekBlockConstraint, MaxShiftsPerWeekConstraint,
|
||||
NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift,
|
||||
WarningTermination, days, WorkerRequirement
|
||||
)
|
||||
from rota_generator.workers import NotAvailableToWork, WorkRequests, Worker, generate_not_available_to_works
|
||||
from rota_generator.workers import (
|
||||
MaxDaysPerWeekBlockConstraint,
|
||||
MaxUniqueShiftsPerWeekBlockConstraint,
|
||||
NotAvailableToWork,
|
||||
WorkRequests,
|
||||
Worker,
|
||||
generate_not_available_to_works,
|
||||
)
|
||||
|
||||
def generate_basic_rota(weeks_to_rota=10, workers=2, start_date=datetime.date(2022, 3, 7)):
|
||||
Rota = RotaBuilder(
|
||||
@@ -89,6 +97,70 @@ def test_max_shifts_per_week_block_shift_number_fail():
|
||||
Rota.export_rota_to_html("max_shifts_per_week_block")
|
||||
assert Rota.results.solver.status in ("warning", "error")
|
||||
|
||||
|
||||
def test_worker_max_days_per_week_block_fail():
|
||||
Rota = RotaBuilder(datetime.date(2022, 3, 7), weeks_to_rota=2)
|
||||
Rota.add_worker(
|
||||
Worker(
|
||||
name="worker1",
|
||||
site="group1",
|
||||
grade=1,
|
||||
max_days_per_week_block=[
|
||||
MaxDaysPerWeekBlockConstraint(max_days=1, week_block=1)
|
||||
],
|
||||
)
|
||||
)
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=days[:3],
|
||||
workers_required=1,
|
||||
)
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.000})
|
||||
assert Rota.results.solver.termination_condition == "infeasible"
|
||||
|
||||
|
||||
def test_worker_max_unique_shifts_per_week_block_fail():
|
||||
Rota = RotaBuilder(datetime.date(2022, 3, 7), weeks_to_rota=2)
|
||||
Rota.add_worker(
|
||||
Worker(
|
||||
name="worker1",
|
||||
site="group1",
|
||||
grade=1,
|
||||
max_unique_shifts_per_week_block=[
|
||||
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=1, week_block=1)
|
||||
],
|
||||
)
|
||||
)
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=["Mon"],
|
||||
workers_required=1,
|
||||
),
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="b",
|
||||
length=12.5,
|
||||
days=["Tue"],
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.000})
|
||||
assert Rota.results.solver.termination_condition == "infeasible"
|
||||
|
||||
|
||||
def test_worker_block_constraint_validation():
|
||||
with pytest.raises(ValidationError):
|
||||
MaxDaysPerWeekBlockConstraint(max_days=0, week_block=1)
|
||||
with pytest.raises(ValidationError):
|
||||
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=0, week_block=1)
|
||||
|
||||
def test_pre_shift_constraint():
|
||||
Rota = generate_basic_rota()
|
||||
for i, d in enumerate(days[:6]):
|
||||
|
||||
Reference in New Issue
Block a user