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,
|
NonWorkingDays,
|
||||||
WorkRequests,
|
WorkRequests,
|
||||||
PreferenceNotToWork,
|
PreferenceNotToWork,
|
||||||
OutOfProgramme,
|
OutOfProgramme, MaxUniqueShiftsPerWeekBlockConstraint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -465,6 +465,8 @@ def load_workers():
|
|||||||
else:
|
else:
|
||||||
w.add_hard_day_dependency({"Fri", "Sun"})
|
w.add_hard_day_dependency({"Fri", "Sun"})
|
||||||
w.add_hard_day_exclusion({"Sat", "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;
|
if (!workers) return;
|
||||||
workers.forEach(w => {
|
workers.forEach(w => {
|
||||||
const name = (w.name || '') + '';
|
const name = (w.name || '') + '';
|
||||||
|
const displayName = (w.display_name || '') + '';
|
||||||
const site = (w.site || '') + '';
|
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
|
// 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
|
// attempt to read per-worker targets and counts from DOM if not present in JSON
|
||||||
let targets = w.shift_target_number || {};
|
let targets = w.shift_target_number || {};
|
||||||
@@ -443,7 +450,8 @@ function renderWorkerList() {
|
|||||||
if (!hasTarget && !hasAssigned) return;
|
if (!hasTarget && !hasAssigned) return;
|
||||||
}
|
}
|
||||||
const fte = w.fte || '';
|
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
|
// show shift targets summary
|
||||||
if (w.shift_target_number) {
|
if (w.shift_target_number) {
|
||||||
const tbl = $("<table style='margin-top:6px; width:100%;'></table>");
|
const tbl = $("<table style='margin-top:6px; width:100%;'></table>");
|
||||||
|
|||||||
@@ -1055,6 +1055,35 @@ class RotaBuilder(object):
|
|||||||
initialize=0,
|
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:
|
if self.constraint_options_model.balance_bank_holidays:
|
||||||
# Bank holidays
|
# Bank holidays
|
||||||
self.model.bank_holiday_count = Var(
|
self.model.bank_holiday_count = Var(
|
||||||
@@ -2374,6 +2403,55 @@ class RotaBuilder(object):
|
|||||||
<= max_days
|
<= 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):
|
for week_blocks in self.get_week_block_iterator(4):
|
||||||
# Prevent more than n number shifts per 4 weeks
|
# Prevent more than n number shifts per 4 weeks
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -135,6 +135,32 @@ class AvoidShiftOnDates(BaseModel):
|
|||||||
raise ValueError(f"Cannot parse date: {v}")
|
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):
|
class Worker(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
site: str
|
site: str
|
||||||
@@ -171,6 +197,8 @@ class Worker(BaseModel):
|
|||||||
forced_assignments: List[tuple[int, str, str]] = [] # (week, day, shift_name)
|
forced_assignments: List[tuple[int, str, str]] = [] # (week, day, shift_name)
|
||||||
forced_assignments_by_date: List[tuple[datetime.date, str]] = [] # (date, 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_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)
|
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
|
||||||
|
|||||||
+73
-1
@@ -1,11 +1,19 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
from rota_generator.shifts import (
|
from rota_generator.shifts import (
|
||||||
InvalidShift, MaxShiftsPerWeekBlockConstraint, MaxShiftsPerWeekConstraint,
|
InvalidShift, MaxShiftsPerWeekBlockConstraint, MaxShiftsPerWeekConstraint,
|
||||||
NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift,
|
NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift,
|
||||||
WarningTermination, days, WorkerRequirement
|
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)):
|
def generate_basic_rota(weeks_to_rota=10, workers=2, start_date=datetime.date(2022, 3, 7)):
|
||||||
Rota = RotaBuilder(
|
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")
|
Rota.export_rota_to_html("max_shifts_per_week_block")
|
||||||
assert Rota.results.solver.status in ("warning", "error")
|
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():
|
def test_pre_shift_constraint():
|
||||||
Rota = generate_basic_rota()
|
Rota = generate_basic_rota()
|
||||||
for i, d in enumerate(days[:6]):
|
for i, d in enumerate(days[:6]):
|
||||||
|
|||||||
Reference in New Issue
Block a user