diff --git a/gen_cons.py b/gen_cons.py
index 371e329..29a3962 100644
--- a/gen_cons.py
+++ b/gen_cons.py
@@ -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)]
diff --git a/output/timetable.js b/output/timetable.js
index 28ebb21..791f546 100644
--- a/output/timetable.js
+++ b/output/timetable.js
@@ -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 = $(`
${name} ${site}FTE: ${fte}
`);
+ const labelName = displayName || name;
+ const card = $(`${labelName} ${site}FTE: ${fte}
`);
// show shift targets summary
if (w.shift_target_number) {
const tbl = $("");
diff --git a/rota_generator/shifts.py b/rota_generator/shifts.py
index ca13636..55af49a 100644
--- a/rota_generator/shifts.py
+++ b/rota_generator/shifts.py
@@ -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:
diff --git a/rota_generator/workers.py b/rota_generator/workers.py
index 5635d27..92559a8 100644
--- a/rota_generator/workers.py
+++ b/rota_generator/workers.py
@@ -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)
diff --git a/test/test_shifts.py b/test/test_shifts.py
index 2c0d83b..e3ec13b 100644
--- a/test/test_shifts.py
+++ b/test/test_shifts.py
@@ -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]):