6387 lines
276 KiB
Python
6387 lines
276 KiB
Python
from calendar import week
|
|
import datetime
|
|
import itertools
|
|
from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type, Literal, Optional
|
|
import time
|
|
|
|
from pydantic import BaseModel, ConfigDict, Extra, constr, field_validator, model_validator, StrictInt, Field
|
|
|
|
from pathlib import Path
|
|
|
|
import uuid
|
|
|
|
import datetime
|
|
|
|
from pyomo.environ import *
|
|
from pyomo.opt import SolverFactory, TerminationCondition, SolverStatus
|
|
import urllib.parse
|
|
|
|
from rota_generator.workers import Worker
|
|
from rota_generator.console import console
|
|
from rota_generator.bank_holidays import bank_holiday_map
|
|
|
|
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
|
|
from rich import print
|
|
|
|
from loguru import logger
|
|
|
|
logger.add("rota.log", rotation="1 MB", level="DEBUG")
|
|
|
|
import ctypes
|
|
import platform
|
|
|
|
# Optional dependency: prefer setproctitle if available, fall back to libc on macOS
|
|
try:
|
|
import setproctitle # type: ignore
|
|
except Exception:
|
|
setproctitle = None
|
|
|
|
|
|
def _get_proc_title() -> Optional[str]:
|
|
try:
|
|
if setproctitle is not None:
|
|
return setproctitle.getproctitle()
|
|
if platform.system() == "Darwin":
|
|
libc = ctypes.CDLL("libc.dylib")
|
|
libc.getprogname.restype = ctypes.c_char_p
|
|
res = libc.getprogname()
|
|
if res:
|
|
return res.decode()
|
|
except Exception:
|
|
logger.debug("Could not read current process title")
|
|
return None
|
|
|
|
|
|
def _set_proc_title(title: str) -> bool:
|
|
try:
|
|
if setproctitle is not None:
|
|
setproctitle.setproctitle(title)
|
|
return True
|
|
if platform.system() == "Darwin":
|
|
libc = ctypes.CDLL("libc.dylib")
|
|
# setprogname expects a C string
|
|
try:
|
|
libc.setprogname(ctypes.c_char_p(title.encode()))
|
|
return True
|
|
except Exception:
|
|
# some platforms may not support setprogname via ctypes
|
|
pass
|
|
except Exception:
|
|
logger.debug("Failed to set process title to %s", title)
|
|
return False
|
|
|
|
|
|
def _safe_constraint_info(obj):
|
|
"""Return a tuple (type_name, safe_dump) for logging without calling repr()
|
|
|
|
Avoids triggering pydantic __repr__ which can call __getattr__ and raise
|
|
when models come from mixed pydantic versions. Prefer model_dump() when
|
|
available; fall back to __dict__ or the class name. Any failure returns a
|
|
short error string.
|
|
"""
|
|
tname = type(obj).__name__
|
|
try:
|
|
# Only call model_dump on real pydantic BaseModel instances. Some
|
|
# objects may expose a model_dump attribute or similar but aren't
|
|
# proper BaseModel instances in this runtime; calling model_dump on
|
|
# those can raise PydanticUserError. Use isinstance check to be safe.
|
|
if isinstance(obj, BaseModel) and hasattr(obj, "model_dump"):
|
|
try:
|
|
return tname, obj.model_dump()
|
|
except Exception:
|
|
# fall through to other strategies
|
|
pass
|
|
if hasattr(obj, "__dict__"):
|
|
return tname, dict(obj.__dict__)
|
|
return tname, str(type(obj))
|
|
except Exception as e:
|
|
return tname, f"<dump-failed: {e!r}>"
|
|
|
|
|
|
def _safe_as_dict(obj):
|
|
"""Return a plain dict view of obj without triggering pydantic __getattr__.
|
|
|
|
Prefer model_dump(), then __dict__, then if obj is a mapping return it.
|
|
Returns None if we couldn't obtain a dict safely.
|
|
"""
|
|
try:
|
|
# Only call model_dump on actual pydantic BaseModel instances. This
|
|
# avoids raising PydanticUserError for objects that merely expose a
|
|
# model_dump attribute but aren't proper BaseModel instances.
|
|
if isinstance(obj, BaseModel) and hasattr(obj, "model_dump"):
|
|
try:
|
|
return obj.model_dump()
|
|
except Exception:
|
|
pass
|
|
|
|
if isinstance(obj, dict):
|
|
return obj
|
|
|
|
if hasattr(obj, "__dict__"):
|
|
try:
|
|
return dict(obj.__dict__)
|
|
except Exception:
|
|
pass
|
|
|
|
except Exception:
|
|
# Defensive: anything going wrong, return None so caller can fall back
|
|
return None
|
|
return None
|
|
|
|
|
|
def _ensure_constraint_weeks(constraint, rota_builder):
|
|
"""Ensure constraint has a `weeks` value, computing from start/end if needed.
|
|
|
|
This avoids directly reading attributes on pydantic models which may trigger
|
|
problematic __getattr__ behavior in mixed-version environments. If we can
|
|
extract a dict view, use that; otherwise fall back to guarded getattr/setattr.
|
|
"""
|
|
# Don't mutate unknown constraint objects (some can be pydantic objects
|
|
# from different versions that break __setattr__). Instead compute the
|
|
# weeks and cache them on the rota_builder to be used by callers.
|
|
try:
|
|
if not hasattr(rota_builder, "_constraint_weeks_cache"):
|
|
rota_builder._constraint_weeks_cache = {}
|
|
|
|
key = id(constraint)
|
|
if key in rota_builder._constraint_weeks_cache:
|
|
return
|
|
|
|
cdict = _safe_as_dict(constraint)
|
|
if cdict is not None:
|
|
weeks = cdict.get("weeks", None)
|
|
if weeks is None:
|
|
start = cdict.get("start_date", None)
|
|
end = cdict.get("end_date", None)
|
|
computed = rota_builder.get_weeks_by_date_range(start, end)
|
|
rota_builder._constraint_weeks_cache[key] = computed
|
|
else:
|
|
rota_builder._constraint_weeks_cache[key] = weeks
|
|
return
|
|
|
|
# Fallback: try getattr but don't attempt to setattr on the object
|
|
try:
|
|
weeks = getattr(constraint, "weeks")
|
|
rota_builder._constraint_weeks_cache[key] = weeks
|
|
return
|
|
except Exception:
|
|
start = getattr(constraint, "start_date", None)
|
|
end = getattr(constraint, "end_date", None)
|
|
computed = rota_builder.get_weeks_by_date_range(start, end)
|
|
rota_builder._constraint_weeks_cache[key] = computed
|
|
return
|
|
except Exception:
|
|
tname, dump = _safe_constraint_info(constraint)
|
|
logger.exception("Error accessing/setting 'weeks' on constraint type=%s dump=%s", tname, dump)
|
|
raise
|
|
|
|
|
|
def _get_constraint_weeks(constraint, rota_builder):
|
|
"""Return the weeks list for a constraint using the cache or safe accessors.
|
|
|
|
Never calls repr()/__repr__ on the constraint.
|
|
"""
|
|
if hasattr(rota_builder, "_constraint_weeks_cache"):
|
|
wk = rota_builder._constraint_weeks_cache.get(id(constraint), None)
|
|
if wk is not None:
|
|
return wk
|
|
|
|
cdict = _safe_as_dict(constraint)
|
|
if cdict is not None:
|
|
return cdict.get("weeks", None)
|
|
|
|
try:
|
|
return getattr(constraint, "weeks")
|
|
except Exception:
|
|
return None
|
|
|
|
ShiftName = str
|
|
DayStr = str
|
|
WeekInt = int
|
|
|
|
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
|
|
full_weekend_count = 2
|
|
|
|
|
|
SHIFT_BOUNDS = {
|
|
"bank_holiday": (0, 9),
|
|
"shift_count": (0, 400),
|
|
"night_shift_count": (0, 100),
|
|
"weekend_count": (0, 60),
|
|
}
|
|
|
|
#VALID_SHIFT_CONSTRAINTS = Literal[
|
|
# "night",
|
|
# "pre",
|
|
# "post",
|
|
# "balance_across_groups",
|
|
# "limit_grade_number",
|
|
# "minimum_grade_number",
|
|
# "max_shifts_per_week",
|
|
# "max_shifts_per_week_block",
|
|
# "require_remote_site_presence",
|
|
# "require_remote_site_presence_week",
|
|
#]
|
|
|
|
class BaseShiftConstraint(BaseModel):
|
|
weeks: Optional[List[int]] = Field(
|
|
None,
|
|
description=(
|
|
"List of week numbers when this constraint is active. "
|
|
"These are selectors that limit WHEN the constraint applies; they must not be used together with start_date/end_date."
|
|
),
|
|
)
|
|
start_date: Optional[datetime.date] = Field(
|
|
None,
|
|
description="Inclusive start date for the period when this constraint is active (cannot be used with `weeks`).",
|
|
)
|
|
end_date: Optional[datetime.date] = Field(
|
|
None,
|
|
description="Inclusive end date for the period when this constraint is active (cannot be used with `weeks`).",
|
|
)
|
|
|
|
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
|
|
|
|
@field_validator("weeks")
|
|
def weeks_none_if_dates_set(cls, weeks, values):
|
|
data = values.data
|
|
if (data.get("start_date") is not None or data.get("end_date") is not None) and weeks is not None:
|
|
raise ValueError("If start_date or end_date is set, weeks must not be set")
|
|
return weeks
|
|
|
|
|
|
class ShiftConstraint(BaseShiftConstraint):
|
|
"""Compatibility wrapper for legacy constraint dicts of the form
|
|
{'name': <constraint_name>, 'options': {...}}.
|
|
|
|
The DB historically stored constraints as name/options pairs. When we
|
|
encounter that shape we convert into this wrapper so validation passes
|
|
and the builder code can still inspect `name`/`options` if needed.
|
|
"""
|
|
name: str
|
|
options: Any = None
|
|
|
|
class NightConstraint(BaseShiftConstraint):
|
|
options: Any = None
|
|
|
|
class PreShiftConstraint(BaseShiftConstraint):
|
|
"""Pre-shift constraint.
|
|
|
|
Semantics:
|
|
- `weeks`, `start_date`, `end_date` (inherited) are selectors that determine WHEN the constraint is active (which weeks or date window).
|
|
- `days` is a DURATION: the number of days the constraint applies for (e.g. 3 means apply the constraint for 3 days relative to the shift). This field is required.
|
|
- `ignore_shifts`, `exclude_days`, `exclude_dates` further refine which shifts/dates are considered.
|
|
"""
|
|
days: int = Field(
|
|
...,
|
|
description=(
|
|
"Duration in days: the number of days the constraint applies for. "
|
|
"This is different from selector fields (weeks/start_date/end_date) which select WHEN the constraint is active."
|
|
),
|
|
)
|
|
ignore_shifts: List[str] = Field(default_factory=list, description="List of shift names to ignore when applying this constraint")
|
|
exclude_days: List[str] = Field(default_factory=list, description="Weekday names to exclude (e.g. ['Mon','Tue'])")
|
|
exclude_dates: List[datetime.date] = Field(default_factory=list, description="Specific dates to exclude from the constraint")
|
|
allow_self: bool = Field(True, description="Allow the same worker to satisfy this constraint (default: True)")
|
|
|
|
class PostShiftConstraint(BaseShiftConstraint):
|
|
"""Post-shift constraint.
|
|
|
|
See PreShiftConstraint docs: selectors (weeks/start_date/end_date) choose WHEN the constraint is active; `days` is a duration and is required.
|
|
"""
|
|
days: int = Field(
|
|
...,
|
|
description=(
|
|
"Duration in days: the number of days the constraint applies for after the shift. "
|
|
"Different from selector fields which choose which weeks/dates the constraint is active on."
|
|
),
|
|
)
|
|
ignore_shifts: List[str] = Field(default_factory=list, description="List of shift names to ignore when applying this constraint")
|
|
exclude_days: List[str] = Field(default_factory=list, description="Weekday names to exclude (e.g. ['Mon','Tue'])")
|
|
exclude_dates: List[datetime.date] = Field(default_factory=list, description="Specific dates to exclude from the constraint")
|
|
allow_self: bool = Field(True, description="Allow the same worker to satisfy this constraint (default: True)")
|
|
|
|
class BalanceAcrossGroupsConstraint(BaseShiftConstraint):
|
|
""""""
|
|
|
|
|
|
class MinSummedGradeByShiftsPerDayConstraint(BaseShiftConstraint):
|
|
shifts: List[str] = Field(..., description="List of shift names to sum grades over")
|
|
min_grade_sum: int = Field(..., description="Minimum total grade required")
|
|
days: Optional[List[str]] = None
|
|
|
|
class LimitGradeNumberConstraint(BaseShiftConstraint):
|
|
grade: int
|
|
max_number: int
|
|
|
|
class MinimumGradeNumberConstraint(BaseShiftConstraint):
|
|
grade: int
|
|
min_number: int
|
|
|
|
class RequireRemoteSitePresenceConstraint(BaseShiftConstraint):
|
|
site: str
|
|
required_number: int
|
|
|
|
class MaxShiftsPerWeekConstraint(BaseShiftConstraint):
|
|
max_shifts: int | None = None
|
|
days: Optional[List[str]] = None
|
|
|
|
class MaxShiftsPerWeekBlockConstraint(BaseShiftConstraint):
|
|
week_block : int
|
|
max_shifts: int | None = None
|
|
|
|
def _pydantic_to_dict(obj):
|
|
# Recursively convert pydantic models, lists and dicts to JSON-serializable structures
|
|
if obj is None:
|
|
return None
|
|
if isinstance(obj, (list, set, tuple)):
|
|
return [_pydantic_to_dict(x) for x in obj]
|
|
if isinstance(obj, dict):
|
|
return {k: _pydantic_to_dict(v) for k, v in obj.items()}
|
|
# datetime/date -> ISO string
|
|
if isinstance(obj, (datetime.date, datetime.datetime)):
|
|
return obj.isoformat()
|
|
if hasattr(obj, "model_dump"):
|
|
return _pydantic_to_dict(obj.model_dump())
|
|
return obj
|
|
|
|
|
|
class WorkerRequirement(BaseModel):
|
|
"""
|
|
start date is inclusive, end date is not
|
|
"""
|
|
|
|
# NOTE: if number is changed mid week block allocations may not work?
|
|
number: int = 1
|
|
start_date: datetime.date | None = None
|
|
end_date: datetime.date | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def check_dates(self):
|
|
if self.start_date is not None and self.end_date is not None:
|
|
if self.start_date > self.end_date:
|
|
raise ValueError("Start date must be before end date")
|
|
return self
|
|
|
|
|
|
class SingleShift(BaseModel):
|
|
"""Class to hold all details for a shift
|
|
|
|
start_date: The day that the shift starts on
|
|
end_date: The day that the shift ends on (the last day it should be rota'd for)
|
|
|
|
Due to week block assigments these (probably) need to be week ba
|
|
|
|
Valid constraints
|
|
|
|
balance_across_groups (requires block assignment)
|
|
|
|
post / pre clear
|
|
|
|
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: StrictInt | list[WorkerRequirement] = 1
|
|
rota_on_nwds: bool = False
|
|
assign_as_block: bool = False
|
|
force_as_block: bool = False
|
|
force_as_block_unless_nwd: bool | List[List[str]] = False
|
|
hard_constrain_shift: bool = True
|
|
bank_holidays_only: bool = False
|
|
#constraint: list[ShiftConstraint] = []
|
|
# NOTE: avoid typing this as List[BaseModel] — instructing pydantic to
|
|
# validate items as `BaseModel` can cause it to attempt to instantiate
|
|
# `BaseModel` directly which raises a PydanticUserError. Use the
|
|
# concrete BaseShiftConstraint base type so pydantic will validate into
|
|
# actual constraint subclasses instead of BaseModel itself.
|
|
constraints: List["BaseShiftConstraint"] = Field(default_factory=list)
|
|
|
|
start_date: datetime.date | None = None
|
|
end_date: datetime.date | None = None
|
|
pair_proxy: bool = False
|
|
display_char: str | None = None # Add this line
|
|
force_assign_with: list[str] = [] # List of shift names to force assign with this shift on the same day
|
|
include_groups: set[str] = set()
|
|
exclude_groups: set[str] = set()
|
|
|
|
@model_validator(mode="after")
|
|
def check_groups_non_overlapping(self) -> "SingleShift":
|
|
intersection = self.include_groups.intersection(self.exclude_groups)
|
|
if intersection:
|
|
raise ValueError(
|
|
f"include_groups and exclude_groups cannot overlap. Intersection: {intersection}"
|
|
)
|
|
return self
|
|
|
|
model_config = ConfigDict(
|
|
extra="allow",
|
|
# orm_mode = True
|
|
)
|
|
|
|
def __init__(self, **data: Any):
|
|
# Backwards-compat: accept legacy `constraints` list entries in the
|
|
# form {'name': ..., 'options': {...}} and convert them to concrete
|
|
# pydantic constraint models before validation. This avoids errors
|
|
# where the DB stored legacy name/options pairs.
|
|
if "constraints" in data and isinstance(data["constraints"], (list, tuple)):
|
|
converted = []
|
|
_name_map = {
|
|
"pre": PreShiftConstraint,
|
|
"post": PostShiftConstraint,
|
|
"night": NightConstraint,
|
|
"balance_across_groups": BalanceAcrossGroupsConstraint,
|
|
"min_summed_grade_by_shifts_per_day": MinSummedGradeByShiftsPerDayConstraint,
|
|
"limit_grade_number": LimitGradeNumberConstraint,
|
|
"minimum_grade_number": MinimumGradeNumberConstraint,
|
|
"require_remote_site_presence": RequireRemoteSitePresenceConstraint,
|
|
"max_shifts_per_week": MaxShiftsPerWeekConstraint,
|
|
"max_shifts_per_week_block": MaxShiftsPerWeekBlockConstraint,
|
|
}
|
|
for item in data["constraints"]:
|
|
if isinstance(item, dict) and "name" in item:
|
|
cname = item.get("name")
|
|
opts = item.get("options") or {}
|
|
cls = _name_map.get(cname)
|
|
if cls is not None:
|
|
try:
|
|
inst = cls.model_validate(opts) if hasattr(cls, "model_validate") else cls(**(opts or {}))
|
|
except Exception:
|
|
# fallback to generic wrapper
|
|
inst = ShiftConstraint(name=cname, options=opts)
|
|
else:
|
|
inst = ShiftConstraint(name=cname, options=opts)
|
|
converted.append(inst)
|
|
else:
|
|
converted.append(item)
|
|
data["constraints"] = converted
|
|
|
|
if "constraint" in data:
|
|
raise ValueError(
|
|
"The 'constraint' field on SingleShift has been deprecated and removed. "
|
|
"Please use the 'constraints' field with Pydantic constraint models instead."
|
|
)
|
|
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.display_char is not None and len(self.display_char) > 1:
|
|
raise ValueError("display_char must be a single character or None")
|
|
|
|
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}, "
|
|
f"sites: {self.sites}, "
|
|
f"length: {self.length}, "
|
|
f"balance_weighting: {self.balance_weighting}, "
|
|
f"balance_offset: {self.balance_offset}, "
|
|
f"hard_constrain_shift: {self.hard_constrain_shift}, "
|
|
f"workers_required: {self.workers_required}, "
|
|
f"rota_on_nwds: {self.rota_on_nwds}, "
|
|
f"assign_as_block: {self.assign_as_block}, "
|
|
f"force_as_block: {self.force_as_block}, "
|
|
f"constraints: {self.constraints}"
|
|
)
|
|
|
|
def has_constraint(self, constraint: Type) -> bool:
|
|
for c in self.constraints:
|
|
if isinstance(c, constraint):
|
|
return True
|
|
return False
|
|
|
|
def get_constraint(self, constraint: Type) -> BaseShiftConstraint | None:
|
|
for c in self.constraints:
|
|
if isinstance(c, constraint):
|
|
return c
|
|
return None
|
|
|
|
def get_constraints(self, constraint: Type) -> list[BaseShiftConstraint]:
|
|
return [c for c in self.constraints if isinstance(c, constraint)]
|
|
|
|
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)
|
|
|
|
def get_worker_requirement_by_date(self, date: datetime.date):
|
|
"""Must be called after build_shifts
|
|
|
|
Returns the number of workers required for a given date
|
|
|
|
If none are required 0 is returned"""
|
|
if isinstance(self.workers_required, int):
|
|
return self.workers_required
|
|
|
|
for wr in self.workers_required:
|
|
# print(date, wr)
|
|
if wr.start_date <= date < wr.end_date:
|
|
return wr.number
|
|
|
|
return 0
|
|
|
|
def get_display_char(self):
|
|
"""Returns the display character for the shift"""
|
|
if self.display_char is not None:
|
|
return self.display_char
|
|
return self.name[0]
|
|
|
|
|
|
class RotaConstraintOptions(BaseModel):
|
|
"""Pydantic model holding rota-level constraint options with types, defaults and descriptions.
|
|
|
|
Validate/coerce raw JSON (e.g. from DB) via `RotaConstraintOptions.model_validate(raw)`.
|
|
Use `model_dump()` to produce a JSON-serializable dict for storage.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
|
|
balance_nights: bool = Field(True, description="Balance night shifts across workers")
|
|
constrain_time_off_after_nights: bool = Field(False, description="Apply time-off constraints after night shifts")
|
|
balance_nights_across_sites: bool = Field(True, description="Balance night shifts across sites")
|
|
balance_bank_holidays: bool = Field(True, description="Balance bank-holiday shifts across staff")
|
|
balance_blocks: bool = Field(True, description="Balance shifts across week-blocks")
|
|
balance_shifts: bool = Field(True, description="Balance shifts across workers")
|
|
balance_shifts_true_quadratic: bool = Field(False, description="Use true quadratic balancing for shifts")
|
|
balance_shifts_quadratic: bool = Field(False, description="Use quadratic penalty to discourage spread across shifts")
|
|
balance_shifts_over_workers: bool = Field(True, description="Balance shift distribution over workers")
|
|
minimise_shift_diffs: bool = Field(False, description="Use simpler minimisation to reduce shift differences")
|
|
maximum_allowed_shift_diff: Optional[int] = Field(None, description="Maximum allowed per-worker shift difference (None = no limit)")
|
|
balance_weekends: bool = Field(True, description="Balance weekend allocations")
|
|
max_weekends: int = Field(100, description="Maximum number of weekends considered")
|
|
# If true, try to balance number of weekend days worked per worker separately
|
|
# for Saturday and Sunday (i.e. equivalent workers should have similar Sat counts
|
|
# and similar Sun counts).
|
|
balance_weekend_days: bool = Field(False, description="Balance weekend days (Sat/Sun) separately")
|
|
balance_weekend_days_weight: int = Field(5, description="Objective weight for balancing weekend days per day")
|
|
balance_weekend_days_quadratic: bool = Field(False, description="Use quadratic penalty to balance weekend days per day")
|
|
balance_weekend_days_use_previous_shifts: bool = Field(False, description="When true, adjust per-day weekend (Sat/Sun) targets using worker.previous_shifts (allocated-worked) distributed per shift day")
|
|
weekend_day_hard_max_deviation: Optional[float] = Field(
|
|
None,
|
|
description=(
|
|
"Hard maximum allowed absolute deviation (number) per-worker from the per-day "
|
|
"weekend target (Sat/Sun). If set, adds constraints enforcing |assigned - target| <= value."
|
|
),
|
|
)
|
|
max_weekend_frequency: int = Field(1, description="Don't assign multiple shifts every N weeks (requires balance_weekends)")
|
|
max_night_frequency: int = Field(1, description="Don't assign multiple night shifts every N weeks")
|
|
max_night_frequency_week_exclusions: List[int] = Field(default_factory=list, description="Weeks to exclude from night frequency checks")
|
|
max_shifts_per_week: int = Field(7, description="Max shifts per worker per week")
|
|
max_shifts_per_month: int = Field(40, description="Max shifts per worker per month")
|
|
max_days_per_week_block: List[tuple] = Field(default_factory=list, description="Optional list of (max_days, week_block) tuples to limit days in blocks")
|
|
prevent_monday_after_full_weekends: List[int] = Field(default_factory=list, description="Prevent assignment on Monday after full weekends")
|
|
prevent_monday_and_tuesday_after_full_weekends: List[int] = Field(default_factory=list, description="Prevent Monday/Tuesday after full weekends")
|
|
prevent_fridays_before_full_weekends: List[int] = Field(default_factory=list, description="Prevent Fridays before full weekends")
|
|
prevent_thursdays_before_full_weekends: List[int] = Field(default_factory=list, description="Prevent Thursdays before full weekends")
|
|
hard_constrain_pair_separation: bool = Field(False, description="Enforce hard pair separation constraint")
|
|
# These are lists of dicts with keys like {"grades": [...], "weeks": [...], "shifts": [...]}
|
|
avoid_shifts_by_grades: List[dict] = Field(default_factory=list, description="List of rules to avoid assigning grades to shifts")
|
|
avoid_shifts_by_worker_names: List[dict] = Field(default_factory=list, description="List of rules to avoid assigning specific workers to shifts")
|
|
# Rules to avoid assigning specific workers to particular shifts on specific dates.
|
|
# Each entry is a dict with keys: {"names": [...], "shifts": [...], "start_date": date|None, "end_date": date|None, "dates": [date,...]|None}
|
|
avoid_shifts_by_worker_dates: List[dict] = Field(default_factory=list, description="List of rules to avoid assigning specific workers to shifts on particular dates or date ranges")
|
|
allocate_locum_shifts: bool = Field(True, description="Allow allocation of locum shifts")
|
|
distribute_locum_shifts: bool = Field(True, description="Distribute locum shifts among available workers")
|
|
balance_locum_shifts: bool = Field(False, description="Balance locum shifts similarly to normal shifts")
|
|
maximum_allowed_locum_shifts_per_worker: Optional[int] = Field(None, description="Maximum locum shifts allowed per worker (None = no limit)")
|
|
minimum_allowed_locum_shifts_per_worker: Optional[int] = Field(None, description="Minimum locum shifts required per worker (None = no minimum)")
|
|
min_summed_grade_by_shifts_per_day: List[MinSummedGradeByShiftsPerDayConstraint] = Field(default_factory=list, description="List of minimum-summed-grade constraints per day")
|
|
|
|
# (Removed legacy dict-like convenience methods to enforce
|
|
# attribute access on the typed `RotaConstraintOptions` model.)
|
|
|
|
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: float = 1.0,
|
|
ltft_balance_offset: int = 1,
|
|
use_previous_shifts: bool = False,
|
|
use_shift_balance_extra: bool = False,
|
|
use_bank_holiday_extra: bool = False,
|
|
allow_force_assignment_with_leave_conflict: bool = False,
|
|
SHIFT_BOUNDS=SHIFT_BOUNDS,
|
|
name: str = "",
|
|
set_process_title: bool = True,
|
|
bank_holidays: dict[datetime.date, str] = bank_holiday_map,
|
|
constraint_options: RotaConstraintOptions | dict | None = None,
|
|
):
|
|
self.name = name
|
|
self.set_process_title = set_process_title
|
|
# record original process title so we can restore it after the run
|
|
try:
|
|
self._original_proc_title = _get_proc_title()
|
|
except Exception:
|
|
self._original_proc_title = None
|
|
|
|
console.print(
|
|
Panel(
|
|
f"""[white]
|
|
{locals()}
|
|
""",
|
|
style="green",
|
|
title=f"Generating Rota: {name}",
|
|
),
|
|
justify="left",
|
|
)
|
|
|
|
self.SHIFT_BOUNDS = SHIFT_BOUNDS
|
|
self.shifts: List[SingleShift] = [] # type List[SingleShift]
|
|
# self.allowed_multi_shift_sets: list[set[str]] = []
|
|
|
|
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: set = set()
|
|
|
|
self.balance_offset_modifier = balance_offset_modifier
|
|
self.ltft_balance_offset = ltft_balance_offset
|
|
|
|
# Initialize constraint options with typed Pydantic model. Accept either
|
|
# None (use defaults), a dict (raw values), or an existing model instance.
|
|
# Store the model instance in `self.constraint_options_model` and also
|
|
# expose it as `self.constraint_options` for minimal code changes.
|
|
if constraint_options is None:
|
|
opts_model = RotaConstraintOptions()
|
|
elif isinstance(constraint_options, RotaConstraintOptions):
|
|
opts_model = constraint_options
|
|
elif isinstance(constraint_options, dict):
|
|
opts_model = RotaConstraintOptions.model_validate(constraint_options)
|
|
else:
|
|
opts_model = RotaConstraintOptions()
|
|
|
|
self.constraint_options_model: RotaConstraintOptions = opts_model
|
|
|
|
logger.debug(f"Rota constraint options: {self.constraint_options_model.model_dump()}")
|
|
|
|
self.terminate_on_warning = [
|
|
"Worker/duplicate id",
|
|
"Worker/duplicate name",
|
|
"Worker/no valid shifts",
|
|
"Shift/invalid start date",
|
|
"Shift/invalid end date",
|
|
"Locum/no locum availability",
|
|
#"Force assignment/leave conflict", # Currently will always be infeasible
|
|
]
|
|
|
|
self.results = None
|
|
|
|
self.warnings: list[tuple[str, str]] = []
|
|
|
|
self.ignore_valid_shifts = False
|
|
|
|
self.set_rota_dates(start_date, weeks_to_rota)
|
|
|
|
self.run_start_time: datetime.datetime | None = None
|
|
self.run_end_time: datetime.datetime | None = None
|
|
|
|
self.allow_force_assignment_with_leave_conflict = allow_force_assignment_with_leave_conflict
|
|
|
|
self.bank_holidays = bank_holidays
|
|
|
|
self.paired_shifts = []
|
|
|
|
self.exported_rota_file = None
|
|
|
|
self.enable_unavailabilities = True
|
|
|
|
self.MAX_SHIFTS_PER_DAY = 2
|
|
|
|
def set_rota_constraint(self, constraint: str, value: Any):
|
|
# Ensure the model has the attribute and set it on the typed model
|
|
if not hasattr(self.constraint_options_model, constraint):
|
|
raise ValueError(f"Constraint {constraint} not valid")
|
|
|
|
setattr(self.constraint_options_model, constraint, value)
|
|
|
|
@property
|
|
def constraint_options(self):
|
|
"""Backward-compat dict-like proxy for the typed
|
|
`constraint_options_model`. This keeps older code/tests working
|
|
while the canonical storage is `constraint_options_model`.
|
|
"""
|
|
|
|
class _Proxy:
|
|
def __init__(self, model):
|
|
self._m = model
|
|
|
|
def __getitem__(self, key):
|
|
return getattr(self._m, key)
|
|
|
|
def __setitem__(self, key, value):
|
|
setattr(self._m, key, value)
|
|
|
|
def get(self, key, default=None):
|
|
return getattr(self._m, key, default)
|
|
|
|
def items(self):
|
|
return self._m.model_dump().items()
|
|
|
|
def __contains__(self, key):
|
|
return hasattr(self._m, key)
|
|
|
|
return _Proxy(self.constraint_options_model)
|
|
|
|
def set_rota_dates(self, start_date: datetime.date, 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 = {}
|
|
self.date_week_day_map = {}
|
|
|
|
n: int = 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
|
|
self.date_week_day_map[d] = (week, day)
|
|
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 | uuid.UUID | int, WeekInt, DayStr, ShiftName]
|
|
] = set()
|
|
self.work_requests_map = {}
|
|
self.locum_availability: set[
|
|
Tuple[str | uuid.UUID | int, WeekInt, DayStr, ShiftName]
|
|
] = set()
|
|
self.locum_availability_map = {}
|
|
|
|
def solve_model(
|
|
self,
|
|
solver: str = "appsi_highs",
|
|
options: dict = {},
|
|
debug_if_fail: bool = False,
|
|
):
|
|
console.print("Setting up solver")
|
|
console.print(f"{options=}")
|
|
|
|
if solver == "scip":
|
|
if "seconds" in options:
|
|
options["limits/time"] = options.pop("seconds")
|
|
if "ratio" in options:
|
|
options["limits/gap"] = options.pop("ratio")
|
|
if "threads" in options:
|
|
options.pop("threads")
|
|
self.opt = SolverFactory(solver, executable="scip")
|
|
elif solver == "appsi_highs":
|
|
self.opt = SolverFactory(solver)
|
|
if "seconds" in options:
|
|
options["time_limit"] = options.pop("seconds")
|
|
if "ratio" in options:
|
|
options["mip_rel_gap"] = options.pop("ratio")
|
|
if "threads" in options:
|
|
options.pop("threads")
|
|
else:
|
|
self.opt = SolverFactory(solver)
|
|
|
|
try:
|
|
console.print("Solving")
|
|
console.print(f"Options: {options}")
|
|
|
|
log_file = f"logs/{self.name}_{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
|
|
results = self.opt.solve(
|
|
self.model,
|
|
tee=True,
|
|
options=options,
|
|
# options={
|
|
# "threads": 10,
|
|
# },
|
|
# logfile=log_file,
|
|
keepfiles=True,
|
|
load_solutions=False,
|
|
)
|
|
except KeyboardInterrupt:
|
|
return
|
|
pass
|
|
|
|
if (
|
|
not results.solver.termination_condition == TerminationCondition.infeasible
|
|
# and not results.solver.status == SolverStatus.aborted
|
|
):
|
|
self.model.solutions.load_from(results)
|
|
|
|
self.results = results
|
|
|
|
console.print(f"Complete - outcome: {results.solver.status}")
|
|
console.print(f"Termination condition: {results.solver.termination_condition}")
|
|
|
|
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,
|
|
export=False,
|
|
export_with_timestamp=False,
|
|
solver="appsi_highs",
|
|
):
|
|
# Optionally set an informative process title so OS tools show which rota is running
|
|
self._proc_title_changed = False
|
|
if getattr(self, "set_process_title", False):
|
|
title = f"rota-gen:{self.name}" if self.name else "rota-gen"
|
|
try:
|
|
if _set_proc_title(title):
|
|
self._proc_title_changed = True
|
|
logger.debug("Process title set to %s", title)
|
|
except Exception:
|
|
logger.debug("Could not set process title")
|
|
|
|
self.run_start_time = datetime.datetime.now()
|
|
|
|
self.build_shifts()
|
|
self.build_workers()
|
|
self.build_model()
|
|
|
|
console.print("Building model")
|
|
console.print(f"{options=}")
|
|
|
|
if solve:
|
|
self.solve_model(
|
|
options=options, debug_if_fail=debug_if_fail, solver=solver
|
|
)
|
|
|
|
self.run_end_time = datetime.datetime.now()
|
|
|
|
# Restore the original process title if we changed it
|
|
if getattr(self, "_proc_title_changed", False):
|
|
try:
|
|
if getattr(self, "_original_proc_title", None) is not None:
|
|
_set_proc_title(self._original_proc_title)
|
|
else:
|
|
# best-effort restore to a generic python name
|
|
_set_proc_title("python")
|
|
except Exception:
|
|
logger.debug("Could not restore original process title")
|
|
|
|
if export:
|
|
self.export_rota_to_html(self.name, timestamp_filename=export_with_timestamp)
|
|
|
|
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.works_day = Var(
|
|
(
|
|
(worker.id, week, day)
|
|
for worker in self.workers
|
|
for week, day in self.get_week_day_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,
|
|
)
|
|
|
|
blocks_worker_keys = []
|
|
for worker, week, shift_name in self.worker_week_shifts_to_assign_as_blocks():
|
|
shift = self.get_shift_by_name(shift_name)
|
|
sub_blocks = self.get_shift_sub_blocks(shift)
|
|
for sb_idx in range(len(sub_blocks)):
|
|
blocks_worker_keys.append((worker.id, week, shift_name, sb_idx))
|
|
|
|
self.model.blocks_worker_shift_assigned = Var(
|
|
blocks_worker_keys,
|
|
within=Binary,
|
|
initialize=0,
|
|
)
|
|
|
|
blocks_assigned_keys = []
|
|
for week, shift_name in self.week_shifts_to_assign_as_blocks():
|
|
shift = self.get_shift_by_name(shift_name)
|
|
sub_blocks = self.get_shift_sub_blocks(shift)
|
|
for sb_idx in range(len(sub_blocks)):
|
|
blocks_assigned_keys.append((week, shift_name, sb_idx))
|
|
|
|
self.model.blocks_assigned = Var(
|
|
blocks_assigned_keys,
|
|
within=NonNegativeIntegers,
|
|
initialize=0,
|
|
)
|
|
|
|
# Helper binary variables for per-worker unique-shift block limits.
|
|
# Index is (worker_id, block_length, block_start_week, shift_name).
|
|
unique_shift_block_indices = []
|
|
for worker in self.workers:
|
|
for constraint in worker.max_unique_shifts_per_week_block:
|
|
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
|
if not week_blocks:
|
|
continue
|
|
block_start_week = week_blocks[0]
|
|
for shift_name in self.get_shift_names():
|
|
if any(
|
|
shift_name in self.get_shift_names_by_week_day(
|
|
week,
|
|
day,
|
|
return_empty_if_week_day_not_found=True,
|
|
)
|
|
for week in week_blocks
|
|
for day in days
|
|
):
|
|
unique_shift_block_indices.append(
|
|
(worker.id, constraint.week_block, block_start_week, shift_name)
|
|
)
|
|
|
|
self.model.worker_shift_used_in_week_block = Var(
|
|
unique_shift_block_indices,
|
|
within=Binary,
|
|
initialize=0,
|
|
)
|
|
|
|
if self.constraint_options_model.balance_bank_holidays:
|
|
# Bank holidays
|
|
self.model.bank_holiday_count = Var(
|
|
((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,
|
|
)
|
|
|
|
self.model.multi_shift_together = Var(
|
|
(
|
|
(worker.id, week, day, idx)
|
|
for worker in self.workers
|
|
for week, day in self.get_week_day_combinations()
|
|
for idx, allowed_set in enumerate(
|
|
getattr(worker, "allowed_multi_shift_sets", [])
|
|
)
|
|
if allowed_set.issubset(self.get_shift_names_by_week_day(week, day))
|
|
),
|
|
domain=Binary,
|
|
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_model.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(NightConstraint)
|
|
),
|
|
# 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(NightConstraint)
|
|
),
|
|
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(NightConstraint)
|
|
),
|
|
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(BalanceAcrossGroupsConstraint)
|
|
),
|
|
# 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(BalanceAcrossGroupsConstraint)
|
|
),
|
|
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(BalanceAcrossGroupsConstraint)
|
|
),
|
|
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_model.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_model.balance_shifts
|
|
or self.constraint_options_model.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_model.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_model.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_model.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,
|
|
)
|
|
|
|
# Per-day weekend balancing (separate Sat / Sun counts)
|
|
# Also create the bookkeeping variables when a hard deviation constraint is requested
|
|
logger.debug(f"Weekend day balancing options: {self.constraint_options_model.balance_weekend_days=}, {self.constraint_options_model.balance_weekend_days_quadratic=}, {self.constraint_options_model.weekend_day_hard_max_deviation=}")
|
|
if (
|
|
self.constraint_options_model.balance_weekend_days
|
|
or self.constraint_options_model.balance_weekend_days_quadratic
|
|
or self.constraint_options_model.weekend_day_hard_max_deviation is not None
|
|
):
|
|
logger.debug("Creating per-day weekend balancing variables")
|
|
self.model.weekend_day_shift_count = Var(
|
|
((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.weekend_day_shift_count_t1 = Var(
|
|
((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.weekend_day_shift_count_t2 = Var(
|
|
((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.weekend_day_shift_count_w = Var(
|
|
((worker.id, d) for worker in self.workers for d in ("Sat", "Sun")),
|
|
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)
|
|
|
|
|
|
for worker in self.workers:
|
|
for week, day, shift_name in getattr(worker, "forced_assignments", []):
|
|
# Only add if this shift is valid for this week/day
|
|
if shift_name in self.get_shift_names_by_week_day(week, day, return_empty_if_week_day_not_found=True):
|
|
# Check for leave conflict
|
|
if hasattr(self, "unavailable_to_work"):
|
|
if (worker.id, week, day) in self.unavailable_to_work:
|
|
if self.allow_force_assignment_with_leave_conflict:
|
|
self.unavailable_to_work.remove((worker.id, week, day))
|
|
self.add_warning(
|
|
"Force assignment/leave conflict",
|
|
f"Worker '{worker.name}' has a forced assignment for shift '{shift_name}' on week {week}, day {day} (date: {self.get_date_by_week_day(week, day)}), which conflicts with a leave request. The leave request has been overridden.",
|
|
)
|
|
else:
|
|
self.add_warning(
|
|
"Force assignment/leave conflict",
|
|
f"Worker '{worker.name}' has a forced assignment for shift '{shift_name}' on week {week}, day {day} (date: {self.get_date_by_week_day(week, day)}), which conflicts with a leave request.",
|
|
)
|
|
|
|
if not self.enable_unavailabilities:
|
|
self.unavailable_to_work = set()
|
|
self.unavailable_to_work_reason = {}
|
|
|
|
def availability_init(model, wid, week, day):
|
|
# This needs to be higher than the max number of possible shifts per day
|
|
if (wid, week, day) in self.unavailable_to_work:
|
|
# print((wid, week, day))
|
|
return 0
|
|
return self.MAX_SHIFTS_PER_DAY + 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,
|
|
)
|
|
|
|
locum_request_sets = set()
|
|
for locum_request in self.locum_availability:
|
|
if locum_request[3] == "*":
|
|
pass
|
|
|
|
else:
|
|
locum_request_sets.add(locum_request[3])
|
|
|
|
# Validate locum shifts are valid
|
|
invalid_shifts = locum_request_sets - set(self.get_shift_names())
|
|
if invalid_shifts and not self.ignore_valid_shifts:
|
|
raise InvalidShift(
|
|
f"Invalid locum shift request [shift(s): ${invalid_shifts}]"
|
|
)
|
|
|
|
def locum_request_init(model, wid, week, day, shift):
|
|
if (wid, week, day, shift) in self.locum_availability:
|
|
self.locum_availability_map[(wid, week, day)] = shift
|
|
return 1
|
|
return 0
|
|
|
|
self.model.locum_availability = Param(
|
|
(
|
|
(worker.id, week, day, shift)
|
|
for worker in self.workers
|
|
if not worker.locum
|
|
for week, day, shift in self.get_all_shiftname_combinations()
|
|
),
|
|
initialize=locum_request_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
|
|
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)
|
|
|
|
if self.get_workers_who_require_locums():
|
|
# We use this as a time check that their is some valid locum ability
|
|
if not self.get_all_locum_availability():
|
|
self.add_warning(
|
|
"Locum/no locum availability", "No locum availability set"
|
|
)
|
|
|
|
self.model.locum_required = Var(
|
|
(
|
|
(week, day, shiftname)
|
|
for week, day, shiftname in self.get_all_shiftname_combinations()
|
|
),
|
|
within=Binary,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.locum_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.locum_shifts_by_worker = Var(
|
|
(worker.id for worker in self.workers),
|
|
within=Integers,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.locum_shifts_t1 = Var(
|
|
(worker.id for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.model.locum_shifts_t2 = Var(
|
|
(worker.id for worker in self.workers),
|
|
domain=NonNegativeReals,
|
|
initialize=0,
|
|
)
|
|
|
|
self.build_model_constraints()
|
|
|
|
def build_model_constraints(self):
|
|
self.model.constraints = ConstraintList() # Create a set of constraints
|
|
|
|
|
|
for constraint in self.constraint_options_model.min_summed_grade_by_shifts_per_day:
|
|
# Safely extract weeks/days without invoking pydantic __getattr__
|
|
cdict = _safe_as_dict(constraint)
|
|
if cdict is not None:
|
|
weeks = cdict.get("weeks") if cdict.get("weeks") is not None else self.weeks
|
|
days_list = cdict.get("days") if cdict.get("days") is not None else self.days
|
|
else:
|
|
try:
|
|
weeks = constraint.weeks if constraint.weeks is not None else self.weeks
|
|
days_list = constraint.days if constraint.days is not None else self.days
|
|
except Exception:
|
|
tname, dump = _safe_constraint_info(constraint)
|
|
logger.exception("Error accessing 'weeks' or 'days' on constraint type=%s dump=%s", tname, dump)
|
|
raise
|
|
for week in weeks:
|
|
for day in days_list:
|
|
# Only consider if all shifts are present that day
|
|
if all(shift in self.get_shift_names_by_week_day(week, day) for shift in constraint.shifts):
|
|
expr_terms = [
|
|
worker.grade * self.model.works[worker.id, week, day, shift]
|
|
for shift in constraint.shifts
|
|
for worker in self.get_workers_for_shift(self.get_shift_by_name(shift))
|
|
]
|
|
if expr_terms: # Only add constraint if there are eligible workers
|
|
self.model.constraints.add(
|
|
sum(expr_terms) >= constraint.min_grade_sum
|
|
)
|
|
|
|
|
|
|
|
# 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():
|
|
shift_obj = self.get_shift_by_name(shift)
|
|
eligible_workers = [
|
|
worker for worker in self.workers
|
|
if self.is_worker_eligible_for_shift(worker, shift_obj)
|
|
]
|
|
self.model.constraints.add(
|
|
workers_required
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for worker in eligible_workers
|
|
)
|
|
)
|
|
|
|
if self.get_workers_who_require_locums():
|
|
self.model.constraints.add(
|
|
self.model.locum_required[week, day, shift]
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for worker in eligible_workers
|
|
if worker.locum
|
|
)
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.locum_required[week, day, shift]
|
|
== sum(
|
|
self.model.locum_works[worker.id, week, day, shift]
|
|
for worker in self.workers
|
|
# if not worker.locum
|
|
)
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
0
|
|
== sum(
|
|
self.model.locum_works[worker.id, week, day, shift]
|
|
for worker in self.workers
|
|
if worker.locum
|
|
)
|
|
)
|
|
|
|
# And it is not assigned if the worker is ineligible
|
|
ineligible_workers = [
|
|
worker for worker in self.workers
|
|
if not self.is_worker_eligible_for_shift(worker, shift_obj)
|
|
]
|
|
if ineligible_workers:
|
|
self.model.constraints.add(
|
|
0
|
|
== sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for worker in ineligible_workers
|
|
)
|
|
)
|
|
# # 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(LimitGradeNumberConstraint):
|
|
for constraint in shift.get_constraints(LimitGradeNumberConstraint):
|
|
grade = constraint.grade
|
|
limit = constraint.max_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],
|
|
[limit],
|
|
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(MinimumGradeNumberConstraint):
|
|
constraint = shift.get_constraints(MinimumGradeNumberConstraint)[0]
|
|
grade, min_required = constraint.grade, constraint.min_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(
|
|
RequireRemoteSitePresenceConstraint
|
|
):
|
|
constraint = shift.get_constraints(RequireRemoteSitePresenceConstraint)[0]
|
|
site, required_number = constraint.site, constraint.required_number
|
|
|
|
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(BalanceAcrossGroupsConstraint):
|
|
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():
|
|
|
|
for week, shift_name in self.week_shifts_to_assign_as_blocks():
|
|
shift = self.get_shift_by_name(shift_name)
|
|
sub_blocks = self.get_shift_sub_blocks(shift)
|
|
|
|
for sb_idx, sb in enumerate(sub_blocks):
|
|
for worker in self.get_workers_for_shift(shift):
|
|
if (worker.id, week, shift.name, sb_idx) in self.model.blocks_worker_shift_assigned:
|
|
try:
|
|
self.model.constraints.add(
|
|
8
|
|
* self.model.blocks_worker_shift_assigned[
|
|
worker.id, week, shift_name, sb_idx
|
|
]
|
|
>= sum(
|
|
self.model.works[worker.id, week, day, shift_name]
|
|
for day in sb
|
|
)
|
|
)
|
|
except KeyError:
|
|
pass
|
|
|
|
self.model.constraints.add(
|
|
self.model.blocks_assigned[week, shift_name, sb_idx]
|
|
== sum(
|
|
self.model.blocks_worker_shift_assigned[
|
|
worker.id, week, shift_name, sb_idx
|
|
]
|
|
for worker in self.workers
|
|
if (worker.id, week, shift.name, sb_idx) in self.model.blocks_worker_shift_assigned
|
|
)
|
|
)
|
|
|
|
workers_required = shift.get_worker_requirement_by_date(
|
|
self.get_week_start_date(week)
|
|
)
|
|
|
|
if shift.force_as_block_unless_nwd:
|
|
workers = self.get_workers_for_shift(shift)
|
|
# Get workers who have a nwd on the sub-block
|
|
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 sb:
|
|
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, sb_idx
|
|
]
|
|
for worker in self.workers
|
|
if (worker.id, week, shift.name, sb_idx) in self.model.blocks_worker_shift_assigned
|
|
)
|
|
<= workers_required
|
|
)
|
|
else:
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.blocks_worker_shift_assigned[
|
|
worker.id, week, shift.name, sb_idx
|
|
]
|
|
for worker in full_workers
|
|
if (worker.id, week, shift.name, sb_idx) in self.model.blocks_worker_shift_assigned
|
|
)
|
|
<= workers_required + 1
|
|
)
|
|
|
|
elif shift.force_as_block:
|
|
if workers_required:
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.blocks_worker_shift_assigned[
|
|
worker.id, week, shift.name, sb_idx
|
|
]
|
|
for worker in self.workers
|
|
if (worker.id, week, shift.name, sb_idx) in self.model.blocks_worker_shift_assigned
|
|
)
|
|
<= workers_required
|
|
)
|
|
|
|
# Most of our constraints apply per worker
|
|
# Worker constraint loop (worker loop)
|
|
worker: Worker
|
|
for worker in self.workers:
|
|
console.rule(
|
|
f"Generate worker constraints: [bold blue]{worker.name}[/bold blue]"
|
|
)
|
|
logger.debug(f"Generate worker constraints: {worker.name}")
|
|
|
|
for week, day in self.get_week_day_combinations():
|
|
shifts_today = self.get_shift_names_by_week_day(week, day)
|
|
total_assigned = sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for shift in shifts_today
|
|
)
|
|
# If assigned to any shift, works_day must be 1
|
|
self.model.constraints.add(total_assigned >= self.model.works_day[worker.id, week, day])
|
|
# If not assigned to any shift, works_day must be 0
|
|
self.model.constraints.add(total_assigned <= 1000 * self.model.works_day[worker.id, week, day])
|
|
|
|
# --- Max days worked per week constraint (global or per-worker) ---
|
|
# Use per-worker value if set, otherwise use global constraint option if set
|
|
max_days_worked_per_week = getattr(worker, "max_days_worked_per_week", None)
|
|
if max_days_worked_per_week is None:
|
|
max_days_worked_per_week = getattr(self.constraint_options_model, "max_days_worked_per_week", None)
|
|
if max_days_worked_per_week is not None:
|
|
for week in self.weeks:
|
|
# Create a binary variable for each day to indicate if the worker works any shift that day
|
|
for day in self.days:
|
|
var_name = f"works_any_{worker.id}_{week}_{day}"
|
|
if not hasattr(self.model, "works_any_day_vars"):
|
|
self.model.works_any_day_vars = {}
|
|
if (worker.id, week, day) not in self.model.works_any_day_vars:
|
|
self.model.works_any_day_vars[(worker.id, week, day)] = Var(within=Binary)
|
|
setattr(self.model, var_name, self.model.works_any_day_vars[(worker.id, week, day)])
|
|
works_any = self.model.works_any_day_vars[(worker.id, week, day)]
|
|
total_assigned = sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for shift in self.get_shift_names_by_week_day(week, day)
|
|
)
|
|
# If assigned to any shift, works_any must be 1
|
|
self.model.constraints.add(total_assigned >= works_any)
|
|
# If not assigned to any shift, works_any must be 0
|
|
self.model.constraints.add(total_assigned <= 1000 * works_any)
|
|
# Now sum the binaries for the week
|
|
self.model.constraints.add(
|
|
sum(self.model.works_any_day_vars[(worker.id, week, day)] for day in self.days)
|
|
<= max_days_worked_per_week
|
|
)
|
|
|
|
for week, day, shift_name in getattr(worker, "forced_assignments", []):
|
|
# Only add if this shift is valid for this week/day
|
|
if shift_name in self.get_shift_names_by_week_day(week, day, return_empty_if_week_day_not_found=True):
|
|
# We have already checked for leave conflicts when building the model
|
|
self.model.constraints.add(
|
|
self.model.works[worker.id, week, day, shift_name] == 1
|
|
)
|
|
else:
|
|
date = self.get_date_by_week_day(week, day)
|
|
self.add_warning(
|
|
"Invalid forced assignment",
|
|
f"Worker '{worker.name}' has forced assignment for non-existent shift: {shift_name} on week {week}, day {day} (date: {date})",
|
|
)
|
|
|
|
if hasattr(worker, "max_shifts_per_week_by_shift_name"):
|
|
for shift_type, max_per_week in worker.max_shifts_per_week_by_shift_name.items():
|
|
if shift_type not in self.get_shift_names():
|
|
raise InvalidShift(
|
|
f"Worker '{worker.name}' has max_shifts_per_week_by_shift_name referencing non-existent shift: {shift_type}"
|
|
)
|
|
for week in self.weeks:
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.works[worker.id, week, day, shift_type]
|
|
for day in days
|
|
if shift_type in self.get_shift_names_by_week_day(week, day)
|
|
) <= max_per_week
|
|
)
|
|
|
|
for week, day, shift in self.get_all_shiftclass_combinations():
|
|
# Only apply if this shift has force_assign_with set
|
|
if hasattr(shift, "force_assign_with") and shift.force_assign_with:
|
|
for other_shift_name in shift.force_assign_with:
|
|
# Only add constraint if the other shift is available on this day
|
|
if other_shift_name in self.get_shift_names_by_week_day(week, day):
|
|
for worker in self.get_workers_for_shift(shift):
|
|
self.model.constraints.add(
|
|
self.model.works[worker.id, week, day, shift.name]
|
|
<= self.model.works[worker.id, week, day, other_shift_name]
|
|
)
|
|
|
|
# Add hard shift dependencies for this worker
|
|
if hasattr(worker, "hard_day_dependencies"):
|
|
for dep in worker.hard_day_dependencies:
|
|
day_group = dep.days
|
|
|
|
weeks_to_apply = self.weeks
|
|
if dep.weeks is not None:
|
|
weeks_to_apply = dep.weeks
|
|
if dep.start_date is not None or dep.end_date is not None:
|
|
if dep.start_date is None:
|
|
dep.start_date = self.start_date
|
|
if dep.end_date is None:
|
|
dep.end_date = self.rota_end_date
|
|
|
|
weeks_to_apply = self.get_weeks_by_date_range(
|
|
dep.start_date, dep.end_date
|
|
)
|
|
|
|
for week in weeks_to_apply:
|
|
# compute which days in the day_group should be ignored
|
|
ignored_days = set()
|
|
try:
|
|
for ignore_date in getattr(dep, "ignore_dates", []) or []:
|
|
try:
|
|
ww, dd = self.date_week_day_map.get(ignore_date, (None, None))
|
|
except Exception:
|
|
ww = dd = None
|
|
if ww == week and dd in day_group:
|
|
ignored_days.add(dd)
|
|
except Exception:
|
|
ignored_days = set()
|
|
# If the worker has any forced assignment in the dependency group,
|
|
# emit a concise warning. For visibility include every day in the
|
|
# group showing the worker's forced shifts (if any) and other
|
|
# workers' forced shifts on those days so blocking days are visible.
|
|
worker_has_forced_in_group = False
|
|
for (fw, fd, fs) in getattr(worker, "forced_assignments", []):
|
|
if fw == week and fd in day_group:
|
|
worker_has_forced_in_group = True
|
|
break
|
|
if not worker_has_forced_in_group:
|
|
try:
|
|
for (d_date, d_shift) in getattr(worker, "forced_assignments_by_date", []):
|
|
ww, dd = self.date_week_day_map.get(d_date, (None, None))
|
|
if ww == week and dd in day_group:
|
|
worker_has_forced_in_group = True
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
if worker_has_forced_in_group:
|
|
forced_day_details = []
|
|
try:
|
|
date_map_for_week = {self.get_date_by_week_day(week, d): d for d in day_group}
|
|
except Exception:
|
|
date_map_for_week = {}
|
|
|
|
for day in day_group:
|
|
if day in ignored_days:
|
|
# skip ignored dates for warning/visibility
|
|
continue
|
|
worker_forced_shifts = set()
|
|
# week-based forced assignments
|
|
for (fw, fd, fs) in getattr(worker, "forced_assignments", []):
|
|
if fw == week and fd == day:
|
|
worker_forced_shifts.add(fs)
|
|
# date-based forced assignments
|
|
try:
|
|
date = self.get_date_by_week_day(week, day)
|
|
for (d_date, d_shift) in getattr(worker, "forced_assignments_by_date", []):
|
|
try:
|
|
ww, dd = self.date_week_day_map.get(d_date, (None, None))
|
|
except Exception:
|
|
ww = dd = None
|
|
if ww == week and dd == day:
|
|
worker_forced_shifts.add(d_shift)
|
|
else:
|
|
try:
|
|
if str(d_date) == str(date):
|
|
worker_forced_shifts.add(d_shift)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# collect other workers' forced shifts on this day
|
|
others_forced = {}
|
|
try:
|
|
date_for_day = self.get_date_by_week_day(week, day)
|
|
except Exception:
|
|
date_for_day = None
|
|
|
|
for other in self.workers:
|
|
if other is worker:
|
|
continue
|
|
other_shifts = set()
|
|
# week-based forced assignments
|
|
for (ofw, ofd, ofs) in getattr(other, "forced_assignments", []):
|
|
if ofw == week and ofd == day:
|
|
other_shifts.add(ofs)
|
|
# date-based forced assignments: try mapping then fallback
|
|
for (odate, oshift) in getattr(other, "forced_assignments_by_date", []):
|
|
try:
|
|
oww, odd = self.date_week_day_map.get(odate, (None, None))
|
|
except Exception:
|
|
oww = odd = None
|
|
if oww == week and odd == day:
|
|
other_shifts.add(oshift)
|
|
continue
|
|
try:
|
|
if date_for_day is not None and str(odate) == str(date_for_day):
|
|
other_shifts.add(oshift)
|
|
except Exception:
|
|
pass
|
|
|
|
if other_shifts:
|
|
others_forced[other.name] = sorted(list(other_shifts))
|
|
|
|
forced_day_details.append((day, sorted(list(worker_forced_shifts)), others_forced))
|
|
|
|
parts = []
|
|
for d, wfs, others in forced_day_details:
|
|
olist = ", ".join([f"{n}: {', '.join(s)}" for n, s in others.items()]) or "none"
|
|
wfs_str = ", ".join(wfs) or "none"
|
|
parts.append(f"{d} - worker forced: {wfs_str}; others forced: {olist}")
|
|
details = "; ".join(parts)
|
|
logger.warning(f"HardDayDependency forced-assignment warning for worker={worker.name} week={week}: {details}")
|
|
self.add_warning(
|
|
"HardDayDependency forced assignment",
|
|
f"Worker '{worker.name}' has hard_day_dependency {set(day_group)} in week {week}; forced assignments on dependency days: {details}. If the rota is not infeasible this is unlikely to be an issue.",
|
|
)
|
|
# Build constraints only for non-ignored days
|
|
effective_days = [d for d in day_group if d not in ignored_days]
|
|
if not effective_days:
|
|
# nothing to constrain for this dependency in this week
|
|
continue
|
|
|
|
assigned_any_shift = {}
|
|
for day in effective_days:
|
|
shifts_today = self.get_shift_names_by_week_day(week, day)
|
|
var_name = f"hard_day_dep_{worker.id}_{week}_{day}"
|
|
if not hasattr(self.model, "hard_day_dep_vars"):
|
|
self.model.hard_day_dep_vars = {}
|
|
if (worker.id, week, day) not in self.model.hard_day_dep_vars:
|
|
self.model.hard_day_dep_vars[(worker.id, week, day)] = Var(within=Binary)
|
|
setattr(self.model, var_name, self.model.hard_day_dep_vars[(worker.id, week, day)])
|
|
assigned_any_shift[day] = self.model.hard_day_dep_vars[(worker.id, week, day)]
|
|
total_assigned = sum(self.model.works[worker.id, week, day, shift] for shift in shifts_today)
|
|
self.model.constraints.add(total_assigned >= assigned_any_shift[day])
|
|
self.model.constraints.add(total_assigned <= len(shifts_today) * assigned_any_shift[day])
|
|
# All-or-none: all effective (non-ignored) days in the group must have the same assignment status
|
|
vals = list(assigned_any_shift.values())
|
|
for i in range(1, len(vals)):
|
|
self.model.constraints.add(vals[i] == vals[0])
|
|
|
|
# Add hard day exclusions for this worker
|
|
if hasattr(worker, "hard_day_exclusions"):
|
|
for exclusion in worker.hard_day_exclusions:
|
|
days_to_exclude = exclusion.days
|
|
weeks_to_apply = self.weeks
|
|
if exclusion.weeks is not None:
|
|
weeks_to_apply = exclusion.weeks
|
|
if exclusion.start_date is not None or exclusion.end_date is not None:
|
|
if exclusion.start_date is None:
|
|
exclusion.start_date = self.start_date
|
|
if exclusion.end_date is None:
|
|
exclusion.end_date = self.rota_end_date
|
|
|
|
weeks_to_apply = self.get_weeks_by_date_range(
|
|
exclusion.start_date, exclusion.end_date
|
|
)
|
|
|
|
for week in weeks_to_apply:
|
|
# For each week, prevent any shift being assigned on both days
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.works_day[worker.id, week, day]
|
|
for day in days_to_exclude
|
|
#for shift in self.get_shift_names_by_week_day(week, day)
|
|
)
|
|
<= 1
|
|
)
|
|
#self.model.constraints.add(
|
|
# sum(
|
|
# self.model.works[worker.id, week, day1, shift]
|
|
# for shift in self.get_shift_names_by_week_day(week, day1)
|
|
# )
|
|
# + sum(
|
|
# self.model.works[worker.id, week, day2, shift]
|
|
# for shift in self.get_shift_names_by_week_day(week, day2)
|
|
# )
|
|
# <= 1
|
|
#)
|
|
|
|
|
|
for week, day in self.get_week_day_combinations():
|
|
shifts_today = self.get_shift_names_by_week_day(week, day)
|
|
worker_allowed_sets = getattr(worker, "allowed_multi_shift_sets", [])
|
|
# for idx, allowed_set in enumerate(worker_allowed_sets):
|
|
# if allowed_set.issubset(shifts_today):
|
|
# var = self.model.multi_shift_together[worker.id, week, day, idx]
|
|
# self.model.constraints.add(
|
|
# sum(self.model.works[worker.id, week, day, shift] for shift in allowed_set)
|
|
# == len(allowed_set) * var
|
|
# )
|
|
for idx, allowed_set in enumerate(worker_allowed_sets):
|
|
if allowed_set.issubset(shifts_today):
|
|
var = self.model.multi_shift_together[worker.id, week, day, idx]
|
|
# var is 1 iff all shifts in allowed_set are assigned
|
|
for shift in allowed_set:
|
|
self.model.constraints.add(
|
|
var <= self.model.works[worker.id, week, day, shift]
|
|
)
|
|
self.model.constraints.add(
|
|
var
|
|
>= sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for shift in allowed_set
|
|
)
|
|
- len(allowed_set)
|
|
+ 1
|
|
)
|
|
shifts_today = self.get_shift_names_by_week_day(week, day)
|
|
for shift_name in shifts_today:
|
|
if hasattr(worker, "force_assign_with") and shift_name in worker.force_assign_with:
|
|
for other_shift in worker.force_assign_with[shift_name]:
|
|
if other_shift in shifts_today:
|
|
self.model.constraints.add(
|
|
self.model.works[worker.id, week, day, shift_name]
|
|
<= self.model.works[worker.id, week, day, other_shift]
|
|
)
|
|
|
|
|
|
|
|
if self.get_workers_who_require_locums():
|
|
for week, day, shift in self.get_all_shiftclass_combinations():
|
|
if not worker.locum:
|
|
self.model.constraints.add(
|
|
self.get_locum_availability(worker, week, day, shift)
|
|
>= self.model.locum_works[worker.id, week, day, shift.name]
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.locum_shifts_by_worker[worker.id]
|
|
== sum(
|
|
self.model.locum_works[worker.id, week, day, shift]
|
|
for week, day, shift in self.get_all_shiftname_combinations()
|
|
)
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.locum_shifts_t1[worker.id]
|
|
- self.model.locum_shifts_t2[worker.id]
|
|
== self.model.locum_shifts_by_worker[worker.id]
|
|
)
|
|
|
|
if worker.locum_max_shifts:
|
|
self.model.constraints.add(
|
|
worker.locum_max_shifts
|
|
>= sum(
|
|
self.model.locum_works[worker.id, week, day, shiftname]
|
|
for week, day, shiftname in self.get_all_shiftname_combinations()
|
|
)
|
|
)
|
|
|
|
if worker.locum_max_shifts_per_week:
|
|
for week_to_check in self.weeks:
|
|
self.model.constraints.add(
|
|
worker.locum_max_shifts_per_week
|
|
>= sum(
|
|
self.model.locum_works[worker.id, week, day, shiftname]
|
|
for week, day, shiftname in self.get_all_shiftname_combinations(
|
|
week=week_to_check
|
|
)
|
|
)
|
|
)
|
|
|
|
if self.constraint_options_model.distribute_locum_shifts:
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.locum_required[week, day, shift]
|
|
for week, day, shift in self.get_all_shiftname_combinations()
|
|
)
|
|
/ (len(self.get_locum_workers()) - 1)
|
|
>= self.model.locum_shifts_by_worker[worker.id]
|
|
)
|
|
|
|
if (
|
|
self.constraint_options_model.maximum_allowed_locum_shifts_per_worker
|
|
is not None
|
|
):
|
|
self.model.constraints.add(
|
|
self.constraint_options_model.maximum_allowed_locum_shifts_per_worker
|
|
>= self.model.locum_shifts_by_worker[worker.id]
|
|
)
|
|
if worker.locum_availability:
|
|
if (
|
|
self.constraint_options_model.minimum_allowed_locum_shifts_per_worker
|
|
is not None
|
|
):
|
|
self.model.constraints.add(
|
|
self.constraint_options_model.minimum_allowed_locum_shifts_per_worker
|
|
<= self.model.locum_shifts_by_worker[worker.id]
|
|
)
|
|
|
|
if (
|
|
self.constraint_options_model.minimise_shift_diffs
|
|
or self.constraint_options_model.maximum_allowed_shift_diff is not None
|
|
):
|
|
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()
|
|
)
|
|
)
|
|
|
|
if self.constraint_options_model.maximum_allowed_shift_diff is not None:
|
|
self.model.constraints.add(
|
|
self.model.shift_count_diff_summed[worker.id]
|
|
<= self.constraint_options_model.maximum_allowed_shift_diff
|
|
)
|
|
self.model.constraints.add(
|
|
self.model.shift_count_diff_summed[worker.id]
|
|
>= -self.constraint_options_model.maximum_allowed_shift_diff
|
|
)
|
|
|
|
for max_days, weeks in self.constraint_options_model.max_days_per_week_block:
|
|
for week_blocks in self.get_week_block_iterator(weeks):
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.works_day[worker.id, week, day]
|
|
for week, day in self.get_week_day_combinations()
|
|
if week in week_blocks
|
|
)
|
|
<= max_days
|
|
)
|
|
|
|
for constraint in worker.max_days_per_week_block:
|
|
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
|
# Determine which (week, day) combinations to ignore
|
|
ignored_week_days = set()
|
|
for ignore_date in (constraint.ignore_dates or []):
|
|
try:
|
|
w, d = self.date_week_day_map.get(ignore_date, (None, None))
|
|
if w is not None and d is not None:
|
|
ignored_week_days.add((w, d))
|
|
except Exception:
|
|
pass
|
|
|
|
included_days = [
|
|
(week, day)
|
|
for week, day in self.get_week_day_combinations()
|
|
if week in week_blocks and (week, day) not in ignored_week_days
|
|
]
|
|
if included_days:
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.works_day[worker.id, week, day]
|
|
for week, day in included_days
|
|
)
|
|
<= constraint.max_days
|
|
)
|
|
|
|
for constraint in worker.max_unique_shifts_per_week_block:
|
|
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
|
if not week_blocks:
|
|
continue
|
|
|
|
# Determine which (week, day) combinations to ignore
|
|
ignored_week_days = set()
|
|
for ignore_date in (constraint.ignore_dates or []):
|
|
try:
|
|
w, d = self.date_week_day_map.get(ignore_date, (None, None))
|
|
if w is not None and d is not None:
|
|
ignored_week_days.add((w, d))
|
|
except Exception:
|
|
pass
|
|
|
|
block_start_week = week_blocks[0]
|
|
used_shift_vars = []
|
|
for shift_name in self.get_shift_names():
|
|
shift_assignments = []
|
|
for week, day in self.get_week_day_combinations():
|
|
if week in week_blocks and (week, day) not in ignored_week_days:
|
|
if shift_name in self.get_shift_names_by_week_day(
|
|
week,
|
|
day,
|
|
return_empty_if_week_day_not_found=True,
|
|
):
|
|
shift_assignments.append(
|
|
self.model.works[worker.id, week, day, shift_name]
|
|
)
|
|
if not shift_assignments:
|
|
continue
|
|
|
|
used_shift_var = self.model.worker_shift_used_in_week_block[
|
|
worker.id,
|
|
constraint.week_block,
|
|
block_start_week,
|
|
shift_name,
|
|
]
|
|
used_shift_vars.append(used_shift_var)
|
|
self.model.constraints.add(
|
|
sum(shift_assignments)
|
|
<= len(shift_assignments) * used_shift_var
|
|
)
|
|
|
|
if used_shift_vars:
|
|
self.model.constraints.add(
|
|
sum(used_shift_vars) <= constraint.max_unique_shifts
|
|
)
|
|
|
|
for week_blocks in self.get_week_block_iterator(4):
|
|
# Prevent more than n number shifts per 4 weeks
|
|
try:
|
|
self.model.constraints.add(
|
|
self.constraint_options_model.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)
|
|
self.add_warning(
|
|
"max shifts per month constraint",
|
|
f"Failed to constrain max_shifts_per_month for worker: {worker.name}",
|
|
)
|
|
pass
|
|
|
|
# Date-based per-worker per-shift avoidance rules
|
|
for constraint_dict in self.constraint_options_model.avoid_shifts_by_worker_dates:
|
|
# validate shift names
|
|
if invalid_shifts := set(constraint_dict["shifts"]).difference(set(self.get_shift_names())):
|
|
message = f"avoid shifts by worker/dates constraint contains a non existent shift: {invalid_shifts}"
|
|
if self.ignore_valid_shifts:
|
|
self.add_warning("Non existent shift", message)
|
|
continue
|
|
else:
|
|
raise InvalidShift(message)
|
|
|
|
if worker.name not in constraint_dict["names"]:
|
|
continue
|
|
|
|
# Build set of allowed (week,day) pairs that fall within the date selector
|
|
selected_weeks_days = set()
|
|
|
|
# explicit dates take precedence
|
|
if constraint_dict.get("dates"):
|
|
date_set = set(constraint_dict["dates"])
|
|
for wk, d in self.get_week_day_combinations():
|
|
date = self.get_date_by_week_day(wk, d)
|
|
if date in date_set:
|
|
selected_weeks_days.add((wk, d))
|
|
else:
|
|
sd = constraint_dict.get("start_date")
|
|
ed = constraint_dict.get("end_date")
|
|
# If neither provided, apply to whole rota
|
|
if sd is None and ed is None:
|
|
for wk, d in self.get_week_day_combinations():
|
|
selected_weeks_days.add((wk, d))
|
|
else:
|
|
if sd is None:
|
|
sd = self.start_date
|
|
if ed is None:
|
|
ed = self.rota_end_date
|
|
for wk, d in self.get_week_day_combinations():
|
|
date = self.get_date_by_week_day(wk, d)
|
|
if sd <= date <= ed:
|
|
selected_weeks_days.add((wk, d))
|
|
|
|
if not selected_weeks_days:
|
|
# nothing to constrain
|
|
continue
|
|
|
|
# Determine which of the requested shifts actually occur on the selected dates
|
|
present_shifts = set(
|
|
shift
|
|
for wk, d, shift in self.get_all_shiftname_combinations()
|
|
if (wk, d) in selected_weeks_days
|
|
)
|
|
|
|
requested_shifts = set(constraint_dict["shifts"])
|
|
used_shifts = requested_shifts.intersection(present_shifts)
|
|
|
|
missing_shifts = requested_shifts - present_shifts
|
|
if missing_shifts:
|
|
self.add_warning(
|
|
"avoid shifts by worker/dates",
|
|
(
|
|
f"Requested avoid shifts {sorted(list(missing_shifts))} for worker '{worker.name}'"
|
|
" are not present in the rota dates selected and will be ignored"
|
|
),
|
|
)
|
|
|
|
if not used_shifts:
|
|
# Nothing to constrain after filtering — skip adding the constraint
|
|
continue
|
|
|
|
# Add constraint that forbids the given shifts for this worker on the selected dates
|
|
self.model.constraints.add(
|
|
0
|
|
== sum(
|
|
self.model.works[worker.id, wk, d, shift]
|
|
for wk, d, shift in self.get_all_shiftname_combinations()
|
|
if (wk, d) in selected_weeks_days and shift in used_shifts
|
|
)
|
|
)
|
|
|
|
for constraint_dict in self.constraint_options_model.avoid_shifts_by_worker_names:
|
|
if invalid_shifts := set(constraint_dict["shifts"]).difference(
|
|
set(self.get_shift_names())
|
|
):
|
|
message = f"avoid shifts by worker constraint contains a non existent shift: {invalid_shifts}"
|
|
if self.ignore_valid_shifts:
|
|
self.add_warning("Non existent shift", message)
|
|
continue # Skip if we don't want to raise an error
|
|
else:
|
|
raise InvalidShift(message)
|
|
|
|
if worker.name in constraint_dict["names"]:
|
|
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"]
|
|
)
|
|
)
|
|
|
|
for constraint_dict in self.constraint_options_model.avoid_shifts_by_grades:
|
|
if invalid_shifts := set(constraint_dict["shifts"]).difference(
|
|
set(self.get_shift_names())
|
|
):
|
|
message = f"avoid shifts by grade constraint contains a non existent shift: {invalid_shifts}"
|
|
if self.ignore_valid_shifts:
|
|
self.add_warning("Non existent shift", message)
|
|
continue # Skip if we don't want to raise an error
|
|
else:
|
|
raise InvalidShift(message)
|
|
if invalid_grades := set(constraint_dict["grades"]).difference(
|
|
set(self.get_worker_grades())
|
|
):
|
|
message = f"avoid shifts by grade constraint contains a non existent worker grade: {invalid_grades}"
|
|
self.add_warning("Non existent shift", message)
|
|
raise ValueError(message)
|
|
|
|
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"]
|
|
)
|
|
)
|
|
|
|
# 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_model.max_weekends > -1:
|
|
self.model.constraints.add(
|
|
self.constraint_options_model.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 (
|
|
self.is_worker_eligible_for_shift(worker, shift)
|
|
): # Each site specfies which sites self.workers can fullfill
|
|
total_shifts = self.shift_worker_counts[shift.name]
|
|
|
|
# Find workers who have exact shifts for this shift type
|
|
exact_workers = [
|
|
w for w in self.workers
|
|
if self.is_worker_eligible_for_shift(w, shift) and shift.name in getattr(w, "exact_shifts", {})
|
|
]
|
|
exact_shifts_sum = sum(w.exact_shifts[shift.name] for w in exact_workers)
|
|
|
|
if exact_shifts_sum > total_shifts:
|
|
warning_msg = f"For shift {shift.name}, total exact shifts ({exact_shifts_sum}) exceeds total required shifts ({total_shifts})."
|
|
if not any(warn[1] == warning_msg for warn in self.warnings):
|
|
self.add_warning("Exact shifts exceed total shifts", warning_msg)
|
|
|
|
if worker in exact_workers:
|
|
target_shifts = worker.exact_shifts[shift.name]
|
|
else:
|
|
remaining_shifts = total_shifts - exact_shifts_sum
|
|
# Sum FTE only for workers who do not have exact shifts for this shift type
|
|
full_time_equivalent_joined = sum(
|
|
w.get_fte(shift=shift.name)
|
|
for w in self.workers
|
|
if self.is_worker_eligible_for_shift(w, shift) and w not in exact_workers
|
|
)
|
|
|
|
if not full_time_equivalent_joined:
|
|
# avoid ZeroDivisionError
|
|
if remaining_shifts > 0:
|
|
self.add_warning(
|
|
"Zero FTE for sites with remaining shifts",
|
|
f"full_time_equivalent sum is zero for shift {shift.name} but remaining_shifts = {remaining_shifts}; setting target_shifts=0 for worker {worker.name}",
|
|
)
|
|
target_shifts = 0
|
|
else:
|
|
target_shifts = (
|
|
max(0.0, float(remaining_shifts))
|
|
/ full_time_equivalent_joined
|
|
* worker.get_fte(shift=shift.name)
|
|
)
|
|
|
|
if worker not in exact_workers:
|
|
if self.use_previous_shifts:
|
|
if shift.name in worker.previous_shifts:
|
|
entry = worker.previous_shifts[shift.name]
|
|
# entry may be a tuple (worked, allocated) or a dict of per-day tuples
|
|
if isinstance(entry, dict):
|
|
# prefer overall tuple if present
|
|
if "__all__" in entry:
|
|
try:
|
|
worked = float(entry["__all__"][0])
|
|
allocated = float(entry["__all__"][1])
|
|
target_shifts = target_shifts + allocated - worked
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# sum any per-day entries
|
|
try:
|
|
total_worked = 0.0
|
|
total_alloc = 0.0
|
|
for v in entry.values():
|
|
total_worked += float(v[0])
|
|
total_alloc += float(v[1])
|
|
target_shifts = target_shifts + total_alloc - total_worked
|
|
except Exception:
|
|
pass
|
|
else:
|
|
try:
|
|
worked, allocated = entry
|
|
target_shifts = (
|
|
target_shifts + float(allocated) - float(worked)
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
if self.use_shift_balance_extra:
|
|
if shift.name in worker.shift_balance_extra:
|
|
extra = worker.shift_balance_extra[shift.name]
|
|
|
|
# TODO look at how this affects allocation (how does it affect fte)
|
|
match extra:
|
|
case "double":
|
|
target_shifts = target_shifts * 2
|
|
case "half":
|
|
target_shifts = target_shifts / 2
|
|
case _:
|
|
target_shifts = target_shifts + extra
|
|
|
|
# print(worker.name, shift.name, target_shifts)
|
|
worker.shift_target_number[shift.name] = target_shifts
|
|
|
|
if worker in exact_workers:
|
|
self.model.constraints.add(
|
|
self.model.shift_count[worker.id, shift.name] == target_shifts
|
|
)
|
|
elif shift.hard_constrain_shift:
|
|
adjusted_balance_offset = shift.balance_offset
|
|
if worker.get_fte(shift=shift.name) < 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
|
|
)
|
|
|
|
# --- Store for export ---
|
|
if not hasattr(worker, "hard_constrain_shift_limits"):
|
|
worker.hard_constrain_shift_limits = {}
|
|
worker.hard_constrain_shift_limits[shift.name] = {
|
|
"min_shifts": min_shifts,
|
|
"max_shifts": max_shifts,
|
|
"target_shifts": target_shifts,
|
|
}
|
|
|
|
if self.get_week_day_combinations_for_shift(shift):
|
|
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
|
|
)
|
|
),
|
|
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_model.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_model.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
|
|
|
|
# NOTE: as this balances across the sum of the worker's shifts
|
|
# they can cancel each other out if a high weighting is given to multiple shifts
|
|
if self.constraint_options_model.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_model.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 self.bank_holidays
|
|
)
|
|
+ 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_model.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(
|
|
NightConstraint
|
|
)
|
|
# 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(NightConstraint)
|
|
)
|
|
|
|
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_model.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_model.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
|
|
)
|
|
|
|
# Per-day weekend balancing (Sat and Sun separately)
|
|
logger.debug(f"Adding per-day weekend balancing constraints for worker {worker.name} ({worker.id})")
|
|
logger.debug(self.constraint_options_model.weekend_day_hard_max_deviation)
|
|
if (
|
|
self.constraint_options_model.balance_weekend_days
|
|
or self.constraint_options_model.balance_weekend_days_quadratic
|
|
or self.constraint_options_model.weekend_day_hard_max_deviation is not None
|
|
):
|
|
logger.debug(f"Worker {worker.name}: per-day weekend block active (hard_dev={self.constraint_options_model.weekend_day_hard_max_deviation})")
|
|
console.print(f"Worker {worker.name}: per-day weekend block active (hard_dev={self.constraint_options_model.weekend_day_hard_max_deviation})")
|
|
hard_weekend_constraints_added = 0
|
|
for day_name in ("Sat", "Sun"):
|
|
# count assignments on this specific weekend day
|
|
self.model.constraints.add(
|
|
self.model.weekend_day_shift_count[worker.id, day_name]
|
|
== sum(
|
|
self.model.works[worker.id, w, d, shift]
|
|
for w, d, shift in self.get_all_shiftname_combinations()
|
|
if d == day_name
|
|
)
|
|
)
|
|
|
|
# target number for this day (derived from shift targets)
|
|
weekend_day_shift_target_number = sum(
|
|
worker.shift_target_number[shift.name] / len(shift.days)
|
|
for shift in self.get_shifts()
|
|
if day_name in shift.days
|
|
)
|
|
|
|
# Optionally adjust per-day weekend targets using previous_shifts
|
|
if self.use_previous_shifts and self.constraint_options_model.balance_weekend_days_use_previous_shifts:
|
|
# For each shift that includes this day, if previous_shifts contains an entry
|
|
# add (allocated - worked) / len(shift.days) to the per-day target
|
|
prev_adj = 0.0
|
|
for shift in self.get_shifts():
|
|
if day_name in shift.days and shift.name in getattr(worker, "previous_shifts", {}):
|
|
entry = worker.previous_shifts.get(shift.name)
|
|
# support per-day nested dicts: { 'Sat': (worked, allocated), 'Sun': (...) }
|
|
if isinstance(entry, dict):
|
|
# prefer explicit day entry if present
|
|
if day_name in entry:
|
|
try:
|
|
worked = float(entry[day_name][0])
|
|
allocated = float(entry[day_name][1])
|
|
prev_adj += (allocated - worked)
|
|
except Exception:
|
|
continue
|
|
# fallback: if an overall tuple was preserved under '__all__', use distributed value
|
|
elif "__all__" in entry:
|
|
try:
|
|
worked = float(entry["__all__"][0])
|
|
allocated = float(entry["__all__"][1])
|
|
prev_adj += (allocated - worked) / max(1, len(shift.days))
|
|
except Exception:
|
|
continue
|
|
else:
|
|
try:
|
|
worked = float(entry[0])
|
|
allocated = float(entry[1])
|
|
prev_adj += (allocated - worked) / max(1, len(shift.days))
|
|
except Exception:
|
|
continue
|
|
weekend_day_shift_target_number = weekend_day_shift_target_number + prev_adj
|
|
# store adjusted per-day weekend target on the worker object for later use in objectives
|
|
if not hasattr(worker, 'weekend_day_target'):
|
|
worker.weekend_day_target = {}
|
|
worker.weekend_day_target[day_name] = weekend_day_shift_target_number
|
|
self.model.constraints.add(
|
|
self.model.weekend_day_shift_count_t1[worker.id, day_name]
|
|
- self.model.weekend_day_shift_count_t2[worker.id, day_name]
|
|
== self.model.weekend_day_shift_count[worker.id, day_name]
|
|
- weekend_day_shift_target_number
|
|
)
|
|
|
|
xU_day = self.constraint_options_model.max_weekends
|
|
xL_day = 1
|
|
self.model.constraints.add(
|
|
inequality(
|
|
xL_day,
|
|
self.model.weekend_day_shift_count_t1[worker.id, day_name]
|
|
+ self.model.weekend_day_shift_count_t2[worker.id, day_name]
|
|
+ 1,
|
|
xU_day,
|
|
)
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.weekend_day_shift_count_w[worker.id, day_name]
|
|
>= xL_day
|
|
* (
|
|
self.model.weekend_day_shift_count_t1[worker.id, day_name]
|
|
+ self.model.weekend_day_shift_count_t2[worker.id, day_name]
|
|
+ 1
|
|
)
|
|
* 2
|
|
- xL_day * xL_day
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
self.model.weekend_day_shift_count_w[worker.id, day_name]
|
|
>= xU_day
|
|
* (
|
|
self.model.weekend_day_shift_count_t1[worker.id, day_name]
|
|
+ self.model.weekend_day_shift_count_t2[worker.id, day_name]
|
|
+ 1
|
|
)
|
|
* 2
|
|
- xU_day * xU_day
|
|
)
|
|
|
|
# Optional hard constraint: limit absolute deviation from per-day target
|
|
if self.constraint_options_model.weekend_day_hard_max_deviation is not None:
|
|
d = self.constraint_options_model.weekend_day_hard_max_deviation
|
|
console.print(f"Adding hard weekend-day deviation constraint: worker={worker.name} ({worker.id}), day={day_name}, target={weekend_day_shift_target_number:.3f}, d={d}")
|
|
# assigned - target <= d
|
|
self.model.constraints.add(
|
|
self.model.weekend_day_shift_count[worker.id, day_name]
|
|
- weekend_day_shift_target_number
|
|
<= d
|
|
)
|
|
# target - assigned <= d
|
|
self.model.constraints.add(
|
|
weekend_day_shift_target_number
|
|
- self.model.weekend_day_shift_count[worker.id, day_name]
|
|
<= d
|
|
)
|
|
hard_weekend_constraints_added += 2
|
|
|
|
if hard_weekend_constraints_added:
|
|
console.print(f"Worker {worker.name}: added {hard_weekend_constraints_added} hard weekend-day constraints")
|
|
|
|
# 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 this shift is force assigned, allow it but add a warning
|
|
if start <= self.week_day_date_map[(week, day)] < end:
|
|
if (week, day, shift.name) in getattr(worker, "forced_assignments", []):
|
|
self.add_warning(
|
|
"Force assignment/NWD conflict",
|
|
f"Worker '{worker.name}' has a forced assignment for shift '{shift.name}' on week {week}, day {day} (date: {self.get_date_by_week_day(week, day)}), which is a non-working day."
|
|
)
|
|
else:
|
|
self.model.constraints.add(
|
|
0
|
|
== self.model.works[
|
|
worker.id, week, day, shift.name
|
|
]
|
|
)
|
|
|
|
if self.get_workers_who_require_locums():
|
|
if not worker.locum_on_nwds and day == n:
|
|
if start <= self.week_day_date_map[(week, day)] < end:
|
|
self.model.constraints.add(
|
|
0
|
|
== self.model.locum_works[
|
|
worker.id, week, day, shift.name
|
|
]
|
|
)
|
|
|
|
if self.constraint_options_model.balance_blocks:
|
|
if self.constraint_options_model.max_night_frequency:
|
|
for week_blocks in self.get_week_block_iterator(
|
|
self.constraint_options_model.max_night_frequency
|
|
):
|
|
# Ignore excluded weeks
|
|
if set(week_blocks).intersection(
|
|
set(
|
|
self.constraint_options_model.max_night_frequency_week_exclusions
|
|
)
|
|
):
|
|
continue
|
|
|
|
if self.get_shifts_with_constraint(NightConstraint):
|
|
# 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(
|
|
NightConstraint
|
|
)
|
|
)
|
|
)
|
|
|
|
# if self.constraint_options_model.balance_weekends:
|
|
if self.constraint_options_model.max_weekend_frequency:
|
|
for week_blocks in self.get_week_block_iterator(
|
|
self.constraint_options_model.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(
|
|
MaxShiftsPerWeekBlockConstraint
|
|
):
|
|
constraint = constraint_shift.get_constraint(MaxShiftsPerWeekBlockConstraint)
|
|
|
|
# If not supplied, default to the number of days per shift
|
|
if constraint.max_shifts is not None:
|
|
shift_number = constraint.max_shifts
|
|
else:
|
|
shift_number = constraint_shift.get_shift_number()
|
|
|
|
for week_blocks in self.get_week_block_iterator(
|
|
constraint.week_block
|
|
):
|
|
# 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:
|
|
|
|
weekend_days = self.days[5:] # ["Sat", "Sun"]
|
|
total_assigned = sum(
|
|
self.model.works[worker.id, week, day, shiftname]
|
|
for w, day, shiftname in self.get_all_shiftname_combinations(week=week)
|
|
if day in weekend_days
|
|
)
|
|
# If any shift is assigned on Sat or Sun, works_weekend is 1
|
|
self.model.constraints.add(total_assigned >= self.model.works_weekend[worker.id, week])
|
|
# If works_weekend is 1, at least one shift must be assigned
|
|
self.model.constraints.add(total_assigned <= 1000 * self.model.works_weekend[worker.id, week])
|
|
|
|
|
|
|
|
for constraint_shift in self.get_shifts_with_constraints(
|
|
MaxShiftsPerWeekConstraint
|
|
):
|
|
|
|
# If not supplied, default to the number of days per shift
|
|
constraint = constraint_shift.get_constraint(MaxShiftsPerWeekConstraint)
|
|
if constraint.max_shifts is not None:
|
|
shift_number = constraint.max_shifts
|
|
else:
|
|
shift_number = constraint_shift.get_shift_number()
|
|
try:
|
|
# If the constraint specifies particular days, only count those days,
|
|
# otherwise count the shift's own days.
|
|
constraint_days = (
|
|
constraint.days if constraint.days is not None else self.days
|
|
)
|
|
|
|
self.model.constraints.add(
|
|
shift_number
|
|
>= sum(
|
|
self.model.works[worker.id, w, day, constraint_shift.name]
|
|
for w, day in self.get_week_day_combinations_for_shift(constraint_shift)
|
|
if w == week and day in constraint_days
|
|
)
|
|
)
|
|
except ValueError:
|
|
# This happens if a shift if not assigned on the week (should we test for this instead?)
|
|
pass
|
|
|
|
try:
|
|
self.model.constraints.add(
|
|
self.constraint_options_model.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_model.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_model.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_model.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_model.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"]:
|
|
|
|
|
|
# Try disabling tihs to allow more than 2 shifts ot be assigned on a weekend
|
|
#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(week=week):
|
|
if shift.force_as_block:
|
|
if shift.get_worker_requirement_by_date(
|
|
self.get_week_start_date(week)
|
|
):
|
|
# Force 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_model.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)
|
|
)
|
|
)
|
|
if self.get_locum_workers():
|
|
self.model.constraints.add(
|
|
self.model.available[worker.id, week, day]
|
|
>= sum(
|
|
self.model.locum_works[worker.id, week, day, shift]
|
|
for shift in self.get_shift_names_by_week_day(week, day)
|
|
)
|
|
)
|
|
|
|
# single shift per day (unless multi-shift allowed)
|
|
# This is signifantly slower so only enable if required
|
|
shifts_today = self.get_shift_names_by_week_day(week, day)
|
|
assigned_vars = [
|
|
self.model.works[worker.id, week, day, shift]
|
|
for shift in shifts_today
|
|
]
|
|
worker_allowed_sets = getattr(
|
|
worker, "allowed_multi_shift_sets", []
|
|
)
|
|
force_assign_sets = []
|
|
for shift_name in shifts_today:
|
|
if hasattr(worker, "force_assign_with") and shift_name in worker.force_assign_with:
|
|
force_set = set([shift_name] + list(worker.force_assign_with[shift_name]))
|
|
if force_set.issubset(shifts_today):
|
|
force_assign_sets.append(force_set)
|
|
shift_obj = self.get_shift_by_name(shift_name)
|
|
if getattr(shift_obj, "force_assign_with", []):
|
|
force_set = set([shift_name] + list(shift_obj.force_assign_with))
|
|
if force_set.issubset(shifts_today):
|
|
force_assign_sets.append(force_set)
|
|
|
|
# If no allowed sets, we can only assign one shift
|
|
allowed_sets_today = [
|
|
allowed_set
|
|
for allowed_set in worker_allowed_sets
|
|
if allowed_set.issubset(shifts_today)
|
|
] + force_assign_sets
|
|
|
|
if allowed_sets_today:
|
|
# Forbid any multi-shift assignment that includes shifts from different allowed sets
|
|
# Build all possible multi-shift assignments
|
|
all_multi = [
|
|
set(c)
|
|
for i in range(2, len(shifts_today) + 1)
|
|
for c in itertools.combinations(shifts_today, i)
|
|
]
|
|
# Build all allowed multi-shift patterns (all non-empty subsets of each allowed set)
|
|
allowed_patterns = [{shift} for shift in shifts_today]
|
|
for allowed_set in allowed_sets_today:
|
|
allowed_patterns += [
|
|
set(s)
|
|
for i in range(2, len(allowed_set) + 1)
|
|
for s in itertools.combinations(allowed_set, i)
|
|
]
|
|
# Forbid all other multi-shift combinations
|
|
forbidden = [s for s in all_multi if s not in allowed_patterns]
|
|
for forbidden_set in forbidden:
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for shift in forbidden_set
|
|
)
|
|
<= len(forbidden_set) - 1
|
|
)
|
|
# Still, at most all shifts per day
|
|
self.model.constraints.add(
|
|
sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
for shift in shifts_today
|
|
)
|
|
<= len(shifts_today)
|
|
)
|
|
else:
|
|
# Only one shift per day if no allowed sets
|
|
self.model.constraints.add(sum(assigned_vars) <= 1)
|
|
|
|
# This applies to locums as well
|
|
if self.get_workers_who_require_locums():
|
|
self.model.constraints.add(
|
|
1
|
|
>= sum(
|
|
self.model.locum_works[worker.id, week, day, shift]
|
|
for shift in self.get_shift_names_by_week_day(week, day)
|
|
)
|
|
)
|
|
self.model.constraints.add(
|
|
1
|
|
>= sum(
|
|
self.model.works[worker.id, week, day, shift]
|
|
+ self.model.locum_works[worker.id, week, day, shift]
|
|
for shift in self.get_shift_names_by_week_day(week, day)
|
|
)
|
|
)
|
|
|
|
if self.constraint_options_model.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
|
|
)
|
|
)
|
|
|
|
# NOTE: due to the way that this is implemented, if a
|
|
# shift spans 7 days you may get >7 allocations in a row
|
|
# as it only checks for a different shift allocation
|
|
for constraint_shift in self.get_shifts_with_constraints(
|
|
PreShiftConstraint
|
|
):
|
|
constraint = constraint_shift.get_constraint(PreShiftConstraint)
|
|
if constraint.allow_self:
|
|
ignore_shifts = constraint.ignore_shifts + [constraint_shift.name]
|
|
else:
|
|
ignore_shifts = constraint.ignore_shifts
|
|
weeks = _get_constraint_weeks(constraint, self)
|
|
if weeks is not None and week not in weeks:
|
|
continue
|
|
date_main = self.get_date_by_week_day(week, day)
|
|
for n in range(0, constraint.days):
|
|
# skip if main day excluded by weekday or specific date
|
|
if day in getattr(constraint, "exclude_days", []) or date_main in getattr(constraint, "exclude_dates", []):
|
|
continue
|
|
if day in constraint_shift.days:
|
|
# Only apply if worker can work this shift
|
|
if not self.is_worker_eligible_for_shift(worker, constraint_shift):
|
|
continue
|
|
try:
|
|
works = self.model.works[
|
|
worker.id, week, day, constraint_shift.name
|
|
]
|
|
except KeyError:
|
|
continue
|
|
# compute the pre date and skip if it's in exclude_dates
|
|
try:
|
|
pre_date = self.get_date_by_week_day(pre_map[n][1], pre_map[n][2])
|
|
except Exception:
|
|
pre_date = None
|
|
if pre_date is not None and pre_date in getattr(constraint, "exclude_dates", []):
|
|
continue
|
|
|
|
self.model.constraints.add(
|
|
1
|
|
>= works
|
|
+ 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 not in ignore_shifts
|
|
for w in workers
|
|
if self.is_worker_eligible_for_shift(w, constraint_shift) # Only workers who can work this shift
|
|
)
|
|
)
|
|
|
|
for constraint_shift in self.get_shifts_with_constraints(
|
|
PostShiftConstraint
|
|
):
|
|
constraint = constraint_shift.get_constraint(PostShiftConstraint)
|
|
if constraint.allow_self:
|
|
ignore_shifts = constraint.ignore_shifts + [constraint_shift.name]
|
|
else:
|
|
ignore_shifts = constraint.ignore_shifts
|
|
weeks = _get_constraint_weeks(constraint, self)
|
|
if weeks is not None and week not in weeks:
|
|
continue
|
|
date_main = self.get_date_by_week_day(week, day)
|
|
for n in range(0, constraint.days):
|
|
# skip if main day excluded by weekday or specific date
|
|
if day in getattr(constraint, "exclude_days", []) or date_main in getattr(constraint, "exclude_dates", []):
|
|
continue
|
|
if day in constraint_shift.days:
|
|
# Only apply if worker can work this shift
|
|
if not self.is_worker_eligible_for_shift(worker, constraint_shift):
|
|
continue
|
|
try:
|
|
works = self.model.works[
|
|
worker.id, week, day, constraint_shift.name
|
|
]
|
|
except KeyError:
|
|
continue
|
|
# compute the post date and skip if it's in exclude_dates
|
|
try:
|
|
post_date = self.get_date_by_week_day(post_map[n][1], post_map[n][2])
|
|
except Exception:
|
|
post_date = None
|
|
if post_date is not None and post_date in getattr(constraint, "exclude_dates", []):
|
|
continue
|
|
|
|
self.model.constraints.add(
|
|
1
|
|
>= works
|
|
+ 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 not in ignore_shifts
|
|
for w in workers
|
|
if self.is_worker_eligible_for_shift(w, constraint_shift) # Only workers who can work this shift
|
|
)
|
|
)
|
|
|
|
# Night constraint means we won't assign a shift the day before
|
|
# an unavailability
|
|
for constraint_shift in self.get_shifts_with_constraint(NightConstraint):
|
|
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,
|
|
]
|
|
)
|
|
|
|
## 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.locum
|
|
# )
|
|
# )
|
|
|
|
# for worker in self.workers:
|
|
# if worker.locum:
|
|
# for week, day, shift in self.get_all_shiftname_combinations():
|
|
# self.model.constraints.add(
|
|
# self.model.locum_works[worker.id, week, day, shift]
|
|
# == self.model.works[worker.id, week, day, shift]
|
|
# )
|
|
|
|
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)
|
|
prefer_multi_shift_expr = sum(
|
|
getattr(worker, "prefer_multi_shift_together", 0)
|
|
* self.model.multi_shift_together[worker.id, week, day, idx]
|
|
for worker in self.workers
|
|
for week, day in self.get_week_day_combinations()
|
|
for idx, allowed_set in enumerate(
|
|
getattr(worker, "allowed_multi_shift_sets", [])
|
|
)
|
|
if allowed_set.issubset(self.get_shift_names_by_week_day(week, day))
|
|
)
|
|
|
|
balance_modifier_constant = 10
|
|
balance_quadratic_shift_modifier_constant = 3
|
|
block_shift_balancing_constant = 1
|
|
locum_shift_balance_modifier_constant = 40
|
|
|
|
locum_shift_balancing = 0
|
|
if self.get_workers_who_require_locums():
|
|
if self.constraint_options_model.balance_locum_shifts:
|
|
locum_shift_balancing = sum(
|
|
locum_shift_balance_modifier_constant
|
|
* (
|
|
self.model.locum_shifts_t1[(worker.id)]
|
|
+ self.model.locum_shifts_t2[(worker.id)]
|
|
)
|
|
for worker in self.workers
|
|
)
|
|
|
|
if self.constraint_options_model.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_model.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_model.balance_shifts_quadratic:
|
|
quadratic_shift_balancing = sum(
|
|
balance_quadratic_shift_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:
|
|
quadratic_shift_balancing = 0
|
|
|
|
true_quadratic_shift_balancing = 0
|
|
if self.constraint_options_model.balance_shifts_true_quadratic:
|
|
shift_diff_modifier_constant = 10
|
|
true_quadratic_shift_balancing = sum(
|
|
shift_diff_modifier_constant
|
|
* self.model.shift_count_diff_summed[(worker.id)]
|
|
for worker in self.workers
|
|
)
|
|
|
|
if self.constraint_options_model.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_model.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_model.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_model.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
|
|
|
|
# Balance each weekend day (Sat / Sun) separately if requested.
|
|
# Support either a linear term or a stronger quadratic penalty.
|
|
weekend_day_term = 0
|
|
if self.constraint_options_model.balance_weekend_days_quadratic:
|
|
wd_weight = self.constraint_options_model.balance_weekend_days_weight
|
|
# HiGHS/appsi does not accept arbitrary nonlinear objective expressions here.
|
|
# Use the existing t1/t2 linear representation (which approximates absolute
|
|
# deviation) in the objective instead of a true squared term.
|
|
weekend_day_term = sum(
|
|
wd_weight
|
|
* (
|
|
self.model.weekend_day_shift_count_t1[(worker.id, day_name)]
|
|
+ self.model.weekend_day_shift_count_t2[(worker.id, day_name)]
|
|
)
|
|
for worker in self.workers
|
|
for day_name in ("Sat", "Sun")
|
|
)
|
|
elif self.constraint_options_model.balance_weekend_days:
|
|
weekend_day_modifier = self.constraint_options_model.balance_weekend_days_weight
|
|
weekend_day_term = sum(
|
|
weekend_day_modifier * self.model.weekend_day_shift_count_w[(worker.id, day_name)]
|
|
for worker in self.workers
|
|
for day_name in ("Sat", "Sun")
|
|
)
|
|
|
|
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_model.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(NightConstraint)
|
|
for site in self.sites
|
|
)
|
|
else:
|
|
nights_site_balancing = 0
|
|
|
|
if self.get_shifts_with_constraint(BalanceAcrossGroupsConstraint):
|
|
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(BalanceAcrossGroupsConstraint)
|
|
for site in self.sites
|
|
)
|
|
else:
|
|
block_site_balancing = 0
|
|
|
|
if self.constraint_options_model.balance_blocks:
|
|
blocks_balancing = sum(
|
|
block_shift_balancing_constant
|
|
* self.model.blocks_assigned[week, shift, sb_idx]
|
|
for week in self.weeks
|
|
for shift in self.shifts_to_assign_as_blocks()
|
|
for sb_idx in range(len(self.get_shift_sub_blocks(self.get_shift_by_name(shift))))
|
|
if (week, shift, sb_idx) in self.model.blocks_assigned
|
|
)
|
|
else:
|
|
blocks_balancing = 0
|
|
|
|
prefer_block_expr = sum(
|
|
worker.assign_as_block_preferences.get(shift.name, 0)
|
|
* self.model.blocks_worker_shift_assigned[worker.id, week, shift.name, sb_idx]
|
|
for worker in self.workers
|
|
for week in self.weeks
|
|
for shift in self.shifts
|
|
if hasattr(worker, "assign_as_block_preferences")
|
|
and shift.name in worker.assign_as_block_preferences
|
|
and self.is_worker_eligible_for_shift(worker, shift)
|
|
for sb_idx in range(len(self.get_shift_sub_blocks(shift)))
|
|
if (worker.id, week, shift.name, sb_idx)
|
|
in self.model.blocks_worker_shift_assigned
|
|
)
|
|
|
|
# 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
|
|
+ quadratic_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
|
|
+ locum_shift_balancing
|
|
+ true_quadratic_shift_balancing
|
|
- prefer_multi_shift_expr
|
|
+ prefer_block_expr
|
|
+ weekend_day_term
|
|
)
|
|
|
|
# add objective function to the model. rule (pass function) or expr (pass expression directly)
|
|
self.model.obj = Objective(rule=obj_rule, sense=minimize)
|
|
|
|
# def allow_shifts_together(self, *shift_names: str):
|
|
# """
|
|
# Allow the given set of shift names to be assigned together on a single day.
|
|
# """
|
|
# self.allowed_multi_shift_sets.append(set(shift_names))
|
|
|
|
def add_warning(self, warning_type: str, message: str):
|
|
print(f"[bold red]WARNING:[/bold red] {warning_type} - {message}")
|
|
self.warnings.append((warning_type, message))
|
|
|
|
if warning_type in self.terminate_on_warning:
|
|
raise WarningTermination(warning_type)
|
|
|
|
def get_warnings(self, warning_type: None | str = None):
|
|
if warning_type is None:
|
|
return self.warnings
|
|
else:
|
|
return [warning for warning in self.warnings if warning[0] == warning_type]
|
|
|
|
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 hasattr(self, "shifts_by_name"):
|
|
self.build_shifts()
|
|
|
|
if not self.workers:
|
|
raise NoWorkers("Workers must be added prior to calling build_workers")
|
|
|
|
self.workers_id_map: dict[str, Worker] = {}
|
|
self.workers_name_map: dict[str, Worker] = {}
|
|
|
|
for worker in track(self.workers, description="Building workers"):
|
|
print(worker.name)
|
|
worker.load_rota(self)
|
|
for s_name in getattr(worker, "exact_shifts", {}):
|
|
try:
|
|
s = self.get_shift_by_name(s_name)
|
|
if worker.site not in s.sites:
|
|
self.add_warning(
|
|
"Invalid exact shift site",
|
|
f"Worker {worker.name} requested exact shifts for shift {s_name} but their site {worker.site} is not in the shift sites {s.sites}",
|
|
)
|
|
elif not self.is_worker_eligible_for_shift(worker, s):
|
|
self.add_warning(
|
|
"Invalid exact shift group",
|
|
f"Worker {worker.name} requested exact shifts for shift {s_name} but is not eligible for it",
|
|
)
|
|
except KeyError:
|
|
self.add_warning(
|
|
"Invalid exact shift",
|
|
f"Worker {worker.name} requested exact shifts for non-existent shift {s_name}",
|
|
)
|
|
wid = worker.id
|
|
if wid in self.workers_id_map:
|
|
message = f"Worker with id '{wid}' has been added twice"
|
|
self.add_warning("Worker/duplicate id", message)
|
|
|
|
self.workers_id_map[wid] = worker
|
|
|
|
if worker.name in self.workers_name_map:
|
|
message = f"Worker with name '{worker.name}' has been added twice"
|
|
self.add_warning("Worker/duplicate name", message)
|
|
self.workers_name_map[worker.name] = worker
|
|
|
|
worker_has_valid_shift = False
|
|
for shift in self.shifts:
|
|
if self.is_worker_eligible_for_shift(worker, shift):
|
|
worker_has_valid_shift = True
|
|
break
|
|
|
|
if not worker_has_valid_shift:
|
|
message = f"Worker with name '{worker.name}' ({worker.id}) has no valid shifts (site: {worker.site})"
|
|
logger.warning(message)
|
|
self.add_warning("Worker/no valid shifts", message)
|
|
|
|
|
|
for date, shift_name in worker.forced_assignments_by_date:
|
|
try:
|
|
week, day = self.get_week_day_by_date(date)
|
|
worker.force_assign_shift(week, day, shift_name)
|
|
except WeekDayNotFound:
|
|
self.add_warning(
|
|
"Worker/forced assignment date not found",
|
|
f"Worker {worker.name} ({worker.id}) has a forced assignment for {date} ({shift_name}) but this date is not in the rota",
|
|
)
|
|
continue
|
|
|
|
self.workers = sorted(self.workers)
|
|
|
|
# Pairwise hard constraints to limit per-day weekend skew among equivalent workers
|
|
# Group workers by (site, grade) and enforce for each pair and each day:
|
|
# assigned_i_day - assigned_j_day <= d
|
|
# assigned_j_day - assigned_i_day <= d
|
|
# Use `weekend_day_hard_max_deviation` as `d` when set.
|
|
if self.constraint_options_model.weekend_day_hard_max_deviation is not None:
|
|
d = self.constraint_options_model.weekend_day_hard_max_deviation
|
|
groups = defaultdict(list)
|
|
for w in self.workers:
|
|
groups[(w.site, w.grade)].append(w)
|
|
|
|
pairwise_added = 0
|
|
for (site, grade), workers_group in groups.items():
|
|
if len(workers_group) < 2:
|
|
continue
|
|
# iterate unique unordered pairs
|
|
for i in range(len(workers_group)):
|
|
for j in range(i + 1, len(workers_group)):
|
|
wi = workers_group[i]
|
|
wj = workers_group[j]
|
|
for day_name in ("Sat", "Sun"):
|
|
try:
|
|
# wi - wj <= d
|
|
self.model.constraints.add(
|
|
self.model.weekend_day_shift_count[wi.id, day_name]
|
|
- self.model.weekend_day_shift_count[wj.id, day_name]
|
|
<= d
|
|
)
|
|
# wj - wi <= d
|
|
self.model.constraints.add(
|
|
self.model.weekend_day_shift_count[wj.id, day_name]
|
|
- self.model.weekend_day_shift_count[wi.id, day_name]
|
|
<= d
|
|
)
|
|
pairwise_added += 2
|
|
except Exception:
|
|
# If variables aren't present for some worker/day, skip
|
|
continue
|
|
|
|
console.print(f"Added {pairwise_added} pairwise weekend-day hard constraints (d={d})")
|
|
|
|
self.full_time_equivalent = sum(
|
|
w.fte_adj for w in self.workers
|
|
) # TODO: rewrite as per shift
|
|
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] = {}
|
|
|
|
for shift in self.get_shifts():
|
|
self.full_time_equivalent_sites[site][shift.name] = sum(
|
|
w.get_fte(shift=shift.name) 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_worker_name_constraint_by_week(
|
|
self, names: Iterable[str], weeks: Iterable[int], shifts
|
|
):
|
|
self.constraint_options_model.avoid_shifts_by_worker_names.append(
|
|
{"names": names, "weeks": weeks, "shifts": shifts}
|
|
)
|
|
|
|
def add_avoid_shifts_for_workers_on_dates(
|
|
self,
|
|
names: Iterable[str],
|
|
shifts: Iterable[str],
|
|
start_date: datetime.date | str | None = None,
|
|
end_date: datetime.date | str | None = None,
|
|
dates: Iterable[datetime.date] | None = None,
|
|
reason: str | None = None,
|
|
):
|
|
"""Add a rule preventing the given workers from being assigned the given shifts
|
|
on the provided date range or explicit dates.
|
|
|
|
- `names`: iterable of worker names to which the rule applies.
|
|
- `shifts`: iterable of shift names to avoid.
|
|
- `start_date`/`end_date`: inclusive date range (accepts date or string "%d/%m/%Y" or "%d/%m/%y").
|
|
- `dates`: optional explicit iterable of `datetime.date` objects.
|
|
"""
|
|
def _coerce_date(d):
|
|
if d is None:
|
|
return None
|
|
if isinstance(d, datetime.date):
|
|
return d
|
|
if isinstance(d, str):
|
|
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
|
try:
|
|
return datetime.datetime.strptime(d, fmt).date()
|
|
except ValueError:
|
|
continue
|
|
raise ValueError(f"Cannot parse date: {d}")
|
|
|
|
sd = _coerce_date(start_date)
|
|
ed = _coerce_date(end_date)
|
|
|
|
entry = {"names": list(names), "shifts": list(shifts), "start_date": sd, "end_date": ed, "dates": None, "reason": reason}
|
|
if dates is not None:
|
|
entry["dates"] = [d if isinstance(d, datetime.date) else _coerce_date(d) for d in dates]
|
|
|
|
self.constraint_options_model.avoid_shifts_by_worker_dates.append(entry)
|
|
|
|
def add_grade_constraint_by_week(
|
|
self, grades: Iterable[int], weeks: Iterable[int], shifts
|
|
):
|
|
self.constraint_options_model.avoid_shifts_by_grades.append(
|
|
{"grades": grades, "weeks": weeks, "shifts": shifts}
|
|
)
|
|
|
|
def add_min_summed_grade_by_shifts_per_day_constraint(
|
|
self,
|
|
shifts: list[str],
|
|
min_grade_sum: int,
|
|
weeks: list[int] = None,
|
|
days: list[str] = None,
|
|
):
|
|
"""
|
|
Helper to add a minimum summed grade constraint for a set of shifts per day.
|
|
"""
|
|
constraint = MinSummedGradeByShiftsPerDayConstraint(
|
|
shifts=shifts,
|
|
min_grade_sum=min_grade_sum,
|
|
weeks=weeks,
|
|
days=days,
|
|
)
|
|
self.constraint_options_model.min_summed_grade_by_shifts_per_day.append(constraint)
|
|
|
|
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, return_empty_if_week_day_not_found:bool=False) -> set:
|
|
"""Returns the shifts required for a specific day
|
|
|
|
Returns:
|
|
set: Set of ShiftName
|
|
|
|
"""
|
|
if (week, day) not in self.week_day_shifts_dict:
|
|
if return_empty_if_week_day_not_found:
|
|
return set()
|
|
raise WeekDayNotFound(f"Week {week}, Day {day} not found in week_day_shifts_dict")
|
|
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 get_date_by_week_day(self, week: WeekInt, day: DayStr) -> datetime.date:
|
|
try:
|
|
return self.week_day_date_map[(week, day)]
|
|
except KeyError:
|
|
self.add_warning(
|
|
"Rota/week day date map",
|
|
f"Week {week}, Day {day} not found in week_day_date_map",
|
|
)
|
|
return datetime.date(1900, 1, 1)
|
|
|
|
def get_weeks_by_date_range(self, start_date: datetime.date | None, end_date: datetime.date | None) -> list[WeekInt]:
|
|
"""Get a list of weeks that fall within a date range.
|
|
|
|
Args:
|
|
start_date (datetime.date): The start date of the range.
|
|
end_date (datetime.date): The end date of the range.
|
|
|
|
Returns:
|
|
list[WeekInt]: A list of week numbers that fall within the date range.
|
|
"""
|
|
# TODO: think about error if not monday start date?
|
|
weeks = []
|
|
if start_date is None:
|
|
start_date = self.start_date
|
|
if end_date is None:
|
|
end_date = self.rota_end_date
|
|
|
|
for week in self.weeks:
|
|
week_start = self.get_date_by_week_day(week, "Mon")
|
|
week_end = self.get_date_by_week_day(week, "Sun")
|
|
if week_start >= start_date and week_end <= end_date:
|
|
weeks.append(week)
|
|
return weeks
|
|
|
|
def pair_shifts(self, *shifts: SingleShift):
|
|
# NOTE: currently designed to pair two shifts
|
|
# this could be extended...
|
|
if len(shifts) < 2:
|
|
raise ValueError(f"Must pair at least two shifts: {shifts}")
|
|
self.paired_shifts.append([*shifts])
|
|
|
|
def get_date_range(
|
|
self, start_date: datetime.date = None, end_date: datetime.date = None
|
|
):
|
|
"""Gets a range of dates
|
|
|
|
If either start_date or end_date are not provided defaults to the rota dates"""
|
|
if start_date is None:
|
|
start_date = self.start_date
|
|
if end_date is None:
|
|
end_date = self.rota_end_date
|
|
|
|
for n in range(int((end_date - start_date).days) + 1):
|
|
yield start_date + datetime.timedelta(n)
|
|
|
|
def allow_shifts_together_for_all_workers(self, *shift_names: str):
|
|
"""
|
|
Allow the given set of shift names to be assigned together on a single day for all workers.
|
|
"""
|
|
for worker in self.workers:
|
|
worker.allow_shifts_together(*shift_names)
|
|
|
|
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}")
|
|
|
|
if s.start_date is not None:
|
|
if s.start_date > self.rota_end_date or s.start_date < self.start_date:
|
|
self.add_warning(
|
|
"Shift/invalid start date",
|
|
f"Shift '{s.name}' has a start date outside of the rota limits ({s.start_date} vs [{self.start_date}---{self.rota_end_date}])",
|
|
)
|
|
else:
|
|
s.start_date = self.start_date
|
|
|
|
if s.end_date is not None:
|
|
if s.end_date > self.rota_end_date or s.end_date < self.start_date:
|
|
self.add_warning(
|
|
"Shift/invalid end date",
|
|
f"Shift '{s.name}' has an end date outside of the rota limits ({s.end_date} vs [{self.start_date}---{self.rota_end_date}])",
|
|
)
|
|
else:
|
|
s.end_date = self.rota_end_date
|
|
|
|
|
|
if hasattr(s, "force_assign_with") and s.force_assign_with:
|
|
missing = set(s.force_assign_with) - set(shift.name for shift in self.shifts)
|
|
if missing:
|
|
raise InvalidShift(
|
|
f"Shift '{s.name}' has force_assign_with referencing non-existent shift(s): {missing}"
|
|
)
|
|
|
|
all_shift_names = set(shift.name for shift in self.shifts)
|
|
for worker in self.workers:
|
|
if hasattr(worker, "force_assign_with"):
|
|
for shift_name, with_shifts in getattr(worker, "force_assign_with", {}).items():
|
|
missing = set([shift_name] + list(with_shifts)) - all_shift_names
|
|
if missing:
|
|
raise InvalidShift(
|
|
f"Worker '{worker.name}' has force_assign_with referencing non-existent shift(s): {missing}"
|
|
)
|
|
|
|
# Check worker requirements
|
|
if not isinstance(s.workers_required, int):
|
|
# Validate the worker requirements
|
|
for worker_requirement in s.workers_required:
|
|
if not isinstance(worker_requirement, WorkerRequirement):
|
|
raise InvalidShift(
|
|
f"Invalid worker requirement: {worker_requirement}"
|
|
)
|
|
|
|
if worker_requirement.start_date is None:
|
|
worker_requirement.start_date = s.start_date
|
|
elif worker_requirement.start_date > s.end_date:
|
|
self.add_warning(
|
|
"Shift/worker requirement start date",
|
|
f"Worker requirement start date is after shift end date: {worker_requirement.start_date} > {s.end_date}",
|
|
)
|
|
|
|
if worker_requirement.end_date is None:
|
|
worker_requirement.end_date = s.end_date
|
|
elif worker_requirement.end_date < s.start_date:
|
|
self.add_warning(
|
|
"Shift/worker requirement end date",
|
|
f"Worker requirement end date is before shift start date: {worker_requirement.end_date} < {s.start_date}",
|
|
)
|
|
|
|
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)
|
|
|
|
for constraint in s.constraints:
|
|
# Use a safe accessor to avoid triggering pydantic __getattr__ bugs
|
|
_ensure_constraint_weeks(constraint, self)
|
|
|
|
self.shift_counts = defaultdict(int)
|
|
self.shift_worker_counts = defaultdict(int)
|
|
for week, day in self.weeks_days_product:
|
|
self.week_day_shifts_dict[(week, day)] = set()
|
|
|
|
current_date = self.get_date_by_week_day(week, day)
|
|
for s in self.shifts:
|
|
# Check if the shift has started or ended
|
|
if current_date < s.start_date or current_date > s.end_date:
|
|
continue
|
|
|
|
# Shift not required if there are no worker requirements
|
|
workers_required = s.get_worker_requirement_by_date(current_date)
|
|
if not workers_required:
|
|
self.add_warning(
|
|
"Shift/worker requirement is zero",
|
|
f"Shift '{s.name}' has no worker requirements on {current_date}",
|
|
)
|
|
continue
|
|
|
|
if s.bank_holidays_only:
|
|
if current_date in self.bank_holidays:
|
|
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
|
|
self.shift_worker_counts[s.name] = (
|
|
self.shift_worker_counts[s.name] + workers_required
|
|
)
|
|
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
|
|
self.shift_worker_counts[s.name] = (
|
|
self.shift_worker_counts[s.name] + workers_required
|
|
)
|
|
|
|
self.max_pre = 1
|
|
self.max_post = 1
|
|
for shift in self.get_shifts_with_constraint(PreShiftConstraint):
|
|
self.max_pre = max(shift.get_constraint(PreShiftConstraint).days, self.max_pre)
|
|
|
|
for shift in self.get_shifts_with_constraint(PostShiftConstraint):
|
|
self.max_post = max(shift.get_constraint(PostShiftConstraint).days, self.max_post)
|
|
|
|
# --- Check allowed_multi_shift_sets validity ---
|
|
all_shift_names = set(s.name for s in self.shifts)
|
|
for worker in self.workers:
|
|
for allowed_set in getattr(worker, "allowed_multi_shift_sets", []):
|
|
missing = set(allowed_set) - all_shift_names
|
|
if missing:
|
|
raise InvalidShift(
|
|
f"Worker '{worker.name}' has allowed_multi_shift_set(s) with non-existent shift(s): {missing}"
|
|
)
|
|
|
|
## For paired shifts we create proxy shifts
|
|
# self.paired_shift_map = {}
|
|
# for shifts in self.paired_shifts:
|
|
# sites = shifts[0].sites
|
|
# for shift in shifts:
|
|
# self.paired_shift_map[shift] = shifts
|
|
#
|
|
# if shift.sites != sites:
|
|
# # This will lead to odd/broken behaviour
|
|
# self.add_warning(
|
|
# "Paired shifts/site mismatch",
|
|
# f"Shift '{shift.name}' has different sites to paired shifts",
|
|
# )
|
|
|
|
# # 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_by_date(self, date: datetime.date) -> tuple[int, DayStr]:
|
|
"""Returns the week and day for a given date
|
|
|
|
Args:
|
|
date (datetime.date): Date to get week and day for
|
|
|
|
Returns:
|
|
tuple[int, DayStr]: Tuple containing week number and day string
|
|
"""
|
|
try:
|
|
week, day = self.date_week_day_map[date]
|
|
return week, day
|
|
except KeyError:
|
|
raise WeekDayNotFound(f"No week/day found for date {date}")
|
|
|
|
def get_week_day_combinations_for_shift(self, shift: SingleShift) -> 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, week: int | None = None) -> List[SingleShift]:
|
|
"""Returns a list of all the registered shifts
|
|
|
|
Returns:
|
|
List[SingleShift]: list of registered shifts (as SingleShift)
|
|
"""
|
|
if week is not None:
|
|
date = self.get_week_start_date(week)
|
|
|
|
shifts = []
|
|
|
|
for shift in self.shifts:
|
|
if shift.start_date <= date < shift.end_date:
|
|
if shift.get_worker_requirement_by_date(date):
|
|
shifts.append(shift)
|
|
|
|
return shifts
|
|
|
|
else:
|
|
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)
|
|
return [shift for shift in self.shifts if self.is_worker_eligible_for_shift(worker, shift)]
|
|
|
|
def get_shifts_with_constraint(self, constraint) -> List[SingleShift]:
|
|
return [shift for shift in self.shifts if shift.has_constraint(constraint)]
|
|
|
|
def get_shifts_with_constraints(
|
|
self, *constraints, week: int | None = None
|
|
) -> List[SingleShift]:
|
|
shift_names = set()
|
|
|
|
for constraint in constraints:
|
|
shift_names.update(
|
|
[
|
|
shift.name
|
|
for shift in self.get_shifts(week)
|
|
if shift.has_constraint(constraint)
|
|
]
|
|
)
|
|
|
|
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:
|
|
workers_required = shift.get_worker_requirement_by_date(
|
|
self.get_date_by_week_day(week, day)
|
|
)
|
|
# if day in shift.days:
|
|
l.append((week, day, shift.name, 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, week: int | None = None) -> List[str]:
|
|
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 worker_week_shifts_to_assign_as_blocks(self) -> List[Tuple[Worker, int, str]]:
|
|
"""Returns a list of tuples containing worker id, week and shift name for shifts to assign as blocks"""
|
|
return [
|
|
(worker, week, shift.name)
|
|
for worker in self.workers
|
|
for week in self.weeks
|
|
for shift in self.get_shifts()
|
|
if (
|
|
shift.assign_as_block
|
|
or shift.force_as_block
|
|
or shift.force_as_block_unless_nwd
|
|
or (
|
|
hasattr(worker, "assign_as_block_preferences")
|
|
and shift.name in getattr(worker, "assign_as_block_preferences", {})
|
|
and getattr(worker, "assign_as_block_preferences")[shift.name] != 0
|
|
)
|
|
)
|
|
]
|
|
|
|
def week_shifts_to_assign_as_blocks(self) -> List[Tuple[int, str]]:
|
|
"""Returns a list of tuples containing week and shift name for shifts to assign as blocks"""
|
|
return set([
|
|
(week, shift.name)
|
|
for worker in self.workers
|
|
for week in self.weeks
|
|
for shift in self.get_shifts()
|
|
if (
|
|
shift.assign_as_block
|
|
or shift.force_as_block
|
|
or shift.force_as_block_unless_nwd
|
|
or (
|
|
hasattr(worker, "assign_as_block_preferences")
|
|
and shift.name in getattr(worker, "assign_as_block_preferences", {})
|
|
and getattr(worker, "assign_as_block_preferences")[shift.name] != 0
|
|
)
|
|
)
|
|
])
|
|
|
|
def get_shift_sub_blocks(self, shift: SingleShift) -> List[List[str]]:
|
|
if getattr(shift, "force_as_block_unless_nwd", None):
|
|
val = shift.force_as_block_unless_nwd
|
|
if isinstance(val, (list, tuple)) and all(isinstance(x, (list, tuple, set)) for x in val):
|
|
return [list(x) for x in val]
|
|
return [list(shift.days)]
|
|
|
|
# Check if it is a block shift globally or per-worker
|
|
is_block = (
|
|
shift.assign_as_block
|
|
or shift.force_as_block
|
|
or any(
|
|
hasattr(worker, "assign_as_block_preferences")
|
|
and getattr(worker, "assign_as_block_preferences", {}).get(shift.name, 0) != 0
|
|
for worker in self.workers
|
|
)
|
|
)
|
|
if is_block:
|
|
return [list(shift.days)]
|
|
return []
|
|
|
|
def get_all_locum_availability(self):
|
|
return self.locum_availability_map
|
|
|
|
def get_locum_availability(self, worker: Worker, week: int, day: DayStr, shift):
|
|
if (worker.id, week, day) in self.locum_availability_map:
|
|
if shift.name in self.locum_availability_map[(worker.id, week, day)]:
|
|
return 1
|
|
return 0
|
|
|
|
def get_all_workers(self) -> List[Worker]:
|
|
return self.workers
|
|
|
|
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, list[Worker]]:
|
|
group_workers: dict[str, list[Worker]] = 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, list[Worker]]:
|
|
group_workers: dict[str, list[Worker]] = defaultdict(list)
|
|
for worker in self.workers:
|
|
group_workers[worker.remote_site].append(worker)
|
|
|
|
return group_workers
|
|
|
|
def get_workers_by_site(self, site: str) -> list[Worker]:
|
|
"""
|
|
Returns a list of workers assigned to a specific site.
|
|
|
|
Args:
|
|
site (str): The site name.
|
|
|
|
Returns:
|
|
list[Worker]: List of workers at the given site.
|
|
"""
|
|
return [worker for worker in self.workers if worker.site == site]
|
|
|
|
def get_workers_by_grade(self) -> Dict[int, list[Worker]]:
|
|
group_workers: dict[int, list[Worker]] = defaultdict(list)
|
|
for worker in self.workers:
|
|
group_workers[worker.grade].append(worker)
|
|
|
|
return group_workers
|
|
|
|
def is_worker_eligible_for_shift(self, worker: Worker, shift: SingleShift) -> bool:
|
|
"""Returns True if the worker is eligible for the shift based on site and group options."""
|
|
# 1. Group checks
|
|
worker_groups = getattr(worker, "groups", set())
|
|
if not isinstance(worker_groups, (set, list, tuple)):
|
|
worker_groups = {worker_groups} if worker_groups else set()
|
|
worker_groups_set = set(worker_groups)
|
|
|
|
# exclude_groups check: worker MUST NOT belong to any group in exclude_groups
|
|
if getattr(shift, "exclude_groups", None):
|
|
exclude_set = set(shift.exclude_groups)
|
|
if worker_groups_set.intersection(exclude_set):
|
|
return False
|
|
|
|
# Additive Site or Include Group check
|
|
has_site = worker.site in shift.sites
|
|
has_include_group = False
|
|
if getattr(shift, "include_groups", None):
|
|
include_set = set(shift.include_groups)
|
|
if worker_groups_set.intersection(include_set):
|
|
has_include_group = True
|
|
|
|
if getattr(shift, "include_groups", None):
|
|
if not (has_site or has_include_group):
|
|
return False
|
|
else:
|
|
if not has_site:
|
|
return False
|
|
|
|
return True
|
|
|
|
def get_workers_in_group(self, group_name: str) -> List[Worker]:
|
|
"""Returns a list of all workers belonging to the specified group."""
|
|
return [
|
|
w for w in self.workers
|
|
if group_name in getattr(w, "groups", set())
|
|
]
|
|
|
|
def get_workers_for_shift(self, shift: SingleShift) -> List[Worker]:
|
|
return [worker for worker in self.workers if self.is_worker_eligible_for_shift(worker, shift)]
|
|
|
|
def get_workers_total_fte(self) -> float:
|
|
"""Does not take into account shift adjusted ftes"""
|
|
return self.full_time_equivalent
|
|
|
|
def get_worker_details(self):
|
|
w: dict[str, Worker] = defaultdict(list)
|
|
worker: Worker
|
|
for worker in self.workers:
|
|
w[worker.site].append(worker)
|
|
|
|
l = []
|
|
for site in w:
|
|
l.append(f"{site}: {sum(worker.get_fte() 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_workers_who_require_locums(self):
|
|
return [worker for worker in self.get_all_workers() if worker.locum]
|
|
|
|
def get_locum_workers(self):
|
|
return [
|
|
worker
|
|
for worker in self.get_all_workers()
|
|
if not worker.locum and worker.locum_availability
|
|
]
|
|
|
|
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 self.bank_holidays
|
|
]
|
|
|
|
def get_week_start_date(self, week: int):
|
|
"""Week start date is 1 indexed"""
|
|
week = week - 1
|
|
return self.start_date + datetime.timedelta(weeks=week)
|
|
|
|
def force_assign_shift(self, worker_name: str, week: int, day: str, shift_name: str):
|
|
"""
|
|
Force a specific worker to be assigned to a shift on a specific week and day.
|
|
"""
|
|
worker = self.get_worker_by_name(worker_name)
|
|
worker.forced_assignments.append((week, day, shift_name))
|
|
|
|
def force_assign_shift_by_date(self, worker_name: str, date: datetime.date, shift_name: str):
|
|
"""
|
|
Force a specific worker to be assigned to a shift on a specific date.
|
|
"""
|
|
worker = self.get_worker_by_name(worker_name)
|
|
worker.forced_assignments_by_date.append((date, shift_name))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# RESULTS
|
|
def export_rota_to_html(
|
|
self, filename: str = "rota", folder=None, timestamp_filename=False
|
|
):
|
|
if timestamp_filename:
|
|
filename = f"{filename}_{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
|
|
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))
|
|
url = urllib.parse.quote(str(output_file.resolve()))
|
|
print(f"Rota exported [bold blue]('[link url={url}]{url}')[/bold blue]")
|
|
self.exported_rota_file = output_file
|
|
|
|
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)
|
|
|
|
l5 = ["Exact Shifts"]
|
|
l5.extend([json.dumps(getattr(worker, "exact_shifts", {})) for worker in self.workers])
|
|
wr.writerow(l5)
|
|
|
|
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_shift_timetable_by_week(self):
|
|
"""Build a timetable of the week as a dictionary from the model's optimal solution."""
|
|
works = self.model.works
|
|
shift_timetable = {
|
|
shift.name: {week: {day: [] for day in days} for week in self.weeks}
|
|
for shift in self.shifts
|
|
}
|
|
for shift in self.shifts:
|
|
for week in self.weeks:
|
|
for day in days:
|
|
assigned_workers = []
|
|
for worker in self.workers:
|
|
if works[worker.id, week, day, shift.name].value > 0.5:
|
|
assigned_workers.append(worker)
|
|
shift_timetable[shift.name][week][day] = assigned_workers
|
|
return shift_timetable
|
|
|
|
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 in days:
|
|
assigned_shifts = []
|
|
for shift in self.get_shift_names_by_week_day(week, day):
|
|
if works[worker.id, week, day, shift].value > 0.5:
|
|
assigned_shifts.append(shift)
|
|
timetable[worker.name][week][day] = assigned_shifts
|
|
return timetable
|
|
|
|
def get_assigned_shifts_for_worker_on_day(self, worker_id: int, week: int, day: DayStr) -> list:
|
|
"""Return a list of assigned SingleShift objects for the given worker on a specific week/day.
|
|
|
|
This is a small compatibility helper used by tests that expect objects rather than shift-name strings.
|
|
"""
|
|
works = self.model.works
|
|
assigned = []
|
|
for shift_name in self.get_shift_names_by_week_day(week, day, return_empty_if_week_day_not_found=True):
|
|
try:
|
|
if works[worker_id, week, day, shift_name].value > 0.5:
|
|
assigned.append(self.get_shift_by_name(shift_name))
|
|
except Exception:
|
|
# If variable missing or access error, skip
|
|
continue
|
|
return assigned
|
|
|
|
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():
|
|
shift_display_char = "-"
|
|
shift_names = []
|
|
for shift in self.get_shift_names_by_week_day(week, day):
|
|
if model.works[worker.id, week, day, shift].value > 0.5:
|
|
shift_names.append(
|
|
self.get_shift_by_name(shift).get_display_char()
|
|
)
|
|
if shift_names:
|
|
shift_display_char = "".join(shift_names)
|
|
shifts.extend(shift_names)
|
|
w.append(shift_display_char)
|
|
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}\\#"
|
|
)
|
|
|
|
# compute assigned Saturday / Sunday counts for this worker
|
|
sat_count = 0
|
|
sun_count = 0
|
|
for w, d in self.get_week_day_combinations():
|
|
if d == "Sat":
|
|
for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True):
|
|
if model.works[worker.id, w, d, s].value > 0.5:
|
|
sat_count += 1
|
|
break
|
|
if d == "Sun":
|
|
for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True):
|
|
if model.works[worker.id, w, d, s].value > 0.5:
|
|
sun_count += 1
|
|
break
|
|
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", html_static_prefix=None
|
|
):
|
|
model = self.model
|
|
|
|
# Build a lookup of per-worker/day avoid-shift rules (worker.id, week, day) -> {shifts: [...], reason: str}
|
|
avoid_lookup: dict[tuple, dict] = {}
|
|
try:
|
|
for entry in getattr(self.constraint_options_model, "avoid_shifts_by_worker_dates", []):
|
|
names = entry.get("names", []) or []
|
|
shifts = entry.get("shifts", []) or []
|
|
reason = entry.get("reason")
|
|
dates = entry.get("dates")
|
|
sd = entry.get("start_date")
|
|
ed = entry.get("end_date")
|
|
|
|
date_list = []
|
|
if dates:
|
|
date_list = dates
|
|
elif sd is not None or ed is not None:
|
|
try:
|
|
for d in self.get_date_range(sd, ed):
|
|
date_list.append(d)
|
|
except Exception:
|
|
# fall back to trying to iterate
|
|
pass
|
|
|
|
for name in names:
|
|
matching_workers = [w for w in self.workers if w.name.strip() == name.strip()]
|
|
if not matching_workers:
|
|
continue
|
|
wid = matching_workers[0].id
|
|
for d in date_list:
|
|
try:
|
|
wk, day = self.get_week_day_by_date(d)
|
|
except Exception:
|
|
continue
|
|
key = (wid, wk, day)
|
|
existing = avoid_lookup.get(key, {"shifts": [], "reason": None})
|
|
existing["shifts"].extend(shifts)
|
|
# preserve reason if provided
|
|
if reason:
|
|
existing["reason"] = reason
|
|
avoid_lookup[key] = existing
|
|
except Exception:
|
|
# If constraint options not present or parsing fails, continue without avoid info
|
|
avoid_lookup = {}
|
|
|
|
timetable = []
|
|
|
|
# Run times and heading go outside the table; store for later insertion
|
|
run_times_html = ""
|
|
if self.run_start_time is not None and self.run_end_time is not None:
|
|
run_times_html = (
|
|
"<div id='run_times_row' style='display: flex; gap: 2em; margin-bottom: 1em;'>"
|
|
f"<div id='run_start_time_cell'>Start time: <span id='run_start_time'>{self.run_start_time:%Y-%m-%d %H:%M:%S%z}</span></div>"
|
|
f"<div id='run_end_time_cell'>End time: <span id='run_end_time'>{self.run_end_time:%Y-%m-%d %H:%M:%S%z}</span></div>"
|
|
f"<div id='run_elapsed_time_cell'>Elapsed: <span id='run_elapsed_time'>{self.run_end_time - self.run_start_time}</span></div>"
|
|
"</div>"
|
|
)
|
|
|
|
heading_html = 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 self.bank_holidays:
|
|
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
|
|
# Header row (dates) should be placed in a proper thead
|
|
header_row_html = f"<thead><tr class='data-row'>{''.join(date_row)}</tr></thead>"
|
|
|
|
current_site = ""
|
|
|
|
for worker in self.get_all_workers():
|
|
if worker.site != current_site:
|
|
try:
|
|
site = worker.site
|
|
site_count = len(self.workers_at_sites.get(site, []))
|
|
timetable.append(
|
|
f"<tr><th class='site-title'>{site} n={site_count}</th></tr>"
|
|
)
|
|
except KeyError as e:
|
|
print(e)
|
|
current_site = worker.site
|
|
|
|
shifts = []
|
|
locum_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
|
|
shift_display_char = "-"
|
|
shift_names = []
|
|
assigned_shift_names = []
|
|
locum = False
|
|
shift_name = ""
|
|
force_assigned = False
|
|
|
|
for shift in self.get_shift_names_by_week_day(week, day):
|
|
shift_obj = self.get_shift_by_name(shift)
|
|
if model.works[worker.id, week, day, shift].value > 0.8:
|
|
shift_names.append(shift_obj.get_display_char())
|
|
assigned_shift_names.append(shift)
|
|
# Check if this shift was force assigned
|
|
if (week, day, shift) in getattr(worker, "forced_assignments", []):
|
|
force_assigned = True
|
|
elif self.get_workers_who_require_locums():
|
|
if model.locum_works[worker.id, week, day, shift].value > 0.8:
|
|
locum_shifts.append(shift)
|
|
shift_names.append(shift_obj.get_display_char())
|
|
assigned_shift_names.append(shift)
|
|
locum = True
|
|
# Check if this shift was force assigned
|
|
if (week, day, shift) in getattr(worker, "forced_assignments", []):
|
|
force_assigned = True
|
|
if shift_names:
|
|
shift_display_char = "<br/>".join(
|
|
[
|
|
"<span class='multi-shift-shift'>" + name + "</span>"
|
|
for name in shift_names
|
|
]
|
|
)
|
|
shifts.extend(assigned_shift_names)
|
|
shift_name = assigned_shift_names[0] if assigned_shift_names else ""
|
|
|
|
# Visual indication for multiple shifts
|
|
multi_shift = len(shift_names) > 1
|
|
css_class = day
|
|
if multi_shift:
|
|
css_class += " multi-shift"
|
|
|
|
title = f"{shift_name} ({d})"
|
|
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}"
|
|
|
|
|
|
# --- Highlight force assigned shifts ---
|
|
if force_assigned:
|
|
css_class += " force-assigned"
|
|
title = f"{title} [FORCE ASSIGNED]"
|
|
|
|
bank_holiday = ""
|
|
if d in self.bank_holidays:
|
|
title = f"{title} [Bank Holiday]"
|
|
css_class = " ".join((css_class, "bank-holiday"))
|
|
bank_holiday = f" data-bank-holiday='{self.bank_holidays[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]}'"
|
|
|
|
if (worker.id, week, day) in self.locum_availability_map:
|
|
css_class = " ".join((css_class, "locum-availability"))
|
|
title = " ".join((title, "[LOCUM AVAILABILITY]"))
|
|
requests = f" data-locum-request='{self.locum_availability_map[worker.id, week, day]}'"
|
|
|
|
if locum:
|
|
css_class = " ".join((css_class, "locum-shift"))
|
|
|
|
# Add visual marker/data if this day/worker has avoid-shift rules
|
|
avoid_info = avoid_lookup.get((worker.id, week, day))
|
|
avoid_attr = ""
|
|
if avoid_info is not None:
|
|
# mark the cell as having avoids; include which shifts and the reason
|
|
avoid_shifts = ",".join(avoid_info.get("shifts", []))
|
|
avoid_reason = avoid_info.get("reason") or ""
|
|
css_class = " ".join((css_class, "avoid-shift"))
|
|
avoid_attr = f" data-avoid-shifts='{avoid_shifts}' data-avoid-reason='{avoid_reason}'"
|
|
|
|
remote_site = ""
|
|
if shift_name:
|
|
shift = self.get_shift_by_name(shift_name)
|
|
if shift.has_constraint(RequireRemoteSitePresenceConstraint):
|
|
remote_site = f" data-shift-remote-site='{shift.get_constraint(RequireRemoteSitePresenceConstraint).site}'"
|
|
if shift.has_constraint(NightConstraint):
|
|
css_class = " ".join((css_class, "night-shift"))
|
|
|
|
title_escaped = title.replace("'", "'").replace('"', """)
|
|
shift_tds.append(
|
|
f"<td title='{title_escaped}' class='rota-day {css_class}'"
|
|
f" data-shift='{','.join(assigned_shift_names)}'"
|
|
f" data-shift-display='{','.join(shift_names)}'"
|
|
f" data-available='{available}'"
|
|
f" data-unavailable_reason='{unavailable_reason}'"
|
|
f" data-date='{d}' data-week='{week}' data-day='{day}'"
|
|
f"{remote_site}{requests}{bank_holiday}{avoid_attr}>{shift_display_char}</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}, "
|
|
|
|
locum_shift_count = ""
|
|
locum_shift_count_dict = {}
|
|
for s in set(locum_shifts):
|
|
c = locum_shifts.count(s)
|
|
locum_shift_count_dict[s] = c
|
|
locum_shift_count = locum_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
|
|
|
|
# compute assigned Saturday / Sunday counts for this worker (for HTML data attributes)
|
|
sat_count = 0
|
|
sun_count = 0
|
|
for w, d in self.get_week_day_combinations():
|
|
if d == "Sat":
|
|
for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True):
|
|
if model.works[worker.id, w, d, s].value > 0.5:
|
|
sat_count += 1
|
|
break
|
|
if d == "Sun":
|
|
for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True):
|
|
if model.works[worker.id, w, d, s].value > 0.5:
|
|
sun_count += 1
|
|
break
|
|
# Small HTML summary to be inserted inside the worker td (friendly display)
|
|
# Build a small adjustments summary from prior allocations (if any)
|
|
prior_map = {}
|
|
adjustments_list = []
|
|
for shift in self.get_shifts():
|
|
prev = getattr(worker, "previous_shifts", {}) or {}
|
|
if shift.name in prev:
|
|
entry = prev[shift.name]
|
|
# entry may be:
|
|
# - a tuple/list: (worked, allocated)
|
|
# - a dict with per-day entries (e.g., {'Sat': (w,a), 'Sun': (w,a)})
|
|
# - a dict with an '__all__' key or other buckets
|
|
if isinstance(entry, dict):
|
|
# If the dict already contains per-day keys (Sat/Sun) or an explicit
|
|
# '__all__' bucket, preserve the dict as-is so downstream code
|
|
# can render per-day adjustments. Otherwise, attempt to aggregate
|
|
# any tuple-like values found in the dict into totals.
|
|
if any(k in entry for k in ("Sat", "Sun")) or "__all__" in entry:
|
|
prior_map[shift.name] = entry
|
|
# also compute an overall adjustment for the adjustments list
|
|
try:
|
|
# compute totals by summing tuple/list values where present
|
|
worked = sum(v[0] for v in entry.values() if isinstance(v, (list, tuple)))
|
|
allocated = sum(v[1] for v in entry.values() if isinstance(v, (list, tuple)))
|
|
adj = float(allocated) - float(worked)
|
|
except Exception:
|
|
adj = 0
|
|
else:
|
|
# fallback: sum any tuple values found in the dict
|
|
try:
|
|
worked = sum(v[0] for v in entry.values() if isinstance(v, (list, tuple)))
|
|
allocated = sum(v[1] for v in entry.values() if isinstance(v, (list, tuple)))
|
|
adj = float(allocated) - float(worked)
|
|
except Exception:
|
|
worked = 0
|
|
allocated = 0
|
|
adj = 0
|
|
prior_map[shift.name] = dict(worked=worked, allocated=allocated, adjustment=adj)
|
|
else:
|
|
# tuple/list style entry
|
|
try:
|
|
worked, allocated = entry
|
|
adj = float(allocated) - float(worked)
|
|
except Exception:
|
|
worked = 0
|
|
allocated = 0
|
|
adj = 0
|
|
prior_map[shift.name] = dict(worked=worked, allocated=allocated, adjustment=adj)
|
|
|
|
if adj != 0:
|
|
adjustments_list.append(f"{shift.name}:{adj:+g}")
|
|
|
|
# If weekend prior is present as a top-level worked/allocated dict (not per-day),
|
|
# expand it into per-day entries so Sat/Sun adjustments are always available.
|
|
if 'weekend' in prior_map:
|
|
w = prior_map['weekend']
|
|
# detect the simple dict shape produced earlier: {'worked': x, 'allocated': y, 'adjustment': z}
|
|
if isinstance(w, dict) and 'worked' in w and 'allocated' in w and not ('Sat' in w or 'Sun' in w or '__all__' in w):
|
|
try:
|
|
worked = float(w.get('worked', 0))
|
|
allocated = float(w.get('allocated', 0))
|
|
# split evenly across Sat and Sun
|
|
sat_worked = worked / 2.0
|
|
sun_worked = worked - sat_worked
|
|
sat_alloc = allocated / 2.0
|
|
sun_alloc = allocated - sat_alloc
|
|
prior_map['weekend'] = {
|
|
'Sat': (sat_worked, sat_alloc),
|
|
'Sun': (sun_worked, sun_alloc),
|
|
}
|
|
except Exception:
|
|
# leave as-is if parsing fails
|
|
pass
|
|
|
|
adjustments_html = ""
|
|
if adjustments_list:
|
|
adjustments_html = (
|
|
"<div class='worker-adjustments auto-generated' style='margin-top:3px; font-size:0.85em; color:#666;'>"
|
|
+ "Adjust: "
|
|
+ ", ".join(adjustments_list)
|
|
+ "</div>"
|
|
)
|
|
|
|
# Compute per-day (Sat/Sun) adjustments for the 'weekend' shift if provided
|
|
sat_adj = None
|
|
sun_adj = None
|
|
prev_all = getattr(worker, "previous_shifts", {}) or {}
|
|
weekend_entry = prev_all.get("weekend")
|
|
if weekend_entry is not None:
|
|
try:
|
|
if isinstance(weekend_entry, dict):
|
|
if "Sat" in weekend_entry:
|
|
w_worked, w_alloc = weekend_entry["Sat"]
|
|
sat_adj = float(w_alloc) - float(w_worked)
|
|
if "Sun" in weekend_entry:
|
|
w_worked, w_alloc = weekend_entry["Sun"]
|
|
sun_adj = float(w_alloc) - float(w_worked)
|
|
if sat_adj is None and sun_adj is None and "__all__" in weekend_entry:
|
|
w_worked, w_alloc = weekend_entry["__all__"]
|
|
total = float(w_alloc) - float(w_worked)
|
|
sat_adj = sun_adj = total / 2.0
|
|
if sat_adj is None and sun_adj is None:
|
|
# fallback: sum any tuple values and split
|
|
worked_sum = sum(v[0] for v in weekend_entry.values() if isinstance(v, (list, tuple)))
|
|
alloc_sum = sum(v[1] for v in weekend_entry.values() if isinstance(v, (list, tuple)))
|
|
total = float(alloc_sum) - float(worked_sum)
|
|
sat_adj = sun_adj = total / 2.0
|
|
else:
|
|
# tuple-style entry, split evenly across Sat/Sun
|
|
w_worked, w_alloc = weekend_entry
|
|
total = float(w_alloc) - float(w_worked)
|
|
sat_adj = sun_adj = total / 2.0
|
|
except Exception:
|
|
sat_adj = sun_adj = None
|
|
|
|
def _adj_html(adj):
|
|
if adj is None:
|
|
return ""
|
|
try:
|
|
adjf = float(adj)
|
|
except Exception:
|
|
return ""
|
|
sign_text = f"{adjf:+.2f}"
|
|
# colour intensity scaled by magnitude (cap divisor at 3.0 to avoid overpowering)
|
|
mag = min(1.0, abs(adjf) / 3.0)
|
|
# ensure a minimum alpha so small adjustments remain visible
|
|
if mag < 0.12:
|
|
mag = 0.12
|
|
if adjf > 0:
|
|
bg = f"rgba(0,128,0,{mag:.2f})"
|
|
color = "#fff"
|
|
elif adjf < 0:
|
|
bg = f"rgba(196,0,0,{mag:.2f})"
|
|
color = "#fff"
|
|
else:
|
|
bg = "rgba(128,128,128,0.15)"
|
|
color = "#000"
|
|
return f" <span class='weekend-day-adjust' style='margin-left:6px; padding:1px 6px; border-radius:4px; background:{bg}; color:{color}; font-weight:600; font-size:0.85em;'>({sign_text})</span>"
|
|
|
|
sat_adj_html = _adj_html(sat_adj)
|
|
sun_adj_html = _adj_html(sun_adj)
|
|
|
|
worker_summary_html = (
|
|
f"<div class='worker-summary auto-generated' style='margin-top:4px; font-size:0.9em; color:#333;'>"
|
|
f"<span class='weekend-sat'>Sat: <strong>{sat_count}</strong>{sat_adj_html}</span>"
|
|
f" <span class='weekend-sun'>Sun: <strong>{sun_count}</strong>{sun_adj_html}</span>"
|
|
f"{adjustments_html}"
|
|
f"</div>"
|
|
)
|
|
|
|
worker_td = """<td title='Site: {site}' class='worker {site}'
|
|
data-worker-id='{worker_id}'
|
|
data-grade='{grade}'
|
|
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-oops='{oops}'
|
|
data-worker-targets='{targets}'
|
|
data-shift-counts='{worker_shift_counts}'
|
|
data-locum-shift-counts='{locum_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-weekend-sat='{weekend_sat}'
|
|
data-weekend-sun='{weekend_sun}'
|
|
data-previous-shifts='{previous_shifts}'
|
|
data-exact-shifts='{exact_shifts}'
|
|
data-shift-diff='{shift_diff}'
|
|
data-shift-diff-summed='{shift_diff_summed}'
|
|
>
|
|
<span class='name' title='{name} ({worker_id})'>{name}</span> ({grade}) [{fte}]{worker_summary_html}</td>""".format(
|
|
site=worker.site,
|
|
nwds=nwds,
|
|
worker_id=worker.id,
|
|
name=worker.name,
|
|
fte=worker.fte,
|
|
fte_adj=worker.get_fte(),
|
|
start_date=worker.calculated_start_date,
|
|
end_date=worker.calculated_end_date,
|
|
oops=json.dumps(json.dumps(worker.oop, default=str)),
|
|
targets=worker_targets,
|
|
worker_shift_counts=json.dumps(shift_count_dict),
|
|
locum_shift_counts=json.dumps(locum_shift_count_dict),
|
|
weekend_target=worker.weekend_shift_target_number,
|
|
remote_site=worker.remote_site,
|
|
grade=worker.grade,
|
|
weekend_sat=sat_count,
|
|
weekend_sun=sun_count,
|
|
worker_summary_html=worker_summary_html,
|
|
previous_shifts=json.dumps(prior_map),
|
|
exact_shifts=json.dumps(getattr(worker, "exact_shifts", {})),
|
|
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_model.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"
|
|
|
|
# Wrap the remaining rows in a tbody and prepend the thead
|
|
joined_timetable = header_row_html + "\n<tbody>\n" + "\n".join(timetable) + "\n</tbody>"
|
|
|
|
hard_constrain_shift_info = []
|
|
for worker in self.workers:
|
|
for shift_name, limits in getattr(worker, "hard_constrain_shift_limits", {}).items():
|
|
hard_constrain_shift_info.append({
|
|
"worker": worker.name,
|
|
"shift": shift_name,
|
|
**limits,
|
|
})
|
|
|
|
# Add warnings export
|
|
warnings_html = "<details><summary><h2>Warnings</h2></summary><pre>\n"
|
|
for warning_type, message in self.warnings:
|
|
warnings_html += f"{warning_type}: {message}\n"
|
|
warnings_html += "</pre></details>"
|
|
|
|
|
|
# Human-readable worker details export
|
|
worker_details_human = []
|
|
for worker in self.get_all_workers():
|
|
details = [
|
|
f"Name: {worker.name}",
|
|
f"ID: {worker.id}",
|
|
f"Site: {worker.site}",
|
|
f"Grade: {worker.grade}",
|
|
f"FTE: {worker.fte}",
|
|
f"Remote site: {getattr(worker, 'remote_site', '')}",
|
|
f"Pair: {getattr(worker, 'pair', '')}",
|
|
f"Start date: {getattr(worker, 'calculated_start_date', '')}",
|
|
f"End date: {getattr(worker, 'calculated_end_date', '')}",
|
|
f"Bank holiday extra: {getattr(worker, 'bank_holiday_extra', '')}",
|
|
f"Shift balance extra: {getattr(worker, 'shift_balance_extra', '')}",
|
|
f"Non-working days: {getattr(worker, 'non_working_day_list', '')}",
|
|
f"OOP: {getattr(worker, 'oop', '')}",
|
|
f"Unavailable: {''}",
|
|
f"Shift targets: {json.dumps(getattr(worker, 'shift_target_number', {}))}",
|
|
]
|
|
# compute Sat / Sun assigned counts for this worker
|
|
sat_count = 0
|
|
sun_count = 0
|
|
for w, d in self.get_week_day_combinations():
|
|
if d == "Sat":
|
|
for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True):
|
|
if self.model.works[worker.id, w, d, s].value > 0.5:
|
|
sat_count += 1
|
|
break
|
|
if d == "Sun":
|
|
for s in self.get_shift_names_by_week_day(w, d, return_empty_if_week_day_not_found=True):
|
|
if self.model.works[worker.id, w, d, s].value > 0.5:
|
|
sun_count += 1
|
|
break
|
|
details.insert(
|
|
-1,
|
|
f"Sat assigned: {sat_count}, Sun assigned: {sun_count}",
|
|
)
|
|
# Build unavailability list for this worker
|
|
unavail_list = []
|
|
for entry in sorted(self.unavailable_to_work):
|
|
wid, wk, d = entry
|
|
if wid != worker.id:
|
|
continue
|
|
date = self.get_date_by_week_day(wk, d)
|
|
reason = self.unavailable_to_work_reason.get((wid, wk, d), "")
|
|
if reason:
|
|
unavail_list.append(f"{date.isoformat()} ({d}) [week {wk}]: {reason}")
|
|
else:
|
|
# date may be a string if fallback used
|
|
if isinstance(date, str):
|
|
unavail_list.append(f"{date} ({d}) [week {wk}]")
|
|
else:
|
|
unavail_list.append(f"{date.isoformat()} ({d}) [week {wk}]")
|
|
|
|
# replace placeholder Unavailable entry
|
|
details = [line for line in details if not line.startswith("Unavailable:")]
|
|
details.insert(
|
|
-1, # before Shift targets
|
|
f"Unavailable: {', '.join(unavail_list) if unavail_list else ''}",
|
|
)
|
|
worker_details_human.append("\n".join(details))
|
|
worker_details_human_str = "\n\n".join(worker_details_human)
|
|
|
|
|
|
# Collect force assigned shifts for export
|
|
force_assigned_info = []
|
|
for worker in self.get_all_workers():
|
|
for assignment in getattr(worker, "forced_assignments", []):
|
|
# Support both tuple and pydantic model
|
|
if hasattr(assignment, "week") and hasattr(assignment, "day") and hasattr(assignment, "shift_name"):
|
|
week = assignment.week
|
|
day = assignment.day
|
|
shift_name = assignment.shift_name
|
|
else:
|
|
week, day, shift_name = assignment
|
|
date = self.get_date_by_week_day(week, day)
|
|
force_assigned_info.append({
|
|
"worker": worker.name,
|
|
"week": week,
|
|
"day": day,
|
|
"date": str(date),
|
|
"shift": shift_name,
|
|
})
|
|
|
|
html = f"""
|
|
<body>
|
|
<div class="table-div" id="{table_name}">
|
|
{run_times_html}
|
|
{heading_html}
|
|
<table id="main-table">{joined_timetable}</table>
|
|
</div>
|
|
{warnings_html}
|
|
<details>
|
|
<summary><h2>Rota settings</h2></summary>
|
|
<div>
|
|
<pre>
|
|
{json.dumps({k: _pydantic_to_dict(v) for k, v in self.constraint_options_model.model_dump().items()}, 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])}' data-shifts-json='{json.dumps([_pydantic_to_dict(i) for i in self.shifts])}'>
|
|
<div id="shifts-controls">
|
|
<input type="text" id="shift-filter" placeholder="Filter shifts by name" style="width:100%; margin-bottom:8px;" />
|
|
</div>
|
|
<div id="shifts-table">
|
|
{"<br/>".join(str(i) for i in self.shifts)}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
<details>
|
|
<summary><h2>Worker details</h2></summary>
|
|
|
|
<div id="worker_details" data-workers='{json.dumps([json.loads(worker.model_dump_json()) for worker in self.get_all_workers()])}'>
|
|
<span id="workers-json-target"></span>
|
|
<pre>
|
|
{worker_details_human_str}
|
|
</pre>
|
|
</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>
|
|
<details>
|
|
<summary><h2>Extra</h2></summary>
|
|
<div id="extra-options">
|
|
</div>
|
|
<div id="extra-div">
|
|
<h3>Locum availability</h3>
|
|
{"TEST"}
|
|
{self.locum_availability_map}
|
|
<br/>
|
|
<details>
|
|
<summary>Force assigned shifts</summary>
|
|
<pre>
|
|
{json.dumps(force_assigned_info, indent=4)}
|
|
</pre>
|
|
</details>
|
|
<div>
|
|
</details>
|
|
<details><summary><h2>Hard Constrain Shift Min/Max</h2></summary>
|
|
<pre>
|
|
{json.dumps(hard_constrain_shift_info, indent=4)}
|
|
"</pre></details>
|
|
|
|
</div
|
|
</body>
|
|
"""
|
|
|
|
if include_html_tag:
|
|
# html_static_prefix controls how CSS/JS are referenced:
|
|
# - If html_static_prefix is None: try to use Django STATIC_URL when running inside Django, otherwise use relative links
|
|
# - If html_static_prefix is a falsey value (e.g. empty string or False): use relative links ("timetable.css")
|
|
# - If html_static_prefix is a string: use it as the prefix (ensuring trailing slash)
|
|
static_prefix = None
|
|
if html_static_prefix is not None:
|
|
static_prefix = html_static_prefix
|
|
|
|
if static_prefix is None:
|
|
# Attempt to detect Django settings; if not present, fall back to relative links
|
|
try:
|
|
from django.conf import settings
|
|
|
|
static_prefix = getattr(settings, "STATIC_URL", "")
|
|
except Exception:
|
|
static_prefix = ""
|
|
|
|
# If a prefix is provided and not empty, ensure trailing slash
|
|
if isinstance(static_prefix, str) and static_prefix:
|
|
if not static_prefix.endswith("/"):
|
|
static_prefix = static_prefix + "/"
|
|
|
|
if static_prefix:
|
|
css_href = f"{static_prefix}timetable.css"
|
|
js_src = f"{static_prefix}timetable.js"
|
|
else:
|
|
# Relative links for standalone usage
|
|
css_href = "timetable.css"
|
|
js_src = "timetable.js"
|
|
|
|
html = f"""<html>
|
|
<head>
|
|
<link rel="stylesheet" type="text/css" href="{css_href}">
|
|
<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="{js_src}" 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] = round(
|
|
self.model.shift_count[worker.id, shift].value
|
|
)
|
|
|
|
return timetable
|
|
|
|
def get_worker_shifts_by_date(self, worker: Worker) -> Dict[datetime.date, str]:
|
|
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.5:
|
|
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_shift_worker_list(self, shift_name: str) -> List[set[Worker]]:
|
|
"""
|
|
Returns a list of workers assigned to a specific shift.
|
|
|
|
Args:
|
|
shift_name (str): The name of the shift.
|
|
|
|
Returns:
|
|
List[Worker]: List of workers assigned to the specified shift.
|
|
"""
|
|
shift_list = []
|
|
for week, day in self.get_week_day_combinations():
|
|
workers: set = set()
|
|
for worker in self.workers:
|
|
if self.model.works[worker.id, week, day, shift_name].value > 0.5:
|
|
workers.add(worker)
|
|
|
|
shift_list.append(workers)
|
|
|
|
return shift_list
|
|
|
|
|
|
def get_worker_shift_list(self, worker: Worker, include_locums=False, search_multiple_assignments=False) -> List:
|
|
"""
|
|
This function returns a list of shifts assigned to a worker for each week and day.
|
|
|
|
It was originally designed to return a list of shift names (when only a single shift per day was supported), but it can also return a set of shift names if `search_multiple_assignments` is True.
|
|
"""
|
|
shifts = []
|
|
|
|
for week, day in self.get_week_day_combinations():
|
|
# d = self.start_date + datetime.timedelta(n)
|
|
# n = n + 1
|
|
|
|
if search_multiple_assignments:
|
|
temp = set()
|
|
else:
|
|
temp = ""
|
|
for shift in self.get_shift_names_by_week_day(week, day):
|
|
if self.model.works[worker.id, week, day, shift].value > 0.90:
|
|
if search_multiple_assignments:
|
|
temp.add(shift)
|
|
else:
|
|
temp = shift
|
|
elif (
|
|
include_locums
|
|
and self.model.locum_works[worker.id, week, day, shift].value > 0.90
|
|
):
|
|
if search_multiple_assignments:
|
|
temp.add(shift)
|
|
else:
|
|
temp = shift
|
|
shifts.append(temp)
|
|
|
|
return shifts
|
|
|
|
def get_worker_shift_count(self, worker: Worker, shift_name: str) -> int:
|
|
"""Returns the number of shifts of a specific type assigned to a worker."""
|
|
count = 0
|
|
for week, day in self.get_week_day_combinations():
|
|
if shift_name in self.get_shift_names_by_week_day(week, day):
|
|
if self.model.works[worker.id, week, day, shift_name].value > 0.5:
|
|
count += 1
|
|
return count
|
|
|
|
def get_worker_days_worked(self, worker: Worker) -> int:
|
|
"""
|
|
Returns the number of unique days on which the worker is assigned at least one shift.
|
|
"""
|
|
days_worked = 0
|
|
for week, day in self.get_week_day_combinations():
|
|
for shift in self.get_shift_names_by_week_day(week, day):
|
|
if self.model.works[worker.id, week, day, shift].value > 0.5:
|
|
days_worked += 1
|
|
break # Only count each day once, even if multiple shifts
|
|
return days_worked
|
|
|
|
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(
|
|
[
|
|
self.get_shift_by_name(i).get_display_char() 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
|
|
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.get_display_char(): "" for shift in self.get_shifts()
|
|
}
|
|
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
|
|
shift_obj = self.get_shift_by_name(shift)
|
|
timetable[worker.get_details()][shift_obj.get_display_char()] = 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
|
|
|
|
|
|
class WarningTermination(Exception):
|
|
"""Raised when a warning in the termination group is raised"""
|
|
|
|
pass
|
|
|
|
class WeekDayNotFound(Exception):
|
|
"""Raised when a week/day combination is not found"""
|
|
|
|
pass |