3361 lines
129 KiB
Python
3361 lines
129 KiB
Python
from calendar import week
|
|
import datetime
|
|
from distutils.log import debug
|
|
import itertools
|
|
from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type
|
|
import time
|
|
|
|
from pydantic import BaseModel, Extra, constr
|
|
|
|
from pathlib import Path
|
|
|
|
import datetime
|
|
|
|
from pyomo.environ import *
|
|
from pyomo.opt import SolverFactory
|
|
|
|
from rota.workers import Worker
|
|
from rota.console import console
|
|
|
|
import json
|
|
|
|
import csv
|
|
|
|
from collections import defaultdict
|
|
|
|
from io import StringIO
|
|
import sys
|
|
|
|
from rich.pretty import pprint
|
|
from rich.progress import track
|
|
from rich.panel import Panel
|
|
|
|
import logging
|
|
|
|
logging.basicConfig(
|
|
filename="rota.log",
|
|
filemode="w",
|
|
format="%(name)s - %(levelname)s - %(message)s",
|
|
level=logging.DEBUG,
|
|
)
|
|
|
|
ShiftName = str
|
|
DayStr = str
|
|
WeekInt = int
|
|
|
|
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
|
|
full_weekend_count = 2
|
|
|
|
# Load uk based bank holidays
|
|
from govuk_bank_holidays.bank_holidays import BankHolidays
|
|
|
|
bank_holidays = BankHolidays()
|
|
bank_holiday_map = {}
|
|
logging.debug("Creating bank holiday map")
|
|
for bank_holiday in bank_holidays.get_holidays(division="england-and-wales"):
|
|
bank_holiday_map[bank_holiday["date"]] = bank_holiday["title"]
|
|
|
|
logging.debug(bank_holiday_map)
|
|
|
|
SHIFT_BOUNDS = {
|
|
"bank_holiday": (0, 9),
|
|
"shift_count": (0, 400),
|
|
"night_shift_count": (0, 100),
|
|
"weekend_count": (0, 60),
|
|
}
|
|
|
|
|
|
class ShiftConstraint(BaseModel):
|
|
name: str
|
|
options: bool | int | Dict | tuple = False
|
|
|
|
|
|
class SingleShift(BaseModel):
|
|
"""Class to hold all details for a shift
|
|
|
|
|
|
Valid constraints
|
|
|
|
balance_across_groups (requires block assignment)
|
|
|
|
postclear(2) / preclear(2)
|
|
|
|
require_remote_site_presence_week:
|
|
options: (site, required_number)
|
|
|
|
night
|
|
|
|
limit_grade_number:
|
|
options: {grade: max_number, grade2: max_number2}
|
|
|
|
minimum_grade_number:
|
|
options: (grade, min_number)
|
|
|
|
|
|
"""
|
|
|
|
sites: List[str]
|
|
name: str
|
|
length: float
|
|
days: str | List[str]
|
|
balance_offset: float | None = None
|
|
balance_weighting: float = 1
|
|
workers_required: float = 1
|
|
rota_on_nwds: bool = False
|
|
assign_as_block: bool = False
|
|
force_as_block: bool = False
|
|
force_as_block_unless_nwd: bool = False
|
|
hard_constrain_shift: bool = True
|
|
bank_holidays_only: bool = False
|
|
constraint: List[ShiftConstraint] = []
|
|
|
|
class Config:
|
|
extra = Extra.allow
|
|
orm_mode = True
|
|
|
|
def __init__(self, **data: Any):
|
|
super().__init__(**data)
|
|
# self.site = sites
|
|
# self.name = name
|
|
# self.length = length
|
|
# self.days = days
|
|
# self.balance_by_site = balance_by_site
|
|
# balance_offset defines the max difference in allocated shifts
|
|
# versus target shifts (hard constraint)
|
|
# if 0 exactly equal shifts must be assigend (unlikely to be possible)
|
|
|
|
if self.balance_offset is None:
|
|
self.balance_offset = len(self.days)
|
|
|
|
if self.force_as_block_unless_nwd:
|
|
self.assign_as_block = True
|
|
|
|
# self.assign_as_block = assign_as_block
|
|
|
|
self.constraint_options = {}
|
|
self.constraints = []
|
|
for c in self.constraint:
|
|
self.constraints.append(c.name)
|
|
|
|
if c.options:
|
|
self.constraint_options[c.name] = c.options
|
|
|
|
# constraint = c.constraint
|
|
# match c:
|
|
# case (constraint, options):
|
|
# self.constraint_options[constraint] = options
|
|
# case constraint:
|
|
# self.constraints.append(constraint)
|
|
|
|
if isinstance(self.days, str):
|
|
self.days = [self.days]
|
|
|
|
def __str__(self):
|
|
return f"name: {self.name}, site: {self.sites}, length: {self.length}, balance weighting: {self.balance_weighting}, balance offset: {self.balance_offset}, hard_constrain: {self.hard_constrain_shift}, workers required : {self.workers_required}, rota on nwds {self.rota_on_nwds}, assign as block: {self.assign_as_block}, force as block: {self.force_as_block}, constraints: {self.constraints}, constraint_options: {self.constraint_options}" # , total shifts: {self.total_shifts}"
|
|
|
|
def get_shift_summary(self):
|
|
"""
|
|
Returns a text summary of the shift
|
|
"""
|
|
|
|
return f"{self.name}: {self.workers_required} workers for sites ({', '.join(self.site)}) on days ({', '.join(self.days)})"
|
|
|
|
def get_shift_number(self):
|
|
return len(self.days)
|
|
|
|
|
|
class RotaBuilder(object):
|
|
"""Class to hold and manipulate shifts"""
|
|
|
|
def __init__(
|
|
self,
|
|
start_date: datetime.date = datetime.date.today(),
|
|
weeks_to_rota: int = 26,
|
|
balance_offset_modifier: int = 1,
|
|
ltft_balance_offset: int = 1,
|
|
use_previous_shifts: bool = False,
|
|
use_shift_balance_extra: bool = False,
|
|
use_bank_holiday_extra: bool = False,
|
|
SHIFT_BOUNDS=SHIFT_BOUNDS,
|
|
):
|
|
|
|
console.print(Panel(f"""[white]
|
|
{locals()}
|
|
""", style="green", title="Generating Rota"), justify="left")
|
|
|
|
self.SHIFT_BOUNDS = SHIFT_BOUNDS
|
|
self.shifts: List[SingleShift] = [] # type List[SingleShift]
|
|
|
|
self.use_previous_shifts = use_previous_shifts
|
|
self.use_shift_balance_extra = use_shift_balance_extra
|
|
self.use_bank_holiday_extra = use_bank_holiday_extra
|
|
|
|
|
|
self.workers: list[Worker] = []
|
|
|
|
# self.night_blocks = ["weekday", "weekend", "none"]
|
|
|
|
self.sites = None
|
|
|
|
self.balance_offset_modifier = balance_offset_modifier
|
|
self.ltft_balance_offset = ltft_balance_offset
|
|
|
|
self.constraint_options = {
|
|
"balance_nights": True,
|
|
"constrain_time_off_after_nights": False,
|
|
"balance_nights_across_sites": True,
|
|
"balance_bank_holidays": True,
|
|
"balance_blocks": True,
|
|
"balance_shifts": True, # Does not use a quadratic function
|
|
"balance_shifts_quadratic": False, # Will prevent spreading of spreading across different shifts
|
|
"balance_shifts_over_workers": True,
|
|
"minimise_shift_diffs": False, # less sophisticated version of balance_shifts_over_workers
|
|
"balance_weekends": True,
|
|
"max_weekends": 100,
|
|
# Don't assign multiple shifts every (n) weeks
|
|
"max_weekend_frequency": 1, # Requires balance weekends
|
|
# Don't assign multiple shifts every (n) weeks
|
|
"max_night_frequency": 1,
|
|
"max_night_frequency_week_exclusions": [],
|
|
"max_shifts_per_week": 7,
|
|
"max_shifts_per_month": 40,
|
|
# The following will cause an unsolvable problem if a shift
|
|
# is forced as a block and spans the time peroid
|
|
"prevent_monday_after_full_weekends": [],
|
|
"prevent_monday_and_tuesday_after_full_weekends": [],
|
|
"prevent_fridays_before_full_weekends": [],
|
|
"prevent_thursdays_before_full_weekends": [],
|
|
"avoid_st2_first_month": False,
|
|
"hard_constrain_pair_separation": False,
|
|
"avoid_shifts_by_grades": [],
|
|
}
|
|
|
|
|
|
self.results = None
|
|
|
|
self.ignore_valid_shifts = False
|
|
|
|
self.set_rota_dates(start_date, weeks_to_rota)
|
|
|
|
def set_rota_dates(self, start_date: datetime.datetime, weeks_to_rota: int):
|
|
|
|
self.weeks_to_rota = weeks_to_rota
|
|
|
|
self.days = days
|
|
|
|
self.weeks = [i for i in range(1, weeks_to_rota + 1)]
|
|
|
|
self.weeks_days_product = list(itertools.product(self.weeks, days))
|
|
|
|
if start_date.weekday() != 0:
|
|
raise ValueError(
|
|
f"Start date {start_date.isoformat()} must be a Mon (not a {days[start_date.weekday()]})"
|
|
)
|
|
|
|
self.start_date = start_date
|
|
self.rota_days_length = len(self.weeks) * 7
|
|
self.rota_end_date = self.start_date + datetime.timedelta(self.rota_days_length)
|
|
|
|
console.print(
|
|
f"Weeks to rota: {weeks_to_rota} ({self.start_date} - {self.rota_end_date})"
|
|
)
|
|
|
|
# Generate a map for week, day combinations to a given date
|
|
self.week_day_date_map = {}
|
|
|
|
n = 0
|
|
for week, day in self.get_week_day_combinations():
|
|
d = self.start_date + datetime.timedelta(n)
|
|
self.week_day_date_map[(week, day)] = d
|
|
n = n + 1
|
|
|
|
self.unavailable_to_work = set()
|
|
self.unavailable_to_work_reason = {}
|
|
self.pref_not_to_work = {}
|
|
self.pref_not_to_work_reason = {}
|
|
self.work_requests: set[Tuple[str, WeekInt, DayStr, ShiftName]] = set()
|
|
self.work_requests_map = {}
|
|
|
|
def solve_model(
|
|
self,
|
|
solver: str = "cbc",
|
|
use_neos: bool = False,
|
|
options: dict = {},
|
|
debug_if_fail: bool = False,
|
|
):
|
|
console.print("Setting up solver")
|
|
|
|
#solver = "scip"
|
|
|
|
if solver == "scip":
|
|
self.opt = SolverFactory(solver, executable="scip")
|
|
else:
|
|
self.opt = SolverFactory(solver)
|
|
|
|
|
|
|
|
try:
|
|
console.print("Solving")
|
|
if use_neos:
|
|
solver_manager = SolverManagerFactory("neos") # Solve using neos server
|
|
# results = solver_manager.solve(Rota.model, opt=opt, logfile="test.log")
|
|
results = solver_manager.solve(
|
|
self.model, keepfiles=True, tee=True, opt=self.opt, logfile="test.log"
|
|
)
|
|
else:
|
|
results = self.opt.solve(
|
|
self.model,
|
|
tee=True,
|
|
options=options,
|
|
# options={
|
|
# "threads": 10,
|
|
# },
|
|
logfile="test.log",
|
|
keepfiles=True,
|
|
)
|
|
except KeyboardInterrupt:
|
|
return
|
|
pass
|
|
|
|
self.results = results
|
|
|
|
console.print(f"Complete - outcome: {results.solver.status}")
|
|
|
|
if results.solver.status != "ok" and debug_if_fail:
|
|
console.print(f"Attempting each shift individually")
|
|
self.solve_shifts_individually(options)
|
|
|
|
#if not results.solver.status:
|
|
# sys.exit(0)
|
|
|
|
def solve_shifts_by_block(self, options, block_length: int, folder="block_shifts"):
|
|
shifts = self.shifts
|
|
|
|
outcomes = {}
|
|
|
|
no_blocks = self.weeks_to_rota - block_length
|
|
|
|
original_start_date = self.start_date
|
|
|
|
for block in range(0, no_blocks):
|
|
start_time = time.time()
|
|
start_date = original_start_date + datetime.timedelta(weeks=block)
|
|
self.set_rota_dates(start_date = start_date,weeks_to_rota=block_length)
|
|
console.print(f"Testing block: start_date - {self.start_date} , length {self.weeks_to_rota} weeks")
|
|
self.clear_shifts()
|
|
self.add_shifts(*shifts)
|
|
self.build_and_solve(options)
|
|
self.export_rota_to_html(filename=f"{start_date}_{self.weeks_to_rota}", folder=folder)
|
|
end_time = time.time()
|
|
time_taken = end_time - start_time
|
|
|
|
outcomes[f"{start_date}_{self.weeks}"] = (
|
|
self.results.solver.status,
|
|
self.results.solver.termination_condition,
|
|
time_taken
|
|
)
|
|
|
|
console.print(outcomes)
|
|
sys.exit(0)
|
|
|
|
def solve_shifts_individually(self, options, folder="individual_shifts"):
|
|
shifts = self.shifts
|
|
|
|
outcomes = {}
|
|
|
|
for shift in shifts:
|
|
console.print(f"Testing shifts: {shift.name}")
|
|
self.ignore_valid_shifts = True
|
|
self.clear_shifts()
|
|
self.add_shift(shift)
|
|
self.build_and_solve(options)
|
|
self.export_rota_to_html(filename=shift.name, folder=folder)
|
|
|
|
outcomes[shift.name] = (
|
|
self.results.solver.status,
|
|
self.results.solver.termination_condition,
|
|
)
|
|
|
|
console.print(outcomes)
|
|
|
|
def build_and_solve(
|
|
self,
|
|
options={"ratio": 0.1, "seconds": 1000, "threads": 10},
|
|
solve=True,
|
|
debug_if_fail: bool = False,
|
|
):
|
|
self.build_shifts()
|
|
self.build_workers()
|
|
self.build_model()
|
|
|
|
if solve:
|
|
self.solve_model(options=options, debug_if_fail=debug_if_fail)
|
|
|
|
def build_model(self):
|
|
# Initialize model
|
|
self.model = ConcreteModel()
|
|
|
|
# binary variables representing if a worker is scheduled somewhere
|
|
# NOTE: this will assign to all possible shift combinations (which we probably don't want)
|
|
self.model.works = Var(
|
|
(
|
|
(worker.id, week, day, shiftname)
|
|
for worker in self.workers
|
|
for week, day, shiftname in self.get_all_shiftname_combinations()
|
|
),
|
|
within=Binary,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.shift_week_worker_assigned = Var(
|
|
(
|
|
(shift, week, worker.id)
|
|
for worker in self.workers
|
|
for week in self.weeks
|
|
for shift in self.get_shift_names()
|
|
),
|
|
within=Binary,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.worker_block_shift_split = Var(
|
|
(
|
|
(week, shift)
|
|
for week in self.weeks
|
|
for shift in self.shifts_to_assign_or_force_as_blocks()
|
|
),
|
|
within=Binary,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.blocks_worker_shift_assigned = Var(
|
|
(
|
|
(worker.id, week, shift)
|
|
for worker in self.workers
|
|
for week in self.weeks
|
|
for shift in self.shifts_to_assign_or_force_as_blocks()
|
|
),
|
|
within=Binary,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.blocks_assigned = Var(
|
|
(
|
|
(week, shift)
|
|
for week in self.weeks
|
|
for shift in self.shifts_to_assign_or_force_as_blocks()
|
|
),
|
|
within=NonNegativeIntegers,
|
|
initialize=0,
|
|
)
|
|
|
|
if self.constraint_options["balance_bank_holidays"]:
|
|
# Bank holidays
|
|
self.model.bank_holiday_count = Var(
|
|
((worker.id) for worker in self.workers),
|
|
within=NonNegativeIntegers,
|
|
initialize=0,
|
|
bounds=self.SHIFT_BOUNDS["bank_holiday"],
|
|
)
|
|
self.model.bank_holiday_count_w = Var(
|
|
((worker.id) for worker in self.workers),
|
|
within=NonNegativeIntegers,
|
|
initialize=0,
|
|
)
|
|
|
|
# Used to limit number of workers on night shift per site
|
|
# if we force a binary it will in effect hard constrain to < 2
|
|
# from a single site
|
|
if self.constraint_options["balance_nights_across_sites"]:
|
|
self.model.night_per_site = Var(
|
|
(
|
|
(week, shift.name, site)
|
|
for site in self.sites
|
|
for week in self.weeks
|
|
for shift in self.get_shifts_with_constraint("night")
|
|
),
|
|
# within=Binary,
|
|
within=NonNegativeIntegers,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.night_per_site_t1 = Var(
|
|
(
|
|
(week, shift.name, site)
|
|
for site in self.sites
|
|
for week in self.weeks
|
|
for shift in self.get_shifts_with_constraint("night")
|
|
),
|
|
domain=NonNegativeIntegers,
|
|
initialize=0,
|
|
)
|
|
self.model.night_per_site_t2 = Var(
|
|
(
|
|
(week, shift.name, site)
|
|
for site in self.sites
|
|
for week in self.weeks
|
|
for shift in self.get_shifts_with_constraint("night")
|
|
),
|
|
domain=NonNegativeIntegers,
|
|
initialize=0,
|
|
)
|
|
|
|
# Replacement for balance_nights_across_sites
|
|
self.model.shift_per_site = Var(
|
|
(
|
|
(week, shift.name, site)
|
|
for site in self.sites
|
|
for week in self.weeks
|
|
for shift in self.get_shifts_with_constraint("balance_across_groups")
|
|
),
|
|
# within=Binary,
|
|
within=NonNegativeIntegers,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.shift_per_site_t1 = Var(
|
|
(
|
|
(week, shift.name, site)
|
|
for site in self.sites
|
|
for week in self.weeks
|
|
for shift in self.get_shifts_with_constraint("balance_across_groups")
|
|
),
|
|
domain=NonNegativeIntegers,
|
|
initialize=0,
|
|
)
|
|
self.model.shift_per_site_t2 = Var(
|
|
(
|
|
(week, shift.name, site)
|
|
for site in self.sites
|
|
for week in self.weeks
|
|
for shift in self.get_shifts_with_constraint("balance_across_groups")
|
|
),
|
|
domain=NonNegativeIntegers,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.shift_count = Var(
|
|
(
|
|
(worker.id, shift)
|
|
for worker in self.workers
|
|
for shift in self.get_shift_names()
|
|
),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
bounds=self.SHIFT_BOUNDS["shift_count"],
|
|
)
|
|
|
|
self.model.shift_count_diff = Var(
|
|
(
|
|
(worker.id, shift)
|
|
for worker in self.workers
|
|
for shift in self.get_shift_names()
|
|
),
|
|
within=Reals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.shift_count_diff_summed = Var(
|
|
(worker.id for worker in self.workers),
|
|
within=Reals,
|
|
initialize=0,
|
|
)
|
|
|
|
if self.constraint_options["balance_shifts_over_workers"]:
|
|
self.model.worker_shift_count_t1 = Var(
|
|
((worker.id) for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.worker_shift_count_t2 = Var(
|
|
((worker.id) for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
if (
|
|
self.constraint_options["balance_shifts"]
|
|
or self.constraint_options["balance_shifts_quadratic"]
|
|
):
|
|
self.model.shift_count_t1 = Var(
|
|
(
|
|
(worker.id, shift.name)
|
|
for worker in self.workers
|
|
for shift in self.get_shifts()
|
|
),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.shift_count_t2 = Var(
|
|
(
|
|
(worker.id, shift.name)
|
|
for worker in self.workers
|
|
for shift in self.get_shifts()
|
|
),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
if self.constraint_options["balance_shifts_quadratic"]:
|
|
self.model.shift_count_w = Var(
|
|
(
|
|
(worker.id, shift.name)
|
|
for worker in self.workers
|
|
for shift in self.get_shifts()
|
|
),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
if self.constraint_options["balance_nights"]:
|
|
# We also try to even out the night shifts seperately
|
|
self.model.night_shift_count = Var(
|
|
((worker.id) for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
bounds=self.SHIFT_BOUNDS["night_shift_count"],
|
|
)
|
|
|
|
self.model.night_shift_count_t1 = Var(
|
|
((worker.id) for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.night_shift_count_t2 = Var(
|
|
((worker.id) for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.night_shift_count_w = Var(
|
|
((worker.id) for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
# self.model.worker_weekend_assigned = Var(
|
|
# ((worker.id, week) for worker in self.workers
|
|
# for week in self.weeks),
|
|
# domain=NonNegativeReals,
|
|
# initialize=0,
|
|
# )
|
|
|
|
# self.model.works_weekend_count = Var(
|
|
# ((worker.id, week) for worker in self.workers
|
|
# for week in self.weeks),
|
|
# domain=NonNegativeIntegers,
|
|
# initialize=0,
|
|
# )
|
|
# self.model.works_saturday = Var(
|
|
# ((worker.id, week) for worker in self.workers
|
|
# for week in self.weeks),
|
|
# domain=Binary,
|
|
# initialize=0,
|
|
# )
|
|
# self.model.works_sunday = Var(
|
|
# ((worker.id, week) for worker in self.workers
|
|
# for week in self.weeks),
|
|
# domain=Binary,
|
|
# initialize=0,
|
|
# )
|
|
|
|
# self.model.works_whole_weekend = Var(
|
|
# ((worker.id, week) for worker in self.workers
|
|
# for week in self.weeks),
|
|
# domain=Binary,
|
|
# initialize=0,
|
|
# )
|
|
|
|
self.model.works_weekend = Var(
|
|
((worker.id, week) for worker in self.workers for week in self.weeks),
|
|
domain=Binary,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.worker_weekend_count = Var(
|
|
((worker.id) for worker in self.workers),
|
|
domain=NonNegativeIntegers,
|
|
initialize=0,
|
|
bounds=self.SHIFT_BOUNDS["weekend_count"],
|
|
)
|
|
|
|
if self.constraint_options["balance_weekends"]:
|
|
self.model.weekend_shift_count_t1 = Var(
|
|
((worker.id) for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.weekend_shift_count_t2 = Var(
|
|
((worker.id) for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.weekend_shift_count_w = Var(
|
|
((worker.id) for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
# self.model.weekend_count_t1 = Var(
|
|
# ((worker.id) for worker in self.workers),
|
|
# domain=NonNegativeReals,
|
|
# initialize=0,
|
|
# )
|
|
|
|
# self.model.weekend_count_t2 = Var(
|
|
# ((worker.id) for worker in self.workers),
|
|
# domain=NonNegativeReals,
|
|
# initialize=0,
|
|
# )
|
|
|
|
# self.model.unavailable = Var(((worker, week, day) for worker in self.workers for week in weeks for day in days),
|
|
# within=Binary, initialize=0)
|
|
|
|
def availability_init(model, wid, week, day):
|
|
if (wid, week, day) in self.unavailable_to_work:
|
|
# print((wid, week, day))
|
|
return 0
|
|
return 1
|
|
|
|
self.model.available = Param(
|
|
(
|
|
(worker.id, week, day)
|
|
for worker in self.workers
|
|
for week, day in self.get_week_day_combinations()
|
|
),
|
|
initialize=availability_init,
|
|
)
|
|
|
|
work_request_sets = set()
|
|
for work_request in self.work_requests:
|
|
if work_request[3] == "*":
|
|
pass
|
|
|
|
else:
|
|
work_request_sets.add(work_request[3])
|
|
|
|
# Validate shifts are valid
|
|
# work_request_sets = set([i[3] for i in self.work_requests])
|
|
invalid_shifts = work_request_sets - set(self.get_shift_names())
|
|
if invalid_shifts and not self.ignore_valid_shifts:
|
|
raise InvalidShift(
|
|
f"Invalid worker shift request [shift(s): ${invalid_shifts}]"
|
|
)
|
|
|
|
def work_request_init(model, wid, week, day, shift):
|
|
if (wid, week, day, shift) in self.work_requests:
|
|
self.work_requests_map[(wid, week, day)] = shift
|
|
return 1
|
|
return 0
|
|
|
|
self.model.work_requests = Param(
|
|
(
|
|
(worker.id, week, day, shift)
|
|
for worker in self.workers
|
|
for week, day, shift in self.get_all_shiftname_combinations()
|
|
),
|
|
initialize=work_request_init,
|
|
)
|
|
|
|
def pref_init(model, wid, week, day):
|
|
if (wid, week, day) in self.pref_not_to_work:
|
|
return self.pref_not_to_work[(wid, week, day)]
|
|
return 0
|
|
|
|
# It would also be possible to assign sequential days as blocks
|
|
# which would allow more weight to be given if the whole block
|
|
# is not allocated
|
|
self.model.pref_not_to_work = Param(
|
|
(
|
|
(worker.id, week, day)
|
|
for worker in self.workers
|
|
for week, day in self.get_week_day_combinations()
|
|
),
|
|
initialize=pref_init,
|
|
)
|
|
|
|
# binary variables representing if a worker is necessary
|
|
# self.model.needed = Var([worker.id for worker in self.workers], within=Binary, initialize=0)
|
|
|
|
# binary variables representing if a worker worked on sunday but not on saturday (avoid if possible)
|
|
# self.model.no_pref = Var([worker.id for worker in self.workers], within=Binary, initialize=0)
|
|
|
|
self.build_model_constraints()
|
|
|
|
def build_model_constraints(self):
|
|
self.model.constraints = ConstraintList() # Create a set of constraints
|
|
|
|
# Ensure each shift has the requisit number of workers assigned
|
|
for (
|
|
week,
|
|
day,
|
|
shift,
|
|
workers_required,
|
|
site_required,
|
|
) in self.get_required_workers_and_site_combinations():
|
|
# print(week, day, shift, workers_required, site_required)
|
|
self.model.constraints.add(
|
|
workers_required
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for worker in self.workers
|
|
if worker.site in site_required
|
|
)
|
|
)
|
|
# And it is not assigned if the worker is from the wrong site
|
|
if [worker for worker in self.workers if worker.site not in site_required]:
|
|
self.model.constraints.add(
|
|
0
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for worker in self.workers
|
|
if worker.site not in site_required
|
|
)
|
|
)
|
|
# # Ensure shifts are only assigned by workers from the allowed sites
|
|
# self.model.constraints.add(0 == sum(
|
|
# self.model.works[worker.id, week, day, shift]
|
|
# for worker in self.workers if worker.site not in site_required))
|
|
|
|
# Constraint: total hours worked hours worked
|
|
# for worker in self.workers:
|
|
# self.model.constraints.add(
|
|
# 1200 >= sum(shift_lengths[shift] * self.model.works[worker, week, day, shift] for week in weeks for day in days for shift in days_shifts[day])
|
|
# )
|
|
|
|
# for week in weeks:
|
|
# for worker in self.workers:
|
|
# self.model.constraints.add(
|
|
# 48 >= sum(shift_lengths[shift] * self.model.works[worker, week, day, shift] for day in days for shift in days_shifts[day])
|
|
# )
|
|
|
|
# Define a maximum number hours that can be worked per week
|
|
# NOTE: this works on a calander week not a 7 day period
|
|
|
|
# TODO: consider fixing
|
|
# def maxHoursPerWeekRule(model, wid, week):
|
|
# return 72 >= sum(
|
|
# shift.length * model.works[wid, week, day, shift.name]
|
|
# for day, shift in self.get_day_shiftclass_combinations())
|
|
|
|
# self.model.max_hours_per_week_constraint = Constraint(
|
|
# [worker.id for worker in self.workers],
|
|
# self.weeks,
|
|
# rule=maxHoursPerWeekRule)
|
|
|
|
# Define an upper limit for hours worked over the rota period
|
|
# def maxHoursRule(model, wid):
|
|
# return 8000 >= sum(
|
|
# shift.length * model.works[wid, week, day, shift.name]
|
|
# for week, day, shift in self.get_all_shiftclass_combinations())
|
|
|
|
# self.model.max_hours_constraint = Constraint(
|
|
# [worker.id for worker in self.workers], rule=maxHoursRule)
|
|
|
|
# Limit grades
|
|
def limitGradesRule(model, week, shift, grade, limit):
|
|
workers = [w for w in self.workers if w.grade == grade]
|
|
if not workers:
|
|
return Constraint.Skip
|
|
return (
|
|
sum(
|
|
model.shift_week_worker_assigned[shift, week, w.id] for w in workers
|
|
)
|
|
<= limit
|
|
)
|
|
|
|
for shift in self.get_shifts_with_constraint("limit_grade_number"):
|
|
if not shift.constraint_options["limit_grade_number"]:
|
|
raise ValueError(
|
|
"Constraint option must be defined for 'limit_grade_number'"
|
|
)
|
|
for grade in shift.constraint_options["limit_grade_number"]:
|
|
# self.model.limit_grades_constraint = Constraint(
|
|
setattr(
|
|
self.model,
|
|
f"limit_grades_constraint_{shift.name}_{grade}",
|
|
Constraint(
|
|
[week for week in self.weeks],
|
|
# [shift for shift in self.get_shifts_with_constraint("limit_grade_number")],
|
|
[shift.name],
|
|
[grade],
|
|
[shift.constraint_options["limit_grade_number"][grade]],
|
|
rule=limitGradesRule,
|
|
# name=f"limit_grades_constraint_{shift.name}_{grade}"
|
|
),
|
|
)
|
|
|
|
def minimumGradesRule(model, week, shift, grade, limit):
|
|
workers = [w for w in self.workers if w.grade >= grade]
|
|
if len(workers) < limit:
|
|
# return Constraint.Skip
|
|
raise ValueError("minimum_grade_number: not enough valid workers")
|
|
return (
|
|
sum(
|
|
model.shift_week_worker_assigned[shift, week, w.id] for w in workers
|
|
)
|
|
>= limit
|
|
)
|
|
|
|
for shift in self.get_shifts_with_constraint("minimum_grade_number"):
|
|
if not shift.constraint_options["minimum_grade_number"]:
|
|
raise ValueError(
|
|
"Constraint option must be defined for 'minimum_grade_number'"
|
|
)
|
|
grade, min_required = shift.constraint_options["minimum_grade_number"]
|
|
# self.model.limit_grades_constraint = Constraint(
|
|
setattr(
|
|
self.model,
|
|
f"minimum_grade_number_{shift.name}",
|
|
Constraint(
|
|
[week for week in self.weeks],
|
|
# [shift for shift in self.get_shifts_with_constraint("limit_grade_number")],
|
|
[shift.name],
|
|
[grade],
|
|
[min_required],
|
|
rule=minimumGradesRule,
|
|
# name=f"limit_grades_constraint_{shift.name}_{grade}"
|
|
),
|
|
)
|
|
|
|
def presenceAtRemoteSiteWeek(
|
|
model, week, shift, required_site, required_number
|
|
):
|
|
required_site_workers = [
|
|
w for w in self.workers if required_site == w.remote_site
|
|
]
|
|
|
|
return (
|
|
sum(
|
|
model.shift_week_worker_assigned[shift, week, w.id]
|
|
for w in required_site_workers
|
|
)
|
|
>= required_number
|
|
)
|
|
|
|
for shift in self.get_shifts_with_constraint(
|
|
"require_remote_site_presence_week"
|
|
):
|
|
site, required_number = shift.constraint_options[
|
|
"require_remote_site_presence_week"
|
|
]
|
|
# self.model.require_presence_at_site_overnight_rule = Constraint(
|
|
setattr(
|
|
self.model,
|
|
f"require_remote_site_presence_week_{shift.name}",
|
|
Constraint(
|
|
[week for week in self.weeks],
|
|
[shift.name],
|
|
[site],
|
|
[required_number],
|
|
rule=presenceAtRemoteSiteWeek,
|
|
),
|
|
)
|
|
|
|
def presenceAtRemoteSite(
|
|
model, day, week, shift, required_site, required_number
|
|
):
|
|
required_site_workers = [
|
|
w for w in self.workers if required_site == w.remote_site
|
|
]
|
|
|
|
for w in required_site_workers:
|
|
if (w.id, week, day, shift) not in model.works:
|
|
return Constraint.Skip
|
|
|
|
return (
|
|
sum(model.works[w.id, week, day, shift] for w in required_site_workers)
|
|
>= required_number
|
|
)
|
|
|
|
for shift in self.get_shifts_with_constraint("require_remote_site_presence"):
|
|
site, required_number = shift.constraint_options[
|
|
"require_remote_site_presence"
|
|
]
|
|
# self.model.require_presence_at_site_overnight_rule = Constraint(
|
|
setattr(
|
|
self.model,
|
|
f"require_remote_site_presence_{shift.name}",
|
|
Constraint(
|
|
[day for day in self.days],
|
|
[week for week in self.weeks],
|
|
[shift.name],
|
|
[site],
|
|
[required_number],
|
|
rule=presenceAtRemoteSite,
|
|
),
|
|
)
|
|
|
|
# # Count the number of workers from each site on each night shift
|
|
# # As 1 or 0 is optimum we can simply subtract 1 from the number
|
|
# if self.constraint_options["balance_nights_across_sites"]:
|
|
# for site in self.sites:
|
|
# for shift in self.get_shifts_with_constraint("night"):
|
|
# block = shift.name
|
|
# for week in self.weeks:
|
|
# self.model.constraints.add(
|
|
# self.model.night_per_site[week, block, site]
|
|
# == sum(
|
|
# self.model.shift_week_worker_assigned[
|
|
# block, week, worker.id
|
|
# ]
|
|
# for worker in self.workers
|
|
# if worker.site == site
|
|
# )
|
|
# # 1 >= sum(self.model.shift_week_worker_assigned[shift.name, week, worker.id] for worker in self.workers if worker.site == site)
|
|
# )
|
|
# self.model.constraints.add(
|
|
# self.model.night_per_site_t1[week, block, site]
|
|
# - self.model.night_per_site_t2[week, block, site]
|
|
# == self.model.night_per_site[week, block, site] - 1
|
|
# )
|
|
for site in track(self.sites, description="Generating site balance constraints..."):
|
|
for shift in self.get_shifts_with_constraint("balance_across_groups"):
|
|
if site in shift.sites:
|
|
block = shift.name
|
|
for week in self.weeks:
|
|
self.model.constraints.add(
|
|
self.model.shift_per_site[week, block, site]
|
|
== sum(
|
|
self.model.shift_week_worker_assigned[
|
|
block, week, worker.id
|
|
]
|
|
for worker in self.workers
|
|
if worker.site == site
|
|
)
|
|
# 1 >= sum(self.model.shift_week_worker_assigned[shift.name, week, worker.id] for worker in self.workers if worker.site == site)
|
|
)
|
|
self.model.constraints.add(
|
|
self.model.shift_per_site_t1[week, block, site]
|
|
- self.model.shift_per_site_t2[week, block, site]
|
|
== self.model.shift_per_site[week, block, site] - 1
|
|
)
|
|
|
|
weeks_days = self.get_week_day_combinations()
|
|
|
|
for week in track(self.weeks, description="Generating week constraints..."):
|
|
for shift_name in self.shifts_to_assign_or_force_as_blocks():
|
|
shift = self.get_shift_by_name(shift_name)
|
|
for worker in self.get_workers_for_shift(shift):
|
|
self.model.constraints.add(
|
|
8
|
|
* self.model.blocks_worker_shift_assigned[
|
|
worker.id, week, shift_name
|
|
]
|
|
>= sum(
|
|
self.model.works[worker.id, week, day, shift_name]
|
|
for day in shift.days
|
|
)
|
|
)
|
|
self.model.constraints.add(
|
|
self.model.blocks_assigned[week, shift_name]
|
|
== sum(
|
|
self.model.blocks_worker_shift_assigned[
|
|
worker.id, week, shift_name
|
|
]
|
|
for worker in self.workers
|
|
)
|
|
)
|
|
|
|
if shift.force_as_block_unless_nwd:
|
|
workers = self.get_workers_for_shift(shift)
|
|
# Get workers who have a nwd on the shift
|
|
nwd_workers = []
|
|
full_workers = []
|
|
|
|
for w in workers:
|
|
if w.non_working_day_list:
|
|
l = []
|
|
for (
|
|
nwd,
|
|
start_nwd_date,
|
|
end_nwd_date,
|
|
) in w.non_working_day_list:
|
|
if nwd in shift.days:
|
|
if start_nwd_date > self.get_week_start_date(week):
|
|
continue
|
|
if end_nwd_date < self.get_week_start_date(week):
|
|
continue
|
|
l.append(w)
|
|
|
|
if l:
|
|
nwd_workers.append(w)
|
|
else:
|
|
full_workers.append(w)
|
|
|
|
else:
|
|
full_workers.append(w)
|
|
|
|
if not nwd_workers: # Just do the usual
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.blocks_worker_shift_assigned[
|
|
worker.id, week, shift.name
|
|
]
|
|
for worker in self.workers
|
|
)
|
|
<= shift.workers_required
|
|
)
|
|
|
|
else:
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.blocks_worker_shift_assigned[
|
|
worker.id, week, shift.name
|
|
]
|
|
for worker in full_workers
|
|
)
|
|
<= shift.workers_required + 1
|
|
)
|
|
|
|
elif shift.force_as_block:
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.blocks_worker_shift_assigned[
|
|
worker.id, week, shift.name
|
|
]
|
|
for worker in self.workers
|
|
)
|
|
<= shift.workers_required
|
|
)
|
|
|
|
# Most of our constraints apply per worker
|
|
for worker in self.workers:
|
|
console.rule(f"Generate worker constraints: [bold blue]{worker.name}[/bold blue]")
|
|
logging.debug(f"Generate worker constraints: {worker.name}")
|
|
|
|
if self.constraint_options["minimise_shift_diffs"]:
|
|
self.model.constraints.add(
|
|
self.model.shift_count_diff_summed[worker.id]
|
|
== sum(
|
|
self.model.shift_count_diff[worker.id, shift.name]
|
|
for shift in self.get_shifts()
|
|
)
|
|
)
|
|
|
|
for week_blocks in self.get_week_block_iterator(4):
|
|
# Prevent more than n number shifts per 4 weeks
|
|
try:
|
|
self.model.constraints.add(
|
|
self.constraint_options["max_shifts_per_month"]
|
|
>= sum(
|
|
self.model.works[worker.id, week, day, shiftname]
|
|
for week, day, shiftname in self.get_all_shiftname_combinations()
|
|
if week in week_blocks
|
|
)
|
|
)
|
|
except ValueError:
|
|
# Occurs if there are no shifts within a defined block
|
|
# TODO: test if this breaks (and we should check rathar than except)
|
|
logging.debug(f"Failed to constrain max_shifts_per_month for worker: {worker.name}")
|
|
pass
|
|
|
|
for constraint_dict in self.constraint_options["avoid_shifts_by_grades"]:
|
|
if invalid_shifts := set(constraint_dict["shifts"]).difference(
|
|
set(self.get_shift_names())
|
|
):
|
|
if self.ignore_valid_shifts:
|
|
continue # Skip if we don't want to raise an error
|
|
else:
|
|
raise InvalidShift(
|
|
f"avoid shifts by grade constraint contains a non existent shift: {invalid_shifts}"
|
|
)
|
|
if invalid_grades := set(constraint_dict["grades"]).difference(
|
|
set(self.get_worker_grades())
|
|
):
|
|
raise ValueError(
|
|
f"avoid shifts by grade constraint contains a non existent worker grade: {invalid_grades}"
|
|
)
|
|
|
|
if worker.grade in constraint_dict["grades"]:
|
|
self.model.constraints.add(
|
|
0
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for week, day, shift in self.get_all_shiftname_combinations()
|
|
if week in constraint_dict["weeks"]
|
|
and shift in constraint_dict["shifts"]
|
|
)
|
|
)
|
|
|
|
# sys.exit(0)
|
|
|
|
if self.constraint_options["avoid_st2_first_month"] and worker.grade == 2:
|
|
# Avoid ST1s on the first month
|
|
try:
|
|
self.model.constraints.add(
|
|
0
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for week, day, shift in self.get_all_shiftname_combinations()
|
|
if week in (1, 2, 3, 4)
|
|
and shift in ("night_weekday", "night_weekend")
|
|
)
|
|
)
|
|
except ValueError as e:
|
|
print("Failure setting constraint", "avoid_st2_first_month")
|
|
print(e)
|
|
|
|
# Count number of weekends an worker works
|
|
# if self.constraint_options["balance_weekends"]:
|
|
self.model.constraints.add(
|
|
self.model.worker_weekend_count[worker.id]
|
|
== sum(self.model.works_weekend[worker.id, week] for week in self.weeks)
|
|
)
|
|
|
|
if self.constraint_options["max_weekends"] > -1:
|
|
self.model.constraints.add(
|
|
self.constraint_options["max_weekends"]
|
|
>= self.model.worker_weekend_count[worker.id]
|
|
)
|
|
|
|
# Balance shifts within required limits (set by balance_offset)
|
|
# If balance_offset is too restrictive (for the rota length)
|
|
# no solution will be possible
|
|
for shift in track(self.get_shifts(), description="Generate shift balance constraints"):
|
|
if (
|
|
worker.site in shift.sites
|
|
): # Each site specfies which sites self.workers can fullfill it
|
|
|
|
# calaculate the total number of shifts that need assigning over the rota
|
|
# total_shifts = (len(self.weeks) * len(shift.days) *
|
|
total_shifts = (
|
|
self.shift_counts[shift.name] * shift.workers_required
|
|
)
|
|
|
|
full_time_equivalent_joined = sum(
|
|
self.full_time_equivalent_sites[i] for i in shift.sites
|
|
)
|
|
|
|
target_shifts = (
|
|
total_shifts / full_time_equivalent_joined * worker.fte_adj
|
|
)
|
|
|
|
if self.use_previous_shifts:
|
|
if shift.name in worker.previous_shifts:
|
|
worked, allocated = worker.previous_shifts[shift.name]
|
|
target_shifts = (
|
|
target_shifts + float(allocated) - float(worked)
|
|
)
|
|
|
|
if self.use_shift_balance_extra:
|
|
if shift.name in worker.shift_balance_extra:
|
|
extra = worker.shift_balance_extra[shift.name]
|
|
target_shifts = target_shifts + extra
|
|
|
|
# print(worker.name, shift.name, target_shifts)
|
|
worker.shift_target_number[shift.name] = target_shifts
|
|
|
|
if shift.hard_constrain_shift:
|
|
adjusted_balance_offset = shift.balance_offset
|
|
if worker.fte_adj < 100:
|
|
adjusted_balance_offset = (
|
|
adjusted_balance_offset * self.ltft_balance_offset
|
|
)
|
|
|
|
max_shifts = (
|
|
target_shifts
|
|
+ adjusted_balance_offset * self.balance_offset_modifier
|
|
)
|
|
min_shifts = (
|
|
target_shifts
|
|
- adjusted_balance_offset * self.balance_offset_modifier
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
inequality(
|
|
min_shifts,
|
|
sum(
|
|
self.model.works[worker.id, week, day, shift.name]
|
|
for week, day in self.get_week_day_combinations_for_shift(
|
|
shift
|
|
)
|
|
# for week, day in self.get_week_day_combinations()
|
|
# if shift.name in self.get_shift_names_by_week_day(week, day)
|
|
),
|
|
max_shifts,
|
|
)
|
|
)
|
|
else:
|
|
# Make sure shifts aren't assigned to those from other sites
|
|
# Not needed if there are no shifts
|
|
if self.get_week_day_combinations_for_shift(shift):
|
|
self.model.constraints.add(
|
|
0
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift.name]
|
|
for week, day in self.get_week_day_combinations_for_shift(
|
|
shift
|
|
)
|
|
# for week, day in self.get_week_day_combinations()
|
|
)
|
|
)
|
|
|
|
# model.shift_count stores the number of shifts that a worker is assigned to worker by shift
|
|
# this is used (by the objective) to balance the total number of shifts worked
|
|
self.model.constraints.add(
|
|
self.model.shift_count[worker.id, shift.name]
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift.name]
|
|
for week, day in self.get_week_day_combinations_for_shift(shift)
|
|
)
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.shift_count_diff[worker.id, shift.name]
|
|
== self.model.shift_count[worker.id, shift.name]
|
|
- worker.shift_target_number[shift.name]
|
|
)
|
|
|
|
if self.constraint_options["balance_shifts"]:
|
|
self.model.constraints.add(
|
|
self.model.shift_count_t1[worker.id, shift.name]
|
|
- self.model.shift_count_t2[worker.id, shift.name]
|
|
== self.model.shift_count_diff[worker.id, shift.name]
|
|
)
|
|
|
|
if self.constraint_options["balance_shifts_quadratic"]:
|
|
# This may need to be updated
|
|
xU = 25
|
|
xL = 1
|
|
self.model.constraints.add(
|
|
inequality(
|
|
xL,
|
|
self.model.shift_count_t1[worker.id, shift.name]
|
|
+ self.model.shift_count_t2[worker.id, shift.name]
|
|
+ 1,
|
|
xU,
|
|
)
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.shift_count_w[worker.id, shift.name]
|
|
>= xL
|
|
* (
|
|
self.model.shift_count_t1[worker.id, shift.name]
|
|
+ self.model.shift_count_t2[worker.id, shift.name]
|
|
+ 1
|
|
)
|
|
* 2
|
|
- xL * xL
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.shift_count_w[worker.id, shift.name]
|
|
>= xU
|
|
* (
|
|
self.model.shift_count_t1[worker.id, shift.name]
|
|
+ self.model.shift_count_t2[worker.id, shift.name]
|
|
+ 1
|
|
)
|
|
* 2
|
|
- xU * xU
|
|
)
|
|
|
|
# Define worker_shift_count_t1 and worker_shift_count_t2 constraints for the object
|
|
# Thus bypassing the need for a quadratic solver
|
|
# t1-t2 is the target
|
|
# As the objective is to minimise t1+t2 and t1 and t2 are positive reals
|
|
# t1+t2 approximates the absolute target (which otherwise requires a quadratic solver)
|
|
# TODO: quadratic implementation so perfect solutions will be chosen
|
|
if self.constraint_options["balance_shifts_over_workers"]:
|
|
self.model.constraints.add(
|
|
self.model.worker_shift_count_t1[worker.id]
|
|
- self.model.worker_shift_count_t2[worker.id]
|
|
== sum(
|
|
(self.model.shift_count_diff[worker.id, shift.name])
|
|
* shift.balance_weighting
|
|
for shift in self.get_shifts()
|
|
)
|
|
)
|
|
|
|
if self.constraint_options["balance_bank_holidays"]:
|
|
|
|
extra_bank_holiday = 0
|
|
if self.use_bank_holiday_extra:
|
|
extra_bank_holiday = worker.bank_holiday_extra
|
|
|
|
self.model.constraints.add(
|
|
self.model.bank_holiday_count[worker.id]
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for week, day, shift in self.get_all_shiftname_combinations()
|
|
if self.week_day_date_map[(week, day)] in bank_holiday_map
|
|
)
|
|
+ extra_bank_holiday
|
|
)
|
|
|
|
xU = len(self.get_bank_holiday_week_days()) + 1
|
|
xL = 1
|
|
self.model.constraints.add(
|
|
inequality(
|
|
xL,
|
|
self.model.bank_holiday_count[worker.id] + 1,
|
|
xU,
|
|
)
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.bank_holiday_count_w[worker.id]
|
|
>= xL * (self.model.bank_holiday_count[worker.id] + 1) * 2 - xL * xL
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.bank_holiday_count_w[worker.id]
|
|
>= xU * (self.model.bank_holiday_count[worker.id] + 1) * 2 - xU * xU
|
|
)
|
|
|
|
if self.constraint_options["balance_nights"]:
|
|
self.model.constraints.add(
|
|
self.model.night_shift_count[worker.id]
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift.name]
|
|
for week, day, shift in self.get_week_day_shift_combinations_for_constraint(
|
|
"night"
|
|
)
|
|
# for week, day in self.get_week_day_combinations_for_shift(shift)
|
|
# for shift in self.get_shifts_with_constraint("night")
|
|
)
|
|
)
|
|
|
|
night_shift_target_number = sum(
|
|
worker.shift_target_number[shift.name]
|
|
for shift in self.get_shifts_with_constraint("night")
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.night_shift_count_t1[worker.id]
|
|
- self.model.night_shift_count_t2[worker.id]
|
|
== self.model.night_shift_count[worker.id]
|
|
- night_shift_target_number
|
|
)
|
|
|
|
# This may need to be updated
|
|
xU = 6
|
|
xL = 1
|
|
self.model.constraints.add(
|
|
inequality(
|
|
xL,
|
|
self.model.night_shift_count_t1[worker.id]
|
|
+ self.model.night_shift_count_t2[worker.id]
|
|
+ 1,
|
|
xU,
|
|
)
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.night_shift_count_w[worker.id]
|
|
>= xL
|
|
* (
|
|
self.model.night_shift_count_t1[worker.id]
|
|
+ self.model.night_shift_count_t2[worker.id]
|
|
+ 1
|
|
)
|
|
* 2
|
|
- xL * xL
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.night_shift_count_w[worker.id]
|
|
>= xU
|
|
* (
|
|
self.model.night_shift_count_t1[worker.id]
|
|
+ self.model.night_shift_count_t2[worker.id]
|
|
+ 1
|
|
)
|
|
* 2
|
|
- xU * xU
|
|
)
|
|
|
|
# self.model.constraints.add(
|
|
# self.model.night_shift_count_w[worker.id] >= 0)
|
|
|
|
# We use a similar method to balance the number of weekends worked
|
|
# This works as long as weekend shifts are assigned as blocks!
|
|
weekend_shift_target_number = sum(
|
|
worker.shift_target_number[shift.name] / len(shift.days)
|
|
for shift in self.get_shifts()
|
|
if "Sat" in shift.days or "Sun" in shift.days
|
|
)
|
|
|
|
worker.weekend_shift_target_number = weekend_shift_target_number
|
|
|
|
if self.constraint_options["balance_weekends"]:
|
|
|
|
if weekend_shift_target_number > 0:
|
|
self.model.constraints.add(
|
|
self.model.weekend_shift_count_t1[worker.id]
|
|
- self.model.weekend_shift_count_t2[worker.id]
|
|
== self.model.worker_weekend_count[worker.id]
|
|
- weekend_shift_target_number
|
|
)
|
|
|
|
xU = self.constraint_options["max_weekends"]
|
|
|
|
xL = 1
|
|
self.model.constraints.add(
|
|
inequality(
|
|
xL,
|
|
self.model.weekend_shift_count_t1[worker.id]
|
|
+ self.model.weekend_shift_count_t2[worker.id]
|
|
+ 1,
|
|
xU,
|
|
)
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.weekend_shift_count_w[worker.id]
|
|
>= xL
|
|
* (
|
|
self.model.weekend_shift_count_t1[worker.id]
|
|
+ self.model.weekend_shift_count_t2[worker.id]
|
|
+ 1
|
|
)
|
|
* 2
|
|
- xL * xL
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.weekend_shift_count_w[worker.id]
|
|
>= xU
|
|
* (
|
|
self.model.weekend_shift_count_t1[worker.id]
|
|
+ self.model.weekend_shift_count_t2[worker.id]
|
|
+ 1
|
|
)
|
|
* 2
|
|
- xU * xU
|
|
)
|
|
|
|
# Ensure worker is not allocated shifts on non working days
|
|
if worker.non_working_day_list:
|
|
for week, day, shift in self.get_all_shiftclass_combinations():
|
|
for n, start, end in worker.non_working_day_list:
|
|
if not shift.rota_on_nwds and day == n:
|
|
# print(start, self.week_day_date_map[(week, day)], end)
|
|
if start <= self.week_day_date_map[(week, day)] < end:
|
|
self.model.constraints.add(
|
|
0
|
|
== self.model.works[
|
|
worker.id, week, day, shift.name
|
|
]
|
|
)
|
|
|
|
if self.constraint_options["balance_blocks"]:
|
|
if self.constraint_options["max_night_frequency"]:
|
|
for week_blocks in self.get_week_block_iterator(
|
|
self.constraint_options["max_night_frequency"]
|
|
):
|
|
# Ignore excluded weeks
|
|
if set(week_blocks).intersection(set(self.constraint_options["max_night_frequency_week_exclusions"])):
|
|
continue
|
|
|
|
|
|
if self.get_shifts_with_constraint("night"):
|
|
# Prevent nights more than once every n weeks
|
|
self.model.constraints.add(
|
|
1
|
|
>= sum(
|
|
self.model.shift_week_worker_assigned[
|
|
shift.name, week, worker.id
|
|
]
|
|
for week in week_blocks
|
|
for shift in self.get_shifts_with_constraint(
|
|
"night"
|
|
)
|
|
)
|
|
)
|
|
|
|
# if self.constraint_options["balance_weekends"]:
|
|
if self.constraint_options["max_weekend_frequency"]:
|
|
for week_blocks in self.get_week_block_iterator(
|
|
self.constraint_options["max_weekend_frequency"]
|
|
):
|
|
# Prevent weekend shifts more than once every n weeks
|
|
self.model.constraints.add(
|
|
1
|
|
>= sum(
|
|
self.model.works_weekend[worker.id, week]
|
|
for week in week_blocks
|
|
)
|
|
)
|
|
|
|
for constraint_shift in self.get_shifts_with_constraints(
|
|
"max_shifts_per_week_block"
|
|
):
|
|
constraint_options = constraint_shift.constraint_options[
|
|
"max_shifts_per_week_block"
|
|
]
|
|
|
|
# If not supplied, default to the number of days per shift
|
|
shift_number = constraint_shift.get_shift_number()
|
|
if "shift_number" in constraint_options:
|
|
shift_number = constraint_options["shift_number"]
|
|
|
|
for week_blocks in self.get_week_block_iterator(
|
|
constraint_options["weeks"]
|
|
):
|
|
# Prevent shifts more than once every n weeks
|
|
self.model.constraints.add(
|
|
shift_number
|
|
>= sum(
|
|
self.model.works[
|
|
worker.id, week, day, constraint_shift.name
|
|
]
|
|
for week in week_blocks
|
|
for day in constraint_shift.days
|
|
)
|
|
)
|
|
|
|
for week in self.weeks:
|
|
for constraint_shift in self.get_shifts_with_constraints(
|
|
"max_shifts_per_week"
|
|
):
|
|
self.model.constraints.add(
|
|
constraint_shift.constraint_options["max_shifts_per_week"]
|
|
>= sum(
|
|
self.model.works[worker.id, w, day, constraint_shift.name]
|
|
# for shiftname in self.get_shift_names_by_week_day(week, day)
|
|
# for day in self.days
|
|
for w, day in self.get_week_day_combinations_for_shift(
|
|
constraint_shift
|
|
)
|
|
if w == week
|
|
)
|
|
)
|
|
|
|
try:
|
|
self.model.constraints.add(
|
|
self.constraint_options["max_shifts_per_week"]
|
|
>= sum(
|
|
self.model.works[worker.id, w, day, shiftname]
|
|
# for shiftname in self.get_shift_names_by_week_day(week, day)
|
|
# for day in self.days
|
|
for w, day, shiftname in self.get_all_shiftname_combinations(
|
|
week=week
|
|
)
|
|
)
|
|
)
|
|
except ValueError:
|
|
pass
|
|
|
|
# TODO: consider excluding shifts that span relevant period
|
|
if (
|
|
worker.site
|
|
in self.constraint_options["prevent_monday_after_full_weekends"]
|
|
):
|
|
# Ignore last week
|
|
if week + 1 in self.weeks:
|
|
self.model.constraints.add(
|
|
full_weekend_count
|
|
>= sum(
|
|
self.model.works[worker.id, week, "Sat", shift]
|
|
for shift in self.get_shift_names_by_week_day(
|
|
week, "Sat"
|
|
)
|
|
)
|
|
+ sum(
|
|
self.model.works[worker.id, week, "Sun", shift]
|
|
for shift in self.get_shift_names_by_week_day(
|
|
week, "Sun"
|
|
)
|
|
)
|
|
+ sum(
|
|
self.model.works[worker.id, week + 1, "Mon", shift]
|
|
for shift in self.get_shift_names_by_week_day(
|
|
week + 1, "Mon"
|
|
)
|
|
)
|
|
)
|
|
if (
|
|
worker.site
|
|
in self.constraint_options[
|
|
"prevent_monday_and_tuesday_after_full_weekends"
|
|
]
|
|
):
|
|
# Ignore last week
|
|
if week + 1 in self.weeks:
|
|
self.model.constraints.add(
|
|
full_weekend_count
|
|
>= sum(
|
|
self.model.works[worker.id, week, "Sat", shift]
|
|
for shift in self.get_shift_names_by_week_day(
|
|
week, "Sat"
|
|
)
|
|
)
|
|
+ sum(
|
|
self.model.works[worker.id, week, "Sun", shift]
|
|
for shift in self.get_shift_names_by_week_day(
|
|
week, "Sun"
|
|
)
|
|
)
|
|
+ sum(
|
|
self.model.works[worker.id, week + 1, "Mon", shift]
|
|
for shift in self.get_shift_names_by_week_day(
|
|
week + 1, "Mon"
|
|
)
|
|
)
|
|
+ sum(
|
|
self.model.works[worker.id, week + 1, "Tue", shift]
|
|
for shift in self.get_shift_names_by_week_day(
|
|
week + 1, "Tue"
|
|
)
|
|
)
|
|
)
|
|
if (
|
|
worker.site
|
|
in self.constraint_options["prevent_fridays_before_full_weekends"]
|
|
):
|
|
self.model.constraints.add(
|
|
full_weekend_count
|
|
>= sum(
|
|
self.model.works[worker.id, week, "Sat", shift.name]
|
|
for shift in self.get_shift_names_by_week_day(week, "Sat")
|
|
)
|
|
+ sum(
|
|
self.model.works[worker.id, week, "Sun", shift.name]
|
|
for shift in self.get_shift_names_by_week_day(week, "Sat")
|
|
)
|
|
+ sum(
|
|
self.model.works[worker.id, week, "Fri", shift.name]
|
|
for shift in self.get_shift_names_by_week_day(week, "Sat")
|
|
)
|
|
)
|
|
if (
|
|
worker.site
|
|
in self.constraint_options["prevent_thursdays_before_full_weekends"]
|
|
):
|
|
self.model.constraints.add(
|
|
full_weekend_count
|
|
>= sum(
|
|
self.model.works[worker.id, week, "Sat", shift.name]
|
|
for shift in self.get_shift_names_by_week_day(week, "Sat")
|
|
)
|
|
+ sum(
|
|
self.model.works[worker.id, week, "Sun", shift.name]
|
|
for shift in self.get_shift_names_by_week_day(week, "Sun")
|
|
)
|
|
+ sum(
|
|
self.model.works[worker.id, week, "Thu", shift.name]
|
|
for shift in self.get_shift_names_by_week_day(week, "Thu")
|
|
)
|
|
)
|
|
|
|
# # model.weekend_count stores the number of weekend shifts that a worker is assigned to wor
|
|
# # this is used (by the objective) to balance the total number of weekend shifts worked
|
|
# self.model.constraints.add(
|
|
# # We could use a helper to get required shifts to check
|
|
# self.model.works_whole_weekend[worker.id, week] >= sum(
|
|
# self.model.works[worker.id, week, day, shift.name]
|
|
# for day in self.days[5:]
|
|
# for shift in self.get_shifts()) - 1)
|
|
# self.model.constraints.add(
|
|
# self.model.works_whole_weekend[worker.id, week] <= sum(
|
|
# self.model.works[worker.id, week, day, shift.name]
|
|
# for day in self.days[5:]
|
|
# for shift in self.get_shifts()) / 2)
|
|
# if self.constraint_options["balance_weekends"]:
|
|
self.model.constraints.add(
|
|
self.model.works_weekend[worker.id, week]
|
|
>= sum(
|
|
self.model.works[worker.id, week, day, shiftname]
|
|
for w, day, shiftname in self.get_all_shiftname_combinations(
|
|
week=week
|
|
)
|
|
if day in self.days[5:]
|
|
)
|
|
/ 2
|
|
)
|
|
self.model.constraints.add(
|
|
self.model.works_weekend[worker.id, week]
|
|
<= sum(
|
|
self.model.works[worker.id, week, day, shiftname]
|
|
for w, day, shiftname in self.get_all_shiftname_combinations(
|
|
week=week
|
|
)
|
|
if day in self.days[5:]
|
|
)
|
|
)
|
|
# self.model.constraints.add(
|
|
# self.model.works_saturday[worker.id, week] == sum(
|
|
# self.model.works[worker.id, week, "Sat", shift.name]
|
|
# for shift in self.get_shifts()))
|
|
# self.model.constraints.add(
|
|
# self.model.works_sunday[worker.id, week] == sum(
|
|
# self.model.works[worker.id, week, "Sun", shift.name]
|
|
# for shift in self.get_shifts()))
|
|
|
|
# self.model.constraints.add(1 == sum(
|
|
# self.model.shift_week_worker_assigned[s.name, week, worker.id]
|
|
# for s in self.get_shifts_with_constraint("night")))
|
|
|
|
# for shift in self.get_shifts_with_constraint("single_block_per_week"):
|
|
# if shift.force_as_block:
|
|
# # Force nights to be assigned in blocks
|
|
# # self.model.constraints.add(8* self.model.shift_week_worker_assigned[shift.name, week, worker.id] >= sum(self.model.works[worker.id, week, day, shift.name] for day in self.days)
|
|
# # )
|
|
|
|
# # Force night shifts to be assigned in blocks
|
|
# self.model.constraints.add(
|
|
# self.model.shift_week_worker_assigned[
|
|
# shift.name, week, worker.id
|
|
# ]
|
|
# * len(shift.days)
|
|
# == sum(
|
|
# self.model.works[worker.id, w, d, s]
|
|
# for w, d, s in self.get_all_shiftname_combinations(week=week)
|
|
# #for day in self.days
|
|
# )
|
|
# )
|
|
# else:
|
|
# raise ValueError(f"Requires {shift.name} to have force_as_block")
|
|
|
|
# for shift in self.get_shifts_with_constraint("night"):
|
|
for shift in self.get_shifts():
|
|
if shift.force_as_block:
|
|
# Force nights to be assigned in blocks
|
|
# self.model.constraints.add(8* self.model.shift_week_worker_assigned[shift.name, week, worker.id] >= sum(self.model.works[worker.id, week, day, shift.name] for day in self.days)
|
|
# )
|
|
|
|
# Force night shifts to be assigned in blocks
|
|
self.model.constraints.add(
|
|
self.model.shift_week_worker_assigned[
|
|
shift.name, week, worker.id
|
|
]
|
|
* len(shift.days)
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift.name]
|
|
for day in shift.days
|
|
)
|
|
)
|
|
|
|
#
|
|
|
|
# def get_pre_may(week_days, max_pre)
|
|
|
|
for n in track(range(len(weeks_days)), description="Generate week/day constraints"):
|
|
|
|
week, day = weeks_days[n]
|
|
|
|
pre_map = []
|
|
for pre_n in range(1, self.max_pre + 1):
|
|
try:
|
|
pweek, pday = weeks_days[n - pre_n]
|
|
p = 1
|
|
except:
|
|
pweek, pday = weeks_days[n]
|
|
p = 0
|
|
|
|
pre_map.append((p, pweek, pday))
|
|
|
|
post_map = []
|
|
for post_n in range(1, self.max_post + 1):
|
|
try:
|
|
pweek, pday = weeks_days[n + post_n]
|
|
p = 1
|
|
except:
|
|
pweek, pday = weeks_days[n]
|
|
p = 0
|
|
|
|
post_map.append((p, pweek, pday))
|
|
|
|
# p1 = 1
|
|
# p2 = 1
|
|
# p3 = 1
|
|
# p4 = 1
|
|
# p5 = 1
|
|
# p6 = 1
|
|
# p7 = 1
|
|
# if n > 0:
|
|
# pweek, pday = weeks_days[n - 1]
|
|
# else:
|
|
# p1 = 0
|
|
# pweek, pday = weeks_days[n]
|
|
|
|
# try:
|
|
# p2week, p2day = weeks_days[n - 2]
|
|
# except IndexError:
|
|
# p2week, p2day = weeks_days[n]
|
|
# p2 = 0
|
|
|
|
# try:
|
|
# p3week, p3day = weeks_days[n - 3]
|
|
# except IndexError:
|
|
# p3week, p3day = weeks_days[n]
|
|
# p3 = 0
|
|
|
|
# try:
|
|
# p4week, p4day = weeks_days[n - 4]
|
|
# except IndexError:
|
|
# p4week, p4day = weeks_days[n]
|
|
# p4 = 0
|
|
|
|
# try:
|
|
# p5week, p5day = weeks_days[n - 5]
|
|
# except IndexError:
|
|
# p5week, p5day = weeks_days[n]
|
|
# p5 = 0
|
|
|
|
# try:
|
|
# p6week, p6day = weeks_days[n - 6]
|
|
# except IndexError:
|
|
# p6week, p6day = weeks_days[n]
|
|
# p6 = 0
|
|
|
|
# try:
|
|
# p7week, p7day = weeks_days[n - 7]
|
|
# except IndexError:
|
|
# p7week, p7day = weeks_days[n]
|
|
# p7 = 0
|
|
|
|
# pre_map = [
|
|
# (p1, pweek, pday),
|
|
# (p2, p2week, p2day),
|
|
# (p3, p3week, p3day),
|
|
# (p4, p4week, p4day),
|
|
# (p5, p5week, p5day),
|
|
# (p6, p6week, p6day),
|
|
# (p7, p7week, p7day),
|
|
# ]
|
|
|
|
# n1 = 1
|
|
# n2 = 1
|
|
# n3 = 1
|
|
# n4 = 1
|
|
# n5 = 1
|
|
# n6 = 1
|
|
# n7 = 1
|
|
# if n > 0:
|
|
# nweek, nday = weeks_days[n - 1]
|
|
# else:
|
|
# n1 = 0
|
|
# nweek, nday = weeks_days[n]
|
|
|
|
# try:
|
|
# n2week, n2day = weeks_days[n - 2]
|
|
# except IndexError:
|
|
# n2week, n2day = weeks_days[n]
|
|
# n2 = 0
|
|
|
|
# try:
|
|
# n3week, n3day = weeks_days[n - 3]
|
|
# except IndexError:
|
|
# n3week, n3day = weeks_days[n]
|
|
# n3 = 0
|
|
|
|
# try:
|
|
# n4week, n4day = weeks_days[n - 4]
|
|
# except IndexError:
|
|
# n4week, n4day = weeks_days[n]
|
|
# n4 = 0
|
|
|
|
# try:
|
|
# n5week, n5day = weeks_days[n - 5]
|
|
# except IndexError:
|
|
# n5week, n5day = weeks_days[n]
|
|
# n5 = 0
|
|
|
|
# try:
|
|
# n6week, n6day = weeks_days[n - 6]
|
|
# except IndexError:
|
|
# n6week, n6day = weeks_days[n]
|
|
# n6 = 0
|
|
|
|
# try:
|
|
# n7week, n7day = weeks_days[n - 7]
|
|
# except IndexError:
|
|
# n7week, n7day = weeks_days[n]
|
|
# n7 = 0
|
|
|
|
# post_map = [
|
|
# (n1, nweek, nday),
|
|
# (n2, n2week, n2day),
|
|
# (n3, n3week, n3day),
|
|
# (n4, n4week, n4day),
|
|
# (n5, n5week, n5day),
|
|
# (n6, n6week, n6day),
|
|
# (n7, n7week, n7day),
|
|
# ]
|
|
|
|
# n1 = 1
|
|
# n2 = 1
|
|
# try:
|
|
# nweek, nday = weeks_days[n + 1]
|
|
# except IndexError:
|
|
# nweek, nday = weeks_days[n]
|
|
# n1 = 0
|
|
|
|
# try:
|
|
# n2week, n2day = weeks_days[n + 2]
|
|
# except IndexError:
|
|
# n2week, n2day = weeks_days[n]
|
|
# n2 = 0
|
|
|
|
# try:
|
|
# n3week, n3day = weeks_days[n + 3]
|
|
# except IndexError:
|
|
# n3week, n3day = weeks_days[n]
|
|
# n3 = 0
|
|
|
|
# IF paired we check the following against both workers
|
|
workers = [worker]
|
|
if self.constraint_options["hard_constrain_pair_separation"]:
|
|
for worker_pairs in self.worker_pairs:
|
|
if worker_pairs[0] == worker:
|
|
workers = worker_pairs
|
|
|
|
if self.get_shift_names_by_week_day(week, day):
|
|
|
|
# Unable to work (hard constraint not preference)
|
|
self.model.constraints.add(
|
|
self.model.available[worker.id, week, day]
|
|
>= sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for shift in self.get_shift_names_by_week_day(week, day)
|
|
)
|
|
)
|
|
|
|
# single shift per day
|
|
self.model.constraints.add(
|
|
1
|
|
>= sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for shift in self.get_shift_names_by_week_day(week, day)
|
|
)
|
|
)
|
|
|
|
if self.constraint_options["hard_constrain_pair_separation"]:
|
|
for worker_pairs in self.worker_pairs:
|
|
if worker_pairs[0] == worker:
|
|
self.model.constraints.add(
|
|
1
|
|
>= sum(
|
|
self.model.works[w.id, week, day, shift]
|
|
for shift in self.get_shift_names_by_week_day(
|
|
week, day
|
|
)
|
|
for w in worker_pairs
|
|
)
|
|
)
|
|
|
|
for constraint_shift in self.get_shifts_with_constraints(
|
|
"pre",
|
|
):
|
|
for n in range(0, constraint_shift.constraint_options["pre"]):
|
|
if day in constraint_shift.days:
|
|
self.model.constraints.add(
|
|
1
|
|
>= self.model.works[
|
|
worker.id, week, day, constraint_shift.name
|
|
]
|
|
+ sum(
|
|
pre_map[n][0]
|
|
* self.model.works[
|
|
w.id, pre_map[n][1], pre_map[n][2], shiftname
|
|
]
|
|
for shiftname in self.get_shift_names_by_week_day(
|
|
pre_map[n][1], pre_map[n][2]
|
|
)
|
|
if shiftname != constraint_shift.name
|
|
for w in workers
|
|
)
|
|
)
|
|
# for constraint_shift in self.get_shifts_with_constraint( "pre"):
|
|
# to_sum = []
|
|
# for n in range(
|
|
# 0, constraint_shift.constraint_options["pre"]
|
|
# ):
|
|
# for w in workers:
|
|
# for wk, dy, shiftname in self.get_week_day_shift_names_by_week_day(
|
|
# pre_map[n][1], pre_map[n][2]
|
|
# ):
|
|
# if shiftname != constraint_shift.name:
|
|
# to_sum.append(pre_map[n][0]
|
|
# * self.model.works[
|
|
# w.id, wk, dy, shiftname
|
|
# ])
|
|
|
|
# if day in constraint_shift.days:
|
|
# self.model.constraints.add(
|
|
# 1
|
|
# >= self.model.works[
|
|
# worker.id, week, day, constraint_shift.name
|
|
# ]
|
|
# + sum(
|
|
# to_sum
|
|
# )
|
|
# )
|
|
|
|
for constraint_shift in self.get_shifts_with_constraints(
|
|
"post",
|
|
):
|
|
for n in range(0, constraint_shift.constraint_options["post"]):
|
|
if day in constraint_shift.days:
|
|
self.model.constraints.add(
|
|
1
|
|
>= self.model.works[
|
|
worker.id, week, day, constraint_shift.name
|
|
]
|
|
+ sum(
|
|
post_map[n][0]
|
|
* self.model.works[
|
|
w.id, post_map[n][1], post_map[n][2], shiftname
|
|
]
|
|
for shiftname in self.get_shift_names_by_week_day(
|
|
post_map[n][1], post_map[n][2]
|
|
)
|
|
if shiftname != constraint_shift.name
|
|
for w in workers
|
|
)
|
|
)
|
|
|
|
# for constraint_shift in self.get_shifts_with_constraints(
|
|
# "preclear", "preclear2"
|
|
# ):
|
|
# if day in constraint_shift.days:
|
|
# self.model.constraints.add(
|
|
# 1
|
|
# >= self.model.works[
|
|
# worker.id, week, day, constraint_shift.name
|
|
# ]
|
|
# + sum(
|
|
# p1 * self.model.works[w.id, pweek, pday, shiftname]
|
|
# for shiftname in self.get_shift_names_by_week_day(
|
|
# pweek, pday
|
|
# )
|
|
# if shiftname != constraint_shift.name
|
|
# for w in workers
|
|
# )
|
|
# )
|
|
#
|
|
# for constraint_shift in self.get_shifts_with_constraint("preclear2"):
|
|
# if day in constraint_shift.days:
|
|
# self.model.constraints.add(
|
|
# 1
|
|
# >= self.model.works[
|
|
# worker.id, week, day, constraint_shift.name
|
|
# ]
|
|
# + sum(
|
|
# p2 * self.model.works[w.id, p2week, p2day, shiftname]
|
|
# for shiftname in self.get_shift_names_by_week_day(
|
|
# p2week, p2day
|
|
# )
|
|
# if shiftname != constraint_shift.name
|
|
# for w in workers
|
|
# )
|
|
# )
|
|
|
|
# for constraint_shift in self.get_shifts_with_constraints(
|
|
# "postclear", "postclear2"
|
|
# ):
|
|
# if day in constraint_shift.days:
|
|
# self.model.constraints.add(
|
|
# 1
|
|
# >= self.model.works[
|
|
# worker.id, week, day, constraint_shift.name
|
|
# ]
|
|
# + sum(
|
|
# n1 * self.model.works[w.id, nweek, nday, shiftname]
|
|
# for shiftname in self.get_shift_names_by_week_day(
|
|
# nweek, nday
|
|
# )
|
|
# if shiftname != constraint_shift.name
|
|
# for w in workers
|
|
# )
|
|
# )
|
|
|
|
# for constraint_shift in self.get_shifts_with_constraints("postclear2"):
|
|
# if day in constraint_shift.days:
|
|
# self.model.constraints.add(
|
|
# 1
|
|
# >= self.model.works[
|
|
# worker.id, week, day, constraint_shift.name
|
|
# ]
|
|
# + sum(
|
|
# n2 * self.model.works[w.id, n2week, n2day, shiftname]
|
|
# for shiftname in self.get_shift_names_by_week_day(
|
|
# n2week, n2day
|
|
# )
|
|
# if shiftname != constraint_shift.name
|
|
# for w in workers
|
|
# )
|
|
# )
|
|
|
|
# Night constraint means we won't assign a shift the day before
|
|
# an unavailability
|
|
for constraint_shift in self.get_shifts_with_constraint("night"):
|
|
if (
|
|
worker.id,
|
|
# pweek,
|
|
pre_map[0][1],
|
|
# pday,
|
|
pre_map[0][2],
|
|
constraint_shift.name,
|
|
) in self.model.works:
|
|
# Ensure night prior to unavalibity is not assigned
|
|
self.model.constraints.add(
|
|
self.model.available[worker.id, week, day]
|
|
>= self.model.works[
|
|
# worker.id, pweek, pday, constraint_shift.name
|
|
worker.id,
|
|
pre_map[0][1],
|
|
pre_map[0][2],
|
|
constraint_shift.name,
|
|
]
|
|
)
|
|
|
|
self.define_objectives()
|
|
|
|
print("Building model completed")
|
|
|
|
def define_objectives(self):
|
|
# Define an objective function with model as input, to pass later
|
|
def obj_rule(m):
|
|
|
|
# c = len(workers)
|
|
|
|
balance_modifier_constant = 1
|
|
|
|
if self.constraint_options["balance_shifts_over_workers"]:
|
|
worker_shift_balancing = sum(
|
|
balance_modifier_constant
|
|
* (
|
|
self.model.worker_shift_count_t1[(worker.id)]
|
|
+ self.model.worker_shift_count_t2[(worker.id)]
|
|
)
|
|
for worker in self.workers
|
|
)
|
|
else:
|
|
worker_shift_balancing = 0
|
|
|
|
if self.constraint_options["balance_shifts"]:
|
|
shift_balancing = sum(
|
|
balance_modifier_constant
|
|
* (
|
|
self.model.shift_count_t1[worker.id, shift.name]
|
|
+ self.model.shift_count_t2[worker.id, shift.name]
|
|
)
|
|
for worker in self.workers
|
|
for shift in self.get_shifts()
|
|
)
|
|
else:
|
|
shift_balancing = 0
|
|
|
|
if self.constraint_options["balance_shifts_quadratic"]:
|
|
shift_balancing = sum(
|
|
balance_modifier_constant
|
|
* (
|
|
self.model.shift_count_t1[worker.id, shift.name]
|
|
+ self.model.shift_count_t2[worker.id, shift.name]
|
|
)
|
|
for worker in self.workers
|
|
for shift in self.get_shifts()
|
|
)
|
|
else:
|
|
shift_balancing = 0
|
|
|
|
if self.constraint_options["minimise_shift_diffs"]:
|
|
shift_diff_modifier_constant = 10
|
|
shift_diff_balancing = sum(
|
|
shift_diff_modifier_constant
|
|
* self.model.shift_count_diff_summed[(worker.id)]
|
|
for worker in self.workers
|
|
)
|
|
else:
|
|
shift_diff_balancing = 0
|
|
|
|
if self.constraint_options["balance_nights"]:
|
|
night_balance_modifier_constant = 10
|
|
night_shift_balancing = sum(
|
|
night_balance_modifier_constant
|
|
* self.model.night_shift_count_w[(worker.id)]
|
|
# (self.model.night_shift_count_t1[(worker.id)] +
|
|
# self.model.night_shift_count_t2[(worker.id)])
|
|
for worker in self.workers
|
|
)
|
|
else:
|
|
night_shift_balancing = 0
|
|
|
|
if self.constraint_options["balance_bank_holidays"]:
|
|
bank_holiday_balance_modifier_constant = 1000
|
|
bank_holiday_balancing = sum(
|
|
bank_holiday_balance_modifier_constant
|
|
* (self.model.bank_holiday_count_w[(worker.id)] - 1)
|
|
# (self.model.night_shift_count_t1[(worker.id)] +
|
|
# self.model.night_shift_count_t2[(worker.id)])
|
|
for worker in self.workers
|
|
)
|
|
else:
|
|
bank_holiday_balancing = 0
|
|
|
|
if self.constraint_options["balance_weekends"]:
|
|
weekend_balance_modifier_constant = 5
|
|
weekend_shift_balancing = sum(
|
|
weekend_balance_modifier_constant
|
|
* self.model.weekend_shift_count_w[(worker.id)]
|
|
for worker in self.workers
|
|
)
|
|
else:
|
|
weekend_shift_balancing = 0
|
|
|
|
preference_constant = 10
|
|
# Preferences (not to work)
|
|
preferences = sum(
|
|
self.model.pref_not_to_work[worker.id, week, day]
|
|
* self.model.works[worker.id, week, day, shift]
|
|
* preference_constant
|
|
for worker in self.workers
|
|
for week, day, shift in self.get_all_shiftname_combinations()
|
|
)
|
|
|
|
# Work requsets
|
|
work_request_constant = 10000 # Needs to be very big to override loss from bank holiday requests
|
|
work_requests = sum(
|
|
self.model.work_requests[worker.id, week, day, shift]
|
|
* self.model.works[worker.id, week, day, shift]
|
|
* work_request_constant
|
|
for worker in self.workers
|
|
for week, day, shift in self.get_all_shiftname_combinations()
|
|
)
|
|
|
|
# # Spread nights
|
|
if self.constraint_options["balance_nights_across_sites"]:
|
|
nights_site_balancing = sum(
|
|
(
|
|
self.model.night_per_site_t1[week, shift.name, site]
|
|
+ self.model.night_per_site_t2[week, shift.name, site]
|
|
)
|
|
* 20
|
|
# self.model.night_per_site2[week, block, site]
|
|
for week in self.weeks
|
|
for shift in self.get_shifts_with_constraint("night")
|
|
for site in self.sites
|
|
)
|
|
else:
|
|
nights_site_balancing = 0
|
|
|
|
if self.get_shifts_with_constraint("balance_across_groups"):
|
|
block_site_balancing = sum(
|
|
(
|
|
self.model.shift_per_site_t1[week, shift.name, site]
|
|
+ self.model.shift_per_site_t2[week, shift.name, site]
|
|
)
|
|
* 2000
|
|
# self.model.night_per_site2[week, block, site]
|
|
for week in self.weeks
|
|
for shift in self.get_shifts_with_constraint(
|
|
"balance_across_groups"
|
|
)
|
|
for site in self.sites
|
|
)
|
|
else:
|
|
block_site_balancing = 0
|
|
|
|
if self.constraint_options["balance_blocks"]:
|
|
blocks_balancing = sum(
|
|
1 * self.model.blocks_assigned[week, shift]
|
|
for week in self.weeks
|
|
for shift in self.shifts_to_assign_as_blocks()
|
|
)
|
|
else:
|
|
blocks_balancing = 0
|
|
|
|
# Quadratic :(
|
|
# worker_pairs_balancing = 0
|
|
# worker_pairs_constant = 100000
|
|
# if self.worker_pairs:
|
|
# for worker_a, worker_b in self.worker_pairs:
|
|
# print(worker_a, worker_b)
|
|
# for week, day in self.get_week_day_combinations():
|
|
# worker_pairs_balancing = worker_pairs_balancing + sum(self.model.works[worker_a.id, week, day, shift] for shift in self.get_shift_names_by_week_day(week, day)) * sum(self.model.works[worker_b.id, week, day, shift] for shift in self.get_shift_names_by_week_day(week, day)) * worker_pairs_constant
|
|
return (
|
|
weekend_shift_balancing
|
|
+ shift_balancing
|
|
+ worker_shift_balancing
|
|
+ night_shift_balancing
|
|
+ shift_diff_balancing
|
|
+ preferences
|
|
+ nights_site_balancing
|
|
+ block_site_balancing
|
|
+ bank_holiday_balancing
|
|
+ blocks_balancing
|
|
- work_requests
|
|
)
|
|
|
|
# add objective function to the model. rule (pass function) or expr (pass expression directly)
|
|
self.model.obj = Objective(rule=obj_rule, sense=minimize)
|
|
|
|
def add_worker(self, worker: Worker) -> None:
|
|
"""Add a worker to the rota
|
|
|
|
Args:
|
|
worker (Worker):
|
|
"""
|
|
self.workers.append(worker)
|
|
|
|
def add_workers(self, workers: List) -> None:
|
|
"""Add multiple workers to the rota
|
|
|
|
Args:
|
|
workers (List(Worker)):
|
|
"""
|
|
for worker in workers:
|
|
self.add_worker(worker)
|
|
# self.workers.extend(workers)
|
|
|
|
def build_workers(self) -> None:
|
|
"""Process loaded shifts and workers
|
|
|
|
Must be called prior to attempting to solve
|
|
"""
|
|
if not self.workers:
|
|
raise NoWorkers("Workers must be added prior to calling build_workers")
|
|
|
|
self.workers_id_map = {}
|
|
self.workers_name_map = {}
|
|
|
|
for worker in track(self.workers, description="Building workers"):
|
|
worker.load_rota(self)
|
|
wid = worker.id
|
|
if wid in self.workers_id_map:
|
|
raise ValueError(f"Worker with id '{wid}' has been added twice")
|
|
self.workers_id_map[wid] = worker
|
|
|
|
if worker.name in self.workers_name_map:
|
|
raise ValueError(
|
|
f"Worker with name '{worker.name}' has been added twice"
|
|
)
|
|
self.workers_name_map[worker.name] = worker
|
|
|
|
self.workers = sorted(self.workers)
|
|
|
|
self.full_time_equivalent = sum(w.fte_adj for w in self.workers)
|
|
self.full_time_equivalent_sites = {}
|
|
self.workers_at_sites = {}
|
|
|
|
for site in track(self.sites, description="Generating site workers"):
|
|
self.workers_at_sites[site] = [w for w in self.workers if w.site == site]
|
|
self.full_time_equivalent_sites[site] = sum(
|
|
w.fte_adj for w in self.workers if w.site == site
|
|
)
|
|
|
|
self.worker_pairs = []
|
|
pairs = defaultdict(list)
|
|
for w in self.workers:
|
|
if w.pair is not None:
|
|
pairs[w.pair].append(w)
|
|
|
|
for p in pairs:
|
|
self.worker_pairs.append(tuple(pairs[p]))
|
|
|
|
def add_grade_constraint_by_week(
|
|
self, grades: Iterable[int], weeks: Iterable[int], shifts
|
|
):
|
|
self.constraint_options["avoid_shifts_by_grades"].append(
|
|
{"grades": grades, "weeks": weeks, "shifts": shifts}
|
|
)
|
|
|
|
def add_shift(self, shift: SingleShift) -> None:
|
|
"""Add a shift to the collection
|
|
|
|
:param SingleShift shift: Shift object
|
|
|
|
"""
|
|
self.shifts.append(shift)
|
|
|
|
def add_shifts(self, *shifts: SingleShift ) -> None:
|
|
"""Add multiple shifts
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.shifts.extend(shifts)
|
|
|
|
def clear_shifts(self) -> None:
|
|
self.shifts = []
|
|
|
|
def get_shift_names_by_week_day(self, week, day: DayStr) -> set:
|
|
"""Returns the shifts required for a specific day
|
|
|
|
Returns:
|
|
set: Set of ShiftName
|
|
|
|
"""
|
|
return self.week_day_shifts_dict[(week, day)]
|
|
|
|
def get_week_day_shift_names_by_week_day(self, week, day: DayStr) -> set:
|
|
"""Returns the shifts required for a specific day
|
|
|
|
Returns:
|
|
set: Set of ShiftName
|
|
|
|
"""
|
|
return set(
|
|
(week, day, shift_name)
|
|
for shift_name in self.week_day_shifts_dict[(week, day)]
|
|
)
|
|
|
|
def get_shift_by_name(self, name: str) -> SingleShift:
|
|
"""
|
|
|
|
:param name:
|
|
|
|
"""
|
|
return self.shifts_by_name[name]
|
|
|
|
def get_shift_length_by_name(self, name):
|
|
"""Get the length of a shift
|
|
|
|
Args:
|
|
name (str): Name of a shift
|
|
|
|
Returns:
|
|
int: Length of the shift
|
|
"""
|
|
return self.shifts_by_name[name].length
|
|
|
|
def build_shifts(self):
|
|
"""
|
|
Process the added shifts
|
|
"""
|
|
|
|
self.shifts_by_name = {}
|
|
self.shift_names = [] # type: List[ShiftName]
|
|
|
|
# self.week_day_shifts_dict = {(week, day): set() for week in self.weeks for day in days}
|
|
self.week_day_shifts_dict = {}
|
|
self.sites = set()
|
|
|
|
self.week_day_shift_product = []
|
|
self.week_day_shiftclass_product = []
|
|
|
|
for s in self.shifts:
|
|
pass
|
|
|
|
for s in self.shifts:
|
|
if s.name in self.shift_names:
|
|
raise InvalidShift(f"Duplicate shift: {s.name}")
|
|
|
|
self.shifts_by_name[s.name] = s
|
|
self.shift_names.append(s.name)
|
|
|
|
# for day in s.days:
|
|
# self.week_day_shifts_dict[(week, day)].add(s.name)
|
|
|
|
for site in list(s.sites):
|
|
self.sites.add(site)
|
|
|
|
self.shift_counts = defaultdict(int)
|
|
for week, day in self.weeks_days_product:
|
|
self.week_day_shifts_dict[(week, day)] = set()
|
|
for s in self.shifts:
|
|
if s.bank_holidays_only:
|
|
if self.week_day_date_map[(week, day)] in bank_holiday_map:
|
|
print(s.name, self.week_day_date_map[(week, day)])
|
|
self.week_day_shift_product.append((week, day, s.name))
|
|
self.week_day_shiftclass_product.append((week, day, s))
|
|
self.week_day_shifts_dict[(week, day)].add(s.name)
|
|
self.shift_counts[s.name] = self.shift_counts[s.name] + 1
|
|
else:
|
|
if day in s.days:
|
|
self.week_day_shift_product.append((week, day, s.name))
|
|
self.week_day_shiftclass_product.append((week, day, s))
|
|
self.week_day_shifts_dict[(week, day)].add(s.name)
|
|
self.shift_counts[s.name] = self.shift_counts[s.name] + 1
|
|
# print(self.week_day_shift_product)
|
|
|
|
self.max_pre = 1
|
|
self.max_post = 1
|
|
for shift in self.get_shifts_with_constraint("pre"):
|
|
self.max_pre = max(shift.constraint_options["pre"], self.max_pre)
|
|
|
|
for shift in self.get_shifts_with_constraint("post"):
|
|
self.max_post = max(shift.constraint_options["post"], self.max_post)
|
|
|
|
# # Todo replace with week_day.....
|
|
# self.day_shift_product = []
|
|
# self.day_shiftclass_product = []
|
|
# for s in self.shifts:
|
|
# for day in days:
|
|
# if day in s.days:
|
|
# self.day_shift_product.append((day, s.name))
|
|
# self.day_shiftclass_product.append((day, s))
|
|
#
|
|
# def get_day_shiftname_combinations(self):
|
|
# """Returns a list of all required day / shift combinations
|
|
#
|
|
# Returns:
|
|
# list: list of in the following format (str->day, str->shift)
|
|
# """
|
|
# return self.day_shift_product
|
|
#
|
|
# def get_day_shiftclass_combinations(self) -> List[Tuple[DayStr, SingleShift]]:
|
|
# """Returns a list of all required day / shift combinations
|
|
#
|
|
# Returns:
|
|
# list: list of in the following format (str->day, shiftObject->shift)
|
|
# """
|
|
# return self.day_shiftclass_product
|
|
|
|
def get_all_shiftname_combinations(
|
|
self, week: int = None, day: int = None
|
|
) -> List[Tuple[WeekInt, DayStr, ShiftName]]:
|
|
"""Returns a list of all possible week / day / shift combinations
|
|
|
|
Returns:
|
|
list: contains tuple of all possible week / day / shift combinations
|
|
"""
|
|
week_day_shifts = self.week_day_shift_product
|
|
|
|
if week is not None:
|
|
week_day_shifts = [t for t in week_day_shifts if t[0] == week]
|
|
|
|
if day is not None:
|
|
week_day_shifts = [t for t in week_day_shifts if t[1] == day]
|
|
|
|
return week_day_shifts
|
|
|
|
def get_all_shiftclass_combinations(
|
|
self, week: int = None, day: int = None
|
|
) -> List[Tuple[WeekInt, DayStr, SingleShift]]:
|
|
"""Returns a list of all possible week / day / shift combinations
|
|
|
|
Returns:
|
|
list: contains tuple of all possible week / day / shift combinations
|
|
"""
|
|
week_day_shifts = self.week_day_shiftclass_product
|
|
|
|
if week is not None:
|
|
week_day_shifts = [t for t in week_day_shifts if t[0] == week]
|
|
|
|
if day is not None:
|
|
week_day_shifts = [t for t in week_day_shifts if t[1] == day]
|
|
|
|
return week_day_shifts
|
|
|
|
def get_week_day_combinations(self) -> list:
|
|
"""Returns a list of all week / day tuple combinations
|
|
|
|
Returns:
|
|
list: list of possible week day combinations
|
|
[(1, "Mon"), (1, "Tue"), ... (n, "Fri")]
|
|
"""
|
|
return self.weeks_days_product
|
|
|
|
def get_week_day_combinations_for_shift(self, shift) -> list:
|
|
return [
|
|
(week, day)
|
|
for week, day in self.get_week_day_combinations()
|
|
if shift.name in self.get_shift_names_by_week_day(week, day)
|
|
]
|
|
|
|
def get_week_day_shift_combinations_for_constraint(self, constraint) -> list:
|
|
constraint_shifts = self.get_shifts_with_constraint(constraint)
|
|
|
|
week_day_shifts = []
|
|
|
|
for tup in self.get_all_shiftclass_combinations():
|
|
if tup[2] in constraint_shifts:
|
|
week_day_shifts.append(tup)
|
|
|
|
return week_day_shifts
|
|
|
|
def get_shifts(self) -> List[SingleShift]:
|
|
"""Returns a list of all the registered shifts
|
|
|
|
Returns:
|
|
List[SingleShift]: list of registered shifts (as SingleShift)
|
|
"""
|
|
return self.shifts
|
|
|
|
def get_shifts_for_worker_site(self, worker_site):
|
|
shifts = [shift for shift in self.shifts if worker_site in shift.sites]
|
|
return shifts
|
|
|
|
def get_shifts_for_worker(self, worker_id):
|
|
worker = self.get_worker_by_id(worker_id)
|
|
shifts = [shift for shift in self.shifts if worker.site in shift.sites]
|
|
return shifts
|
|
|
|
def get_shifts_with_constraint(self, constraint) -> List[SingleShift]:
|
|
return [shift for shift in self.shifts if constraint in shift.constraints]
|
|
|
|
def get_shifts_with_constraints(self, *constraints) -> List[SingleShift]:
|
|
shift_names = set()
|
|
|
|
for constraint in constraints:
|
|
shift_names.update(
|
|
[shift.name for shift in self.shifts if constraint in shift.constraints]
|
|
)
|
|
|
|
return [self.get_shift_by_name(s) for s in shift_names]
|
|
|
|
def get_shift_names(self) -> List[ShiftName]:
|
|
"""Returns a list of all the registered shift names
|
|
|
|
Returns:
|
|
List[ShiftName]: List of names of all available shifts
|
|
""" """ """
|
|
return self.shift_names
|
|
|
|
def get_week_block_iterator(self, block_length: int):
|
|
"""Gets a two dimensional list of week blocks in specified length
|
|
|
|
e.g. block_length = 4
|
|
[
|
|
[ 1, 2, 3, 4],
|
|
[ 2, 3, 4, 5],
|
|
...
|
|
]
|
|
|
|
Args:
|
|
block_length (int): length of blocks to create
|
|
|
|
Returns:
|
|
list: two dimensional list containing weeks in blocks
|
|
"""
|
|
blocks = []
|
|
for i in range(len(self.weeks) - block_length + 1):
|
|
block = self.weeks[i : i + block_length]
|
|
blocks.append(block)
|
|
return blocks
|
|
|
|
def get_required_workers_and_site_combinations(self):
|
|
"""Returns a list of all possible shifts, the workers required and site
|
|
|
|
Returns:
|
|
list: list (week, day, shift name, workers required, shift.sites)
|
|
"""
|
|
l = []
|
|
for week, day, shift in self.week_day_shiftclass_product:
|
|
# if day in shift.days:
|
|
l.append((week, day, shift.name, shift.workers_required, shift.sites))
|
|
|
|
return l
|
|
|
|
def get_not_required_shifts(self):
|
|
"""Returns a set of all possible shifts combinations that are not required
|
|
|
|
Includes those on days
|
|
|
|
Returns:
|
|
list: (week, day, shift)
|
|
"""
|
|
l = set()
|
|
|
|
for week in self.weeks:
|
|
for day in days:
|
|
for shift in self.shifts:
|
|
l.add((week, day, shift))
|
|
|
|
return l - set(self.get_all_shiftclass_combinations())
|
|
|
|
def shifts_to_assign_as_blocks(self) -> List[str]:
|
|
return [shift.name for shift in self.get_shifts() if shift.assign_as_block]
|
|
|
|
def shifts_to_force_as_blocks(self) -> List[str]:
|
|
return [
|
|
shift.name
|
|
for shift in self.get_shifts()
|
|
if (shift.force_as_block or shift.force_as_block_unless_nwd)
|
|
]
|
|
|
|
def shifts_to_assign_or_force_as_blocks(self) -> List[str]:
|
|
s = self.shifts_to_assign_as_blocks()
|
|
s.extend(self.shifts_to_force_as_blocks())
|
|
return s
|
|
|
|
def get_worker_by_id(self, worker_id: str) -> Worker:
|
|
return self.workers_id_map[worker_id]
|
|
|
|
def get_worker_by_name(self, name: str) -> Worker:
|
|
return self.workers_name_map[name]
|
|
|
|
def get_workers_by_group(self) -> Dict[str, Worker]:
|
|
group_workers = defaultdict(list)
|
|
for worker in self.workers:
|
|
group_workers[worker.site].append(worker)
|
|
|
|
return group_workers
|
|
|
|
def get_workers_by_remote_group(self) -> Dict[str, Worker]:
|
|
group_workers = defaultdict(list)
|
|
for worker in self.workers:
|
|
group_workers[worker.remote_site].append(worker)
|
|
|
|
return group_workers
|
|
|
|
def get_workers_by_grade(self) -> Dict[str, Worker]:
|
|
group_workers = defaultdict(list)
|
|
for worker in self.workers:
|
|
group_workers[worker.grade].append(worker)
|
|
|
|
return group_workers
|
|
|
|
def get_workers_for_shift(self, shift: SingleShift) -> List[Worker]:
|
|
return [worker for worker in self.workers if worker.site in shift.sites]
|
|
|
|
def get_workers_total_fte(self) -> float:
|
|
return self.full_time_equivalent
|
|
|
|
def get_worker_details(self):
|
|
|
|
w = defaultdict(list)
|
|
for worker in self.workers:
|
|
# print(worker)
|
|
w[worker.site].append(worker)
|
|
|
|
l = []
|
|
for site in w:
|
|
l.append(f"{site}: {sum(worker.fte_adj for worker in w[site]) / 100}")
|
|
t = "\n".join(l)
|
|
return f"Full time equivalent trainees by site:\n{t}"
|
|
|
|
def get_workers(self):
|
|
return self.workers
|
|
|
|
def get_worker_grades(self) -> set[int]:
|
|
return set([worker.grade for worker in self.workers])
|
|
|
|
def get_bank_holiday_week_days(self):
|
|
return [
|
|
(week, day)
|
|
for week, day in self.get_week_day_combinations()
|
|
if self.week_day_date_map[(week, day)] in bank_holiday_map
|
|
]
|
|
|
|
def get_week_start_date(self, week: int):
|
|
return self.start_date + datetime.timedelta(weeks=week)
|
|
|
|
# RESULTS
|
|
def export_rota_to_html(self, filename: str = "rota", folder=None):
|
|
if folder is not None:
|
|
output_file = Path("output", folder, f"{filename}.html")
|
|
else:
|
|
output_file = Path("output", f"{filename}.html")
|
|
|
|
# Make sure path exits
|
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(output_file, "w") as f:
|
|
f.write(self.get_worker_timetable_html(True))
|
|
|
|
def export_rota_to_csv(self, filename: str = "rota"):
|
|
output_file = Path("output", f"{filename}.csv")
|
|
works = self.model.works
|
|
with open(output_file, "w", newline="") as f:
|
|
wr = csv.writer(f, quoting=csv.QUOTE_ALL)
|
|
l = ["Name"]
|
|
l.extend([worker.name for worker in self.workers])
|
|
wr.writerow(l)
|
|
|
|
l2 = ["Site"]
|
|
l2.extend([worker.site for worker in self.workers])
|
|
wr.writerow(l2)
|
|
|
|
l3 = ["Grade"]
|
|
l3.extend([worker.grade for worker in self.workers])
|
|
wr.writerow(l3)
|
|
|
|
l4 = ["FTE"]
|
|
l4.extend([worker.fte for worker in self.workers])
|
|
wr.writerow(l4)
|
|
|
|
for week, day in self.get_week_day_combinations():
|
|
d = [f"Week {week} Day {day}"]
|
|
|
|
for worker in self.workers:
|
|
i = ""
|
|
for shift in self.get_shift_names_by_week_day(week, day):
|
|
if works[worker.id, week, day, shift].value == 1:
|
|
i = shift
|
|
|
|
d.append(i)
|
|
wr.writerow(d)
|
|
|
|
def get_work_table(self):
|
|
"""Build a timetable of the week as a dictionary from the model's optimal solution."""
|
|
works = self.model.works
|
|
week_table = {
|
|
week: {day: {shift: [] for shift in self.get_shift_names()} for day in days}
|
|
for week in self.weeks
|
|
}
|
|
for week in self.weeks:
|
|
for worker in self.workers:
|
|
for day, shift in self.get_day_shiftname_combinations():
|
|
if works[worker.id, week, day, shift].value == 1:
|
|
week_table[week][day][shift].append(worker.get_details())
|
|
return week_table
|
|
|
|
def get_worker_timetable(self):
|
|
works = self.model.works
|
|
timetable = {
|
|
worker.name: {week: {day: "" for day in days} for week in self.weeks}
|
|
for worker in self.workers
|
|
}
|
|
for worker in self.workers:
|
|
for week in self.weeks:
|
|
for day, shift in self.get_day_shiftname_combinations():
|
|
if works[worker.id, week, day, shift].value == 1:
|
|
timetable[worker.name][week][day] = shift
|
|
return timetable
|
|
|
|
def get_worker_timetable_brief(
|
|
self, show_prefs=False, marker_every=30, show_unavailable=False
|
|
):
|
|
model = self.model
|
|
week_string = f"{'-Week-':20}" + "".join(
|
|
[7 * str(f"{w}")[-1:] for w in self.weeks]
|
|
)
|
|
days_string = f"{'-Day-':20}" + "".join("MTWTFSS" * len(self.weeks))
|
|
timetable = []
|
|
for worker in self.workers:
|
|
shifts = []
|
|
w = [f"{worker.name:20}"]
|
|
for week, day in self.get_week_day_combinations():
|
|
a = "-"
|
|
for shift in self.get_shift_names_by_week_day(week, day):
|
|
if model.works[worker.id, week, day, shift].value > 0:
|
|
shifts.append(shift)
|
|
a = shift[0]
|
|
w.append(a)
|
|
|
|
shift_count = ""
|
|
for s in set(shifts):
|
|
shift_count = shift_count + f"{s}: {shifts.count(s)}, "
|
|
|
|
shift_count = (
|
|
shift_count
|
|
+ f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#"
|
|
)
|
|
timetable.append(f"{''.join(w)} {shift_count}")
|
|
|
|
if show_prefs:
|
|
# prefs
|
|
w = [f"{'Preferences':20}"]
|
|
for week, day in self.get_week_day_combinations():
|
|
if model.pref_not_to_work[worker.id, week, day] > 0:
|
|
w.append("Y")
|
|
else:
|
|
w.append("N")
|
|
timetable.append("".join(w))
|
|
|
|
if show_unavailable:
|
|
# prefs
|
|
w = [f"{'Unavailable':20}"]
|
|
for week, day in self.get_week_day_combinations():
|
|
if model.available[worker.id, week, day] > 0:
|
|
w.append("A")
|
|
else:
|
|
w.append("U")
|
|
timetable.append("".join(w))
|
|
|
|
i = marker_every
|
|
while i < len(timetable):
|
|
timetable.insert(i, week_string)
|
|
timetable.insert(i + 1, days_string)
|
|
i = i + marker_every + 2
|
|
|
|
timetable.append(week_string)
|
|
timetable.insert(0, week_string)
|
|
timetable.insert(0, days_string)
|
|
return "\n".join(timetable)
|
|
|
|
def get_worker_timetable_html(
|
|
self, include_html_tag=False, table_name="rota-table"
|
|
):
|
|
model = self.model
|
|
|
|
timetable = []
|
|
|
|
timetable.append(
|
|
f"<h2>Rota start date: {self.start_date.isoformat()} ({self.weeks[-1]} weeks)</h2>"
|
|
)
|
|
|
|
date_row = ["<th class='worker'></th>"]
|
|
|
|
n = 0
|
|
for week, day in self.get_week_day_combinations():
|
|
d = self.start_date + datetime.timedelta(n)
|
|
|
|
th_class = "date"
|
|
if d in bank_holiday_map:
|
|
th_class = th_class + " bank-holiday"
|
|
|
|
date_row.append(
|
|
f"<th title='{d}' class='{th_class}' data-date='{d}'>Week {week}: {day}</th>"
|
|
)
|
|
n = n + 1
|
|
timetable.append(f"<tr class='data-row'>{''.join(date_row)}</tr>")
|
|
|
|
current_site = ""
|
|
|
|
for worker in self.workers:
|
|
if worker.site != current_site:
|
|
try:
|
|
timetable.append(
|
|
f"<tr><th class='site-title'>{worker.site} n={len(self.workers_at_sites[worker.site])} fte={self.full_time_equivalent_sites[worker.site]}</th><tr>"
|
|
)
|
|
except KeyError as e:
|
|
print(e)
|
|
current_site = worker.site
|
|
|
|
shifts = []
|
|
nwds = json.dumps(worker.non_working_day_list, default=str)
|
|
# if worker.nwd:
|
|
# # TODO: limit to dates
|
|
# nwds = ", ".join([i[0] for i in worker.nwd])
|
|
# else:
|
|
# nwds = None
|
|
|
|
worker_targets = json.dumps(worker.shift_target_number)
|
|
|
|
tds = []
|
|
|
|
n = 0
|
|
|
|
shift_tds = []
|
|
for week, day in self.get_week_day_combinations():
|
|
d = self.start_date + datetime.timedelta(n)
|
|
|
|
n = n + 1
|
|
a = "-"
|
|
shift_name = ""
|
|
|
|
# Loop through all the days possible shifts and see
|
|
# if the worker has been assigned
|
|
for shift in self.get_shift_names_by_week_day(week, day):
|
|
if model.works[worker.id, week, day, shift].value > 0:
|
|
shifts.append(shift)
|
|
a = shift[0]
|
|
shift_name = shift
|
|
break
|
|
title = f"{shift_name} ({d})"
|
|
css_class = day
|
|
unavailable_reason = ""
|
|
if model.available[worker.id, week, day] > 0:
|
|
available = True
|
|
else:
|
|
available = False
|
|
css_class = "unavailable"
|
|
try:
|
|
unavailable_reason = self.unavailable_to_work_reason[
|
|
(worker.id, week, day)
|
|
]
|
|
except KeyError:
|
|
print("Error getting reason")
|
|
print(worker.id, worker.name)
|
|
print(f"Week {week}, Day {day}")
|
|
unavailable_reason = "????"
|
|
title = f"{title} / {unavailable_reason}"
|
|
|
|
bank_holiday = ""
|
|
if d in bank_holiday_map:
|
|
title = f"{title} [Bank Holiday]"
|
|
css_class = " ".join((css_class, "bank-holiday"))
|
|
bank_holiday = f" data-bank-holiday='{bank_holiday_map[d]}'"
|
|
|
|
requests = ""
|
|
if (worker.id, week, day) in self.work_requests_map:
|
|
css_class = " ".join((css_class, "shift-requested"))
|
|
title = " ".join((title, "[REQUESTED]"))
|
|
requests = f" data-shift-request='{self.work_requests_map[worker.id, week, day]}'"
|
|
|
|
remote_site = ""
|
|
if shift_name:
|
|
shift = self.get_shift_by_name(shift_name)
|
|
if "require_remote_site_presence_week" in shift.constraints:
|
|
remote_site = f" data-shift-remote-site='{shift.constraint_options['require_remote_site_presence_week'][0]}'"
|
|
|
|
shift_tds.append(
|
|
f"<td title='{title}' class='rota-day {css_class}' data-shift='{shift_name}' data-available='{available}' data-unavailable_reason='{unavailable_reason}' data-date='{d}' data-week='{week}' data-day='{day}'{remote_site}{requests}{bank_holiday}>{a}</td>"
|
|
)
|
|
|
|
shift_count = ""
|
|
shift_count_dict = {}
|
|
for s in set(shifts):
|
|
c = shifts.count(s)
|
|
shift_count_dict[s] = c
|
|
shift_count = shift_count + f"{s}: {c}, "
|
|
|
|
shift_diff_dict = {}
|
|
for shift in self.get_shifts():
|
|
diff = model.shift_count_diff[worker.id, shift.name].value
|
|
shift_diff_dict[shift.name] = diff
|
|
|
|
worker_td = """<td title='Site: {site}' class='worker {site}'
|
|
data-nwds='{nwds}' data-site='{site}' data-worker='{name}'
|
|
data-fte='{fte}' data-fte_adj='{fte_adj}'
|
|
data-start_date='{start_date}'
|
|
data-end_date='{end_date}'
|
|
data-worker-targets='{targets}' data-shift-counts='{worker_shift_counts}'
|
|
data-weekend-target='{weekend_target}'
|
|
data-remote-site='{remote_site}'
|
|
data-pair='{pair}'
|
|
data-shift-balance-extra='{shift_balance_extra}'
|
|
data-bank-holiday-extra='{bank_holiday_extra}'
|
|
data-shift-diff='{shift_diff}'
|
|
data-shift-diff-summed='{shift_diff_summed}'
|
|
>
|
|
<span class='name' title='{name}'>{name}</span> ({grade}) [{fte}]</td>""".format(
|
|
site=worker.site,
|
|
nwds=nwds,
|
|
name=worker.name,
|
|
fte=worker.fte,
|
|
fte_adj=worker.fte_adj,
|
|
start_date=worker.calculated_start_date,
|
|
end_date=worker.calculated_end_date,
|
|
targets=worker_targets,
|
|
worker_shift_counts=json.dumps(shift_count_dict),
|
|
weekend_target=worker.weekend_shift_target_number,
|
|
remote_site=worker.remote_site,
|
|
grade=worker.grade,
|
|
pair=worker.pair,
|
|
shift_balance_extra=json.dumps(worker.shift_balance_extra),
|
|
shift_diff=json.dumps(shift_diff_dict),
|
|
shift_diff_summed=model.shift_count_diff_summed[worker.id].value,
|
|
bank_holiday_extra=worker.bank_holiday_extra,
|
|
)
|
|
|
|
shift_count = (
|
|
shift_count
|
|
+ f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#"
|
|
)
|
|
|
|
if self.constraint_options["balance_bank_holidays"]:
|
|
bank_holiday_count = model.bank_holiday_count[worker.id].value
|
|
|
|
bank_holiday_count_w = model.bank_holiday_count_w[worker.id].value - 1
|
|
else:
|
|
bank_holiday_count = -1
|
|
bank_holiday_count_w = -1
|
|
|
|
# print(worker.name, bank_holiday_count, bank_holiday_count_w)
|
|
timetable.append(
|
|
f"<tr class='worker-row'>{worker_td}{''.join(shift_tds)}</tr>"
|
|
)
|
|
# timetable.append("<tr>{}</tr>".format("".join(w), shift_count))
|
|
|
|
# if show_prefs:
|
|
# # prefs
|
|
# w = ["{:20}".format("Preferences")]
|
|
# for week, day in self.get_week_day_combinations():
|
|
# if model.pref_not_to_work[worker.id, week, day] > 0:
|
|
# w.append("Y")
|
|
# else:
|
|
# w.append("N")
|
|
# timetable.append("".join(w))
|
|
|
|
# if show_unavailable:
|
|
# # prefs
|
|
# w = ["{:20}".format("Unavailable")]
|
|
# for week, day in self.get_week_day_combinations():
|
|
# if model.available[worker.id, week, day] > 0:
|
|
# w.append("A")
|
|
# else:
|
|
# w.append("U")
|
|
# timetable.append("".join(w))
|
|
|
|
result_stream = StringIO()
|
|
|
|
if self.results is not None:
|
|
self.results.write(ostream=result_stream)
|
|
self.results.write_json(ostream=result_stream)
|
|
result_string = result_stream.getvalue()
|
|
else:
|
|
result_string = "Rota not run"
|
|
|
|
joined_timetable = "\n".join(timetable)
|
|
|
|
html = f"""
|
|
<body>
|
|
<div class="table-div" id="{table_name}">
|
|
<table id="main-table">{joined_timetable}</table>
|
|
</div>
|
|
<details>
|
|
<summary><h2>Rota settings</h2></summary>
|
|
<div>
|
|
<pre>
|
|
{json.dumps(self.constraint_options, indent=4)}
|
|
</pre>
|
|
</details>
|
|
</div>
|
|
<details>
|
|
<summary><h2>Shifts settings</h2></summary>
|
|
<div id="shifts-container" data-shifts='{json.dumps([i.name for i in self.shifts])}'>
|
|
{"<br/>".join(str(i) for i in self.shifts)}
|
|
</div>
|
|
</details>
|
|
<details>
|
|
<summary><h2>Output</h2></summary>
|
|
<pre>
|
|
{result_string}
|
|
</pre>
|
|
</details>
|
|
<div>
|
|
<details>
|
|
<summary><h2>Export table</h2></summary>
|
|
<div id="export-div">
|
|
<div>
|
|
</details>
|
|
<details>
|
|
<summary><h2>Shift timetables</h2></summary>
|
|
<div id="shift-timetable-options">
|
|
</div>
|
|
<div id="shift-timetable-div">
|
|
<div>
|
|
</details>
|
|
</div
|
|
</body>
|
|
"""
|
|
|
|
if include_html_tag:
|
|
html = f"""<html>
|
|
<head>
|
|
<link rel="stylesheet" type="text/css" href="timetable.css">
|
|
<link rel="stylesheet" type="text/css" href="https://code.jquery.com/ui/1.13.0/themes/base/jquery-ui.css">
|
|
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
|
<script src="https://code.jquery.com/ui/1.13.0/jquery-ui.min.js"></script>
|
|
<script src="timetable.js" defer></script>
|
|
</head>
|
|
{html}</html>"""
|
|
|
|
return html
|
|
|
|
def get_shift_summary_dict(self):
|
|
""""""
|
|
timetable = {
|
|
worker.name: {shift: "" for shift in self.get_shift_names()}
|
|
for worker in self.workers
|
|
}
|
|
for worker in self.workers:
|
|
for shift in self.get_shift_names():
|
|
timetable[worker.name][shift] = self.model.shift_count[
|
|
worker.id, shift
|
|
].value
|
|
|
|
return timetable
|
|
|
|
def get_worker_shifts_by_date(self, worker: Worker) -> Dict:
|
|
shifts = {}
|
|
|
|
n = 0
|
|
for week, day in self.get_week_day_combinations():
|
|
d = self.start_date + datetime.timedelta(n)
|
|
n = n + 1
|
|
|
|
temp = ""
|
|
for shift in self.get_shift_names_by_week_day(week, day):
|
|
if self.model.works[worker.id, week, day, shift].value > 0:
|
|
shifts[d] = shift
|
|
|
|
return shifts
|
|
|
|
def get_workers_total_shifts(self) -> Dict[str, int]:
|
|
shifts = {}
|
|
|
|
for worker in self.workers:
|
|
shifts[worker.name] = len(
|
|
[i for i in self.get_worker_shift_list(worker) if i != ""]
|
|
)
|
|
|
|
return shifts
|
|
|
|
def get_worker_shift_list(self, worker: Worker) -> List:
|
|
shifts = []
|
|
|
|
for week, day in self.get_week_day_combinations():
|
|
# d = self.start_date + datetime.timedelta(n)
|
|
# n = n + 1
|
|
|
|
temp = ""
|
|
for shift in self.get_shift_names_by_week_day(week, day):
|
|
if self.model.works[worker.id, week, day, shift].value > 0:
|
|
temp = shift
|
|
shifts.append(temp)
|
|
|
|
return shifts
|
|
|
|
def get_worker_shift_list_string(self, worker: Worker) -> str:
|
|
shifts = self.get_worker_shift_list(worker)
|
|
|
|
# Convert shift to a string representation
|
|
return "".join([i if i != "" else "-" for i in shifts])
|
|
|
|
def get_shift_summary(self):
|
|
works = self.model.works
|
|
timetable = {
|
|
worker.get_details(): {shift: "" for shift in self.get_shift_names()}
|
|
for worker in self.workers
|
|
}
|
|
# timetable = { worker.get_details() : { shift : "" for shift in ["truro_twilight"] } for worker in workers }
|
|
t = []
|
|
for worker in self.workers:
|
|
l = []
|
|
|
|
total_shifts = 0
|
|
for shift in self.get_shift_names():
|
|
# for shift in ["truro_twilight"]:
|
|
c = [
|
|
works[worker.id, week, day, shift].value
|
|
for week in self.weeks
|
|
for day in days
|
|
].count(1)
|
|
if c > 0:
|
|
l.append(f"{shift} ({c})")
|
|
total_shifts = total_shifts + c
|
|
# print(worker.id, shift)
|
|
timetable[worker.get_details()][shift] = c
|
|
t.append(f"{worker.get_full_details()} [{total_shifts}]: {', '.join(l)}")
|
|
return "\n".join(t)
|
|
|
|
def get_shift_summary_html(self):
|
|
works = self.model.works
|
|
timetable = {
|
|
worker.get_details(): {shift[0]: "" for shift in self.get_shift_names()}
|
|
for worker in self.workers
|
|
}
|
|
# timetable = { worker.get_details() : { shift : "" for shift in ["truro_twilight"] } for worker in workers }
|
|
t = []
|
|
for worker in self.workers:
|
|
l = []
|
|
|
|
total_shifts = 0
|
|
for shift in self.get_shift_names():
|
|
# for shift in ["truro_twilight"]:
|
|
c = [
|
|
works[worker.id, week, day, shift]
|
|
for week in self.weeks
|
|
for day in days
|
|
].count(1)
|
|
if c > 0:
|
|
l.append(f"{shift} ({c})")
|
|
total_shifts = total_shifts + c
|
|
# print(worker.id, shift)
|
|
timetable[worker.get_details()][shift[0]] = c
|
|
t.append(f"{worker.get_full_details()} [{total_shifts}]: {', '.join(l)}")
|
|
return "\n".join(t)
|
|
|
|
|
|
class NoActiveSites(Exception):
|
|
"""Raised when there are no active sites"""
|
|
|
|
pass
|
|
|
|
|
|
class NoWorkers(Exception):
|
|
"""Raised when there are no active sites"""
|
|
|
|
pass
|
|
|
|
|
|
class InvalidShift(Exception):
|
|
"""Raised when there are no active sites"""
|
|
|
|
pass
|