From dee80ed4fa85f6764df6f0f6727f0528ae714119 Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 19 May 2026 21:23:09 +0100 Subject: [PATCH] Add constraints for maximum days and unique shifts per week block in worker model --- gen_cons.py | 4 +- output/timetable.js | 12 +++++- rota_generator/shifts.py | 78 +++++++++++++++++++++++++++++++++++++++ rota_generator/workers.py | 28 ++++++++++++++ test/test_shifts.py | 74 ++++++++++++++++++++++++++++++++++++- 5 files changed, 192 insertions(+), 4 deletions(-) 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]):