start working on shift paring balancing

This commit is contained in:
Ross
2024-12-26 16:39:04 +00:00
parent 1ed5f4fbcd
commit f0b9ee0ebc
6 changed files with 741 additions and 378 deletions
+170 -35
View File
@@ -3,6 +3,10 @@ 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, days
import typer
import subprocess
app = typer.Typer()
sites = ( sites = (
@@ -15,6 +19,8 @@ sites = (
"proc", "proc",
) )
NIGHT_REGISTRAR_NUMBER = 4
from rota.workers import ( from rota.workers import (
Worker, Worker,
NotAvailableToWork, NotAvailableToWork,
@@ -24,25 +30,21 @@ from rota.workers import (
OutOfProgramme, OutOfProgramme,
) )
@app.command()
def main(
suspend_on_finish: bool = False,
solve: bool = True,
time_to_run: int = 60 * 60,
ratio: float = 0.001,
start_date: datetime.datetime = "2025-03-03",
weeks: int = 26,
bom: int = 1,
):
suspend_on_finish = False rota_start_date = start_date.date()
solve = True
# time_to_run = 508500
# time_to_run = 2660000
time_to_run = 60 * 60 * 100
#time_to_run = 60 * 300
# allow = 5000
ratio = 0.5
#ratio = 0.9
start_date = datetime.date(2024, 3, 4)
# start_date = datetime.date(2023, 5, 1)
# start_date = start_date + datetime.timedelta(weeks=13)
# start_date = datetime.date(2022, 10, 10)
Rota = RotaBuilder( Rota = RotaBuilder(
start_date, weeks_to_rota=8, balance_offset_modifier=1, 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,7 +56,12 @@ Rota.constraint_options["hard_constrain_pair_separation"] = True
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("exeter", "exeter twilights and weekends", "exeter no nights"), sites=(
"exeter",
"exeter twilights and weekends",
"exeter no nights",
"exeter twilights",
),
name="exeter_twilight", name="exeter_twilight",
length=12.5, length=12.5,
days=days[:5], days=days[:5],
@@ -67,7 +74,14 @@ Rota.add_shifts(
], ],
), ),
SingleShift( SingleShift(
sites=("truro", "truro twilights", "truro twilights and weekends"), sites=(
"truro",
"truro no nights",
"truro twilights",
"truro twilights and weekends",
"truro twilights and weekend nights",
"truro twilights, weekends and weekend nights",
),
name="truro_twilight", name="truro_twilight",
length=12.5, length=12.5,
days=days[:4], days=days[:4],
@@ -82,7 +96,12 @@ Rota.add_shifts(
assign_as_block=True, assign_as_block=True,
), ),
SingleShift( SingleShift(
sites=("plymouth", "plymouth twilights", "plymouth twilights and weekends", "plymouth twilights, weekends and weekend nights"), sites=(
"plymouth",
"plymouth twilights",
"plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights",
),
name="plymouth_twilight", name="plymouth_twilight",
length=12.5, length=12.5,
days=days[:5], days=days[:5],
@@ -91,7 +110,12 @@ Rota.add_shifts(
constraint=[{"name": "max_shifts_per_week", "options": 2}], constraint=[{"name": "max_shifts_per_week", "options": 2}],
), ),
SingleShift( SingleShift(
sites=("exeter", "exeter twilights and weekends", "exeter no nights"), sites=(
"exeter",
"exeter twilights and weekends",
"exeter no nights",
"exeter weekends",
),
name="weekend_exeter", name="weekend_exeter",
length=12.5, length=12.5,
days=days[5:], days=days[5:],
@@ -102,13 +126,20 @@ Rota.add_shifts(
# constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},], # constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},],
), ),
SingleShift( SingleShift(
sites=("truro", "truro twilights and weekends"), sites=(
"truro",
"truro no nights",
"truro twilights and weekends",
"truro weekends",
"truro twilights, weekends and weekend nights",
),
name="weekend_truro", name="weekend_truro",
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,
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
), ),
@@ -124,7 +155,12 @@ Rota.add_shifts(
# force_as_block_unless_nwd=True # force_as_block_unless_nwd=True
), ),
SingleShift( SingleShift(
sites=("plymouth", "plymouth twilights and weekends", "plymouth first on weekends only", "plymouth twilights, weekends and weekend nights"), sites=(
"plymouth",
"plymouth twilights and weekends",
"plymouth first on weekends only",
"plymouth twilights, weekends and weekend nights",
),
name="weekend_plymouth1", name="weekend_plymouth1",
length=8, length=8,
days=days[5:], days=days[5:],
@@ -135,7 +171,11 @@ Rota.add_shifts(
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
), ),
SingleShift( SingleShift(
sites=("plymouth", "plymouth twilights and weekends", "plymouth twilights, weekends and weekend nights"), sites=(
"plymouth",
"plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights",
),
name="weekend_plymouth2", name="weekend_plymouth2",
length=8, length=8,
days=days[5:], days=days[5:],
@@ -146,7 +186,12 @@ Rota.add_shifts(
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
), ),
SingleShift( SingleShift(
sites=("plymouth", "plymouth twilights", "plymouth twilights and weekends", "plymouth twilights, weekends and weekend nights"), sites=(
"plymouth",
"plymouth twilights",
"plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights",
),
name="plymouth_bank_holidays", name="plymouth_bank_holidays",
length=8, length=8,
days=days[5:], days=days[5:],
@@ -171,13 +216,23 @@ Rota.add_shifts(
{"name": "night"}, {"name": "night"},
{"name": "pre", "options": 2}, {"name": "pre", "options": 2},
{"name": "post", "options": 2}, {"name": "post", "options": 2},
{"name": "require_remote_site_presence_week", "options": ("plymouth", 1)}, {
"name": "require_remote_site_presence_week",
"options": ("plymouth", 1),
},
{"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(),
), ),
SingleShift( SingleShift(
sites=[*sites, "plymouth twilights, weekends and weekend nights"], sites=[
*sites,
"plymouth twilights, weekends and weekend nights",
"truro twilights and weekend nights",
"weekend nights",
"truro twilights, weekends and weekend nights",
],
name="night_weekend", name="night_weekend",
length=12.25, length=12.25,
days=days[4:], days=days[4:],
@@ -191,10 +246,68 @@ Rota.add_shifts(
{"name": "night"}, {"name": "night"},
{"name": "pre", "options": 2}, {"name": "pre", "options": 2},
{"name": "post", "options": 3}, {"name": "post", "options": 3},
{"name": "require_remote_site_presence_week", "options": ("plymouth", 1)}, {
"name": "require_remote_site_presence_week",
"options": ("plymouth", 1),
},
{"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(),
),
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(),
), ),
) )
@@ -244,8 +357,10 @@ if load_leave:
print("Split nwd", worker_name, i) print("Split nwd", worker_name, i)
a, b = i.split("[")[1][:-1].split("-") a, b = i.split("[")[1][:-1].split("-")
print(f"{a=} {b=}") print(f"{a=} {b=}")
nwd_start_date = datetime.datetime.strptime(a, "%d/%m/%y").date() nwd_start_date = datetime.datetime.strptime(
nwd_end_date = datetime.datetime.strptime(b, "%d/%m/%y").date() a, "%d/%m/%Y"
).date()
nwd_end_date = datetime.datetime.strptime(b, "%d/%m/%Y").date()
nwds.append( nwds.append(
NonWorkingDays( NonWorkingDays(
@@ -261,7 +376,24 @@ if load_leave:
formatted_oops = [] formatted_oops = []
for dates in oop.split(","): for dates in oop.split(","):
print(dates) print(dates)
if "-" in dates:
s, e = dates.split("-") s, e = dates.split("-")
elif "to" in dates:
s, e = dates.split(" to ")
else:
raise ValueError(f"Cannot parse OOP dates: {dates}")
try:
formatted_oops.append(
{
"start_date": datetime.datetime.strptime(
s.strip(), "%d/%m/%Y"
).date(),
"end_date": datetime.datetime.strptime(
e.strip(), "%d/%m/%Y"
).date(),
}
)
except ValueError:
try: try:
formatted_oops.append( formatted_oops.append(
{ {
@@ -279,7 +411,6 @@ if load_leave:
print("DATES", s, e) print("DATES", s, e)
raise raise
oop = formatted_oops oop = formatted_oops
else: else:
oop = [] oop = []
@@ -339,14 +470,14 @@ if load_leave:
Rota.add_worker(w) Rota.add_worker(w)
# Rota.build_workers() # Rota.build_workers()
# 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}
# start_time = time.time() # start_time = time.time()
Rota.build_and_solve(solver_options, export=True, solve=True) 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)
@@ -370,9 +501,13 @@ Rota.build_and_solve(solver_options, export=True, solve=True)
# #
# print(worker_timetable_brief) # print(worker_timetable_brief)
# 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/"])
if suspend_on_finish: if suspend_on_finish:
os.system("systemctl suspend") os.system("systemctl suspend")
if __name__ == "__main__":
app()
+36 -12
View File
@@ -2,6 +2,9 @@ import csv
from datetime import datetime from datetime import datetime
import re import re
from requests import Session from requests import Session
from loguru import logger
from rich.pretty import pprint
from rota.workers import Worker, NotAvailableToWork, NonWorkingDays, WorkRequests, PreferenceNotToWork, OutOfProgramme from rota.workers import Worker, NotAvailableToWork, NonWorkingDays, WorkRequests, PreferenceNotToWork, OutOfProgramme
@@ -9,6 +12,8 @@ from rota.workers import Worker, NotAvailableToWork, NonWorkingDays, WorkRequest
date_re = r"[\d]{1,2}\/[\d]{1,2}\/[\d]{2}" date_re = r"[\d]{1,2}\/[\d]{1,2}\/[\d]{2}"
live_rota = True
def load_leave(Rota): def load_leave(Rota):
with Session() as s: with Session() as s:
@@ -21,14 +26,17 @@ def load_leave(Rota):
"Pragma": "no-cache" "Pragma": "no-cache"
} }
download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vRYqSKq-OYW1tNnGJtx9LIMo_FXVfcHBRPF4HEMcBxWPfmXimWtr5oEDR_rIUSXrOndb9YjP3iC_4bS/pub?gid=0&single=true&output=csv", headers=headers) if live_rota:
download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSRx9VWXSlRubPyA0RhiI-Oqf5eHNYYEc6rFzlraDbR5_8qqr5g13-4uV-gn4u-TjZxiSMv1fBUaESq/pub?gid=814517272&single=true&output=csv", headers=headers)
decoded_content = download.content.decode('utf-8') decoded_content = download.content.decode('utf-8')
reader = csv.reader(decoded_content.splitlines(), delimiter=',') reader = csv.reader(decoded_content.splitlines(), delimiter=',')
#with open("/home/ross/Downloads/Draft PROC Rota March 24 - Sept 24 - Sheet1.csv") as f: else:
# reader = csv.reader(f.read().splitlines(), delimiter=',') with open("/home/ross/Downloads/PROC Draft RK version.csv") as f:
reader = csv.reader(f.read().splitlines(), delimiter=',')
n = 0
leave = {} leave = {}
requests = {} requests = {}
site_prefs = {} site_prefs = {}
@@ -37,7 +45,11 @@ def load_leave(Rota):
n = 1 n = 1
for row in reader: for row in reader:
row_title = row[0] if n < 10:
print(row)
row_title = row[1]
print(row_title)
row_date = row[0]
r = row[2:] r = row[2:]
# header, extract names # header, extract names
if n == 1: if n == 1:
@@ -70,16 +82,16 @@ def load_leave(Rota):
if lower_item == "derriford twilights": if lower_item == "derriford twilights":
lower_item = "plymouth_twilights" lower_item = "plymouth_twilights"
if "PROC site" in row_title: if "PROC site" in row_title or row_title == "PROC nights from":
worker["site_pref"] = lower_item worker["site_pref"] = lower_item
if row_title in ("Rotation", "Site"): elif row_title in ("Rotation", "Site", "Placement", "Placement location"):
worker["site"] = lower_item worker["site"] = lower_item
elif row_title == "Grade": elif row_title in ("Grade", "Grade (ST)"):
worker["grade"] = lower_item worker["grade"] = lower_item
elif row_title == "%FTE": elif row_title in ("%FTE", "%FTE on-call", "FTE", "FTE on-call"):
worker["fte"] = lower_item worker["fte"] = lower_item
elif "NWD" in row_title: elif "NWD" in row_title:
@@ -98,7 +110,13 @@ def load_leave(Rota):
elif row_title in ("Start date",): elif row_title in ("Start date",):
if lower_item: if lower_item:
try:
date = datetime.strptime(lower_item, "%d/%m/%y").date() date = datetime.strptime(lower_item, "%d/%m/%y").date()
except ValueError:
date = datetime.strptime(lower_item, "%d/%m/%Y").date()
except ValueError:
logger.warning(f"Cannot parse date: {lower_item} (n: {n}, row: {row})")
raise ValueError(f"Cannot parse date: {lower_item}")
worker["start_date"] = date worker["start_date"] = date
else: else:
worker["start_date"] = None worker["start_date"] = None
@@ -128,19 +146,25 @@ def load_leave(Rota):
if lower_item: if lower_item:
worker["bank_holiday_extra"] = int(lower_item) worker["bank_holiday_extra"] = int(lower_item)
elif re.match(date_re, row_title) is not None: elif re.match(date_re, row_date) is not None:
if lower_item != "": if lower_item != "":
try: try:
try: try:
date = datetime.strptime(row[0], "%d/%m/%y").date() date = datetime.strptime(row_date, "%d/%m/%y").date()
except ValueError: except ValueError:
date = datetime.strptime(row[0], "%d/%m/%Y").date() date = datetime.strptime(row_date, "%d/%m/%Y").date()
# This may be easier to do as a dict # This may be easier to do as a dict
if lower_item in shifts: if lower_item in shifts:
worker["requests"].append(WorkRequests(date=date, shift=lower_item)) worker["requests"].append(WorkRequests(date=date, shift=lower_item))
elif lower_item in ("nights only"):
if date.weekday() < 5:
worker["requests"].append(WorkRequests(date=date, shift="night_weekday"))
else:
worker["requests"].append(WorkRequests(date=date, shift="night_weekend"))
elif lower_item in ("work_request", "happy to work", "offered"): elif lower_item in ("work_request", "happy to work", "offered", "may be", "volunteered"):
worker["requests"].append(WorkRequests(date=date, shift="*")) worker["requests"].append(WorkRequests(date=date, shift="*"))
+3
View File
@@ -149,6 +149,8 @@ function generateExtra() {
bank_holidays_worked = bank_holidays.length; bank_holidays_worked = bank_holidays.length;
nights_worked= $(tr).find(".night-shift").length;
shift_diff = JSON.parse(worker_td.attr("data-shift-diff")); shift_diff = JSON.parse(worker_td.attr("data-shift-diff"));
summed_shift_diff = Object.values(shift_diff).reduce((a, b) => a + b); summed_shift_diff = Object.values(shift_diff).reduce((a, b) => a + b);
@@ -161,6 +163,7 @@ function generateExtra() {
worker_td.after(`<div class='worker-summary auto-generated'> worker_td.after(`<div class='worker-summary auto-generated'>
<span>Total shifts: ${total_shifts}, </span> <span>Total shifts: ${total_shifts}, </span>
<span>Weekends: ${weekends_worked}, </span> <span>Weekends: ${weekends_worked}, </span>
<span>Nights: ${nights_worked}, </span>
<span>Bank holidays: ${bank_holidays_worked}, </span> <span>Bank holidays: ${bank_holidays_worked}, </span>
<span class='shift-diff-span'>Summed shift diff: ${summed_shift_diff.toPrecision(2)}, </span> <span class='shift-diff-span'>Summed shift diff: ${summed_shift_diff.toPrecision(2)}, </span>
+51 -15
View File
@@ -125,6 +125,7 @@ class SingleShift(BaseModel):
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
model_config = ConfigDict( model_config = ConfigDict(
extra = "allow", extra = "allow",
@@ -280,6 +281,8 @@ class RotaBuilder(object):
self.paired_shifts = [] self.paired_shifts = []
self.exported_rota_file = None
def set_rota_dates(self, start_date: datetime.date, weeks_to_rota: int): def set_rota_dates(self, start_date: datetime.date, weeks_to_rota: int):
self.weeks_to_rota = weeks_to_rota self.weeks_to_rota = weeks_to_rota
@@ -1118,13 +1121,14 @@ class RotaBuilder(object):
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):
try:
self.model.constraints.add( self.model.constraints.add(
8 8
* self.model.blocks_worker_shift_assigned[ * self.model.blocks_worker_shift_assigned[
@@ -1135,6 +1139,8 @@ class RotaBuilder(object):
for day in shift.days for day in shift.days
) )
) )
except KeyError:
pass
self.model.constraints.add( self.model.constraints.add(
self.model.blocks_assigned[week, shift_name] self.model.blocks_assigned[week, shift_name]
== sum( == sum(
@@ -2161,15 +2167,23 @@ 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:
self.model.constraints.add( #week_start_date = self.get_week_start_date(week)
1 #try:
>= self.model.works[ #if constraint_shift.start_date <= week_start_date < constraint_shift.end_date:
# works = 1
try:
works = self.model.works[
worker.id, week, day, constraint_shift.name worker.id, week, day, constraint_shift.name
] ]
except KeyError:
continue
self.model.constraints.add(
1
>= works
+ sum( + sum(
pre_map[n][0] pre_map[n][0]
* self.model.works[ * self.model.works[
@@ -2209,15 +2223,25 @@ 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:
self.model.constraints.add( #week_start_date = self.get_week_start_date(week)
1 #if constraint_shift.start_date <= week_start_date < constraint_shift.end_date:
>= self.model.works[ #works = 1
#else:
try:
works = self.model.works[
worker.id, week, day, constraint_shift.name worker.id, week, day, constraint_shift.name
] ]
except KeyError:
# If we can't find the key the shift is not assigned
# so we don't need to worry about it
continue
self.model.constraints.add(
1
>= works
+ sum( + sum(
post_map[n][0] post_map[n][0]
* self.model.works[ * self.model.works[
@@ -2230,6 +2254,8 @@ class RotaBuilder(object):
for w in workers for w in workers
) )
) )
#except KeyError:
# pass
# for constraint_shift in self.get_shifts_with_constraints( # for constraint_shift in self.get_shifts_with_constraints(
# "preclear", "preclear2" # "preclear", "preclear2"
@@ -2664,7 +2690,7 @@ class RotaBuilder(object):
def pair_shifts(self, shift1: SingleShift, shift2: SingleShift): def pair_shifts(self, shift1: SingleShift, shift2: SingleShift):
# NOTE: currently designed to pair two shifts # NOTE: currently designed to pair two shifts
# this could be extended... # this could be extended...
self.pair_shifts.append(set(shift1, shift2)) self.paired_shifts.append([shift1, shift2])
def build_shifts(self): def build_shifts(self):
""" """
@@ -2684,6 +2710,11 @@ 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}")
@@ -2746,6 +2777,7 @@ 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)
# # Todo replace with week_day..... # # Todo replace with week_day.....
# self.day_shift_product = [] # self.day_shift_product = []
# self.day_shiftclass_product = [] # self.day_shiftclass_product = []
@@ -3038,6 +3070,7 @@ class RotaBuilder(object):
f.write(self.get_worker_timetable_html(True)) f.write(self.get_worker_timetable_html(True))
url = urllib.parse.quote(str(output_file.resolve())) url = urllib.parse.quote(str(output_file.resolve()))
print(f"Rota exported [bold blue]('[link url={url}]{url}')[/bold blue]") 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"): def export_rota_to_csv(self, filename: str = "rota"):
output_file = Path("output", f"{filename}.csv") output_file = Path("output", f"{filename}.csv")
@@ -3272,6 +3305,8 @@ class RotaBuilder(object):
shift = self.get_shift_by_name(shift_name) shift = self.get_shift_by_name(shift_name)
if "require_remote_site_presence_week" in shift.constraints: if "require_remote_site_presence_week" in shift.constraints:
remote_site = f" data-shift-remote-site='{shift.constraint_options['require_remote_site_presence_week'][0]}'" remote_site = f" data-shift-remote-site='{shift.constraint_options['require_remote_site_presence_week'][0]}'"
if "night" in shift.constraints:
css_class = " ".join((css_class, "night-shift"))
shift_tds.append( shift_tds.append(
f"<td title='{title}' class='rota-day {css_class}' data-shift='{shift_name}' data-available='{available}' data-unavailable_reason='{unavailable_reason}' data-date='{d}' data-week='{week}' data-day='{day}'{remote_site}{requests}{bank_holiday}>{a}</td>" f"<td title='{title}' class='rota-day {css_class}' data-shift='{shift_name}' data-available='{available}' data-unavailable_reason='{unavailable_reason}' data-date='{d}' data-week='{week}' data-day='{day}'{remote_site}{requests}{bank_holiday}>{a}</td>"
@@ -3453,7 +3488,7 @@ class RotaBuilder(object):
return timetable return timetable
def get_worker_shifts_by_date(self, worker: Worker) -> Dict: def get_worker_shifts_by_date(self, worker: Worker) -> Dict[datetime.date, str]:
shifts = {} shifts = {}
n = 0 n = 0
@@ -3472,6 +3507,7 @@ class RotaBuilder(object):
shifts = {} shifts = {}
for worker in self.workers: for worker in self.workers:
print("*", worker.name)
shifts[worker.name] = len( shifts[worker.name] = len(
[i for i in self.get_worker_shift_list(worker) if i != ""] [i for i in self.get_worker_shift_list(worker) if i != ""]
) )
@@ -3487,7 +3523,7 @@ class RotaBuilder(object):
temp = "" temp = ""
for shift in self.get_shift_names_by_week_day(week, day): for shift in self.get_shift_names_by_week_day(week, day):
if self.model.works[worker.id, week, day, shift].value > 0: if self.model.works[worker.id, week, day, shift].value > 0.90:
temp = shift temp = shift
shifts.append(temp) shifts.append(temp)
+4
View File
@@ -598,7 +598,11 @@ class TestDemoRotaBalanceShiftSites:
self.Rota.constraint_options["balance_nights"] = False self.Rota.constraint_options["balance_nights"] = False
self.Rota.constraint_options["constrain_time_off_after_nights"] = False self.Rota.constraint_options["constrain_time_off_after_nights"] = False
try:
self.Rota.terminate_on_warning.remove("Worker/no valid shifts") self.Rota.terminate_on_warning.remove("Worker/no valid shifts")
except ValueError:
# Seems to happen with sequential test running
pass
self.Rota.build_and_solve(options={"ratio": 0.1}) self.Rota.build_and_solve(options={"ratio": 0.1})
group_workers = self.Rota.get_workers_by_group() group_workers = self.Rota.get_workers_by_group()
+162 -1
View File
@@ -2,7 +2,7 @@ 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
from rota.workers import Worker from rota.workers import NotAvailableToWork, WorkRequests, Worker
import itertools import itertools
@@ -439,3 +439,164 @@ class TestShiftDates:
for worker in Rota.get_workers(): for worker in Rota.get_workers():
assert Rota.get_worker_shift_list_string(worker).startswith("-"*7) assert Rota.get_worker_shift_list_string(worker).startswith("-"*7)
assert Rota.get_worker_shift_list_string(worker).endswith("-"*28) assert Rota.get_worker_shift_list_string(worker).endswith("-"*28)
def test_shift_start_date_end_date_block_more_workers(self):
Rota = generate_basic_rota(workers=10)
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=4,
constraint=[{"name": "max_shifts_per_week", "options": 4}],
#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="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))),
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("test_shift_start_date")
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():
print(Rota.get_worker_shift_list_string(worker))
assert total_shifts[worker.name] == 22
assert "ab" not in Rota.get_worker_shift_list_string(worker)
assert "ba" not in Rota.get_worker_shift_list_string(worker)
def test_shift_start_date_end_date_block_with_requests(self):
Rota = generate_basic_rota(workers=7)
request_date = Rota.start_date + datetime.timedelta(days=(7*6)+4)
request = [WorkRequests(date=request_date, shift="c")]
Rota.add_workers(
[
Worker(name=f"worker-wr-{i}", site="group2", grade=1, work_requests=request) for i in range(1, 4)
#Worker(name="worker2", site="group1", grade=1),
]
)
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=4,
constraint=[{"name": "max_shifts_per_week", "options": 4}],
#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="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))),
),
)
Rota.build_and_solve(options={"ratio": 0.1})
Rota.export_rota_to_html("test_shift_start_date")
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():
print(Rota.get_worker_shift_list_string(worker))
assert total_shifts[worker.name] in range(24,30)
shift_string = Rota.get_worker_shift_list_string(worker)
assert "ab" not in shift_string
assert "ba" not in shift_string
# Check all has been assigned as block
assert shift_string.count("aaaa") == shift_string.count("a") / 4
assert shift_string.count("bbb") == shift_string.count("b") / 3
assert shift_string.count("ccc") == shift_string.count("c") / 3
# TODO test the request assignment
for worker in Rota.get_workers_by_group()["group2"]:
assert Rota.get_worker_shifts_by_date(worker)[request_date] == "c"
def test_shift_start_date_end_date_block_paired(self):
Rota = generate_basic_rota(workers=7)
request_date = Rota.start_date + datetime.timedelta(days=(7*6)+4)
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))),
))
Rota.add_shifts(
a,
b, c
)
Rota.pair_shifts(b, c)
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.termination_condition == "optimal"