add worker requirements
This commit is contained in:
@@ -2,7 +2,7 @@ import datetime
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days
|
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WorkerRequirement, days
|
||||||
import typer
|
import typer
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ from rota.workers import (
|
|||||||
OutOfProgramme,
|
OutOfProgramme,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def main(
|
def main(
|
||||||
suspend_on_finish: bool = False,
|
suspend_on_finish: bool = False,
|
||||||
@@ -40,11 +41,13 @@ def main(
|
|||||||
weeks: int = 26,
|
weeks: int = 26,
|
||||||
bom: int = 1,
|
bom: int = 1,
|
||||||
):
|
):
|
||||||
|
|
||||||
rota_start_date = start_date.date()
|
rota_start_date = start_date.date()
|
||||||
|
|
||||||
Rota = RotaBuilder(
|
Rota = RotaBuilder(
|
||||||
rota_start_date, weeks_to_rota=weeks, balance_offset_modifier=bom, name="proc_rota"
|
rota_start_date,
|
||||||
|
weeks_to_rota=weeks,
|
||||||
|
balance_offset_modifier=bom,
|
||||||
|
name="proc_rota",
|
||||||
)
|
)
|
||||||
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
|
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
|
||||||
Rota.constraint_options["balance_weekends"] = True
|
Rota.constraint_options["balance_weekends"] = True
|
||||||
@@ -54,6 +57,17 @@ def main(
|
|||||||
Rota.constraint_options["hard_constrain_pair_separation"] = True
|
Rota.constraint_options["hard_constrain_pair_separation"] = True
|
||||||
# Rota.constraint_options["avoid_st2_first_month"] = True
|
# Rota.constraint_options["avoid_st2_first_month"] = True
|
||||||
|
|
||||||
|
wr = [
|
||||||
|
WorkerRequirement(
|
||||||
|
end_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
||||||
|
number=3,
|
||||||
|
),
|
||||||
|
WorkerRequirement(
|
||||||
|
start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
||||||
|
number=4,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
Rota.add_shifts(
|
Rota.add_shifts(
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=(
|
sites=(
|
||||||
@@ -137,8 +151,8 @@ def main(
|
|||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[4:],
|
days=days[4:],
|
||||||
balance_offset=3,
|
balance_offset=3,
|
||||||
#rota_on_nwds=True,
|
# rota_on_nwds=True,
|
||||||
#force_as_block=True,
|
# force_as_block=True,
|
||||||
assign_as_block=True,
|
assign_as_block=True,
|
||||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
||||||
# force_as_block_unless_nwd=True
|
# force_as_block_unless_nwd=True
|
||||||
@@ -209,7 +223,7 @@ def main(
|
|||||||
balance_offset=3.9,
|
balance_offset=3.9,
|
||||||
balance_weighting=1,
|
balance_weighting=1,
|
||||||
# hard_constrain_shift=False,
|
# hard_constrain_shift=False,
|
||||||
workers_required=3,
|
workers_required=wr,
|
||||||
force_as_block=True,
|
force_as_block=True,
|
||||||
rota_on_nwds=True,
|
rota_on_nwds=True,
|
||||||
constraint=[
|
constraint=[
|
||||||
@@ -223,7 +237,7 @@ def main(
|
|||||||
{"name": "limit_grade_number", "options": {2: 1}},
|
{"name": "limit_grade_number", "options": {2: 1}},
|
||||||
{"name": "minimum_grade_number", "options": (4, 1)},
|
{"name": "minimum_grade_number", "options": (4, 1)},
|
||||||
],
|
],
|
||||||
end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=[
|
sites=[
|
||||||
@@ -239,7 +253,7 @@ def main(
|
|||||||
balance_offset=2.9,
|
balance_offset=2.9,
|
||||||
balance_weighting=1,
|
balance_weighting=1,
|
||||||
# hard_constrain_shift=False,
|
# hard_constrain_shift=False,
|
||||||
workers_required=3,
|
workers_required=wr,
|
||||||
force_as_block=True,
|
force_as_block=True,
|
||||||
rota_on_nwds=True,
|
rota_on_nwds=True,
|
||||||
constraint=[
|
constraint=[
|
||||||
@@ -253,62 +267,62 @@ def main(
|
|||||||
{"name": "limit_grade_number", "options": {2: 1}},
|
{"name": "limit_grade_number", "options": {2: 1}},
|
||||||
{"name": "minimum_grade_number", "options": (4, 1)},
|
{"name": "minimum_grade_number", "options": (4, 1)},
|
||||||
],
|
],
|
||||||
end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
||||||
),
|
|
||||||
SingleShift(
|
|
||||||
sites=sites,
|
|
||||||
name="night_weekday4",
|
|
||||||
length=12.25,
|
|
||||||
days=days[:4],
|
|
||||||
balance_offset=3.9,
|
|
||||||
balance_weighting=1,
|
|
||||||
# hard_constrain_shift=False,
|
|
||||||
workers_required=NIGHT_REGISTRAR_NUMBER,
|
|
||||||
force_as_block=True,
|
|
||||||
rota_on_nwds=True,
|
|
||||||
constraint=[
|
|
||||||
{"name": "night"},
|
|
||||||
{"name": "pre", "options": 2},
|
|
||||||
{"name": "post", "options": 2},
|
|
||||||
{
|
|
||||||
"name": "require_remote_site_presence_week",
|
|
||||||
"options": ("plymouth", 1),
|
|
||||||
},
|
|
||||||
{"name": "limit_grade_number", "options": {2: 1}},
|
|
||||||
{"name": "minimum_grade_number", "options": (4, 1)},
|
|
||||||
],
|
|
||||||
start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
|
||||||
),
|
|
||||||
SingleShift(
|
|
||||||
sites=[
|
|
||||||
*sites,
|
|
||||||
"plymouth twilights, weekends and weekend nights",
|
|
||||||
"truro twilights and weekend nights",
|
|
||||||
"weekend nights",
|
|
||||||
"truro twilights, weekends and weekend nights",
|
|
||||||
],
|
|
||||||
name="night_weekend4",
|
|
||||||
length=12.25,
|
|
||||||
days=days[4:],
|
|
||||||
balance_offset=2.9,
|
|
||||||
balance_weighting=1,
|
|
||||||
# hard_constrain_shift=False,
|
|
||||||
workers_required=NIGHT_REGISTRAR_NUMBER,
|
|
||||||
force_as_block=True,
|
|
||||||
rota_on_nwds=True,
|
|
||||||
constraint=[
|
|
||||||
{"name": "night"},
|
|
||||||
{"name": "pre", "options": 2},
|
|
||||||
{"name": "post", "options": 3},
|
|
||||||
{
|
|
||||||
"name": "require_remote_site_presence_week",
|
|
||||||
"options": ("plymouth", 1),
|
|
||||||
},
|
|
||||||
{"name": "limit_grade_number", "options": {2: 1}},
|
|
||||||
{"name": "minimum_grade_number", "options": (4, 1)},
|
|
||||||
],
|
|
||||||
start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
|
||||||
),
|
),
|
||||||
|
# SingleShift(
|
||||||
|
# sites=sites,
|
||||||
|
# name="night_weekday4",
|
||||||
|
# length=12.25,
|
||||||
|
# days=days[:4],
|
||||||
|
# balance_offset=3.9,
|
||||||
|
# balance_weighting=1,
|
||||||
|
# # hard_constrain_shift=False,
|
||||||
|
# workers_required=NIGHT_REGISTRAR_NUMBER,
|
||||||
|
# force_as_block=True,
|
||||||
|
# rota_on_nwds=True,
|
||||||
|
# constraint=[
|
||||||
|
# {"name": "night"},
|
||||||
|
# {"name": "pre", "options": 2},
|
||||||
|
# {"name": "post", "options": 2},
|
||||||
|
# {
|
||||||
|
# "name": "require_remote_site_presence_week",
|
||||||
|
# "options": ("plymouth", 1),
|
||||||
|
# },
|
||||||
|
# {"name": "limit_grade_number", "options": {2: 1}},
|
||||||
|
# {"name": "minimum_grade_number", "options": (4, 1)},
|
||||||
|
# ],
|
||||||
|
# start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
||||||
|
# ),
|
||||||
|
# SingleShift(
|
||||||
|
# sites=[
|
||||||
|
# *sites,
|
||||||
|
# "plymouth twilights, weekends and weekend nights",
|
||||||
|
# "truro twilights and weekend nights",
|
||||||
|
# "weekend nights",
|
||||||
|
# "truro twilights, weekends and weekend nights",
|
||||||
|
# ],
|
||||||
|
# name="night_weekend4",
|
||||||
|
# length=12.25,
|
||||||
|
# days=days[4:],
|
||||||
|
# balance_offset=2.9,
|
||||||
|
# balance_weighting=1,
|
||||||
|
# # hard_constrain_shift=False,
|
||||||
|
# workers_required=NIGHT_REGISTRAR_NUMBER,
|
||||||
|
# force_as_block=True,
|
||||||
|
# rota_on_nwds=True,
|
||||||
|
# constraint=[
|
||||||
|
# {"name": "night"},
|
||||||
|
# {"name": "pre", "options": 2},
|
||||||
|
# {"name": "post", "options": 3},
|
||||||
|
# {
|
||||||
|
# "name": "require_remote_site_presence_week",
|
||||||
|
# "options": ("plymouth", 1),
|
||||||
|
# },
|
||||||
|
# {"name": "limit_grade_number", "options": {2: 1}},
|
||||||
|
# {"name": "minimum_grade_number", "options": (4, 1)},
|
||||||
|
# ],
|
||||||
|
# start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
||||||
|
# ),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Rota.add_grade_constraint_by_week([2], [1, 2], ["night_weekday", "night_weekend"])
|
# Rota.add_grade_constraint_by_week([2], [1, 2], ["night_weekday", "night_weekend"])
|
||||||
@@ -381,7 +395,7 @@ def main(
|
|||||||
elif "to" in dates:
|
elif "to" in dates:
|
||||||
s, e = dates.split(" to ")
|
s, e = dates.split(" to ")
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Cannot parse OOP dates: {dates}")
|
raise ValueError(f"Cannot parse OOP dates: '{oop}' for {worker}")
|
||||||
try:
|
try:
|
||||||
formatted_oops.append(
|
formatted_oops.append(
|
||||||
{
|
{
|
||||||
@@ -474,13 +488,13 @@ def main(
|
|||||||
# Rota.build_model()
|
# Rota.build_model()
|
||||||
|
|
||||||
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
|
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
|
||||||
#solver_options = {"seconds": time_to_run, "threads": 10}
|
# solver_options = {"seconds": time_to_run, "threads": 10}
|
||||||
|
|
||||||
# start_time = time.time()
|
# start_time = time.time()
|
||||||
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs")
|
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs")
|
||||||
|
|
||||||
#Rota.solve_shifts_by_block(solver_options, block_length=13)
|
# Rota.solve_shifts_by_block(solver_options, block_length=13)
|
||||||
#Rota.solve_shifts_individually(solver_options)
|
# Rota.solve_shifts_individually(solver_options)
|
||||||
# Rota.solve_model(options=solver_options)
|
# Rota.solve_model(options=solver_options)
|
||||||
# end_time = time.time()
|
# end_time = time.time()
|
||||||
|
|
||||||
@@ -503,7 +517,9 @@ def main(
|
|||||||
|
|
||||||
# Rota.export_rota_to_html("proc_rota")
|
# Rota.export_rota_to_html("proc_rota")
|
||||||
# Rota.export_rota_to_csv("rota")
|
# Rota.export_rota_to_csv("rota")
|
||||||
subprocess.run(["scp", Rota.exported_rota_file, "ross@46.101.13.46:proc/proc-rota/output/"])
|
subprocess.run(
|
||||||
|
["scp", Rota.exported_rota_file, "ross@46.101.13.46:proc/proc-rota/output/"]
|
||||||
|
)
|
||||||
|
|
||||||
if suspend_on_finish:
|
if suspend_on_finish:
|
||||||
os.system("systemctl suspend")
|
os.system("systemctl suspend")
|
||||||
|
|||||||
+171
-64
@@ -4,7 +4,7 @@ import itertools
|
|||||||
from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type, Literal
|
from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type, Literal
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Extra, constr
|
from pydantic import BaseModel, ConfigDict, Extra, constr, model_validator, StrictInt
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -78,6 +78,23 @@ class ShiftConstraint(BaseModel):
|
|||||||
options: bool | int | Dict | tuple = False
|
options: bool | int | Dict | tuple = False
|
||||||
|
|
||||||
|
|
||||||
|
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 SingleShift(BaseModel):
|
||||||
"""Class to hold all details for a shift
|
"""Class to hold all details for a shift
|
||||||
|
|
||||||
@@ -112,24 +129,24 @@ class SingleShift(BaseModel):
|
|||||||
sites: List[str]
|
sites: List[str]
|
||||||
name: str
|
name: str
|
||||||
length: float
|
length: float
|
||||||
days: str | List[str]
|
days: str | list[str]
|
||||||
balance_offset: float | None = None
|
balance_offset: float | None = None
|
||||||
balance_weighting: float = 1
|
balance_weighting: float = 1
|
||||||
workers_required: float = 1
|
workers_required: StrictInt | list[WorkerRequirement] = 1
|
||||||
rota_on_nwds: bool = False
|
rota_on_nwds: bool = False
|
||||||
assign_as_block: bool = False
|
assign_as_block: bool = False
|
||||||
force_as_block: bool = False
|
force_as_block: bool = False
|
||||||
force_as_block_unless_nwd: bool = False
|
force_as_block_unless_nwd: bool = False
|
||||||
hard_constrain_shift: bool = True
|
hard_constrain_shift: bool = True
|
||||||
bank_holidays_only: bool = False
|
bank_holidays_only: bool = False
|
||||||
constraint: List[ShiftConstraint] = []
|
constraint: list[ShiftConstraint] = []
|
||||||
start_date: datetime.date | None = None
|
start_date: datetime.date | None = None
|
||||||
end_date: datetime.date | None = None
|
end_date: datetime.date | None = None
|
||||||
pair_proxy: bool = False
|
pair_proxy: bool = False
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
extra = "allow",
|
extra="allow",
|
||||||
#orm_mode = True
|
# orm_mode = True
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, **data: Any):
|
def __init__(self, **data: Any):
|
||||||
@@ -182,6 +199,21 @@ class SingleShift(BaseModel):
|
|||||||
def get_shift_number(self):
|
def get_shift_number(self):
|
||||||
return len(self.days)
|
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:
|
||||||
|
if wr.start_date <= date < wr.end_date:
|
||||||
|
return wr.number
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
class RotaBuilder(object):
|
class RotaBuilder(object):
|
||||||
"""Class to hold and manipulate shifts"""
|
"""Class to hold and manipulate shifts"""
|
||||||
@@ -263,7 +295,7 @@ class RotaBuilder(object):
|
|||||||
"Worker/duplicate name",
|
"Worker/duplicate name",
|
||||||
"Worker/no valid shifts",
|
"Worker/no valid shifts",
|
||||||
"Shift/invalid start date",
|
"Shift/invalid start date",
|
||||||
"Shift/invalid end date"
|
"Shift/invalid end date",
|
||||||
]
|
]
|
||||||
|
|
||||||
self.results = None
|
self.results = None
|
||||||
@@ -370,8 +402,7 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
not results.solver.termination_condition == TerminationCondition.infeasible
|
not results.solver.termination_condition == TerminationCondition.infeasible
|
||||||
|
# and not results.solver.status == SolverStatus.aborted
|
||||||
#and not results.solver.status == SolverStatus.aborted
|
|
||||||
):
|
):
|
||||||
self.model.solutions.load_from(results)
|
self.model.solutions.load_from(results)
|
||||||
|
|
||||||
@@ -1119,12 +1150,17 @@ class RotaBuilder(object):
|
|||||||
shift = self.get_shift_by_name(shift_name)
|
shift = self.get_shift_by_name(shift_name)
|
||||||
|
|
||||||
print(shift_name)
|
print(shift_name)
|
||||||
print(week, self.get_week_start_date(week), shift.start_date, shift.end_date)
|
print(
|
||||||
|
week,
|
||||||
|
self.get_week_start_date(week),
|
||||||
|
shift.start_date,
|
||||||
|
shift.end_date,
|
||||||
|
)
|
||||||
|
|
||||||
#if self.get_week_start_date(week) <= shift.start_date:
|
# if self.get_week_start_date(week) <= shift.start_date:
|
||||||
# continue
|
# continue
|
||||||
|
|
||||||
#if self.get_week_start_date(week) > shift.end_date:
|
# if self.get_week_start_date(week) > shift.end_date:
|
||||||
# continue
|
# continue
|
||||||
|
|
||||||
for worker in self.get_workers_for_shift(shift):
|
for worker in self.get_workers_for_shift(shift):
|
||||||
@@ -1151,6 +1187,10 @@ class RotaBuilder(object):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
workers_required = shift.get_worker_requirement_by_date(
|
||||||
|
self.get_week_start_date(week)
|
||||||
|
)
|
||||||
|
|
||||||
if shift.force_as_block_unless_nwd:
|
if shift.force_as_block_unless_nwd:
|
||||||
workers = self.get_workers_for_shift(shift)
|
workers = self.get_workers_for_shift(shift)
|
||||||
# Get workers who have a nwd on the shift
|
# Get workers who have a nwd on the shift
|
||||||
@@ -1188,7 +1228,7 @@ class RotaBuilder(object):
|
|||||||
]
|
]
|
||||||
for worker in self.workers
|
for worker in self.workers
|
||||||
)
|
)
|
||||||
<= shift.workers_required
|
<= workers_required
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -1199,19 +1239,20 @@ class RotaBuilder(object):
|
|||||||
]
|
]
|
||||||
for worker in full_workers
|
for worker in full_workers
|
||||||
)
|
)
|
||||||
<= shift.workers_required + 1
|
<= workers_required + 1
|
||||||
)
|
)
|
||||||
|
|
||||||
elif shift.force_as_block:
|
elif shift.force_as_block:
|
||||||
self.model.constraints.add(
|
if workers_required:
|
||||||
sum(
|
self.model.constraints.add(
|
||||||
self.model.blocks_worker_shift_assigned[
|
sum(
|
||||||
worker.id, week, shift.name
|
self.model.blocks_worker_shift_assigned[
|
||||||
]
|
worker.id, week, shift.name
|
||||||
for worker in self.workers
|
]
|
||||||
)
|
for worker in self.workers
|
||||||
<= shift.workers_required
|
)
|
||||||
)
|
<= workers_required
|
||||||
|
)
|
||||||
|
|
||||||
# Most of our constraints apply per worker
|
# Most of our constraints apply per worker
|
||||||
worker: Worker
|
worker: Worker
|
||||||
@@ -1246,7 +1287,7 @@ class RotaBuilder(object):
|
|||||||
# TODO: test if this breaks (and we should check rathar than except)
|
# TODO: test if this breaks (and we should check rathar than except)
|
||||||
self.add_warning(
|
self.add_warning(
|
||||||
"max shifts per month constraint",
|
"max shifts per month constraint",
|
||||||
f"Failed to constrain max_shifts_per_month for worker: {worker.name}"
|
f"Failed to constrain max_shifts_per_month for worker: {worker.name}",
|
||||||
)
|
)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1326,8 +1367,12 @@ class RotaBuilder(object):
|
|||||||
): # Each site specfies which sites self.workers can fullfill it
|
): # Each site specfies which sites self.workers can fullfill it
|
||||||
# calaculate the total number of shifts that need assigning over the rota
|
# calaculate the total number of shifts that need assigning over the rota
|
||||||
# total_shifts = (len(self.weeks) * len(shift.days) *
|
# total_shifts = (len(self.weeks) * len(shift.days) *
|
||||||
|
# TODO: check if shift_counts is still required?
|
||||||
|
#total_shifts = (
|
||||||
|
# self.shift_counts[shift.name] * shift.workers_required
|
||||||
|
#)
|
||||||
total_shifts = (
|
total_shifts = (
|
||||||
self.shift_counts[shift.name] * shift.workers_required
|
self.shift_worker_counts[shift.name]
|
||||||
)
|
)
|
||||||
|
|
||||||
full_time_equivalent_joined = sum(
|
full_time_equivalent_joined = sum(
|
||||||
@@ -1736,7 +1781,9 @@ class RotaBuilder(object):
|
|||||||
self.model.constraints.add(
|
self.model.constraints.add(
|
||||||
constraint_shift.constraint_options["max_shifts_per_week"]
|
constraint_shift.constraint_options["max_shifts_per_week"]
|
||||||
>= sum(
|
>= sum(
|
||||||
self.model.works[worker.id, w, day, constraint_shift.name]
|
self.model.works[
|
||||||
|
worker.id, w, day, constraint_shift.name
|
||||||
|
]
|
||||||
# for shiftname in self.get_shift_names_by_week_day(week, day)
|
# for shiftname in self.get_shift_names_by_week_day(week, day)
|
||||||
# for day in self.days
|
# for day in self.days
|
||||||
for w, day in self.get_week_day_combinations_for_shift(
|
for w, day in self.get_week_day_combinations_for_shift(
|
||||||
@@ -1938,21 +1985,24 @@ class RotaBuilder(object):
|
|||||||
# for shift in self.get_shifts_with_constraint("night"):
|
# for shift in self.get_shifts_with_constraint("night"):
|
||||||
for shift in self.get_shifts(week=week):
|
for shift in self.get_shifts(week=week):
|
||||||
if shift.force_as_block:
|
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
|
if shift.get_worker_requirement_by_date(
|
||||||
self.model.constraints.add(
|
self.get_week_start_date(week)
|
||||||
self.model.shift_week_worker_assigned[
|
):
|
||||||
shift.name, week, worker.id
|
# Force shifts to be assigned in blocks
|
||||||
]
|
try:
|
||||||
* len(shift.days)
|
self.model.constraints.add(
|
||||||
== sum(
|
self.model.shift_week_worker_assigned[
|
||||||
self.model.works[worker.id, week, day, shift.name]
|
shift.name, week, worker.id
|
||||||
for day in shift.days
|
]
|
||||||
)
|
* len(shift.days)
|
||||||
)
|
== sum(
|
||||||
|
self.model.works[worker.id, week, day, shift.name]
|
||||||
|
for day in shift.days
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
pass # blocks with workerd requirements
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||
@@ -2167,13 +2217,13 @@ class RotaBuilder(object):
|
|||||||
# shift spans 7 days you may get >7 allocations in a row
|
# shift spans 7 days you may get >7 allocations in a row
|
||||||
# as it only checks for a different shift allocation
|
# as it only checks for a different shift allocation
|
||||||
for constraint_shift in self.get_shifts_with_constraints(
|
for constraint_shift in self.get_shifts_with_constraints(
|
||||||
"pre"#, week=week
|
"pre" # , week=week
|
||||||
):
|
):
|
||||||
for n in range(0, constraint_shift.constraint_options["pre"]):
|
for n in range(0, constraint_shift.constraint_options["pre"]):
|
||||||
if day in constraint_shift.days:
|
if day in constraint_shift.days:
|
||||||
#week_start_date = self.get_week_start_date(week)
|
# week_start_date = self.get_week_start_date(week)
|
||||||
#try:
|
# try:
|
||||||
#if constraint_shift.start_date <= week_start_date < constraint_shift.end_date:
|
# if constraint_shift.start_date <= week_start_date < constraint_shift.end_date:
|
||||||
# works = 1
|
# works = 1
|
||||||
try:
|
try:
|
||||||
works = self.model.works[
|
works = self.model.works[
|
||||||
@@ -2223,14 +2273,14 @@ class RotaBuilder(object):
|
|||||||
# )
|
# )
|
||||||
|
|
||||||
for constraint_shift in self.get_shifts_with_constraints(
|
for constraint_shift in self.get_shifts_with_constraints(
|
||||||
"post"#, week=week
|
"post" # , week=week
|
||||||
):
|
):
|
||||||
for n in range(0, constraint_shift.constraint_options["post"]):
|
for n in range(0, constraint_shift.constraint_options["post"]):
|
||||||
if day in constraint_shift.days:
|
if day in constraint_shift.days:
|
||||||
#week_start_date = self.get_week_start_date(week)
|
# week_start_date = self.get_week_start_date(week)
|
||||||
#if constraint_shift.start_date <= week_start_date < constraint_shift.end_date:
|
# if constraint_shift.start_date <= week_start_date < constraint_shift.end_date:
|
||||||
#works = 1
|
# works = 1
|
||||||
#else:
|
# else:
|
||||||
try:
|
try:
|
||||||
works = self.model.works[
|
works = self.model.works[
|
||||||
worker.id, week, day, constraint_shift.name
|
worker.id, week, day, constraint_shift.name
|
||||||
@@ -2254,7 +2304,7 @@ class RotaBuilder(object):
|
|||||||
for w in workers
|
for w in workers
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
#except KeyError:
|
# except KeyError:
|
||||||
# pass
|
# pass
|
||||||
|
|
||||||
# for constraint_shift in self.get_shifts_with_constraints(
|
# for constraint_shift in self.get_shifts_with_constraints(
|
||||||
@@ -2688,10 +2738,15 @@ class RotaBuilder(object):
|
|||||||
"""
|
"""
|
||||||
return self.shifts_by_name[name].length
|
return self.shifts_by_name[name].length
|
||||||
|
|
||||||
def pair_shifts(self, shift1: SingleShift, shift2: SingleShift):
|
def get_date_by_week_day(self, week: WeekInt, day: DayStr) -> datetime.date:
|
||||||
|
return self.week_day_date_map[(week, day)]
|
||||||
|
|
||||||
|
def pair_shifts(self, *shifts: SingleShift):
|
||||||
# NOTE: currently designed to pair two shifts
|
# NOTE: currently designed to pair two shifts
|
||||||
# this could be extended...
|
# this could be extended...
|
||||||
self.paired_shifts.append([shift1, shift2])
|
if len(shifts) < 2:
|
||||||
|
raise ValueError(f"Must pair at least two shifts: {shifts}")
|
||||||
|
self.paired_shifts.append([*shifts])
|
||||||
|
|
||||||
def build_shifts(self):
|
def build_shifts(self):
|
||||||
"""
|
"""
|
||||||
@@ -2711,11 +2766,6 @@ class RotaBuilder(object):
|
|||||||
for s in self.shifts:
|
for s in self.shifts:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
## For paired shifts we create proxy shifts
|
|
||||||
#self.paired_shift_map = {}
|
|
||||||
#for shifts in self.paired_shifts:
|
|
||||||
|
|
||||||
|
|
||||||
for s in self.shifts:
|
for s in self.shifts:
|
||||||
if s.name in self.shift_names:
|
if s.name in self.shift_names:
|
||||||
raise InvalidShift(f"Duplicate shift: {s.name}")
|
raise InvalidShift(f"Duplicate shift: {s.name}")
|
||||||
@@ -2738,6 +2788,33 @@ class RotaBuilder(object):
|
|||||||
else:
|
else:
|
||||||
s.end_date = self.rota_end_date
|
s.end_date = self.rota_end_date
|
||||||
|
|
||||||
|
# Check worker requirements
|
||||||
|
if not isinstance(s.workers_required, int):
|
||||||
|
# Validate the worker requirements
|
||||||
|
print(s.workers_required)
|
||||||
|
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.shifts_by_name[s.name] = s
|
||||||
self.shift_names.append(s.name)
|
self.shift_names.append(s.name)
|
||||||
|
|
||||||
@@ -2748,27 +2825,39 @@ class RotaBuilder(object):
|
|||||||
self.sites.add(site)
|
self.sites.add(site)
|
||||||
|
|
||||||
self.shift_counts = defaultdict(int)
|
self.shift_counts = defaultdict(int)
|
||||||
|
self.shift_worker_counts = defaultdict(int)
|
||||||
for week, day in self.weeks_days_product:
|
for week, day in self.weeks_days_product:
|
||||||
self.week_day_shifts_dict[(week, day)] = set()
|
self.week_day_shifts_dict[(week, day)] = set()
|
||||||
|
|
||||||
current_date = self.week_day_date_map[(week, day)]
|
current_date = self.get_date_by_week_day(week, day)
|
||||||
for s in self.shifts:
|
for s in self.shifts:
|
||||||
# Check if the shift has started or ended
|
# Check if the shift has started or ended
|
||||||
if current_date < s.start_date or current_date > s.end_date:
|
if current_date < s.start_date or current_date > s.end_date:
|
||||||
continue
|
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 s.bank_holidays_only:
|
||||||
if current_date in self.bank_holidays:
|
if current_date in self.bank_holidays:
|
||||||
self.week_day_shift_product.append((week, day, s.name))
|
self.week_day_shift_product.append((week, day, s.name))
|
||||||
self.week_day_shiftclass_product.append((week, day, s))
|
self.week_day_shiftclass_product.append((week, day, s))
|
||||||
self.week_day_shifts_dict[(week, day)].add(s.name)
|
self.week_day_shifts_dict[(week, day)].add(s.name)
|
||||||
self.shift_counts[s.name] = self.shift_counts[s.name] + 1
|
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:
|
else:
|
||||||
if day in s.days:
|
if day in s.days:
|
||||||
self.week_day_shift_product.append((week, day, s.name))
|
self.week_day_shift_product.append((week, day, s.name))
|
||||||
self.week_day_shiftclass_product.append((week, day, s))
|
self.week_day_shiftclass_product.append((week, day, s))
|
||||||
self.week_day_shifts_dict[(week, day)].add(s.name)
|
self.week_day_shifts_dict[(week, day)].add(s.name)
|
||||||
self.shift_counts[s.name] = self.shift_counts[s.name] + 1
|
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_pre = 1
|
||||||
self.max_post = 1
|
self.max_post = 1
|
||||||
@@ -2778,6 +2867,19 @@ class RotaBuilder(object):
|
|||||||
for shift in self.get_shifts_with_constraint("post"):
|
for shift in self.get_shifts_with_constraint("post"):
|
||||||
self.max_post = max(shift.constraint_options["post"], self.max_post)
|
self.max_post = max(shift.constraint_options["post"], self.max_post)
|
||||||
|
|
||||||
|
## 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.....
|
# # Todo replace with week_day.....
|
||||||
# self.day_shift_product = []
|
# self.day_shift_product = []
|
||||||
@@ -2867,7 +2969,7 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
return week_day_shifts
|
return week_day_shifts
|
||||||
|
|
||||||
def get_shifts(self, week: int | None=None) -> List[SingleShift]:
|
def get_shifts(self, week: int | None = None) -> List[SingleShift]:
|
||||||
"""Returns a list of all the registered shifts
|
"""Returns a list of all the registered shifts
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -2880,7 +2982,8 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
for shift in self.shifts:
|
for shift in self.shifts:
|
||||||
if shift.start_date < date <= shift.end_date:
|
if shift.start_date < date <= shift.end_date:
|
||||||
shifts.append(shift)
|
if shift.get_worker_requirement_by_date(date):
|
||||||
|
shifts.append(shift)
|
||||||
|
|
||||||
return shifts
|
return shifts
|
||||||
|
|
||||||
@@ -2899,7 +3002,9 @@ class RotaBuilder(object):
|
|||||||
def get_shifts_with_constraint(self, constraint) -> List[SingleShift]:
|
def get_shifts_with_constraint(self, constraint) -> List[SingleShift]:
|
||||||
return [shift for shift in self.shifts if constraint in shift.constraints]
|
return [shift for shift in self.shifts if constraint in shift.constraints]
|
||||||
|
|
||||||
def get_shifts_with_constraints(self, *constraints, week : int | None =None) -> List[SingleShift]:
|
def get_shifts_with_constraints(
|
||||||
|
self, *constraints, week: int | None = None
|
||||||
|
) -> List[SingleShift]:
|
||||||
shift_names = set()
|
shift_names = set()
|
||||||
|
|
||||||
for constraint in constraints:
|
for constraint in constraints:
|
||||||
@@ -2911,7 +3016,6 @@ class RotaBuilder(object):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
return [self.get_shift_by_name(s) for s in shift_names]
|
return [self.get_shift_by_name(s) for s in shift_names]
|
||||||
|
|
||||||
def get_shift_names(self) -> List[ShiftName]:
|
def get_shift_names(self) -> List[ShiftName]:
|
||||||
@@ -2952,8 +3056,11 @@ class RotaBuilder(object):
|
|||||||
"""
|
"""
|
||||||
l = []
|
l = []
|
||||||
for week, day, shift in self.week_day_shiftclass_product:
|
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:
|
# if day in shift.days:
|
||||||
l.append((week, day, shift.name, shift.workers_required, shift.sites))
|
l.append((week, day, shift.name, workers_required, shift.sites))
|
||||||
|
|
||||||
return l
|
return l
|
||||||
|
|
||||||
@@ -2984,7 +3091,7 @@ class RotaBuilder(object):
|
|||||||
if (shift.force_as_block or shift.force_as_block_unless_nwd)
|
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, week: int | None = None) -> List[str]:
|
||||||
def shifts_to_assign_or_force_as_blocks(self) -> List[str]:
|
def shifts_to_assign_or_force_as_blocks(self) -> List[str]:
|
||||||
s = self.shifts_to_assign_as_blocks()
|
s = self.shifts_to_assign_as_blocks()
|
||||||
s.extend(self.shifts_to_force_as_blocks())
|
s.extend(self.shifts_to_force_as_blocks())
|
||||||
|
|||||||
+66
-25
@@ -32,6 +32,27 @@ class NotAvailableToWork(BaseModel):
|
|||||||
reason: str = "unknown"
|
reason: str = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_not_available_to_works(
|
||||||
|
start_date: datetime.date,
|
||||||
|
end_date: datetime.date | None = None,
|
||||||
|
days: int | None = None,
|
||||||
|
reason: str = "unknown",
|
||||||
|
) -> List[NotAvailableToWork]:
|
||||||
|
if end_date is None and days is None:
|
||||||
|
raise ValueError("Either end_date or days must be provided")
|
||||||
|
if end_date is not None and days is not None:
|
||||||
|
raise ValueError("Only one of end_date or days must be provided")
|
||||||
|
|
||||||
|
if end_date is not None:
|
||||||
|
days = (end_date - start_date).days
|
||||||
|
|
||||||
|
not_available_to_works = []
|
||||||
|
for day in range(days):
|
||||||
|
not_available_to_works.append(NotAvailableToWork(date=start_date + datetime.timedelta(days=day), reason=reason))
|
||||||
|
|
||||||
|
return not_available_to_works
|
||||||
|
|
||||||
|
|
||||||
class NonWorkingDays(BaseModel):
|
class NonWorkingDays(BaseModel):
|
||||||
day: days
|
day: days
|
||||||
start_date: datetime.date | None = None
|
start_date: datetime.date | None = None
|
||||||
@@ -68,7 +89,7 @@ class Worker(BaseModel):
|
|||||||
# rules is easier.
|
# rules is easier.
|
||||||
grade: int
|
grade: int
|
||||||
# We can either have a user generated ID
|
# We can either have a user generated ID
|
||||||
id: Optional[int| uuid.UUID| str] = None
|
id: Optional[int | uuid.UUID | str] = None
|
||||||
fte: int = 100
|
fte: int = 100
|
||||||
nwds: list[NonWorkingDays] = []
|
nwds: list[NonWorkingDays] = []
|
||||||
start_date: datetime.date | None = None
|
start_date: datetime.date | None = None
|
||||||
@@ -84,7 +105,7 @@ class Worker(BaseModel):
|
|||||||
pair: int | str | None = None
|
pair: int | str | None = None
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
extra = "allow",
|
extra="allow",
|
||||||
)
|
)
|
||||||
|
|
||||||
# def __init__(
|
# def __init__(
|
||||||
@@ -111,14 +132,17 @@ class Worker(BaseModel):
|
|||||||
|
|
||||||
def load_rota(self, Rota: "RotaBuilder"):
|
def load_rota(self, Rota: "RotaBuilder"):
|
||||||
self.proportion_rota_to_work: float = 1
|
self.proportion_rota_to_work: float = 1
|
||||||
self.shift_target_number: dict[ str, int ] = defaultdict(int)
|
self.shift_target_number: dict[str, int] = defaultdict(int)
|
||||||
|
|
||||||
if self.id is None:
|
if self.id is None:
|
||||||
self.id = uuid.uuid4()
|
self.id = uuid.uuid4()
|
||||||
|
|
||||||
if self.start_date is not None and self.end_date is not None:
|
if self.start_date is not None and self.end_date is not None:
|
||||||
if self.start_date >= self.end_date:
|
if self.start_date >= self.end_date:
|
||||||
Rota.add_warning("Worker/Early end date", f"{self.name} [{self.id}] end date is before rota start date: {self.end_date}")
|
Rota.add_warning(
|
||||||
|
"Worker/Early end date",
|
||||||
|
f"{self.name} [{self.id}] end date is before rota start date: {self.end_date}",
|
||||||
|
)
|
||||||
raise ValueError(f"End date must be after start date: {self.name}")
|
raise ValueError(f"End date must be after start date: {self.name}")
|
||||||
|
|
||||||
# if no start date default to the start of the rota
|
# if no start date default to the start of the rota
|
||||||
@@ -132,7 +156,10 @@ class Worker(BaseModel):
|
|||||||
self.calculated_start_date = Rota.start_date
|
self.calculated_start_date = Rota.start_date
|
||||||
elif self.start_date >= Rota.rota_end_date:
|
elif self.start_date >= Rota.rota_end_date:
|
||||||
# If it is after add a warning
|
# If it is after add a warning
|
||||||
Rota.add_warning("Worker/Late start date", f"{self.name} [{self.id}] start date is after rota end date: {self.start_date}")
|
Rota.add_warning(
|
||||||
|
"Worker/Late start date",
|
||||||
|
f"{self.name} [{self.id}] start date is after rota end date: {self.start_date}",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -152,18 +179,21 @@ class Worker(BaseModel):
|
|||||||
|
|
||||||
if date < self.calculated_start_date:
|
if date < self.calculated_start_date:
|
||||||
Rota.unavailable_to_work.add((self.id, week, day))
|
Rota.unavailable_to_work.add((self.id, week, day))
|
||||||
Rota.unavailable_to_work_reason[
|
Rota.unavailable_to_work_reason[(self.id, week, day)] = (
|
||||||
(self.id, week, day)
|
f"START DATE: {self.calculated_start_date}"
|
||||||
] = f"START DATE: {self.calculated_start_date}"
|
)
|
||||||
|
|
||||||
if date >= self.calculated_end_date:
|
if date >= self.calculated_end_date:
|
||||||
Rota.unavailable_to_work.add((self.id, week, day))
|
Rota.unavailable_to_work.add((self.id, week, day))
|
||||||
Rota.unavailable_to_work_reason[
|
Rota.unavailable_to_work_reason[(self.id, week, day)] = (
|
||||||
(self.id, week, day)
|
f"END DATE: {self.calculated_end_date}"
|
||||||
] = f"END DATE: {self.calculated_end_date}"
|
)
|
||||||
|
|
||||||
if not self.not_available_to_work:
|
if not self.not_available_to_work:
|
||||||
Rota.add_warning("Worker/No unavailability", f"{self.name} [{self.id}] has no unavailabilities (leave)")
|
Rota.add_warning(
|
||||||
|
"Worker/No unavailability",
|
||||||
|
f"{self.name} [{self.id}] has no unavailabilities (leave)",
|
||||||
|
)
|
||||||
|
|
||||||
for item in self.oop:
|
for item in self.oop:
|
||||||
start_oop = item.start_date
|
start_oop = item.start_date
|
||||||
@@ -207,9 +237,9 @@ class Worker(BaseModel):
|
|||||||
]:
|
]:
|
||||||
week, day = weeks_days
|
week, day = weeks_days
|
||||||
Rota.unavailable_to_work.add((self.id, week, day))
|
Rota.unavailable_to_work.add((self.id, week, day))
|
||||||
Rota.unavailable_to_work_reason[
|
Rota.unavailable_to_work_reason[(self.id, week, day)] = (
|
||||||
(self.id, week, day)
|
f"OOP ({oop_name})".format(self.oop)
|
||||||
] = f"OOP ({oop_name})".format(self.oop)
|
)
|
||||||
|
|
||||||
days_to_end = (self.calculated_end_date - Rota.start_date).days
|
days_to_end = (self.calculated_end_date - Rota.start_date).days
|
||||||
|
|
||||||
@@ -239,10 +269,11 @@ class Worker(BaseModel):
|
|||||||
week = days_from_start // 7 + 1
|
week = days_from_start // 7 + 1
|
||||||
day = Rota.days[(days_from_start % 7)]
|
day = Rota.days[(days_from_start % 7)]
|
||||||
Rota.unavailable_to_work.add((self.id, week, day))
|
Rota.unavailable_to_work.add((self.id, week, day))
|
||||||
Rota.unavailable_to_work_reason[(self.id, week, day)] = unavalability.reason
|
Rota.unavailable_to_work_reason[(self.id, week, day)] = (
|
||||||
|
unavalability.reason
|
||||||
|
)
|
||||||
unavailable_set.add((week, day))
|
unavailable_set.add((week, day))
|
||||||
|
|
||||||
|
|
||||||
for request in self.work_requests:
|
for request in self.work_requests:
|
||||||
days_from_start = (request.date - Rota.start_date).days
|
days_from_start = (request.date - Rota.start_date).days
|
||||||
week = days_from_start // 7 + 1
|
week = days_from_start // 7 + 1
|
||||||
@@ -262,7 +293,9 @@ class Worker(BaseModel):
|
|||||||
|
|
||||||
if self.fte_adj > 100:
|
if self.fte_adj > 100:
|
||||||
# Shouldn't happen ? bug if it does
|
# Shouldn't happen ? bug if it does
|
||||||
Rota.add_warning("FTE > 100%", f"{self.name} [{self.id}] FTE = {self.fte_adj}")
|
Rota.add_warning(
|
||||||
|
"FTE > 100%", f"{self.name} [{self.id}] FTE = {self.fte_adj}"
|
||||||
|
)
|
||||||
raise ValueError("{} : fte_ajd = {}".format(self.name, self.fte_adj))
|
raise ValueError("{} : fte_ajd = {}".format(self.name, self.fte_adj))
|
||||||
|
|
||||||
# TODO: this has already been validated, consider moving
|
# TODO: this has already been validated, consider moving
|
||||||
@@ -275,14 +308,22 @@ class Worker(BaseModel):
|
|||||||
if non_working_day.end_date is not None:
|
if non_working_day.end_date is not None:
|
||||||
end_date = non_working_day.end_date
|
end_date = non_working_day.end_date
|
||||||
|
|
||||||
self.non_working_day_list.append((non_working_day.day, start_date, end_date))
|
self.non_working_day_list.append(
|
||||||
|
(non_working_day.day, start_date, end_date)
|
||||||
|
)
|
||||||
|
|
||||||
if days_to_work < 1:
|
if days_to_work < 1:
|
||||||
self.fte_adj = 0
|
self.fte_adj = 0
|
||||||
|
|
||||||
if days_to_work > 0 and (days_to_work - len(unavailable_set)) / days_to_work < 0.3:
|
if (
|
||||||
|
days_to_work > 0
|
||||||
|
and (days_to_work - len(unavailable_set)) / days_to_work < 0.3
|
||||||
|
):
|
||||||
console.print(f"{ self.name }: {days_to_work}, {Rota.rota_days_length}")
|
console.print(f"{ self.name }: {days_to_work}, {Rota.rota_days_length}")
|
||||||
console.print(f"Warning {self.name} has excessive unavailability [{len(unavailable_set)}/{days_to_work}, adjusted fte={self.fte_adj}] (this may be infeasible)", style="red")
|
console.print(
|
||||||
|
f"Warning {self.name} has excessive unavailability [{len(unavailable_set)}/{days_to_work}, adjusted fte={self.fte_adj}] (this may be infeasible)",
|
||||||
|
style="red",
|
||||||
|
)
|
||||||
|
|
||||||
def __lt__(self, other) -> bool:
|
def __lt__(self, other) -> bool:
|
||||||
return (self.site, self.grade, self.fte_adj, self.name) < (
|
return (self.site, self.grade, self.fte_adj, self.name) < (
|
||||||
@@ -295,17 +336,17 @@ class Worker(BaseModel):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.name}, {self.site}, {self.oop}"
|
return f"{self.name}, {self.site}, {self.oop}"
|
||||||
|
|
||||||
#nwds = (
|
# nwds = (
|
||||||
# ", ".join([str(i) for i, start, end in self.non_working_day_list])
|
# ", ".join([str(i) for i, start, end in self.non_working_day_list])
|
||||||
# if self.non_working_day_list is not None
|
# if self.non_working_day_list is not None
|
||||||
# else ""
|
# else ""
|
||||||
#)
|
# )
|
||||||
#return "{} {} [{}] REMOTE SITE:{}".format(
|
# return "{} {} [{}] REMOTE SITE:{}".format(
|
||||||
# self.name,
|
# self.name,
|
||||||
# (self.site, self.grade, self.fte_adj),
|
# (self.site, self.grade, self.fte_adj),
|
||||||
# nwds,
|
# nwds,
|
||||||
# self.remote_site,
|
# self.remote_site,
|
||||||
#)
|
# )
|
||||||
|
|
||||||
def get_details(self) -> str:
|
def get_details(self) -> str:
|
||||||
return "{} {} {}".format(self.id, self.site[0], self.grade)
|
return "{} {} {}".format(self.id, self.site[0], self.grade)
|
||||||
|
|||||||
+160
-51
@@ -1,14 +1,13 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import pytest
|
import pytest
|
||||||
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, WarningTermination, days
|
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, WarningTermination, days, WorkerRequirement
|
||||||
|
|
||||||
from rota.workers import NotAvailableToWork, WorkRequests, Worker
|
from rota.workers import NotAvailableToWork, WorkRequests, Worker, generate_not_available_to_works
|
||||||
|
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
|
|
||||||
def generate_basic_rota(weeks_to_rota=10, workers=2):
|
def generate_basic_rota(weeks_to_rota=10, workers=2, start_date=datetime.date(2022, 3, 7)):
|
||||||
start_date = datetime.date(2022, 3, 7)
|
|
||||||
|
|
||||||
Rota = RotaBuilder(
|
Rota = RotaBuilder(
|
||||||
start_date,
|
start_date,
|
||||||
@@ -19,7 +18,6 @@ def generate_basic_rota(weeks_to_rota=10, workers=2):
|
|||||||
Rota.add_workers(
|
Rota.add_workers(
|
||||||
[
|
[
|
||||||
Worker(name=f"worker{i}", site="group1", grade=1) for i in range(1, workers+1)
|
Worker(name=f"worker{i}", site="group1", grade=1) for i in range(1, workers+1)
|
||||||
#Worker(name="worker2", site="group1", grade=1),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -547,56 +545,167 @@ class TestShiftDates:
|
|||||||
for worker in Rota.get_workers_by_group()["group2"]:
|
for worker in Rota.get_workers_by_group()["group2"]:
|
||||||
assert Rota.get_worker_shifts_by_date(worker)[request_date] == "c"
|
assert Rota.get_worker_shifts_by_date(worker)[request_date] == "c"
|
||||||
|
|
||||||
def test_shift_start_date_end_date_block_paired(self):
|
class TestShiftWorkerRequirements:
|
||||||
Rota = generate_basic_rota(workers=7)
|
def test_start_and_end_date(self):
|
||||||
|
WorkerRequirement(start_date=datetime.date(2022, 3, 7), end_date=datetime.date(2022, 3, 14))
|
||||||
|
|
||||||
|
def test_end_date_before_start_date(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
WorkerRequirement(start_date=datetime.date(2022, 3, 14), end_date=datetime.date(2022, 3, 7))
|
||||||
|
|
||||||
request_date = Rota.start_date + datetime.timedelta(days=(7*6)+4)
|
def test_worker_requirement(self):
|
||||||
|
Rota = generate_basic_rota(workers=2)
|
||||||
no_work = [NotAvailableToWork(date=d, shift="*") for d in date_generator(request_date, 18)]
|
|
||||||
|
|
||||||
Rota.add_workers(
|
|
||||||
[
|
|
||||||
Worker(name=f"worker-wr-{i}", site="group2", grade=1, not_available_to_work=no_work) for i in range(1, 4)
|
|
||||||
#Worker(name="worker2", site="group1", grade=1),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
a, b, c = (SingleShift(
|
|
||||||
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
|
||||||
workers_required=8,
|
|
||||||
constraint=[{"name": "max_shifts_per_week", "options": 4}],
|
|
||||||
end_date=(Rota.start_date + datetime.timedelta(days=7)),
|
|
||||||
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
|
||||||
),
|
|
||||||
SingleShift(
|
|
||||||
sites=("group1", "group2"), name="b", length= 12.5, days=days[4:],
|
|
||||||
assign_as_block=True,
|
|
||||||
force_as_block=True,
|
|
||||||
workers_required=4,
|
|
||||||
constraint=[{"name": "max_shifts_per_week", "options": 4},
|
|
||||||
{"name": "pre", "options": 3}, {"name": "post", "options": 3}],
|
|
||||||
start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
|
||||||
end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
|
||||||
),
|
|
||||||
SingleShift(
|
|
||||||
sites=("group1", "group2"), name="c", length= 12.5, days=days[4:],
|
|
||||||
assign_as_block=True,
|
|
||||||
force_as_block=True,
|
|
||||||
workers_required=4,
|
|
||||||
constraint=[{"name": "max_shifts_per_week", "options": 4},
|
|
||||||
{"name": "pre", "options": 3}, {"name": "post", "options": 3}],
|
|
||||||
start_date=(Rota.start_date + datetime.timedelta(days=(7*6))),
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
|
wr = [WorkerRequirement(start_date=datetime.date(2022, 3, 7), end_date=datetime.date(2022, 3, 21))]
|
||||||
|
print(wr)
|
||||||
Rota.add_shifts(
|
Rota.add_shifts(
|
||||||
a,
|
SingleShift(
|
||||||
b, c
|
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
||||||
|
assign_as_block=True,
|
||||||
|
force_as_block=True,
|
||||||
|
workers_required=wr,
|
||||||
|
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
||||||
|
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Rota.pair_shifts(b, c)
|
Rota.build_and_solve(options={"ratio": 0.1})
|
||||||
|
Rota.export_rota_to_html("test_worker_requirement")
|
||||||
Rota.build_and_solve(options={"ratio": 0.01})
|
|
||||||
Rota.export_rota_to_html("test_shift_start_date")
|
|
||||||
|
|
||||||
assert Rota.results.solver.status == "ok"
|
assert Rota.results.solver.status == "ok"
|
||||||
assert Rota.results.solver.termination_condition == "optimal"
|
|
||||||
|
total_shifts = Rota.get_workers_total_shifts()
|
||||||
|
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
assert total_shifts[worker.name] == 4
|
||||||
|
assert "aaaa" in Rota.get_worker_shift_list_string(worker)
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_requirement2(self):
|
||||||
|
Rota = generate_basic_rota(workers=2)
|
||||||
|
|
||||||
|
wr = [WorkerRequirement(start_date=datetime.date(2022, 3, 7), end_date=datetime.date(2022, 3, 21)),
|
||||||
|
WorkerRequirement(start_date=datetime.date(2022, 3, 28))
|
||||||
|
]
|
||||||
|
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
||||||
|
assign_as_block=True,
|
||||||
|
force_as_block=True,
|
||||||
|
workers_required=wr,
|
||||||
|
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
||||||
|
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.1})
|
||||||
|
Rota.export_rota_to_html("test_worker_requirement")
|
||||||
|
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
total_shifts = Rota.get_workers_total_shifts()
|
||||||
|
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
assert total_shifts[worker.name] == 18
|
||||||
|
assert Rota.get_worker_shift_list_string(worker)[14:21] == "-------"
|
||||||
|
|
||||||
|
def test_worker_requirement_increase_workers(self):
|
||||||
|
Rota = generate_basic_rota(workers=2)
|
||||||
|
|
||||||
|
wr = [WorkerRequirement(start_date=datetime.date(2022, 3, 7), end_date=datetime.date(2022, 3, 14)),
|
||||||
|
WorkerRequirement(start_date=datetime.date(2022, 3, 28), number=2)
|
||||||
|
]
|
||||||
|
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
||||||
|
assign_as_block=True,
|
||||||
|
force_as_block=True,
|
||||||
|
workers_required=wr,
|
||||||
|
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
||||||
|
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.1})
|
||||||
|
Rota.export_rota_to_html("test_worker_requirement")
|
||||||
|
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
total_shifts = Rota.get_workers_total_shifts()
|
||||||
|
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
assert total_shifts[worker.name] == 30
|
||||||
|
assert Rota.get_worker_shift_list_string(worker)[7:21] == "-" * 14
|
||||||
|
assert Rota.get_worker_shift_list_string(worker).endswith("aaaa---"*7)
|
||||||
|
|
||||||
|
def test_worker_requirement_balancing(self):
|
||||||
|
Rota = generate_basic_rota(workers=2)
|
||||||
|
|
||||||
|
natws = generate_not_available_to_works(datetime.date(2022,4,18), days=21, reason="holiday")
|
||||||
|
|
||||||
|
Rota.add_worker(
|
||||||
|
Worker(name=f"worker3", site="group1", grade=1, not_available_to_work=natws)
|
||||||
|
)
|
||||||
|
|
||||||
|
wr = [WorkerRequirement(start_date=datetime.date(2022, 3, 7), end_date=datetime.date(2022, 3, 14)),
|
||||||
|
WorkerRequirement(start_date=datetime.date(2022, 3, 28), number=2)
|
||||||
|
]
|
||||||
|
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
||||||
|
assign_as_block=True,
|
||||||
|
workers_required=wr,
|
||||||
|
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
||||||
|
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.0})
|
||||||
|
Rota.export_rota_to_html("test_worker_requirement")
|
||||||
|
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
total_shifts = Rota.get_workers_total_shifts()
|
||||||
|
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
assert total_shifts[worker.name] == 20
|
||||||
|
assert Rota.get_worker_shift_list_string(worker)[7:21] == "-" * 14
|
||||||
|
|
||||||
|
if worker.name == "worker3":
|
||||||
|
assert Rota.get_worker_shift_list_string(worker).endswith("aaaa---")
|
||||||
|
assert Rota.get_worker_shift_list_string(worker).startswith("aaaa---")
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_requirement_multiple_shifts(self):
|
||||||
|
Rota = generate_basic_rota(workers=4)
|
||||||
|
|
||||||
|
wr = [WorkerRequirement(start_date=datetime.date(2022, 3, 7), end_date=datetime.date(2022, 3, 14)),
|
||||||
|
WorkerRequirement(start_date=datetime.date(2022, 3, 28), number=2)
|
||||||
|
]
|
||||||
|
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"), name="b", length= 12.5, days=days[4:],
|
||||||
|
assign_as_block=True,
|
||||||
|
force_as_block=True,
|
||||||
|
workers_required=2,
|
||||||
|
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
||||||
|
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
||||||
|
),
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
||||||
|
assign_as_block=True,
|
||||||
|
force_as_block=True,
|
||||||
|
workers_required=wr,
|
||||||
|
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
||||||
|
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.1})
|
||||||
|
Rota.export_rota_to_html("test_worker_requirement")
|
||||||
|
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
total_shifts = Rota.get_workers_total_shifts()
|
||||||
|
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
assert total_shifts[worker.name] == 30
|
||||||
+54
-10
@@ -2,23 +2,29 @@ import pytest
|
|||||||
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WarningTermination, days
|
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WarningTermination, days
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from rota.workers import Worker
|
from rota.workers import NotAvailableToWork, Worker, generate_not_available_to_works
|
||||||
|
|
||||||
|
def generate_basic_rota(weeks_to_rota=10, workers=0, start_date=datetime.date(2022, 3, 7)):
|
||||||
|
|
||||||
def setup_basic_rota():
|
Rota = RotaBuilder(
|
||||||
weeks_to_rota = 10
|
|
||||||
start_date = datetime.date(2022, 3, 7)
|
|
||||||
|
|
||||||
return RotaBuilder(
|
|
||||||
start_date,
|
start_date,
|
||||||
weeks_to_rota=weeks_to_rota,
|
weeks_to_rota=weeks_to_rota,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add a few workers
|
||||||
|
if workers:
|
||||||
|
Rota.add_workers(
|
||||||
|
[
|
||||||
|
Worker(name=f"worker{i}", site="group1", grade=1) for i in range(1, workers+1)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return Rota
|
||||||
|
|
||||||
class TestWorkers:
|
class TestWorkers:
|
||||||
def test_no_workers(self):
|
def test_no_workers(self):
|
||||||
"""Check error raised if attempt to solve with no workers"""
|
"""Check error raised if attempt to solve with no workers"""
|
||||||
Rota = setup_basic_rota()
|
Rota = generate_basic_rota()
|
||||||
|
|
||||||
Rota.add_shifts(
|
Rota.add_shifts(
|
||||||
SingleShift(
|
SingleShift(
|
||||||
@@ -43,7 +49,7 @@ class TestWorkers:
|
|||||||
|
|
||||||
def test_workers_duplicate_name(self):
|
def test_workers_duplicate_name(self):
|
||||||
|
|
||||||
Rota = setup_basic_rota()
|
Rota = generate_basic_rota()
|
||||||
|
|
||||||
workers = []
|
workers = []
|
||||||
workers.append(Worker(name="worker1", site="group1", grade=1, fte=100))
|
workers.append(Worker(name="worker1", site="group1", grade=1, fte=100))
|
||||||
@@ -325,7 +331,7 @@ class TestWorkers:
|
|||||||
Rota.export_rota_to_html("worker_oops")
|
Rota.export_rota_to_html("worker_oops")
|
||||||
|
|
||||||
def test_worker_pairs(self):
|
def test_worker_pairs(self):
|
||||||
Rota = setup_basic_rota()
|
Rota = generate_basic_rota()
|
||||||
|
|
||||||
Rota.add_shifts(
|
Rota.add_shifts(
|
||||||
SingleShift(
|
SingleShift(
|
||||||
@@ -395,4 +401,42 @@ class TestWorkers:
|
|||||||
Rota.export_rota_to_html("worker_pairs")
|
Rota.export_rota_to_html("worker_pairs")
|
||||||
|
|
||||||
assert Rota.results.solver.status == "ok"
|
assert Rota.results.solver.status == "ok"
|
||||||
assert Rota.get_workers_total_shifts()["worker3"] == 50
|
assert Rota.get_workers_total_shifts()["worker3"] == 50
|
||||||
|
|
||||||
|
def test_not_available_to_work(self):
|
||||||
|
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
natws = generate_not_available_to_works(Rota.start_date, days=21)
|
||||||
|
|
||||||
|
natws.append(NotAvailableToWork(date=Rota.rota_end_date-datetime.timedelta(days=1)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Rota.add_workers(
|
||||||
|
[
|
||||||
|
Worker(name="worker1", site="group1", grade=1),
|
||||||
|
Worker(name="worker2", site="group1", grade=1, not_available_to_work=natws),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
Rota.build_and_solve()
|
||||||
|
Rota.export_rota_to_html("test_not_available_to_work")
|
||||||
|
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
assert Rota.get_workers_total_shifts()["worker1"] == 35
|
||||||
|
assert Rota.get_workers_total_shifts()["worker2"] == 35
|
||||||
|
|
||||||
|
assert Rota.get_worker_shift_list_string(Rota.get_worker_by_name("worker2")).startswith("-" * 21)
|
||||||
|
assert Rota.get_worker_shift_list_string(Rota.get_worker_by_name("worker2")).endswith("-")
|
||||||
Reference in New Issue
Block a user