Compare commits

...
15 Commits
Author SHA1 Message Date
Ross cf48c3c168 latest rotas 2025-07-07 17:49:21 +01:00
Ross adccf8211c fix min summed shifts 2025-06-30 09:23:33 +01:00
Ross 48d6b748d1 fix the weekend count for multishits 2025-06-22 22:34:05 +01:00
Ross 2443ac285b fix a few more things 2025-06-22 22:20:52 +01:00
Ross 82bc624a33 a few more fixes and improvements 2025-06-22 11:48:33 +01:00
Ross 8402cc92fd fix bug with pre / post shift constraints 2025-06-22 10:53:17 +01:00
Ross fc3102ec47 numberous fixes and improvements 2025-06-22 09:38:03 +01:00
Ross 06a551bb68 a lot of changes 2025-06-21 11:45:58 +01:00
Ross e15cc6ee51 rewrite test 2025-06-08 14:25:30 +01:00
Ross d7e5f9e5f1 numerous improvemts 2025-06-08 14:00:15 +01:00
Ross ad75b6b8b5 fix multishifts 2025-06-07 13:02:45 +01:00
Ross b81570f457 basic multishift per day assignement 2025-06-06 22:36:58 +01:00
Ross a647416720 major test refactoring 2025-06-05 23:40:28 +01:00
Ross b9ca76aea1 update rota 2025-06-05 23:40:17 +01:00
Ross e46c24df88 add custom display char for shift 2025-06-05 21:59:26 +01:00
24 changed files with 4975 additions and 3500 deletions
+46 -85
View File
@@ -19,8 +19,6 @@ sites = (
"proc", "proc",
) )
NIGHT_REGISTRAR_NUMBER = 4
from rota.workers import ( from rota.workers import (
Worker, Worker,
NotAvailableToWork, NotAvailableToWork,
@@ -35,11 +33,11 @@ from rota.workers import (
def main( def main(
suspend: bool = False, suspend: bool = False,
solve: bool = True, solve: bool = True,
time_to_run: int = 60 * 60, time_to_run: int = 60 * 60 * 4,
ratio: float = 0.001, ratio: float = 0.001,
start_date: datetime.datetime = "2025-03-03", start_date: datetime.datetime = "2025-09-01",
weeks: int = 26, weeks: int = 16,
bom: int = 1, bom: int = 2,
): ):
rota_start_date = start_date.date() rota_start_date = start_date.date()
suspend_on_finish = suspend suspend_on_finish = suspend
@@ -60,16 +58,16 @@ 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 = [ #wr = [
WorkerRequirement( # WorkerRequirement(
end_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(), # end_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
number=3, # number=3,
), # ),
WorkerRequirement( # WorkerRequirement(
start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(), # start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
number=4, # number=4,
), # ),
] #]
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
@@ -226,7 +224,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=wr, workers_required=4,
force_as_block=True, force_as_block=True,
rota_on_nwds=True, rota_on_nwds=True,
constraint=[ constraint=[
@@ -256,7 +254,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=wr, workers_required=4,
force_as_block=True, force_as_block=True,
rota_on_nwds=True, rota_on_nwds=True,
constraint=[ constraint=[
@@ -267,68 +265,20 @@ def main(
"name": "require_remote_site_presence_week", "name": "require_remote_site_presence_week",
"options": ("plymouth", 1), "options": ("plymouth", 1),
}, },
{"name": "limit_grade_number", "options": {2: 1}}, {"name": "limit_grade_number", "options": {2: 2}},
{"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(),
# ),
) )
# Rota.add_grade_constraint_by_week([2], [1, 2], ["night_weekday", "night_weekend"]) # Prevent shifts for ST2s for 2 weeks
Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6)
Rota.terminate_on_warning.remove("Worker/no valid shifts")
load_leave = True load_leave = True
Rota.build_shifts() Rota.build_shifts()
@@ -341,10 +291,17 @@ def main(
n = 0 n = 0
for worker in workers: for worker in workers:
n = n + 1
worker_name = worker worker_name = worker
if worker_name == "Name":
continue
n = n + 1
w = workers[worker] w = workers[worker]
try:
site = w["site"] site = w["site"]
except KeyError:
print(f"Worker {worker} has no site")
raise KeyError
grade = w["grade"] grade = w["grade"]
try: try:
fte = float(w["fte"]) * 100 fte = float(w["fte"]) * 100
@@ -466,22 +423,26 @@ def main(
shift_fte_overrides = {} shift_fte_overrides = {}
if worker_name == "Ben Kemp": #if worker_name == "Ben Kemp":
# shift_fte_overrides = {
# "plymouth_twilight": 100,
# "weekend_plymouth1": 50,
# "weekend_plymouth2": 50,
# }
#elif worker_name == "Joel Lim":
# shift_fte_overrides = {
# "plymouth_twilight": 100,
# "weekend_exeter": 50,
# }
if worker_name == "Nang Thiriphoo":
shift_fte_overrides = { shift_fte_overrides = {
"plymouth_twilight": 100, "weekend_exeter": 40,
"weekend_plymouth1": 50,
"weekend_plymouth2": 50,
}
elif worker_name == "Joel Lim":
shift_fte_overrides = {
"plymouth_twilight": 100,
"weekend_exeter": 50,
} }
w = Worker( w = Worker(
name=worker_name, name=worker_name,
site=site.lower(), site=site.lower(),
grade=int(grade[2]), grade=int(grade),
id=n, id=n,
fte=int(fte), fte=int(fte),
nwds=nwds, nwds=nwds,
+234
View File
@@ -0,0 +1,234 @@
import datetime
import os
import sys
import time
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WorkerRequirement, days
import typer
import subprocess
from rota.workers import (
Worker,
NotAvailableToWork,
NonWorkingDays,
WorkRequests,
PreferenceNotToWork,
OutOfProgramme,
)
app = typer.Typer()
sites = ("rota a", "rota b", "rota c")
@app.command()
def main(
suspend: bool = False,
solve: bool = True,
time_to_run: int = 60 * 60,
ratio: float = 0.001,
start_date: datetime.datetime = "2025-09-01",
weeks: int = 22,
bom: int = 1,
):
rota_start_date = start_date.date()
suspend_on_finish = suspend
Rota = RotaBuilder(
rota_start_date,
weeks_to_rota=weeks,
balance_offset_modifier=bom,
name="cons rota",
)
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
Rota.constraint_options["balance_weekends"] = True
Rota.constraint_options["max_weekend_frequency"] = 4
Rota.constraint_options["max_shifts_per_week"] = 20
# Rota.constraint_options["avoid_st2_first_month"] = True
Rota.add_shifts(
SingleShift(
sites=("rota a",),
name="oncall",
length=12.5,
days=days,
balance_offset=2,
assign_as_block=False,
constraint=[
#{
# "name": "max_shifts_per_week",
# "options": 3,
#}
],
),
SingleShift(
sites=("rota a",),
name="weekend",
workers_required=1,
length=12.5,
days=days[5:],
balance_offset=2,
constraint=[
#{"name": "pre", "options": 2},
#{"name": "post", "options": 1},
],
display_char="a",
#force_assign_with=["oncall"]
),
SingleShift(
sites=("rota b",),
name="weekend b",
workers_required=1,
length=12.5,
days=days[5:],
balance_offset=2,
constraint=[
{"name": "pre", "options": 1},
{"name": "post", "options": 1},
],
display_char="b",
),
SingleShift(
sites=(
"rota a",
"rota b",
),
name="twilight",
workers_required=1,
length=12.5,
days=days[:5],
balance_offset=5,
#constraint=[
# {"name": "pre", "options": 1},
# {"name": "post", "options": 1},
#],
),
)
#Rota.allow_shifts_together_for_all_workers(
# "oncall",
# "twilight",
#)
#Rota.allow_shifts_together_for_all_workers(
# "oncall",
# "twilight",
#)
# Rota.add_grade_constraint_by_week([2], [1, 2], ["night_weekday", "night_weekend"])
load_leave = True
Rota.build_shifts()
# Rota.add_grade_constraint_by_week([2], [1], Rota.get_shift_names())
for n in range(1, 12):
w = Worker(name=f"A{n:02d}", site="rota a", grade=1)
#w.prefer_multi_shift_together = 10
#w.allow_shifts_together("oncall", "twilight")
#w.allow_shifts_together("oncall", "weekend")
#w.add_hard_day_dependency("Fri", "Sat", "Sun")
w.add_force_assign_with("twilight", "oncall")
w.add_force_assign_with("weekend", "oncall")
w.set_max_shifts_per_week("twilight", 1)
if n <= 2:
pass
w.add_hard_day_dependency("Fri", "Sat", "Sun")
else:
w.add_hard_day_dependency("Fri", "Sun")
#w.set_max_shifts_per_week("weekend", 1)
# #w.add_hard_day_dependency("Sat", "Sun")
print(w)
Rota.add_worker(w)
w13 = Worker(name=f"A13", site="rota a", grade=1, start_date="2025-10-01")
#w.prefer_multi_shift_together = 10
#w.allow_shifts_together("oncall", "twilight")
#w.allow_shifts_together("oncall", "weekend")
#w.add_hard_day_dependency("Fri", "Sat", "Sun")
w13.add_force_assign_with("twilight", "oncall")
w13.add_force_assign_with("weekend", "oncall")
#w.set_max_shifts_per_week("twilight", 1)
Rota.add_worker(w13)
w13 = Worker(name=f"A14", site="rota a", grade=1, start_date="2025-12-01")
#w.prefer_multi_shift_together = 10
#w.allow_shifts_together("oncall", "twilight")
#w.allow_shifts_together("oncall", "weekend")
#w.add_hard_day_dependency("Fri", "Sat", "Sun")
w13.add_force_assign_with("twilight", "oncall")
w13.add_force_assign_with("weekend", "oncall")
#w.set_max_shifts_per_week("twilight", 1)
Rota.add_worker(w13)
for w in range(14, 24):
w = Worker(
name=f"B{w}",
site="rota b",
grade=1,
nwds=[
NonWorkingDays(
day="Fri",
)
],
)
#w.add_hard_day_exclusion("Sat", "Sun")
w.set_max_shifts_per_week("twilight", 2)
w.set_max_shifts_per_week("weekend b", 1)
print(w)
Rota.add_worker(w)
# Rota.build_workers()
# Rota.build_model()
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
# solver_options = {"seconds": time_to_run, "threads": 10}
# start_time = time.time()
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_individually(solver_options)
# Rota.solve_model(options=solver_options)
# end_time = time.time()
# print(f"Time taken {end_time-start_time}")
# print(Rota.get_worker_details())
# optimizer = SolverFactory('cbc')
# result = optimizer.solve(prob,tee=True)
# result.Solver.Status = SolverStatus.warning
# prob.solutions.load_from(result)
# ResultsHolder = RotaResults(Rota)
# worker_timetable_brief = ResultsHolder.get_worker_timetable_brief(
# show_prefs=False, show_unavailable=False
# )
#
# print(worker_timetable_brief)
# Rota.export_rota_to_html("proc_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:
os.system("systemctl suspend")
if __name__ == "__main__":
app()
+20 -8
View File
@@ -27,7 +27,8 @@ def load_leave(Rota):
} }
if live_rota: 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) download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSfcQi5Qs__A8hE2CVvUA_6ULNJALrtZOBHPUZ3xyrxPif9obtQF2IqhioT_4nebjHV1Ac5iqhNtuq4/pub?gid=2024511103&single=true&output=csv", headers=headers)
#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=',')
@@ -56,6 +57,7 @@ def load_leave(Rota):
names = r names = r
order = names order = names
for name in names: for name in names:
name = name.strip()
workers[name] = {} workers[name] = {}
workers[name]["leave"] = [] workers[name]["leave"] = []
@@ -67,13 +69,16 @@ def load_leave(Rota):
workers[name]["start_date"] = None workers[name]["start_date"] = None
workers[name]["pair"] = None workers[name]["pair"] = None
n = n + 1
continue
for i in range(len(r)): for i in range(len(r)):
try: try:
a = order[i] a = order[i].strip()
except IndexError: except IndexError:
continue continue
worker = workers[order[i]] worker = workers[a]
lower_item = r[i].lower() lower_item = r[i].lower()
if lower_item == "derriford": if lower_item == "derriford":
@@ -82,16 +87,18 @@ 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 or row_title == "PROC nights from": if "PROC site" in row_title or row_title == "PROC nights from" or "proc nights" in row_title.lower():
worker["site_pref"] = lower_item worker["site_pref"] = lower_item
elif row_title in ("Rotation", "Site", "Placement", "Placement location"): elif row_title in ("Rotation", "Site", "Placement", "Placement location"):
worker["site"] = lower_item worker["site"] = lower_item
elif row_title in ("Grade", "Grade (ST)"): elif row_title in ("Grade", "Grade (ST)") or "Year of training" in row_title:
worker["grade"] = lower_item worker["grade"] = lower_item.lstrip("ST")
elif row_title in ("%FTE", "%FTE on-call", "FTE", "FTE on-call"): elif row_title in ("%FTE", "%FTE on-call", "FTE", "FTE on-call") or "on-call commitment" in row_title:
if lower_item.endswith("%"):
lower_item = float(lower_item.rstrip("%")) / 100
worker["fte"] = lower_item worker["fte"] = lower_item
elif "NWD" in row_title: elif "NWD" in row_title:
@@ -102,7 +109,10 @@ def load_leave(Rota):
elif row_title in ("End Date", "CCT date"): elif row_title in ("End Date", "CCT 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()
worker["end_date"] = date worker["end_date"] = date
else: else:
worker["end_date"] = None worker["end_date"] = None
@@ -121,7 +131,7 @@ def load_leave(Rota):
else: else:
worker["start_date"] = None worker["start_date"] = None
elif "OOP" in row_title: elif "OOP" in row_title or "out of programme" in row_title.lower():
worker["oop"] = lower_item worker["oop"] = lower_item
elif row_title == "Group": elif row_title == "Group":
@@ -178,6 +188,8 @@ def load_leave(Rota):
n = n + 1 n = n + 1
return workers return workers
+1
View File
@@ -0,0 +1 @@
/home/ross/proc_rota/output/timetable.css
+1
View File
@@ -0,0 +1 @@
/home/ross/proc_rota/output/timetable.js
+4
View File
@@ -322,3 +322,7 @@ table.transposed th.bank-holiday {
.locum-shift { .locum-shift {
background-color: lightgreen; background-color: lightgreen;
} }
.multi-shift {
line-height: 1;
}
+42 -16
View File
@@ -43,7 +43,16 @@ function generateExtra() {
shifts = [] shifts = []
shift_tds.each((n, td) => { shift_tds.each((n, td) => {
shifts.push($(td).attr("data-shift")); let data_shift = $(td).attr("data-shift");
if (data_shift && data_shift !== "") {
// Split by comma for multiple shifts per day
let split_shifts = data_shift.split(",");
split_shifts.forEach(s => {
if (s && s.trim() !== "") {
shifts.push(s.trim());
}
});
}
}) })
shifts_unique = new Set(shifts); shifts_unique = new Set(shifts);
@@ -286,7 +295,10 @@ $(".table-div .worker-row .worker").each((n, tr) => {
locum_shift_counts = jtr.data("locum-shift-counts") locum_shift_counts = jtr.data("locum-shift-counts")
shift_targets = jtr.data("worker-targets") shift_targets = jtr.data("worker-targets")
console.log(shift_counts, locum_shift_counts, shift_targets)
oshifts.forEach((s) => { oshifts.forEach((s) => {
console.log(s, shift_counts, shift_targets, locum_shift_counts)
if (s in shift_targets && shift_targets[s] > 0) { if (s in shift_targets && shift_targets[s] > 0) {
if (s in shift_counts) { if (s in shift_counts) {
c = shift_counts[s]; c = shift_counts[s];
@@ -362,11 +374,12 @@ oshifts.forEach((shift) => {
$("table#main-table th.date").each((n, th) => { $("table#main-table th.date").each((n, th) => {
row = $("<tr>") row = $("<tr>")
row.append(`<td>${th.dataset.date}</td>`); row.append(`<td>${th.dataset.date}</td>`);
$(`table#main-table td[data-date='${th.dataset.date}'][data-shift='${evt.target.dataset.shift}']`).each((n, td) => { $(`table#main-table td[data-date='${th.dataset.date}']`).filter(function() {
let shifts = $(this).attr("data-shift");
//row.append(`<td>${}</td>`) if (!shifts) return false;
row.append($(td).closest("tr").children("td:first").clone()) return shifts.split(",").map(s => s.trim()).includes(evt.target.dataset.shift);
}).each((n, td) => {
row.append($(td).closest("tr").children("td:first").clone());
}); });
table.append(row) table.append(row)
@@ -416,8 +429,9 @@ $(".table-div + pre").each((n, pre) => {
}) })
$("td").hover((e) => { $("td").hover((e) => {
//start hover // start hover
worker_td = $(e.target).closest('tr').find('td:first')[0] worker_td = $(e.target).closest('tr').find('td:first')[0]
pair = worker_td.dataset.pair; pair = worker_td.dataset.pair;
index = $(e.target).index() - 1; index = $(e.target).index() - 1;
@@ -426,24 +440,36 @@ $("td").hover((e) => {
$($(el).closest('tr').find('td').get(index)).addClass("pair-match") $($(el).closest('tr').find('td').get(index)).addClass("pair-match")
}) })
} }
if (e.target.dataset.shift != undefined && e.target.dataset.shift.length > 0) {
$(`td[data-shift=${e.target.dataset.shift}]`).addClass("shift-highlight")
// Find the span under the mouse, if any
let targetSpan = $(e.target).closest("span.multi-shift-shift").get(0) ||
($(e.target).hasClass("multi-shift-shift") ? e.target : null);
$(`td[data-remote-site='${e.target.dataset.shiftRemoteSite}']`).closest('tr').find(`td[data-shift=${e.target.dataset.shift}]`).addClass("remote-site-match") if (targetSpan) {
// if (e.target.dataset.shiftRemoteSite == worker_td.dataset.remoteSite) { // Only highlight spans with the same text
// $(`td[data-shift-remote-site=${e.target.dataset.shiftRemoteSite}]`).addClass("remote-site-match") let shiftDisplay = $(targetSpan).text().trim();
// } $("span.multi-shift-shift").filter(function() {
return $(this).text().trim() === shiftDisplay;
}).addClass("shift-highlight");
} else if (e.target.dataset.shift != undefined && e.target.dataset.shift.length > 0) {
// Fallback: highlight all cells containing ANY of the hovered shifts (legacy)
let hovered_shifts = e.target.dataset.shift.split(",").map(s => s.trim());
$("td").filter(function() {
let shifts = $(this).attr("data-shift");
if (!shifts) return false;
let cell_shifts = shifts.split(",").map(s => s.trim());
return hovered_shifts.some(s => cell_shifts.includes(s));
}).addClass("shift-highlight");
} }
}, (e) => { }, (e) => {
// end hover
//end hover
$("td.pair-match").removeClass("pair-match") $("td.pair-match").removeClass("pair-match")
$("td.shift-highlight").removeClass("shift-highlight") $("td.shift-highlight").removeClass("shift-highlight")
$("span.shift-highlight").removeClass("shift-highlight")
$("td.remote-site-match").removeClass("remote-site-match") $("td.remote-site-match").removeClass("remote-site-match")
});
})
$("#rota-table tr td:first-child").each((n, td) => { $("#rota-table tr td:first-child").each((n, td) => {
$(td).click(() => { $(td).click(() => {
+688 -198
View File
File diff suppressed because it is too large Load Diff
+43 -1
View File
@@ -111,9 +111,23 @@ class Worker(BaseModel):
bank_holiday_extra: int = 0 bank_holiday_extra: int = 0
pair: int | str | None = None pair: int | str | None = None
locum: bool = False locum: bool = False
allowed_multi_shift_sets: list[set] = [] # or list/set of frozensets
prefer_multi_shift_together: int = 0 # Default: no preference
# Set to a positive integer to prefer, negative to discourage, 0 for neutral
hard_day_dependencies: set[frozenset[str]] = set() # e.g. {frozenset({"Mon", "Tue"}), frozenset({"Fri", "Sun"})}
hard_day_exclusions: list[tuple[str, str]] = [] # e.g. [("Fri", "Sun")]
force_assign_with: dict[str, list[str]] = {} # e.g. {"oncall": ["weekend"]}
max_shifts_per_week_by_shift_name: dict[str, int] = {} # e.g. {"night": 2, "a": 3}
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
weekend_shift_target_number: int = 0
model_config = ConfigDict( model_config = ConfigDict(
extra="allow", extra="allow",
) )
@@ -223,7 +237,7 @@ class Worker(BaseModel):
end_oop_date = datetime.datetime.strptime(end_oop, "%d/%m/%y").date() end_oop_date = datetime.datetime.strptime(end_oop, "%d/%m/%y").date()
if start_oop_date >= end_oop_date: if start_oop_date >= end_oop_date:
raise ValueError("End OOP date must be after start date") raise ValueError(f"End OOP date must be after start date [{self.name} - Start date: {self.start_date} / End date: {self.end_date}]")
# ignore oops if they finish before the rota (or worker) start date # ignore oops if they finish before the rota (or worker) start date
if end_oop_date > self.calculated_start_date: if end_oop_date > self.calculated_start_date:
@@ -389,3 +403,31 @@ class Worker(BaseModel):
if shift in self.fte_adj_shifts: if shift in self.fte_adj_shifts:
return self.fte_adj_shifts[shift] return self.fte_adj_shifts[shift]
return self.fte_adj return self.fte_adj
def allow_shifts_together(self, *shifts):
if not hasattr(self, "allowed_multi_shift_sets"):
self.allowed_multi_shift_sets = []
self.allowed_multi_shift_sets.append(frozenset(shifts))
def add_hard_day_dependency(self, *days: str):
if not hasattr(self, "hard_day_dependencies"):
self.hard_day_dependencies = set()
self.hard_day_dependencies.add(frozenset(days))
def add_hard_day_exclusion(self, day1: str, day2: str):
if not hasattr(self, "hard_day_exclusions"):
self.hard_day_exclusions = []
self.hard_day_exclusions.append((day1, day2))
def add_force_assign_with(self, shift: str, with_shifts: list[str] | str):
if not hasattr(self, "force_assign_with"):
self.force_assign_with = {}
if isinstance(with_shifts, str):
with_shifts = [with_shifts]
self.force_assign_with[shift] = with_shifts
def set_max_shifts_per_week(self, shift_name: str, max_per_week: int):
"""Helper to set the maximum number of shifts of a given type per week for this worker."""
if not hasattr(self, "max_shifts_per_week_by_shift_name"):
self.max_shifts_per_week_by_shift_name = {}
self.max_shifts_per_week_by_shift_name[shift_name] = max_per_week
@@ -0,0 +1,94 @@
import datetime
import pytest
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days, MinSummedGradeByShiftsPerDayConstraint
from rota.workers import Worker
import itertools
def generate_basic_rota(weeks_to_rota=9):
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
# Add a few workers
Rota.add_workers(
[
Worker(name="worker1", site="group1", grade=1),
Worker(name="worker2", site="group1", grade=2),
Worker(name="worker3", site="group1", grade=3),
Worker(name="worker4", site="group1", grade=1),
Worker(name="worker5", site="group1", grade=2),
Worker(name="worker6", site="group1", grade=3),
]
)
return Rota
def test_summed_shift_grade_constraints():
Rota = generate_basic_rota(5)
Rota.add_shifts(
SingleShift(
sites=("group1",), name="a", length=12.5, days=days,
workers_required=1,
force_as_block=False,
),
SingleShift(
sites=("group1",), name="b", length=12.5, days=days,
workers_required=1,
force_as_block=False,
),
)
# Add summed shift grade constraints
Rota.constraint_options["min_summed_grade_by_shifts_per_day"].append(
MinSummedGradeByShiftsPerDayConstraint(
shifts=["a", "b"],
min_grade_sum=4,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
Rota.export_rota_to_html("summed_shift_grade_constraints", folder="tests")
a_grades = []
b_grades = []
for shift, shift_timetable in Rota.get_shift_timetable_by_week().items():
for week, week_timetable in shift_timetable.items():
for day, workers in week_timetable.items():
if shift == "a":
for worker in workers:
a_grades.append(worker.grade)
elif shift == "b":
for worker in workers:
b_grades.append(worker.grade)
# Check that the minimum summed grade is respected
summed_grades = [a + b for a, b in zip(a_grades, b_grades)]
for summed_grade in summed_grades:
assert summed_grade >= 4, f"Summed grade {summed_grade} is less than 4"
Rota.constraint_options["min_summed_grade_by_shifts_per_day"] = [
MinSummedGradeByShiftsPerDayConstraint(
shifts=["a", "b"],
min_grade_sum=5,
),
]
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
for worker in Rota.get_workers():
if worker.grade == 1:
assert Rota.get_worker_shift_list(worker).count("a") == 0, f"Worker {worker.name} with grade 1 should not have shift 'a'"
assert Rota.get_worker_shift_list(worker).count("b") == 0, f"Worker {worker.name} with grade 1 should not have shift 'b'"
+139
View File
@@ -0,0 +1,139 @@
import pytest
import datetime
from rota.shifts import RotaBuilder, SingleShift, days
from rota.workers import Worker
@pytest.fixture
def demo_rota_balance_sites():
weeks_to_rota = 8
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
worker1 = Worker(name="worker1", site="group1", grade=1)
worker2 = Worker(name="worker2", site="group1", grade=1)
worker3 = Worker(name="worker3", site="group2", grade=1)
worker4 = Worker(name="worker4", site="group2", grade=1)
worker5 = Worker(name="worker5", site="group3", grade=1)
worker6 = Worker(name="worker6", site="group3", grade=1)
Rota.add_workers((worker1, worker2, worker3, worker4, worker5, worker6))
return Rota
def test_balance_blocks_across_2_groups(demo_rota_balance_sites):
Rota = demo_rota_balance_sites
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="weekday_night",
length=12.5,
days=days[:4],
balance_offset=40,
workers_required=2,
force_as_block=True,
constraint=[{"name": "balance_across_groups"}],
),
)
Rota.constraint_options["balance_nights_across_sites"] = False
Rota.constraint_options["balance_nights"] = False
Rota.constraint_options["constrain_time_off_after_nights"] = False
# Worker 5 and 6 don't have any valid shifts so this should fail
#with pytest.raises(WarningTermination):
# Rota.build_and_solve(options={"ratio": 0.0})
# Unless we remove the warning
Rota.terminate_on_warning.remove("Worker/no valid shifts")
Rota.build_and_solve(options={"ratio": 0.0})
assert len(Rota.get_warnings("Worker/no valid shifts")) == 2
Rota.export_rota_to_html("test5")
group_workers = Rota.get_workers_by_group()
for group in group_workers:
shift_patterns = []
for w in group_workers[group]:
shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns))
for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") <= 1
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
def test_balance_blocks_across_2_groups_unbalanced(demo_rota_balance_sites):
Rota = demo_rota_balance_sites
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="weekday_night",
length=12.5,
days=days[:4],
balance_offset=40,
workers_required=3,
force_as_block=True,
constraint=[{"name": "balance_across_groups"}],
),
)
Rota.constraint_options["balance_nights_across_sites"] = False
Rota.constraint_options["balance_nights"] = False
Rota.constraint_options["constrain_time_off_after_nights"] = False
try:
Rota.terminate_on_warning.remove("Worker/no valid shifts")
except ValueError:
# Seems to happen with sequential test running
pass
Rota.build_and_solve(options={"ratio": 0.1})
group_workers = Rota.get_workers_by_group()
for group in group_workers:
shift_patterns = []
for w in group_workers[group]:
shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns))
for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") <= 2
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
def test_balance_blocks_across_3_groups(demo_rota_balance_sites):
Rota = demo_rota_balance_sites
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="weekday_night",
length=12.5,
days=days[:4],
balance_offset=40,
workers_required=3,
force_as_block=True,
constraint=[{"name": "balance_across_groups"}],
),
)
Rota.constraint_options["balance_nights_across_sites"] = False
Rota.constraint_options["balance_nights"] = False
Rota.constraint_options["constrain_time_off_after_nights"] = False
Rota.build_and_solve(options={"ratio": 0.1})
group_workers = Rota.get_workers_by_group()
for group in group_workers:
shift_patterns = []
for w in group_workers[group]:
shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns))
for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") <= 1
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
+7 -64
View File
@@ -1,39 +1,27 @@
import datetime import datetime
import pytest import pytest
from pytest import approx from pytest import approx
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days from rota.shifts import RotaBuilder, SingleShift, days
from rota.workers import Worker from rota.workers import Worker
def generate_basic_rota(weeks_to_rota=10): def generate_basic_rota(weeks_to_rota=10):
start_date = datetime.date(2022, 3, 7) start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder( Rota = RotaBuilder(
start_date, start_date,
weeks_to_rota=weeks_to_rota, weeks_to_rota=weeks_to_rota,
) )
Rota.constraint_options["balance_weekends"] = True Rota.constraint_options["balance_weekends"] = True
# Add a few workers
Rota.add_workers( Rota.add_workers(
[ [
Worker(name="worker1", site="group1", grade=1), Worker(name="worker1", site="group1", grade=1),
Worker(name="worker2", site="group1", grade=1), Worker(name="worker2", site="group1", grade=1),
Worker(name="worker3", site="group2", grade=1), Worker(name="worker3", site="group2", grade=1),
# Worker(name="worker4", site="group2", grade=1),
# Worker(name="worker5", site="group2", grade=1),
# Worker(name="worker6", site="group2", grade=1, fte=50),
] ]
) )
return Rota return Rota
def test_basic_balancing():
class TestBalancing:
def test_basic_balancing(self):
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -54,21 +42,16 @@ class TestBalancing:
force_as_block=False, force_as_block=False,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.001}) Rota.build_and_solve(options={"ratio": 0.001})
Rota.export_rota_to_html("basic_balancing") Rota.export_rota_to_html("basic_balancing")
shift_summary = Rota.get_shift_summary_dict() shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary: for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name] worker_shifts = shift_summary[worker_name]
#worker = Rota.get_worker_by_name(worker_name)
assert worker_shifts["a"] in (approx(22), approx(23), approx(24), approx(25)) assert worker_shifts["a"] in (approx(22), approx(23), approx(24), approx(25))
assert worker_shifts["b"] in (approx(22), approx(23), approx(24), approx(25)) assert worker_shifts["b"] in (approx(22), approx(23), approx(24), approx(25))
def test_weighted_shift_balancing(self): def test_weighted_shift_balancing():
Rota = generate_basic_rota(20) Rota = generate_basic_rota(20)
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=["group1", "group2"], sites=["group1", "group2"],
@@ -97,32 +80,23 @@ class TestBalancing:
force_as_block=False, force_as_block=False,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.0001}) Rota.build_and_solve(options={"ratio": 0.0001})
Rota.export_rota_to_html("basic_balancing_weighted_shifts") Rota.export_rota_to_html("basic_balancing_weighted_shifts")
shift_summary = Rota.get_shift_summary_dict() shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary: for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name] worker_shifts = shift_summary[worker_name]
worker = Rota.get_worker_by_name(worker_name)
assert worker_shifts["a"] in (approx(46), approx(47), approx(48)) assert worker_shifts["a"] in (approx(46), approx(47), approx(48))
#assert worker_shifts["b"] in (46, 47, 48)
# assert worker_shifts["b"] in (22, 23, 24, 25)
def test_weighted_shift_balancing2(self): def test_weighted_shift_balancing2():
Rota = generate_basic_rota(23) Rota = generate_basic_rota(23)
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
name="a", name="a",
length=12.5, length=12.5,
days=days, days=days,
# balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False, force_as_block=False,
# constraint=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -142,32 +116,23 @@ class TestBalancing:
force_as_block=False, force_as_block=False,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.0001}) Rota.build_and_solve(options={"ratio": 0.0001})
Rota.export_rota_to_html("basic_balancing_weighted_shifts2") Rota.export_rota_to_html("basic_balancing_weighted_shifts2")
shift_summary = Rota.get_shift_summary_dict() shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary: for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name] worker_shifts = shift_summary[worker_name]
worker = Rota.get_worker_by_name(worker_name)
# assert worker_shifts["a"] in (46,47,48)
# assert worker_shifts["c"] in (46,47,48)
assert worker_shifts["b"] in (approx(53), approx(54), approx(55)) assert worker_shifts["b"] in (approx(53), approx(54), approx(55))
def test_weighted_shift_balancing3(self): def test_weighted_shift_balancing3():
Rota = generate_basic_rota(23) Rota = generate_basic_rota(23)
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
name="a", name="a",
length=12.5, length=12.5,
days=days, days=days,
# balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False, force_as_block=False,
# constraint=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -188,26 +153,18 @@ class TestBalancing:
force_as_block=False, force_as_block=False,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.0001}) Rota.build_and_solve(options={"ratio": 0.0001})
Rota.export_rota_to_html("basic_balancing_weighted_shifts3") Rota.export_rota_to_html("basic_balancing_weighted_shifts3")
shift_summary = Rota.get_shift_summary_dict() shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary: for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name] worker_shifts = shift_summary[worker_name]
worker = Rota.get_worker_by_name(worker_name)
# assert worker_shifts["a"] in (46,47,48)
assert worker_shifts["c"] in (approx(7), approx(8)) assert worker_shifts["c"] in (approx(7), approx(8))
#assert worker_shifts["b"] in (53, 54)
def test_weighted_shift_balancing4(self): def test_weighted_shift_balancing4():
Rota = generate_basic_rota(10) Rota = generate_basic_rota(10)
Rota.add_worker( Rota.add_worker(
Worker(name="worker4", site="group3", grade=1), Worker(name="worker4", site="group3", grade=1),
) )
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -218,7 +175,6 @@ class TestBalancing:
balance_weighting=8, balance_weighting=8,
workers_required=2, workers_required=2,
force_as_block=False, force_as_block=False,
# constraint=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
sites=("group2", "group3"), sites=("group2", "group3"),
@@ -226,29 +182,22 @@ class TestBalancing:
length=12.5, length=12.5,
days=days, days=days,
balance_offset=99, balance_offset=99,
# balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False, force_as_block=False,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("basic_balancing_weighted_shifts4") Rota.export_rota_to_html("basic_balancing_weighted_shifts4")
shift_summary = Rota.get_shift_summary_dict() shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary: for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name] worker_shifts = shift_summary[worker_name]
worker = Rota.get_worker_by_name(worker_name)
assert worker_shifts["a"] in (0, 46, 47, 48) assert worker_shifts["a"] in (0, 46, 47, 48)
def test_weighted_shift_balancing5(self): def test_weighted_shift_balancing5():
Rota = generate_basic_rota(10) Rota = generate_basic_rota(10)
Rota.add_worker( Rota.add_worker(
Worker(name="worker4", site="group3", grade=1), Worker(name="worker4", site="group3", grade=1),
) )
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -256,10 +205,8 @@ class TestBalancing:
length=12.5, length=12.5,
days=days, days=days,
balance_offset=99, balance_offset=99,
# balance_weighting=8,
workers_required=2, workers_required=2,
force_as_block=False, force_as_block=False,
# constraint=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
sites=("group2", "group3"), sites=("group2", "group3"),
@@ -272,13 +219,9 @@ class TestBalancing:
force_as_block=False, force_as_block=False,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("basic_balancing_weighted_shifts5") Rota.export_rota_to_html("basic_balancing_weighted_shifts5")
shift_summary = Rota.get_shift_summary_dict() shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary: for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name] worker_shifts = shift_summary[worker_name]
worker = Rota.get_worker_by_name(worker_name)
assert worker_shifts["b"] in (0, 35) assert worker_shifts["b"] in (0, 35)
+325
View File
@@ -0,0 +1,325 @@
import pytest
import datetime
from rota.shifts import RotaBuilder, SingleShift, days
from rota.workers import Worker
@pytest.fixture
def limit_constraint_rota():
weeks_to_rota = 8
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
worker1 = Worker(name="worker1", site="group1", grade=2, remote_site="group1")
worker2 = Worker(name="worker2", site="group1", grade=2, remote_site="group1")
worker3 = Worker(name="worker3", site="group1", grade=2, remote_site="group1")
worker4 = Worker(name="worker4", site="group3", grade=3, remote_site="group2")
worker5 = Worker(name="worker5", site="group3", grade=3, remote_site="group2")
worker6 = Worker(name="worker6", site="group3", grade=3, remote_site="group2")
Rota.add_workers((worker1, worker2, worker3, worker4, worker5, worker6))
Rota.shifts = []
return Rota
def test_constraint_limit_grades(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="weekday_night",
length=12.5,
days=days,
balance_offset=60,
workers_required=4,
force_as_block=True,
constraint=[{"name": "limit_grade_number", "options": {2: 1}}],
),
)
Rota.build_and_solve(options={"ratio": 0.01})
Rota.export_rota_to_html("constraint_limit_grades", folder="tests")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
grade_workers = Rota.get_workers_by_grade()
for grade in grade_workers:
shift_patterns = [Rota.get_worker_shift_list(w) for w in grade_workers[grade]]
zipped_lists = list(zip(*shift_patterns))
limit = 1 if grade == 2 else 3
for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") == limit
def test_constraint_limit_grades2(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="weekday_night",
length=12.5,
days=days,
balance_offset=40,
workers_required=4,
force_as_block=True,
constraint=[{"name": "limit_grade_number", "options": {3: 1}}],
),
)
Rota.constraint_options["balance_shifts_quadratic"] = True
Rota.build_and_solve(options={"ratio": 0.1})
Rota.export_rota_to_html("test9", folder="tests")
grade_workers = Rota.get_workers_by_grade()
for grade in grade_workers:
shift_patterns = [Rota.get_worker_shift_list(w) for w in grade_workers[grade]]
zipped_lists = list(zip(*shift_patterns))
limit = 1 if grade == 3 else 3
for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") == limit
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
def test_constraint_limit_grades3(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.workers = []
worker1 = Worker(name="worker1", site="group1", grade=2, remote_site="group1")
worker2 = Worker(name="worker2", site="group1", grade=2, remote_site="group1")
worker3 = Worker(name="worker3", site="group1", grade=2, remote_site="group1")
worker4 = Worker(name="worker4", site="group3", grade=3, remote_site="group2")
worker5 = Worker(name="worker5", site="group3", grade=3, remote_site="group2")
worker6 = Worker(name="worker6", site="group3", grade=3, remote_site="group2")
worker7 = Worker(name="worker7", site="group3", grade=2, remote_site="group2")
Rota.add_workers((worker1, worker2, worker3, worker4, worker5, worker6, worker7))
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="weekday_night",
length=12.5,
days=days,
balance_offset=60,
workers_required=4,
force_as_block=True,
constraint=[{"name": "limit_grade_number", "options": {2: 3, 3: 1}}],
),
)
Rota.constraint_options["balance_shifts_quadratic"] = True
Rota.build_and_solve(options={"ratio": 0.01})
Rota.export_rota_to_html("test9")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
grade_workers = Rota.get_workers_by_grade()
for grade in grade_workers:
shift_patterns = [Rota.get_worker_shift_list(w) for w in grade_workers[grade]]
zipped_lists = list(zip(*shift_patterns))
limit = 3 if grade == 2 else 1
for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") == limit
def test_constraint_limit_grades4(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="weekday_night",
length=12.5,
days=days,
balance_offset=40,
workers_required=4,
force_as_block=True,
constraint=[{"name": "limit_grade_number", "options": {2: 4, 3: 0}}],
),
)
Rota.constraint_options["balance_shifts_quadratic"] = True
Rota.build_and_solve(options={"ratio": 0.1})
assert Rota.results.solver.termination_condition == "infeasible"
def test_constraint_minimum_grades(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="weekday_night",
length=12.5,
days=days,
balance_offset=40,
workers_required=1,
force_as_block=True,
constraint=[{"name": "minimum_grade_number", "options": (3, 1)}],
),
)
Rota.constraint_options["balance_shifts_quadratic"] = True
Rota.build_and_solve(options={"ratio": 0.1})
Rota.export_rota_to_html("test9")
assert Rota.results.solver.status == "ok"
grade_workers = Rota.get_workers_by_grade()
for grade in grade_workers:
shift_patterns = [Rota.get_worker_shift_list(w) for w in grade_workers[grade]]
zipped_lists = list(zip(*shift_patterns))
limit = 1 if grade > 2 else 0
for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") == limit
def test_constraint_minimum_grades2(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="weekday_night",
length=12.5,
days=days,
balance_offset=40,
workers_required=4,
force_as_block=True,
constraint=[{"name": "minimum_grade_number", "options": (3, 3)}],
),
)
Rota.constraint_options["balance_shifts_quadratic"] = True
Rota.build_and_solve(options={"ratio": 0.1})
Rota.export_rota_to_html("test9")
grade_workers = Rota.get_workers_by_grade()
for grade in grade_workers:
shift_patterns = [Rota.get_worker_shift_list(w) for w in grade_workers[grade]]
zipped_lists = list(zip(*shift_patterns))
limit = 3 if grade > 2 else 1
for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") == limit
assert Rota.results.solver.status == "ok"
def test_constraint_minimum_grades3(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="weekday_night",
length=12.5,
days=days,
balance_offset=40,
workers_required=4,
force_as_block=True,
constraint=[{"name": "minimum_grade_number", "options": (3, 0)}],
),
)
Rota.constraint_options["balance_shifts_quadratic"] = True
Rota.build_and_solve(options={"ratio": 0.1})
Rota.export_rota_to_html("test9")
assert Rota.results.solver.status == "ok"
def test_constraint_minimum_grades_no_valid_worker(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="weekday_night",
length=12.5,
days=days,
balance_offset=40,
workers_required=4,
force_as_block=True,
constraint=[{"name": "minimum_grade_number", "options": (4, 1)}],
),
)
Rota.constraint_options["balance_shifts_quadratic"] = True
with pytest.raises(ValueError):
Rota.build_and_solve(options={"ratio": 0.1})
def test_constraint_require_remote_site_presence_week(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="night",
length=12.5,
days=days,
balance_offset=40,
workers_required=2,
force_as_block=True,
constraint=[
{
"name": "require_remote_site_presence_week",
"options": ("group1", 2),
}
],
),
)
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("remote1")
group_workers = Rota.get_workers_by_group()
for group in group_workers:
shift_patterns = [Rota.get_worker_shift_list(w) for w in group_workers[group]]
zipped_lists = list(zip(*shift_patterns))
limit = 2 if group == "group1" else 0
for day_shifts in zipped_lists:
assert day_shifts.count("night") == limit
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
def test_constraint_require_remote_site_presence_week2(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.constraint_options["minimise_shift_diffs"] = False
Rota.constraint_options["balance_shifts_quadratic"] = False
Rota.constraint_options["balance_shift"] = False
Rota.constraint_options["balance_nights_across_sites"] = True
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="night",
length=12.5,
days=days,
balance_offset=20,
workers_required=3,
force_as_block=True,
constraint=[
{
"name": "require_remote_site_presence_week",
"options": ("group1", 2),
}
],
),
)
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("remote2")
group_workers = Rota.get_workers_by_remote_group()
for group in group_workers:
shift_patterns = [Rota.get_worker_shift_list(w) for w in group_workers[group]]
zipped_lists = list(zip(*shift_patterns))
if group == "group1":
for day_shifts in zipped_lists:
assert day_shifts.count("night") >= 2
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
def test_constraint_require_remote_site_presence_week3(limit_constraint_rota):
Rota = limit_constraint_rota
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1", "group2", "group3"),
name="night",
length=12.5,
days=days,
balance_offset=20,
workers_required=3,
force_as_block=True,
constraint=[
{
"name": "require_remote_site_presence_week",
"options": ("group2", 1),
}
],
),
)
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("remote3")
group_workers = Rota.get_workers_by_remote_group()
for group in group_workers:
shift_patterns = [Rota.get_worker_shift_list(w) for w in group_workers[group]]
zipped_lists = list(zip(*shift_patterns))
if group == "group2":
for day_shifts in zipped_lists:
assert day_shifts.count("night") >= 1
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
+276
View File
@@ -0,0 +1,276 @@
import pytest
import datetime
from rota.shifts import RotaBuilder, SingleShift, days
from rota.workers import Worker
@pytest.fixture
def basic_rota():
start_date = datetime.date(2022, 4, 4) # must be a Monday!
Rota = RotaBuilder(start_date=start_date, weeks_to_rota=2)
worker = Worker(name="worker1", site="site1", grade=1)
Rota.add_worker(worker)
return Rota
@pytest.fixture
def long_rota():
start_date = datetime.date(2022, 4, 4) # must be a Monday!
Rota = RotaBuilder(start_date=start_date, weeks_to_rota=10)
worker = Worker(name="worker1", site="site1", grade=1)
Rota.add_worker(worker)
return Rota
@pytest.fixture
def cons_rota():
start_date = datetime.date(2022, 4, 4) # must be a Monday!
Rota = RotaBuilder(start_date=start_date, weeks_to_rota=10)
worker1 = Worker(name="worker1", site="a")
worker2 = Worker(name="worker2", site="a")
worker3 = Worker(name="worker3", site="a")
worker4 = Worker(name="worker4", site="a")
worker5 = Worker(name="worker5", site="a")
worker6 = Worker(name="worker6", site="a")
worker7 = Worker(name="worker7", site="a")
worker8 = Worker(name="worker8", site="a")
worker9 = Worker(name="worker9", site="a")
worker10 = Worker(name="worker10", site="a")
worker11 = Worker(name="worker11", site="a")
worker12 = Worker(name="worker12", site="b")
worker13 = Worker(name="worker13", site="b")
worker14 = Worker(name="worker14", site="b")
worker15 = Worker(name="worker15", site="b")
worker16 = Worker(name="worker16", site="b")
worker17 = Worker(name="worker17", site="b")
worker18 = Worker(name="worker18", site="b")
worker19 = Worker(name="worker19", site="b")
worker20 = Worker(name="worker20", site="b")
Rota.add_workers([
worker1, worker2, worker3, worker4, worker5,
worker6, worker7, worker8, worker9, worker10,
worker11, worker12, worker13, worker14, worker15,
worker16, worker17, worker18, worker19, worker20
])
return Rota
def test_single_shift_per_day_default(basic_rota):
Rota = basic_rota
Rota.add_shifts(
SingleShift(sites=["site1"], name="a", length=8, days=days[:5], workers_required=1),
SingleShift(sites=["site1"], name="b", length=8, days=days[:5], workers_required=1),
)
Rota.build_and_solve()
assert Rota.results.solver.status == "error"
# Only one shift per day should be assigned
def test_allow_two_shifts_per_day(basic_rota):
Rota = basic_rota
Rota.add_shifts(
SingleShift(sites=["site1"], name="a", length=8, days=days[:2], workers_required=1),
SingleShift(sites=["site1"], name="b", length=8, days=days[:2], workers_required=1),
)
for worker in Rota.workers:
worker.allow_shifts_together("a", "b")
Rota.build_and_solve()
Rota.export_rota_to_html("test_allow_two_shifts", folder="tests")
assert Rota.results.solver.status == "ok"
# Now both shifts can be assigned together
for week, day in Rota.get_week_day_combinations():
assigned = [
shift for shift in Rota.get_shift_names_by_week_day(week, day)
if Rota.model.works[Rota.workers[0].id, week, day, shift].value > 0.5
]
assert len(assigned) <= 2
def test_only_specified_shifts_can_be_double_assigned(basic_rota):
Rota = basic_rota
worker2 = Worker(name="worker2", site="site1", grade=1)
Rota.add_worker(worker2)
#Rota.add_worker(worker3)
Rota.add_shifts(
SingleShift(sites=["site1"], name="a", length=8, days=days[:3], workers_required=1),
SingleShift(sites=["site1"], name="b", length=8, days=days[:3], workers_required=1),
SingleShift(sites=["site1"], name="c", length=8, days=days[:3], workers_required=1),
)
for worker in Rota.workers:
worker.allow_shifts_together("a", "b")
Rota.build_and_solve()
Rota.export_rota_to_html("test_only_specified_shifts_can_be_double_assigned", folder="tests")
assert Rota.results.solver.status == "ok"
# Only "a" and "b" can be assigned together, never "a"+"c" or "b"+"c"
for worker in Rota.workers:
for week, day in Rota.get_week_day_combinations():
assigned = set([
shift for shift in Rota.get_shift_names_by_week_day(week, day)
if Rota.model.works[worker.id, week, day, shift].value > 0.8
])
assert assigned != set(["a", "c"]) and assigned != set(["b", "c"])
def test_triple_shifts_allowed(basic_rota):
Rota = basic_rota
Rota.add_shifts(
SingleShift(sites=["site1"], name="a", length=8, days=days[2:4], workers_required=1),
SingleShift(sites=["site1"], name="b", length=8, days=days[2:4], workers_required=1),
SingleShift(sites=["site1"], name="c", length=8, days=days[2:4], workers_required=1),
)
Rota.allow_shifts_together_for_all_workers("a", "b", "c")
Rota.build_and_solve()
Rota.export_rota_to_html("test_triple_shifts_allowed", folder="tests")
assert Rota.results.solver.status == "ok"
def test_triple_shifts_allowed2(long_rota):
Rota = long_rota
worker2 = Worker(name="worker2", site="site1", grade=1)
worker3 = Worker(name="worker3", site="site1", grade=1)
Rota.add_worker(worker2)
Rota.add_worker(worker3)
Rota.add_shifts(
SingleShift(sites=["site1"], name="a", length=8, days=days[2:4], workers_required=1, balance_offset=1),
SingleShift(sites=["site1"], name="b", length=8, days=days[2:4], workers_required=1, balance_offset=1),
SingleShift(sites=["site1"], name="c", length=8, days=days[2:4], workers_required=1, balance_offset=1),
)
worker2.allow_shifts_together("a", "b", "c")
worker2.prefer_multi_shift_together = 10
worker3.allow_shifts_together("a", "b", "c")
worker3.prefer_multi_shift_together = -10
Rota.build_and_solve()
Rota.export_rota_to_html("test_triple_shifts_allowed2", folder="tests")
assert Rota.results.solver.status == "ok"
def test_multi_shift_prefs(long_rota):
Rota = long_rota
#Rota.constraint_options["max_shifts_per_week"] = 10
worker2 = Worker(name="worker2", site="site1", grade=1)
worker3 = Worker(name="worker3", site="site1", grade=1)
worker4 = Worker(name="worker4", site="site1", grade=1)
#worker5 = Worker(name="worker5", site="site1", grade=1)
Rota.add_worker(worker2)
Rota.add_worker(worker3)
Rota.add_worker(worker4)
#Rota.add_worker(worker5)
Rota.add_worker
for worker in Rota.workers:
worker.allow_shifts_together("a", "b")
worker.prefer_multi_shift_together = 0
Rota.add_shifts(
SingleShift(sites=["site1"], name="a", length=8, days=days[:1], workers_required=1),
SingleShift(sites=["site1"], name="b", length=8, days=days[:1], workers_required=1),
SingleShift(sites=["site1"], name="c", length=8, days=days[:1], workers_required=1),
SingleShift(sites=["site1"], name="d", length=8, days=days[:1], workers_required=1),
SingleShift(sites=["site1"], name="e", length=8, days=days[:1], workers_required=1),
#SingleShift(sites=["site1"], name="d", length=8, days=days[:1], workers_required=1),
)
Rota.build_and_solve(options={"ratio": 0})
Rota.export_rota_to_html("test_multi_shift_prefs", folder="tests")
assert Rota.results.solver.status == "ok"
def test_cons_rota(cons_rota):
Rota = cons_rota
Rota.add_shifts(
SingleShift(sites=["a"], name="oncall", length=8, days=days[:5], workers_required=1),
SingleShift(sites=["a", "b"], name="evening", length=8, days=days[:5], workers_required=1),
)
print("Adding shifts")
for worker in Rota.get_workers_by_site("a"):
print(worker.name)
worker.allow_shifts_together("oncall", "evening")
worker.prefer_multi_shift_together = 1
Rota.build_and_solve()
Rota.export_rota_to_html("test_cons_rota", folder="tests")
assert Rota.results.solver.status == "ok"
# Check that everyone at site "a" is assigned 2 or 3 evening shifts
for worker in Rota.get_workers_by_site("a"):
evening_count = Rota.get_worker_shift_count(worker, "evening")
assert 2 <= evening_count <= 3, f"{worker.name} assigned {evening_count} evening shifts"
if worker.site == "a":
oncall_count = Rota.get_worker_shift_count(worker, "oncall")
assert oncall_count == 4 or oncall_count == 5, f"{worker.name} assigned {oncall_count} oncall shifts"
# Check that days worked is no more than oncall count
days_worked = Rota.get_worker_days_worked(worker)
assert days_worked <= oncall_count, f"{worker.name} worked {days_worked} days, more than oncall shifts {oncall_count}"
def test_cons_rota2(cons_rota):
Rota = cons_rota
Rota.add_shifts(
SingleShift(sites=["a"], name="oncall", length=8, days=days[:5], workers_required=1),
SingleShift(sites=["a", "b"], name="evening", length=8, days=days[:5], workers_required=1),
)
print("Adding shifts")
for worker in Rota.get_workers_by_site("a"):
print(worker.name)
worker.allow_shifts_together("oncall", "evening")
worker.prefer_multi_shift_together = -1
Rota.build_and_solve()
Rota.export_rota_to_html("test_cons_rota", folder="tests")
assert Rota.results.solver.status == "ok"
# Check that everyone at site "a" is assigned 2 or 3 evening shifts
for worker in Rota.get_workers_by_site("a"):
evening_count = Rota.get_worker_shift_count(worker, "evening")
assert 2 <= evening_count <= 3, f"{worker.name} assigned {evening_count} evening shifts"
if worker.site == "a":
oncall_count = Rota.get_worker_shift_count(worker, "oncall")
assert oncall_count == 4 or oncall_count == 5, f"{worker.name} assigned {oncall_count} oncall shifts"
# Check that days worked is now more than oncall count
days_worked = Rota.get_worker_days_worked(worker)
assert days_worked > oncall_count, f"{worker.name} worked {days_worked} days, more than oncall shifts {oncall_count}"
def test_cons_rota3(cons_rota):
Rota = cons_rota
Rota.terminate_on_warning.remove("Worker/no valid shifts")
Rota.add_shifts(
#SingleShift(sites=["a"], name="oncall weekday", length=8, days=days[:4], workers_required=1,
# constraint=[
# {"name": "pre", "options": 2},
# {"name": "post", "options": 2},
# ],
# ),
##SingleShift(sites=["a"], name="oncall weekend", length=8, days=days[4:], workers_required=1),
#SingleShift(sites=["a", "b"], name="evening", length=8, days=days[:4], workers_required=1,
# constraint=[
# {"name": "pre", "options": 2},
# {"name": "post", "options": 2},
# ],
# ),
SingleShift(sites=["a"], name="help", length=8, days=days[4:], workers_required=1,
#assign_as_block=True,
#force_as_block=True
#constraint=[
# {"name": "pre", "options": 2},
# {"name": "post", "options": 2},
#],
) ,
SingleShift(sites=["b"], name="weekend b", length=8, days=days[5:], workers_required=1,
#constraint=[
# {"name": "pre", "options": 2},
# {"name": "post", "options": 2},
#],
),
)
#print("Adding shifts")
#for worker in Rota.get_workers_by_site("a"):
# print(worker.name)
## worker.allow_shifts_together("oncall weekday", "evening")
## #worker.allow_shifts_together("oncall weekend", "weekend")
## worker.prefer_multi_shift_together = 1
# worker.assign_as_block_preferences = {"help":10}
#for worker in Rota.get_workers_by_site("b"):
# worker.assign_as_block_preferences = {"weekend b":-10}
Rota.build_and_solve()
Rota.export_rota_to_html("test_cons_rota", folder="tests")
assert Rota.results.solver.status == "ok"
+146
View File
@@ -0,0 +1,146 @@
import pytest
import datetime
from rota.shifts import RotaBuilder, SingleShift, days
from rota.workers import Worker
@pytest.fixture
def demo_rota_night_unavailable():
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
worker1 = Worker(
name="worker1",
site="group1",
grade=1,
not_available_to_work=[
{
"date": datetime.datetime.strptime("15/03/22", "%d/%m/%y").date(),
"reason": "****",
},
],
)
worker2 = Worker(
name="worker2",
site="group1",
grade=1,
not_available_to_work=[
{
"date": datetime.datetime.strptime("14/03/22", "%d/%m/%y").date(),
"reason": "****",
}
],
)
Rota.add_workers((worker1, worker2))
return Rota
def test_basic_assignment(demo_rota_night_unavailable):
Rota = demo_rota_night_unavailable
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="night_weekend",
length=12.5,
days=days[5:],
constraint=[{"name": "night"}],
),
)
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("testnight")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
summary = Rota.get_shift_summary_dict()
for worker in summary:
assert summary[worker]["night_weekend"] == 10
def test_assign_night_prior_to_unavailablity(demo_rota_night_unavailable):
Rota = demo_rota_night_unavailable
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="night_weekend",
length=12.5,
days=days[5:],
workers_required=2,
constraint=[{"name": "night"}],
),
)
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("testnight_unavail")
assert Rota.results.solver.status in ("warning", "error")
assert Rota.results.solver.termination_condition == "infeasible"
def test_assign_non_night_prior_to_unavailablity(demo_rota_night_unavailable):
Rota = demo_rota_night_unavailable
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="night_weekend",
length=12.5,
days=days[5:],
workers_required=2,
#constraint=[{"name": "night"}],
),
)
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("testnight_unavail")
assert Rota.results.solver.status == "ok"
def test_assign_prior_to_unavailablity_non_night(demo_rota_night_unavailable):
Rota = demo_rota_night_unavailable
Rota.shifts = []
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="night_weekend",
length=12.5,
days=days[5:],
workers_required=2,
constraint=[],
),
)
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("testnight")
assert Rota.results.solver.status == "ok"
def test_assign_split(demo_rota_night_unavailable):
Rota = demo_rota_night_unavailable
Rota.shifts = []
worker3 = Worker(
name="worker3",
site="group1",
grade=1,
not_available_to_work=(
{"date": datetime.datetime.strptime("13/03/22", "%d/%m/%y").date()},
),
)
worker4 = Worker(
name="worker4",
site="group1",
grade=1,
not_available_to_work=(
{"date": datetime.datetime.strptime("12/03/22", "%d/%m/%y").date()},
),
)
Rota.add_workers((worker3, worker4))
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="night_weekend",
length=12.5,
days=days[5:],
workers_required=3,
constraint=[],
),
)
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("testnight")
assert Rota.results.solver.status == "ok"
summary = Rota.get_shift_summary_dict()
for worker in summary:
assert summary[worker]["night_weekend"] == 15
+26 -167
View File
@@ -1,44 +1,33 @@
import datetime import datetime
import pytest import pytest
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days
from rota.workers import Worker from rota.workers import Worker
def generate_basic_rota(weeks_to_rota=10): def generate_basic_rota(weeks_to_rota=10):
start_date = datetime.date(2022, 3, 7) start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder( Rota = RotaBuilder(
start_date, start_date,
weeks_to_rota=weeks_to_rota, weeks_to_rota=weeks_to_rota,
) )
# Add a few workers
Rota.add_workers( Rota.add_workers(
[ [
Worker(name="worker1", site="group1", grade=1), Worker(name="worker1", site="group1", grade=1),
Worker(name="worker2", site="group1", grade=1), Worker(name="worker2", site="group1", grade=1),
] ]
) )
return Rota return Rota
def date_generator(from_date, days): def date_generator(from_date, days):
n = 0 n = 0
while True: while True:
yield from_date yield from_date
n = n + 1 n = n + 1
if n >= days: if n >= days:
break break
from_date = from_date + datetime.timedelta(days=1) from_date = from_date + datetime.timedelta(days=1)
def test_nights():
class TestNightShifts:
def test_nights(self):
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -55,19 +44,15 @@ class TestNightShifts:
length=12.5, length=12.5,
days=days[:3], days=days[:3],
force_as_block=False, force_as_block=False,
# constraint=[{"name": "night"}],
), ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night") Rota.export_rota_to_html("night", folder="tests")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal" assert Rota.results.solver.termination_condition == "optimal"
def test_nights_pre3(self): def test_nights_pre3():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -84,18 +69,14 @@ class TestNightShifts:
length=12.5, length=12.5,
days=days[:2], days=days[:2],
force_as_block=False, force_as_block=False,
# constraint=[{"name": "night"}],
), ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night_pre3") Rota.export_rota_to_html("night_pre3", folder="tests")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_nights_pre3_2(self): def test_nights_pre3_2():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -112,18 +93,14 @@ class TestNightShifts:
length=12.5, length=12.5,
days=days[:3], days=days[:3],
force_as_block=False, force_as_block=False,
# constraint=[{"name": "night"}],
), ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night_pre3") Rota.export_rota_to_html("night_pre3", folder="tests")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_nights_fail(self): def test_nights_fail():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -140,19 +117,14 @@ class TestNightShifts:
length=12.5, length=12.5,
days=days[:3], days=days[:3],
force_as_block=False, force_as_block=False,
# constraint=[{"name": "night"}],
), ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status in ("warning", "error") assert Rota.results.solver.status in ("warning", "error")
def test_nights2(self): def test_nights2():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 0 Rota.constraint_options["max_night_frequency"] = 0
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -169,27 +141,16 @@ class TestNightShifts:
length=12.5, length=12.5,
days=days[:5], days=days[:5],
force_as_block=True, force_as_block=True,
# constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
# constraint=[{"name": "night"}],
workers_required=2, workers_required=2,
), ),
# SingleShift(
# sites=("group1", "group2"), name="b", length= 12.5, days=days[:3],
# force_as_block=False,
# #constraint=[{"name": "night"}],
# ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night") Rota.export_rota_to_html("night", folder="tests")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_nights_pre_wrap_around(self): def test_nights_pre_wrap_around():
Rota = generate_basic_rota(weeks_to_rota=8) Rota = generate_basic_rota(weeks_to_rota=8)
Rota.constraint_options["max_night_frequency"] = 0 Rota.constraint_options["max_night_frequency"] = 0
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -204,20 +165,7 @@ class TestNightShifts:
], ],
workers_required=3, workers_required=3,
), ),
# SingleShift(
# sites=("group1", "group2"), name="m", length= 12.5, days=days[:5],
# force_as_block=True,
# #constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
# #constraint=[{"name": "night"}],
# workers_required=2,
# ),
# SingleShift(
# sites=("group1", "group2"), name="b", length= 12.5, days=days[:3],
# force_as_block=False,
# #constraint=[{"name": "night"}],
# ),
) )
Rota.add_workers( Rota.add_workers(
[ [
Worker( Worker(
@@ -229,25 +177,17 @@ class TestNightShifts:
"date": Rota.rota_end_date - datetime.timedelta(days=1), "date": Rota.rota_end_date - datetime.timedelta(days=1),
"reason": "no wanna", "reason": "no wanna",
}, },
# {
# "date": Rota.rota_end_date - datetime.timedelta(days=5),
# "reason": "no wanna",
# },
], ],
), ),
] ]
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night") Rota.export_rota_to_html("night pre wrap around", folder="tests")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_nights_max_frequency(self): def test_nights_max_frequency():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 1 Rota.constraint_options["max_night_frequency"] = 1
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -258,33 +198,14 @@ class TestNightShifts:
constraint=[{"name": "night"}], constraint=[{"name": "night"}],
workers_required=2, workers_required=2,
), ),
# SingleShift(
# sites=("group1", "group2"),
# name="m",
# length=12.5,
# days=days[:5],
# force_as_block=True,
# # constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
# # constraint=[{"name": "night"}],
# workers_required=2,
# ),
# SingleShift(
# sites=("group1", "group2"), name="b", length= 12.5, days=days[:3],
# force_as_block=False,
# #constraint=[{"name": "night"}],
# ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night") Rota.export_rota_to_html("night max frequency", folder="tests")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_nights_max_frequency_fail(self): def test_nights_max_frequency_fail():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2 Rota.constraint_options["max_night_frequency"] = 2
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -295,33 +216,13 @@ class TestNightShifts:
constraint=[{"name": "night"}], constraint=[{"name": "night"}],
workers_required=2, workers_required=2,
), ),
# SingleShift(
# sites=("group1", "group2"),
# name="m",
# length=12.5,
# days=days[:5],
# force_as_block=True,
# # constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
# # constraint=[{"name": "night"}],
# workers_required=2,
# ),
# SingleShift(
# sites=("group1", "group2"), name="b", length= 12.5, days=days[:3],
# force_as_block=False,
# #constraint=[{"name": "night"}],
# ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night")
assert Rota.results.solver.status in ("warning", "error") assert Rota.results.solver.status in ("warning", "error")
def test_nights_max_frequency3(self): def test_nights_max_frequency3():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2 Rota.constraint_options["max_night_frequency"] = 2
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -338,27 +239,17 @@ class TestNightShifts:
length=12.5, length=12.5,
days=days[:5], days=days[:5],
force_as_block=True, force_as_block=True,
# constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
constraint=[{"name": "night"}], constraint=[{"name": "night"}],
workers_required=1, workers_required=1,
), ),
# SingleShift(
# sites=("group1", "group2"), name="b", length= 12.5, days=days[:3],
# force_as_block=False,
# #constraint=[{"name": "night"}],
# ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night") Rota.export_rota_to_html("night max frequency 3", folder="tests")
assert Rota.results.solver.status in ("warning", "error") assert Rota.results.solver.status in ("warning", "error")
def test_nights_max_frequency4(self): def test_nights_max_frequency4():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2 Rota.constraint_options["max_night_frequency"] = 2
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -375,43 +266,24 @@ class TestNightShifts:
length=12.5, length=12.5,
days=days[:5], days=days[:5],
force_as_block=True, force_as_block=True,
# constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
constraint=[{"name": "night"}], constraint=[{"name": "night"}],
workers_required=1, workers_required=1,
), ),
# SingleShift(
# sites=("group1", "group2"), name="b", length= 12.5, days=days[:3],
# force_as_block=False,
# #constraint=[{"name": "night"}],
# ),
) )
Rota.add_workers( Rota.add_workers(
[ [
Worker( Worker(name="worker3", site="group1", grade=1),
name="worker3", Worker(name="worker4", site="group1", grade=1),
site="group1",
grade=1,
),
Worker(
name="worker4",
site="group1",
grade=1,
),
] ]
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night") Rota.export_rota_to_html("night max frequency 4", folder="tests")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_nights_max_frequency_exclusions(self): def test_nights_max_frequency_exclusions():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2 Rota.constraint_options["max_night_frequency"] = 2
Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(10)) Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(10))
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -423,18 +295,14 @@ class TestNightShifts:
workers_required=2, workers_required=2,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night") Rota.export_rota_to_html("night max frequency exclusions", folder="tests")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_nights_max_frequency_exclusions2(self): def test_nights_max_frequency_exclusions2():
Rota = generate_basic_rota(weeks_to_rota=8) Rota = generate_basic_rota(weeks_to_rota=8)
Rota.constraint_options["max_night_frequency"] = 2 Rota.constraint_options["max_night_frequency"] = 2
Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(1,4)) Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(1,4))
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -446,7 +314,6 @@ class TestNightShifts:
workers_required=2, workers_required=2,
), ),
) )
Rota.add_workers( Rota.add_workers(
[ [
Worker( Worker(
@@ -463,19 +330,14 @@ class TestNightShifts:
), ),
] ]
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night") Rota.export_rota_to_html("night max frequency exclusions 2", folder="tests")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_nights_max_frequency_exclusions3(self): def test_nights_max_frequency_exclusions3():
# TODO: find out the issues with odd rotas and exclusions
Rota = generate_basic_rota(weeks_to_rota=9) Rota = generate_basic_rota(weeks_to_rota=9)
Rota.constraint_options["max_night_frequency"] = 2 Rota.constraint_options["max_night_frequency"] = 2
Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(1,5)) Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(1,5))
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -488,7 +350,6 @@ class TestNightShifts:
workers_required=2, workers_required=2,
), ),
) )
Rota.add_workers( Rota.add_workers(
[ [
Worker( Worker(
@@ -505,8 +366,6 @@ class TestNightShifts:
), ),
] ]
) )
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night") Rota.export_rota_to_html("night max frequency exclusions 3", folder="tests")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
+114 -272
View File
@@ -1,372 +1,214 @@
import pytest import pytest
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days
import datetime import datetime
from rota.workers import Worker from rota.workers import Worker
def weeks_from_list(lst): def weeks_from_list(lst):
"""Yield successive n-sized chunks from a lst.""" """Yield successive n-sized chunks from a lst."""
for i in range(0, len(lst), 7): for i in range(0, len(lst), 7):
yield lst[i : i + 7] yield lst[i : i + 7]
def setup_rota(weeks_to_rota=10, start_date=None):
class TestDemoRota: if start_date is None:
def test_nwd(self):
# Set up rota
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7) start_date = datetime.date(2022, 3, 7)
return RotaBuilder(start_date, weeks_to_rota=weeks_to_rota)
self.Rota = RotaBuilder( def test_nwd():
start_date, Rota = setup_rota()
weeks_to_rota=weeks_to_rota,
)
# Add a few workers
worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}]) worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}])
worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Fri"}]) worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Fri"}])
Rota.add_workers((worker1, worker2))
self.Rota.add_workers((worker1, worker2)) Rota.add_shifts(
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5], sites=("group1",), name="d", length=12.5, days=days[:5], workers_required=1,
# balance_offset=10,
workers_required=1,
), ),
SingleShift( SingleShift(
sites=("group1",), name="w", length= 12.5, days=days[5:], sites=("group1",), name="w", length=12.5, days=days[5:], workers_required=2,
workers_required=2,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.10})
self.Rota.build_and_solve(options={"ratio": 0.10}) Rota.export_rota_to_html("nwd1", folder="tests")
assert Rota.results.solver.status == "ok"
self.Rota.export_rota_to_html("nwd1") assert Rota.results.solver.termination_condition == "optimal"
for worker in Rota.workers:
assert self.Rota.results.solver.status == "ok" shifts = Rota.get_worker_shift_list(worker)
assert self.Rota.results.solver.termination_condition == "optimal"
for worker in self.Rota.workers:
shifts = self.Rota.get_worker_shift_list(worker)
# Convert shift to a string representation
shifts_string = "".join([i if i != "" else "-" for i in shifts]) shifts_string = "".join([i if i != "" else "-" for i in shifts])
if worker.name == "worker1": if worker.name == "worker1":
assert "wwd" not in shifts_string assert "wwd" not in shifts_string
if worker.name == "worker2": if worker.name == "worker2":
assert "dww" not in shifts_string assert "dww" not in shifts_string
def test_nwd_simple_fail(self): def test_nwd_simple_fail():
# Set up rota Rota = setup_rota()
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
self.Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
# Add a few workers
worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}]) worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}])
worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Mon"}]) worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Mon"}])
Rota.add_workers((worker1, worker2))
self.Rota.add_workers((worker1, worker2)) Rota.add_shifts(
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5], sites=("group1",), name="d", length=12.5, days=days[:5], balance_offset=10, workers_required=1,
balance_offset=10,
workers_required=1,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("nwd", folder="tests")
assert Rota.results.solver.status in ("warning", "error")
self.Rota.build_and_solve(options={"ratio": 0.00}) def test_nwd_partial_rota():
Rota = setup_rota()
self.Rota.export_rota_to_html("nwd") start_date = Rota.start_date
assert self.Rota.results.solver.status in ("warning", "error")
def test_nwd_partial_rota(self):
# Set up rota
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
self.Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
d = start_date + datetime.timedelta(weeks=5) d = start_date + datetime.timedelta(weeks=5)
# Add a few workers
worker1 = Worker( worker1 = Worker(
name="worker1", name="worker1", site="group1", grade=1,
site="group1",
grade=1,
nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}], nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}],
) )
worker2 = Worker( worker2 = Worker(
name="worker2", name="worker2", site="group1", grade=1,
site="group1", nwds=[{"day": "Mon", "start_date": d, "end_date": Rota.rota_end_date}],
grade=1,
nwds=[{"day": "Mon", "start_date": d, "end_date": self.Rota.rota_end_date}],
) )
Rota.add_workers((worker1, worker2))
self.Rota.add_workers((worker1, worker2)) Rota.add_shifts(
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5], sites=("group1",), name="d", length=12.5, days=days[:5], balance_offset=10, workers_required=1,
balance_offset=10,
workers_required=1,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.build_and_solve(options={"ratio": 0.00}) Rota.export_rota_to_html("nwd", folder="tests")
assert Rota.results.solver.status == "ok"
self.Rota.export_rota_to_html("nwd") for worker in Rota.workers:
shifts = Rota.get_worker_shift_list(worker)
assert self.Rota.results.solver.status == "ok"
for worker in self.Rota.workers:
shifts = self.Rota.get_worker_shift_list(worker)
# Convert shift to a string representation
shifts_string = "".join([i if i != "" else "-" for i in shifts]) shifts_string = "".join([i if i != "" else "-" for i in shifts])
if worker.name == "worker1": if worker.name == "worker1":
for n in range(0,4): for n in range(0, 4):
assert shifts_string[0 + 7*n] == "-" assert shifts_string[0 + 7 * n] == "-"
if worker.name == "worker2": if worker.name == "worker2":
for n in range(5,9): for n in range(5, 9):
assert shifts_string[0 + 7*n] == "-" assert shifts_string[0 + 7 * n] == "-"
def test_nwd_partial_overlay(self):
# Set up rota
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
self.Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
def test_nwd_partial_overlay():
Rota = setup_rota()
start_date = Rota.start_date
d = start_date + datetime.timedelta(weeks=5) d = start_date + datetime.timedelta(weeks=5)
d2 = start_date + datetime.timedelta(weeks=6) d2 = start_date + datetime.timedelta(weeks=6)
# Add a few workers
worker1 = Worker( worker1 = Worker(
name="worker1", name="worker1", site="group1", grade=1,
site="group1", nwds=[{"day": "Mon", "start_date": start_date, "end_date": d2}],
grade=1,
nwds=[{"day":"Mon", "start_date":start_date, "end_date":d2}],
) )
worker2 = Worker( worker2 = Worker(
name="worker2", name="worker2", site="group1", grade=1,
site="group1", nwds=[{"day": "Mon", "start_date": d, "end_date": Rota.rota_end_date}],
grade=1,
nwds=[{"day":"Mon", "start_date":d, "end_date":self.Rota.rota_end_date}],
) )
Rota.add_workers((worker1, worker2))
self.Rota.add_workers((worker1, worker2)) Rota.add_shifts(
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5], sites=("group1",), name="d", length=12.5, days=days[:5], balance_offset=10, workers_required=1,
balance_offset=10,
workers_required=1,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.00})
assert Rota.results.solver.status in ("warning", "error")
self.Rota.build_and_solve(options={"ratio": 0.00}) def test_nwd_force_as_block():
Rota = setup_rota()
assert self.Rota.results.solver.status in ("warning", "error") start_date = Rota.start_date
def test_nwd_force_as_block(self):
# Set up rota
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
self.Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
d = start_date + datetime.timedelta(weeks=4) d = start_date + datetime.timedelta(weeks=4)
worker1 = Worker( worker1 = Worker(
name="worker1", name="worker1", site="group1", grade=1,
site="group1", nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}],
grade=1,
nwds=[{"day": "Mon", "start_date":start_date, "end_date":d}],
) )
worker2 = Worker(name="worker2", site="group1", grade=1, worker2 = Worker(name="worker2", site="group1", grade=1)
) worker3 = Worker(name="worker3", site="group1", grade=1)
worker3 = Worker(name="worker3", site="group1", grade=1, worker4 = Worker(name="worker4", site="group1", grade=1)
) Rota.add_workers((worker1, worker2, worker3, worker4))
worker4 = Worker(name="worker4", site="group1", grade=1, Rota.add_shifts(
)
self.Rota.add_workers((worker1, worker2, worker3, worker4))
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5], sites=("group1",), name="d", length=12.5, days=days[:5],
balance_offset=10, balance_offset=10, workers_required=2,
workers_required=2, force_as_block_unless_nwd=True, assign_as_block=True,
force_as_block_unless_nwd=True,
assign_as_block=True,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.build_and_solve(options={"ratio": 0.00}) Rota.export_rota_to_html("nwd", folder="tests")
self.Rota.export_rota_to_html("nwd") assert Rota.results.solver.status == "ok"
for worker in Rota.workers:
assert self.Rota.results.solver.status == "ok" shifts = Rota.get_worker_shift_list(worker)
for worker in self.Rota.workers:
shifts = self.Rota.get_worker_shift_list(worker)
# Convert shift to a string representation
shifts_string = "".join([i if i != "" else "-" for i in shifts]) shifts_string = "".join([i if i != "" else "-" for i in shifts])
for week in weeks_from_list(shifts_string): for week in weeks_from_list(shifts_string):
assert week in ("-------", "ddddd--") assert week in ("-------", "ddddd--")
assert shifts_string.count("d") == 25 assert shifts_string.count("d") == 25
def test_nwd_force_as_block_force_split(self): def test_nwd_force_as_block_force_split():
# Set up rota Rota = setup_rota()
weeks_to_rota = 10 start_date = Rota.start_date
start_date = datetime.date(2022, 3, 7)
self.Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
d = start_date + datetime.timedelta(weeks=5) d = start_date + datetime.timedelta(weeks=5)
worker1 = Worker( worker1 = Worker(
name="worker1", name="worker1", site="group1", grade=1,
site="group1", nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}],
grade=1,
nwds=[{"day": "Mon", "start_date":start_date, "end_date":d}],
) )
worker2 = Worker(name="worker2", site="group1", grade=1, worker2 = Worker(name="worker2", site="group1", grade=1)
) worker3 = Worker(name="worker3", site="group1", grade=1)
worker3 = Worker(name="worker3", site="group1", grade=1, worker4 = Worker(name="worker4", site="group1", grade=1)
) Rota.add_workers((worker1, worker2, worker3, worker4))
worker4 = Worker(name="worker4", site="group1", grade=1, Rota.add_shifts(
)
self.Rota.add_workers((worker1, worker2, worker3, worker4))
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5], sites=("group1",), name="d", length=12.5, days=days[:5],
#balance_offset=50, workers_required=2, force_as_block_unless_nwd=True, assign_as_block=True,
workers_required=2,
force_as_block_unless_nwd=True,
assign_as_block=True,
), ),
SingleShift( SingleShift(
sites=("group1",), name="w", length= 12.5, days=days[5:], sites=("group1",), name="w", length=12.5, days=days[5:],
#balance_offset=50, workers_required=4, force_as_block_unless_nwd=True, assign_as_block=True,
workers_required=4,
force_as_block_unless_nwd=True,
assign_as_block=True,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("nwd_block_force_split", folder="tests")
self.Rota.build_and_solve() assert Rota.results.solver.status == "ok"
self.Rota.export_rota_to_html("nwd_block_force_split") for worker in Rota.workers:
shifts = Rota.get_worker_shift_list(worker)
assert self.Rota.results.solver.status == "ok"
for worker in self.Rota.workers:
shifts = self.Rota.get_worker_shift_list(worker)
# Convert shift to a string representation
shifts_string = "".join([i if i != "" else "-" for i in shifts]) shifts_string = "".join([i if i != "" else "-" for i in shifts])
for week in weeks_from_list(shifts_string[7 * 5 :]): for week in weeks_from_list(shifts_string[7 * 5 :]):
assert week in ("-----ww", "dddddww") assert week in ("-----ww", "dddddww")
if worker.name == "worker1": if worker.name == "worker1":
assert shifts_string[: 7 * 5].count("ww-") == 4 assert shifts_string[: 7 * 5].count("ww-") == 4
assert shifts_string.count("d") == 25 assert shifts_string.count("d") == 25
assert shifts_string.count("w") == 20 assert shifts_string.count("w") == 20
def test_nwd_testing(self): def test_nwd_testing():
# Set up rota Rota = setup_rota()
weeks_to_rota = 10 start_date = Rota.start_date
start_date = datetime.date(2022, 3, 7)
self.Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
d = start_date + datetime.timedelta(weeks=5) d = start_date + datetime.timedelta(weeks=5)
worker1 = Worker(name="worker1", site="group1", grade=1, worker1 = Worker(
name="worker1", site="group1", grade=1,
nwds=[ nwds=[
{"day": "Mon", "start_date":d, "end_date":self.Rota.rota_end_date}, {"day": "Mon", "start_date": d, "end_date": Rota.rota_end_date},
{"day": "Tue", "start_date":d, "end_date":self.Rota.rota_end_date}, {"day": "Tue", "start_date": d, "end_date": Rota.rota_end_date},
{"day": "Wed", "start_date":d, "end_date":self.Rota.rota_end_date}, {"day": "Wed", "start_date": d, "end_date": Rota.rota_end_date},
{"day": "Thu", "start_date":d, "end_date":self.Rota.rota_end_date}, {"day": "Thu", "start_date": d, "end_date": Rota.rota_end_date},
{"day": "Fri", "start_date":d, "end_date":self.Rota.rota_end_date}, {"day": "Fri", "start_date": d, "end_date": Rota.rota_end_date},
], ],
) )
worker2 = Worker(name="worker2", site="group1", grade=1, worker2 = Worker(name="worker2", site="group1", grade=1)
) Rota.add_workers((worker1, worker2))
Rota.add_shifts(
self.Rota.add_workers((worker1, worker2))
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5], sites=("group1",), name="d", length=12.5, days=days[:5],
balance_offset=10, balance_offset=10, workers_required=1,
workers_required=1, force_as_block_unless_nwd=True, assign_as_block=True,
force_as_block_unless_nwd=True,
assign_as_block=True,
), ),
SingleShift( SingleShift(
sites=("group1",), name="w", length= 12.5, days=days[5:], sites=("group1",), name="w", length=12.5, days=days[5:],
balance_offset=10, balance_offset=10, workers_required=2,
workers_required=2, force_as_block_unless_nwd=True, assign_as_block=True,
force_as_block_unless_nwd=True,
assign_as_block=True,
), ),
) )
Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.build_and_solve(options={"ratio": 0.00}) Rota.export_rota_to_html("nwd", folder="tests")
self.Rota.export_rota_to_html("nwd") assert Rota.results.solver.status == "ok"
for worker in Rota.workers:
assert self.Rota.results.solver.status == "ok" shifts = Rota.get_worker_shift_list(worker)
for worker in self.Rota.workers:
shifts = self.Rota.get_worker_shift_list(worker)
# Convert shift to a string representation
shifts_string = "".join([i if i != "" else "-" for i in shifts]) shifts_string = "".join([i if i != "" else "-" for i in shifts])
for week in weeks_from_list(shifts_string[7 * 5 :]): for week in weeks_from_list(shifts_string[7 * 5 :]):
if worker.name == "worker1": if worker.name == "worker1":
assert week == "-----ww" assert week == "-----ww"
else: else:
assert week == "dddddww" assert week == "dddddww"
for week in weeks_from_list(shifts_string[: 7 * 5]): for week in weeks_from_list(shifts_string[: 7 * 5]):
if worker.name == "worker1": if worker.name == "worker1":
assert week == "dddddww" assert week == "dddddww"
+47 -1151
View File
File diff suppressed because it is too large Load Diff
+454
View File
@@ -0,0 +1,454 @@
import pytest
from rota.shifts import RotaBuilder, SingleShift, days
from rota.workers import Worker
import datetime
def generate_basic_rota(
weeks_to_rota=10, workers=0, start_date=datetime.date(2022, 3, 7)
):
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
# Add a few workers
if workers:
Rota.add_workers(
[
Worker(name=f"worker{i:02}", site="group1", grade=1)
for i in range(1, workers + 1)
]
)
return Rota
@pytest.fixture
def demo_rota_clear():
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
balance_offset_modifier=1,
)
Rota.constraint_options["max_shifts_per_month"] = 20
workers = [
Worker(name="worker1", site="group1", grade=1),
Worker(name="worker2", site="group1", grade=1),
Worker(name="worker3", site="group1", grade=1),
Worker(name="worker4", site="group1", grade=1),
Worker(name="worker5", site="group2", grade=1, fte=60),
Worker(name="worker6", site="group2", grade=1, fte=40),
Worker(name="worker7", site="group2", grade=1),
Worker(name="worker8", site="group2", grade=1),
]
Rota.add_workers(workers)
return Rota
def test_pre(demo_rota_clear):
Rota = demo_rota_clear
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days[:5],
workers_required=2,
constraint=[{"name": "pre", "options": 1}],
),
SingleShift(
sites=("group1", "group2"),
name="b",
length=12.5,
days=days[:5],
workers_required=2,
),
SingleShift(
sites=("group1", "group2"),
name="c",
length=12.5,
days=days[3],
workers_required=1,
constraint=[{"name": "pre", "options": 1}, {"name": "post", "options": 1}],
),
)
Rota.build_shifts()
Rota.build_workers()
Rota.build_model()
solver_options = {"seconds": 1000, "threads": 10}
Rota.solve_model(options=solver_options)
Rota.export_rota_to_html("test4")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
for worker in Rota.workers:
shifts = Rota.get_worker_shift_list(worker)
shifts_string = "".join([i if i != "" else "-" for i in shifts])
# check for patterns that should not occur
assert "ba" not in shifts_string
assert "ca" not in shifts_string
assert "ac" not in shifts_string
assert "ca" not in shifts_string
assert "bc" not in shifts_string
assert "cb" not in shifts_string
# Uncomment if you want to check for these as well:
# assert "a-c" not in shifts_string
# assert "c-a" not in shifts_string
# assert "b-c" not in shifts_string
# assert "c-b" not in shifts_string
def test_pre_post_clear(demo_rota_clear):
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
workers = Rota.get_workers()
# for worker in workers:
# worker.add_force_assign_with("a", "b")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:5],
workers_required=2,
assign_as_block=False, # global setting is off
constraint=[
{
"name": "pre",
"options": 1,
},
{"name": "post", "options": 1},
],
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[5:],
workers_required=2,
assign_as_block=False, # global setting is off
constraint=[
{
"name": "pre",
"options": 1,
},
{"name": "post", "options": 1},
],
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_pre_post_clear", folder="tests")
assert Rota.results.solver.status == "ok"
for worker in Rota.workers:
shifts = Rota.get_worker_shift_list(worker)
shifts_string = "".join([i if i != "" else "-" for i in shifts])
# check for patterns that should not occur
assert "ab" not in shifts_string
assert "ba" not in shifts_string
# assert "a-b" not in shifts_string
# assert "b-a" not in shifts_string
def test_pre_post_clear2(demo_rota_clear):
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
workers = Rota.get_workers()
# for worker in workers:
# worker.add_force_assign_with("a", "b")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:5],
workers_required=2,
assign_as_block=False, # global setting is off
constraint=[
{
"name": "pre",
"options": 2,
},
{"name": "post", "options": 2},
],
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[5:],
workers_required=2,
assign_as_block=False, # global setting is off
constraint=[
{
"name": "pre",
"options": 2,
},
{"name": "post", "options": 2},
],
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_pre_post_clear", folder="tests")
assert Rota.results.solver.status == "ok"
for worker in Rota.workers:
shifts = Rota.get_worker_shift_list(worker)
shifts_string = "".join([i if i != "" else "-" for i in shifts])
# check for patterns that should not occur
assert "ab" not in shifts_string
assert "ba" not in shifts_string
assert "a-b" not in shifts_string
assert "b-a" not in shifts_string
def test_pre_post_clear_multi_group(demo_rota_clear):
Rota = generate_basic_rota(workers=2, weeks_to_rota=16)
workers = Rota.get_workers()
Rota.add_workers(
(
Worker(name="worker3", site="group2", grade=1),
Worker(name="worker4", site="group2", grade=1),
)
)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=8,
days=days[:5],
workers_required=2,
assign_as_block=False, # global setting is off
constraint=[
{
"name": "pre",
"options": 2,
},
{"name": "post", "options": 2},
],
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[5:],
workers_required=1,
assign_as_block=False, # global setting is off
constraint=[
{
"name": "pre",
"options": 2,
},
{"name": "post", "options": 2},
],
),
SingleShift(
sites=("group2",),
name="c",
length=8,
days=days[5:],
workers_required=1,
assign_as_block=False, # global setting is off
constraint=[
{
"name": "pre",
"options": 2,
},
{"name": "post", "options": 2},
],
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_pre_post_clear", folder="tests")
assert Rota.results.solver.status == "ok"
for worker in Rota.workers:
shifts = Rota.get_worker_shift_list(worker)
shifts_string = "".join([i if i != "" else "-" for i in shifts])
# check for patterns that should not occur
assert "ab" not in shifts_string
assert "ba" not in shifts_string
assert "a-b" not in shifts_string
assert "b-a" not in shifts_string
assert "ac" not in shifts_string
assert "ca" not in shifts_string
assert "a-c" not in shifts_string
assert "c-a" not in shifts_string
def test_pre_post_clear_force_assign(demo_rota_clear):
Rota = generate_basic_rota(workers=6, weeks_to_rota=16)
workers = Rota.get_workers()
for worker in workers:
worker.add_force_assign_with("a", "o")
Rota.add_workers(
(
Worker(name="workera", site="group2", grade=1),
Worker(name="workerb", site="group2", grade=1),
)
)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=8,
days=days[:5],
workers_required=1,
assign_as_block=False, # global setting is off
constraint=[
{
"name": "pre",
"options": 1,
},
{"name": "post", "options": 1},
],
),
SingleShift(
sites=("group1",),
name="o",
length=8,
days=days[:7],
workers_required=1,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[5:],
workers_required=1,
assign_as_block=False, # global setting is off
#constraint=[
# {
# "name": "pre",
# "options": 2,
# },
# {"name": "post", "options": 2},
#],
),
SingleShift(
sites=("group2",),
name="c",
length=8,
days=days[5:],
workers_required=1,
assign_as_block=False, # global setting is off
#constraint=[
# {
# "name": "pre",
# "options": 1,
# },
# {"name": "post", "options": 1},
#],
),
)
Rota.build_and_solve(options={"ratio": 0.01})
Rota.export_rota_to_html("test_pre_post_clear", folder="tests")
assert Rota.results.solver.status == "ok"
def test_pre_post_clear_force_assign2(demo_rota_clear):
Rota = generate_basic_rota(workers=6, weeks_to_rota=16)
workers = Rota.get_workers()
for worker in workers:
worker.add_force_assign_with("a", "o")
worker.add_force_assign_with("b", "o")
Rota.add_workers(
(
Worker(name="workera", site="group2", grade=1),
Worker(name="workerb", site="group2", grade=1),
)
)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=8,
days=days[:5],
workers_required=1,
assign_as_block=False, # global setting is off
constraint=[
#{
# "name": "pre",
# "options": 1,
#},
#{"name": "post", "options": 1},
],
),
SingleShift(
sites=("group1",),
name="o",
length=8,
days=days[:7],
workers_required=1,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[5:],
workers_required=1,
assign_as_block=False, # global setting is off
constraint=[
#{
# "name": "pre",
# "options": 2,
#},
#{"name": "post", "options": 2},
],
),
SingleShift(
sites=("group2",),
name="c",
length=8,
days=days[5:],
workers_required=1,
assign_as_block=False, # global setting is off
constraint=[
{
"name": "pre",
"options": 1,
},
{"name": "post", "options": 1},
],
),
)
Rota.build_and_solve(options={"ratio": 0.01})
Rota.export_rota_to_html("test_pre_post_clear", folder="tests")
assert Rota.results.solver.status == "ok"
for worker in Rota.workers:
shifts = Rota.get_worker_shift_list(worker)
shifts_string = "".join([i if i != "" else "-" for i in shifts])
# check for patterns that should not occur
assert "ac" not in shifts_string
assert "ca" not in shifts_string
+99
View File
@@ -0,0 +1,99 @@
import datetime
import pytest
from pytest import approx
from rota.shifts import RotaBuilder, SingleShift, days
from rota.workers import Worker
@pytest.fixture
def demo_rota_nights():
weeks_to_rota = 12
start_date = datetime.date(2022, 3, 14)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
balance_offset_modifier=1,
)
Rota.constraint_options["max_shifts_per_week"] = 5
Rota.constraint_options["max_shifts_per_month"] = 31
Rota.constraint_options["limit_to_1_st2_on_nights"] = False
Rota.constraint_options["constrain_time_off_after_nights"] = False
Rota.constraint_options["balance_nights_across_site"] = False
Rota.constraint_options["balance_shifts"] = True
Rota.constraint_options["balance_shifts_over_workers"] = True
Rota.constraint_options["balance_nights"] = False
Rota.constraint_options["minimise_shift_diffs"] = False
Rota.constraint_options["balance_blocks"] = False
Rota.constraint_options["max_night_frequency"] = 0
# Add workers
workers = [
Worker(name="worker1", site="group1", grade=1),
Worker(name="worker2", site="group1", grade=1),
Worker(name="worker3", site="group1", grade=1),
Worker(name="worker4", site="group1", grade=1),
Worker(name="worker5", site="group2", grade=1, fte=60),
Worker(name="worker6", site="group2", grade=1, fte=40),
Worker(name="worker7", site="group2", grade=1),
Worker(name="worker8", site="group2", grade=1),
]
Rota.add_workers(workers)
# Add shifts
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="night_weekday",
length=12.5,
days=days[:4],
workers_required=1,
constraint=[{"name": "pre", "options": 1}, {"name": "post", "options": 2}],
force_as_block=True
),
SingleShift(
sites=("group1", "group2"),
name="night_weekend",
length=12.5,
days=days[4:],
constraint=[{"name": "pre", "options": 1}, {"name": "post", "options": 2}],
force_as_block=True,
),
SingleShift(
sites=("group1", "group2"),
name="twilight",
length=12.5,
days=days[:5],
workers_required=2,
),
)
Rota.build_shifts()
Rota.build_workers()
Rota.build_model()
solver_options = {"ratio": 0.01, "seconds": 1000, "threads": 10}
Rota.solve_model(options=solver_options)
Rota.export_rota_to_html("test3")
return Rota
def test_night_assignment(demo_rota_nights):
Rota = demo_rota_nights
assert Rota.results.solver.status == "ok"
shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name]
worker = Rota.get_worker_by_name(worker_name)
if worker.fte == 40:
assert worker_shifts["night_weekday"] in (approx(0), approx(4))
assert worker_shifts["night_weekend"] in (approx(0), approx(3))
elif worker.fte == 60:
assert worker_shifts["night_weekday"] in (approx(0), approx(4), approx(8))
assert worker_shifts["night_weekend"] in (approx(0), approx(3), approx(6))
else:
assert worker_shifts["night_weekday"] in (approx(4), approx(8))
assert worker_shifts["night_weekend"] in (approx(3), approx(6))
+71
View File
@@ -0,0 +1,71 @@
import pytest
import datetime
from copy import deepcopy
from rota.shifts import RotaBuilder, SingleShift, days
from rota.workers import Worker
@pytest.fixture
def rota_2workers():
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
balance_offset_modifier=1,
)
worker1 = Worker(name="worker1", site="group1", grade=1)
worker2 = Worker(name="worker2", site="group1", grade=1)
Rota.add_workers((worker1, worker2))
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=days,
balance_offset=40,
workers_required=1,
),
)
return Rota
@pytest.fixture
def rota_3workers(rota_2workers):
Rota3 = deepcopy(rota_2workers)
worker3 = Worker(name="worker3", site="group1", grade=1)
Rota3.add_worker(worker3)
return Rota3
def test_max_shifts(rota_2workers):
rota_2workers.constraint_options["max_shifts_per_month"] = 14
rota_2workers.build_and_solve()
assert rota_2workers.results.solver.status == "ok"
assert rota_2workers.results.solver.termination_condition == "optimal"
def test_max_shifts_fail(rota_2workers):
rota_2workers.constraint_options["max_shifts_per_month"] = 13
rota_2workers.build_and_solve()
assert rota_2workers.results.solver.status in ("warning", "error")
assert rota_2workers.results.solver.termination_condition == "infeasible"
def test_max_shifts_extra_worker(rota_3workers):
rota_3workers.constraint_options["max_shifts_per_month"] = 12
rota_3workers.build_and_solve()
assert rota_3workers.results.solver.status == "ok"
assert rota_3workers.results.solver.termination_condition == "optimal"
def test_max_shifts_per_week_fail(rota_2workers):
rota_2workers.constraint_options["max_shifts_per_week"] = 3
rota_2workers.build_and_solve()
assert rota_2workers.results.solver.status in ("warning", "error")
assert rota_2workers.results.solver.termination_condition == "infeasible"
def test_max_shifts_per_week_pass(rota_2workers):
rota_2workers.constraint_options["max_shifts_per_month"] = 14
rota_2workers.constraint_options["max_shifts_per_week"] = 4
rota_2workers.build_and_solve()
assert rota_2workers.results.solver.status == "ok"
def test_max_shifts_per_week_extra_worker_pass(rota_3workers):
rota_3workers.constraint_options["max_shifts_per_week"] = 3
rota_3workers.build_and_solve()
assert rota_3workers.results.solver.status == "ok"
+21
View File
@@ -709,3 +709,24 @@ class TestShiftWorkerRequirements:
for worker in Rota.get_workers(): for worker in Rota.get_workers():
assert total_shifts[worker.name] == 30 assert total_shifts[worker.name] == 30
class TestDisplayChar:
def test_display_char_in_output(monkeypatch):
Rota = generate_basic_rota(workers=1)
shift = SingleShift(
sites=("group1",),
name="testshift",
length=8,
days=days[:1],
display_char="Z"
)
Rota.add_shifts(shift)
assert shift.display_char == "Z"
Rota.build_and_solve(options={"ratio": 0.1})
assert Rota.results.solver.status == "ok"
for worker in Rota.get_workers():
assert Rota.get_worker_shift_list_string(worker).startswith("Z------" * 5)
+1 -1
View File
@@ -46,7 +46,7 @@ class TestDemoRota:
self.Rota.build_and_solve(options={"ratio": 0.00}) self.Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.export_rota_to_html("balance_weekend") self.Rota.export_rota_to_html("balance_weekend", folder="tests")
assert self.Rota.results.solver.status == "ok" assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal" assert self.Rota.results.solver.termination_condition == "optimal"
+603 -64
View File
@@ -1,11 +1,10 @@
import pytest import pytest
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days
import datetime import datetime
from rota.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works from rota.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works
from rota.workers import WorkRequests from rota.workers import WorkRequests
def generate_basic_rota( def generate_basic_rota(
weeks_to_rota=10, workers=0, start_date=datetime.date(2022, 3, 7) weeks_to_rota=10, workers=0, start_date=datetime.date(2022, 3, 7)
): ):
@@ -25,9 +24,7 @@ def generate_basic_rota(
return Rota return Rota
def test_no_workers():
class TestWorkers:
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 = generate_basic_rota() Rota = generate_basic_rota()
@@ -52,7 +49,7 @@ class TestWorkers:
with pytest.raises(NoWorkers): with pytest.raises(NoWorkers):
Rota.build_and_solve(solve=False) Rota.build_and_solve(solve=False)
def test_workers_duplicate_name(self): def test_workers_duplicate_name():
Rota = generate_basic_rota() Rota = generate_basic_rota()
workers = [] workers = []
@@ -77,7 +74,7 @@ class TestWorkers:
with pytest.raises(WarningTermination): with pytest.raises(WarningTermination):
Rota.build_and_solve(solve=False) Rota.build_and_solve(solve=False)
def test_worker_ftes(self): def test_worker_ftes():
weeks_to_rota = 10 weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7) start_date = datetime.date(2022, 3, 7)
@@ -87,9 +84,7 @@ class TestWorkers:
) )
worker1_end_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 2) worker1_end_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 2)
worker2_start_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 2) worker2_start_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 2)
worker3_start_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 4) worker3_start_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 4)
worker3_end_date = start_date + datetime.timedelta(weeks=3 * weeks_to_rota / 4) worker3_end_date = start_date + datetime.timedelta(weeks=3 * weeks_to_rota / 4)
@@ -195,7 +190,7 @@ class TestWorkers:
Rota.export_rota_to_html("worker_ftes") Rota.export_rota_to_html("worker_ftes")
def test_worker_oops(self): def test_worker_oops():
weeks_to_rota = 10 weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7) start_date = datetime.date(2022, 3, 7)
@@ -332,7 +327,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():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.add_shifts( Rota.add_shifts(
@@ -405,7 +400,7 @@ class TestWorkers:
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): def test_not_available_to_work():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.add_shifts( Rota.add_shifts(
@@ -448,9 +443,7 @@ class TestWorkers:
Rota.get_worker_by_name("worker2") Rota.get_worker_by_name("worker2")
).endswith("-") ).endswith("-")
def test_worker_ftes_2():
class TestWorkerFtes:
def test_worker_ftes(self):
Rota = generate_basic_rota(workers=2) Rota = generate_basic_rota(workers=2)
Rota.add_workers( Rota.add_workers(
@@ -500,7 +493,7 @@ class TestWorkerFtes:
assert worker.get_fte() == 40 assert worker.get_fte() == 40
assert total_shifts[worker.name] == 8 assert total_shifts[worker.name] == 8
def test_worker_fte_shift_override(self): def test_worker_fte_shift_override():
Rota = generate_basic_rota(workers=1) Rota = generate_basic_rota(workers=1)
Rota.add_workers( Rota.add_workers(
@@ -548,8 +541,7 @@ class TestWorkerFtes:
assert worker.get_fte("b") == 50 assert worker.get_fte("b") == 50
assert total_shifts[worker.name] == 20 assert total_shifts[worker.name] == 20
class TestComplexWorkers: def test_worker_requirement_and_force_block():
def test_worker_requirement_and_force_block(self):
Rota = generate_basic_rota(workers=10, weeks_to_rota=10) Rota = generate_basic_rota(workers=10, weeks_to_rota=10)
wr = [ wr = [
@@ -608,33 +600,19 @@ class TestComplexWorkers:
Rota.export_rota_to_html("test_worker_complex") Rota.export_rota_to_html("test_worker_complex")
#total_shifts = Rota.get_workers_total_shifts()
#for worker in Rota.get_workers():
# match worker.name:
# case "worker1":
# assert worker.get_fte() == 100
# assert total_shifts[worker.name] == 30
# assert worker.get_fte("b") == 100
# case "worker2":
# assert worker.get_fte() == 100
# assert worker.get_fte("b") == 50
# assert total_shifts[worker.name] == 20
#total_shifts = Rota.get_workers_total_shifts()
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
for worker in Rota.get_workers(): for worker in Rota.get_workers():
shift_string = Rota.get_worker_shift_list_string(worker) shift_string = Rota.get_worker_shift_list_string(worker)
assert shift_string.count("cccc") == shift_string.count("c") / 4 assert shift_string.count("cccc") == shift_string.count("c") / 4
assert shift_string.count("ddd") == shift_string.count("d") / 3 assert shift_string.count("ddd") == shift_string.count("d") / 3
def test_split_shift(self): def test_split_shift():
Rota = generate_basic_rota(workers=11, weeks_to_rota=22) Rota = generate_basic_rota(workers=11, weeks_to_rota=22)
Rota.set_rota_constraint("max_shifts_per_week", 2) Rota.set_rota_constraint("max_shifts_per_week", 2)
Rota.set_rota_constraint("max_weekend_frequency", 3) Rota.set_rota_constraint("max_weekend_frequency", 3)
Rota.set_rota_constraint("max_shifts_per_month", 4) Rota.set_rota_constraint("max_shifts_per_month", 4)
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -678,18 +656,14 @@ class TestComplexWorkers:
Rota.export_rota_to_html("test_split") Rota.export_rota_to_html("test_split")
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_locums_basic():
class TestLocums:
def test_locums_basic(self):
Rota = generate_basic_rota(workers=2, weeks_to_rota=6) Rota = generate_basic_rota(workers=2, weeks_to_rota=6)
la = [] la = []
for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)): for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
#for date in Rota.get_date_range():
la.append(WorkRequests(date=date, shift="*")) la.append(WorkRequests(date=date, shift="*"))
la2 = [] la2 = []
for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)): for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
#for date in Rota.get_date_range():
la2.append(WorkRequests(date=date, shift="*")) la2.append(WorkRequests(date=date, shift="*"))
Rota.add_workers( Rota.add_workers(
@@ -731,7 +705,7 @@ class TestLocums:
for worker in Rota.get_workers(): for worker in Rota.get_workers():
assert Rota.get_worker_shift_list(worker).count("a") == 4 assert Rota.get_worker_shift_list(worker).count("a") == 4
def test_locums_nwds(self): def test_locums_nwds():
Rota = generate_basic_rota(workers=2, weeks_to_rota=6) Rota = generate_basic_rota(workers=2, weeks_to_rota=6)
la = [] la = []
@@ -745,8 +719,6 @@ class TestLocums:
[ [
Worker(name="worker03", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1, nwds=[NonWorkingDays(day="Tue")]), Worker(name="worker03", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1, nwds=[NonWorkingDays(day="Tue")]),
Worker(name="worker04", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2, nwds=[NonWorkingDays(day="Wed"),NonWorkingDays(day="Thu")]), Worker(name="worker04", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2, nwds=[NonWorkingDays(day="Wed"),NonWorkingDays(day="Thu")]),
#Worker(name="worker03", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1, nwds=[NonWorkingDays(day="Mon"),NonWorkingDays(day="Tue")]),
#Worker(name="worker04", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2, nwds=[NonWorkingDays(day="Wed"),NonWorkingDays(day="Thu")]),
Worker(name="locum1", site="group1", locum=True), Worker(name="locum1", site="group1", locum=True),
Worker( Worker(
name="locum2", site="group1", locum=True name="locum2", site="group1", locum=True
@@ -769,10 +741,8 @@ class TestLocums:
] ]
), ),
) )
#Rota.set_rota_constraint("distribute_locum_shifts", True)
Rota.set_rota_constraint("maximum_allowed_shift_diff", 0) Rota.set_rota_constraint("maximum_allowed_shift_diff", 0)
Rota.set_rota_constraint("maximum_allowed_locum_shifts_per_worker", 4) Rota.set_rota_constraint("maximum_allowed_locum_shifts_per_worker", 4)
#Rota.set_rota_constraint("minimum_allowed_locum_shifts_per_worker", 3)
Rota.build_and_solve() Rota.build_and_solve()
@@ -787,15 +757,11 @@ class TestLocums:
if worker.name in ("worker03", "worker04"): if worker.name in ("worker03", "worker04"):
assert Rota.get_worker_shift_list(worker, include_locums=True).count("a") > 6 assert Rota.get_worker_shift_list(worker, include_locums=True).count("a") > 6
def test_locums_no_availablity(self): def test_locums_no_availablity():
Rota = generate_basic_rota(workers=2, weeks_to_rota=5) Rota = generate_basic_rota(workers=2, weeks_to_rota=5)
la = [] la = []
#for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
# la.append(WorkRequests(date=date, shift="*"))
la2 = [] la2 = []
#for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
# la2.append(WorkRequests(date=date, shift="*"))
Rota.add_workers( Rota.add_workers(
[ [
@@ -828,22 +794,18 @@ class TestLocums:
with pytest.raises(WarningTermination): with pytest.raises(WarningTermination):
Rota.build_and_solve() Rota.build_and_solve()
def test_locums():
def test_locums(self):
Rota = generate_basic_rota(workers=7, weeks_to_rota=10) Rota = generate_basic_rota(workers=7, weeks_to_rota=10)
Rota.set_rota_constraint("max_shifts_per_week", 2) Rota.set_rota_constraint("max_shifts_per_week", 2)
Rota.set_rota_constraint("max_weekend_frequency", 3) Rota.set_rota_constraint("max_weekend_frequency", 3)
Rota.set_rota_constraint("max_shifts_per_month", 4) Rota.set_rota_constraint("max_shifts_per_month", 4)
#Rota.set_rota_constraint("balance_shifts", False)
Rota.set_rota_constraint("distribute_locum_shifts", True) Rota.set_rota_constraint("distribute_locum_shifts", True)
Rota.set_rota_constraint("maximum_allowed_shift_diff", 1) Rota.set_rota_constraint("maximum_allowed_shift_diff", 1)
Rota.set_rota_constraint("maximum_allowed_locum_shifts_per_worker", 6) Rota.set_rota_constraint("maximum_allowed_locum_shifts_per_worker", 6)
Rota.terminate_on_warning.remove("Worker/no valid shifts") Rota.terminate_on_warning.remove("Worker/no valid shifts")
la = [] la = []
for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)): for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
la.append(WorkRequests(date=date, shift="*")) la.append(WorkRequests(date=date, shift="*"))
@@ -851,7 +813,6 @@ class TestLocums:
for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)): for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
la2.append(WorkRequests(date=date, shift="*")) la2.append(WorkRequests(date=date, shift="*"))
Rota.add_workers( Rota.add_workers(
[ [
Worker(name="worker08", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1), Worker(name="worker08", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1),
@@ -860,19 +821,9 @@ class TestLocums:
Worker( Worker(
name="locum2", site="group1", locum=True name="locum2", site="group1", locum=True
), ),
#Worker(
# name="extra1", site="locum", locum_availability=la,
# locum_max_shifts=40
#),
#Worker(
# name="extra2", site="locum", locum_availability=la,
# locum_max_shifts=4, locum_max_shifts_per_week=1
#),
] ]
) )
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
@@ -917,3 +868,591 @@ class TestLocums:
Rota.export_rota_to_html("test_locums", timestamp_filename=False) Rota.export_rota_to_html("test_locums", timestamp_filename=False)
assert Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_worker_assign_as_block_preference():
"""Test that a worker's assign_as_block preference is respected over the global setting."""
Rota = generate_basic_rota(workers=4, weeks_to_rota=12)
# Worker 1 prefers to have shift 'a' assigned as a block, worker 2 does not
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker4 = workers[3]
# Set a strong preference for worker1 to have 'a' as a block
worker1.assign_as_block_preferences = {"a":1}
worker2.assign_as_block_preferences = {"a":-1} # Worker 2 does not want 'a' as a block
# Add a shift 'a' that is NOT globally set as assign_as_block
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_assign_as_block_preference", folder="tests")
# Worker 1 should have their 'a' shifts grouped together (block), worker 2 may not
shift_list_1 = Rota.get_worker_shift_list_string(worker1)
assert shift_list_1.count("aaaa") == 3, "Worker 1 should have all 'a' shifts grouped together (block)"
shift_list_2 = Rota.get_worker_shift_list_string(worker2)
assert shift_list_2.count("a") == 12, "Worker 2 should have at 12 'a' shift, but may not be grouped together"
shift_list_3 = Rota.get_worker_shift_list_string(worker3)
assert shift_list_3.count("a") == 12, "Worker 3 should have at least 12 'a' shifts, but may not be grouped together"
shift_list_4 = Rota.get_worker_shift_list_string(worker4)
assert shift_list_4.count("a") == 12, "Worker 4 should have at least 12 'a' shifts, but may not be grouped together"
# Modify worker1's preference to prevent block 'a' shifts
worker1.assign_as_block_preferences = {"a":-1}
Rota.build_and_solve(options={"ratio": 0.0})
shift_list_1 = Rota.get_worker_shift_list_string(worker1)
assert shift_list_1.count("aaaa") == 0, "Worker 1 should have all 'a' shifts grouped together (block)"
worker1.assign_as_block_preferences = {"a":0}
a_shift = Rota.get_shift_by_name("a")
a_shift.assign_as_block = True # Set the shift 'a' to be assigned as a block globally
Rota.build_and_solve(options={"ratio": 0.0})
for worker in Rota.get_workers():
shift_list = Rota.get_worker_shift_list_string(worker)
# All workers should have 'a' shifts grouped together (block)
assert shift_list.count("aaaa") == 3, f"Worker {worker.name} should have all 'a' shifts grouped together (block)"
def test_worker_assign_as_block_preference_multiple_shifts():
"""Test that a worker's assign_as_block preference is respected over the global setting."""
Rota = generate_basic_rota(workers=4, weeks_to_rota=12)
# Worker 1 prefers to have shift 'a' assigned as a block, worker 2 does not
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker4 = workers[3]
# Set a strong preference for worker1 to have 'a' as a block
worker1.assign_as_block_preferences = {"a":100}
worker2.assign_as_block_preferences = {"b":-10000} # Worker 2 does not want 'a' as a block
# Add a shift 'a' that is NOT globally set as assign_as_block
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[4:],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
shift_list_1 = Rota.get_worker_shift_list_string(worker1)
assert shift_list_1.count("aaaa") == 3, "Worker 1 should have all 'a' shifts grouped together (block)"
Rota.export_rota_to_html("test_worker_assign_as_block_preference", folder="tests")
def test_worker_assign_as_block_preference_multiple_sites():
"""Test that a worker's assign_as_block preference is respected over the global setting."""
Rota = generate_basic_rota(workers=6, weeks_to_rota=12)
# Worker 1 prefers to have shift 'a' assigned as a block, worker 2 does not
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker4 = workers[3]
worker5 = workers[4]
worker6 = workers[5]
# Set a strong preference for worker1 to have 'a' as a block
worker1.assign_as_block_preferences = {"a":1}
worker4.site = "group2"
worker5.site = "group2"
worker6.site = "group2"
worker4.assign_as_block_preferences = {"b":-10000} # Worker 2 does not want 'a' as a block
# Add a shift 'a' that is NOT globally set as assign_as_block
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group2",),
name="b",
length=8,
days=days[4:],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_assign_as_block_preference", folder="tests")
def test_worker_hard_day_dependency():
Rota = generate_basic_rota(workers=6, weeks_to_rota=16)
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker1.add_hard_day_dependency("Mon", "Tue")
worker2.add_hard_day_dependency("Mon", "Wed")
worker3.add_hard_day_dependency("Mon", "Thu")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days,
workers_required=2,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_hard_day_depend", folder="tests")
for worker in Rota.get_workers():
shift_list = Rota.get_worker_shift_list_string(worker)
if worker.name == "worker1":
# Check that every time 'a' is assigned on Monday, it is also assigned on Tuesday
for week in range(12):
mon_idx = week * 7 + days.index("Mon")
tue_idx = week * 7 + days.index("Tue")
if shift_list[mon_idx] == "a":
assert shift_list[tue_idx] == "a", f"Worker1 assigned 'a' on Mon (week {week}) but not on Tue"
if worker.name == "worker2":
# Check that every time 'a' is assigned on Monday, it is also assigned on Wednesday
for week in range(12):
mon_idx = week * 7 + days.index("Mon")
wed_idx = week * 7 + days.index("Wed")
if shift_list[mon_idx] == "a":
assert shift_list[wed_idx] == "a", f"Worker2 assigned 'a' on Mon (week {week}) but not on Wed"
if worker.name == "worker3":
# Check that every time 'a' is assigned on Monday, it is also assigned on Thursday
for week in range(12):
mon_idx = week * 7 + days.index("Mon")
thu_idx = week * 7 + days.index("Thu")
if shift_list[mon_idx] == "a":
assert shift_list[thu_idx] == "a", f"Worker3 assigned 'a' on Mon (week {week}) but not on Thu"
def test_worker_hard_day_dependency_weekend():
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker4 = workers[3]
worker1.add_hard_day_dependency("Fri", "Sun")
worker2.add_hard_day_dependency("Fri", "Sun")
worker3.add_hard_day_dependency("Fri", "Sun")
worker4.add_hard_day_dependency("Fri", "Sun")
worker1.add_hard_day_exclusion("Fri", "Sat")
worker2.add_hard_day_exclusion("Fri", "Sat")
worker3.add_hard_day_exclusion("Fri", "Sat")
worker4.add_hard_day_exclusion("Fri", "Sat")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[4:],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_hard_day_depend", folder="tests")
for worker in Rota.get_workers():
shift_list = Rota.get_worker_shift_list_string(worker)
# Check that every time 'a' is assigned on Monday, it is also assigned on Tuesday
for week in range(16):
fri_idx = week * 7 + days.index("Fri")
sat_idx = week * 7 + days.index("Sat")
sun_idx = week * 7 + days.index("Sun")
if shift_list[fri_idx] == "a":
assert shift_list[sun_idx] == "a", f"Worker1 assigned 'a' on Fri (week {week}) but not on Sun"
assert shift_list[sat_idx] != "a", f"Worker1 assigned 'a' on Fri (week {week}) but also on Sat, which should not happen due to hard day exclusion"
def test_worker_hard_day_dependency_weekend2():
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
workers = Rota.get_workers()
worker1 = workers[0]
worker2 = workers[1]
worker3 = workers[2]
worker4 = workers[3]
worker1.add_hard_day_dependency("Fri", "Sun")
worker2.add_hard_day_dependency("Fri", "Sun")
worker3.add_hard_day_dependency("Fri", "Sun")
worker4.add_hard_day_dependency("Fri", "Sun")
worker4.add_hard_day_dependency("Fri", "Sat")
worker1.add_hard_day_exclusion("Fri", "Sat")
worker2.add_hard_day_exclusion("Fri", "Sat")
worker3.add_hard_day_exclusion("Fri", "Sat")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[4:],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_hard_day_depend", folder="tests")
for worker in Rota.get_workers():
shift_list = Rota.get_worker_shift_list_string(worker)
# Check that every time 'a' is assigned on Monday, it is also assigned on Tuesday
for week in range(16):
fri_idx = week * 7 + days.index("Fri")
sat_idx = week * 7 + days.index("Sat")
sun_idx = week * 7 + days.index("Sun")
if worker != worker4 and shift_list[fri_idx] == "a":
assert shift_list[sun_idx] == "a", f"Worker1 assigned 'a' on Fri (week {week}) but not on Sun"
assert shift_list[sat_idx] != "a", f"Worker1 assigned 'a' on Fri (week {week}) but also on Sat, which should not happen due to hard day exclusion"
if worker == worker4 and shift_list[fri_idx] == "a":
assert shift_list[sun_idx] == "a", f"Worker4 assigned 'a' on Fri (week {week}) but not on Sun"
assert shift_list[sat_idx] == "a", f"Worker4 assigned 'a' on Fri (week {week}) but not on Sun"
def test_worker_force_assign_with_invalid_shift():
Rota = generate_basic_rota(workers=4, weeks_to_rota=2)
workers = Rota.get_workers()
for worker in workers:
worker.add_force_assign_with("a", "c")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
with pytest.raises(InvalidShift):
# This should raise an InvalidShift error because 'c' does not exist
Rota.build_and_solve(options={"ratio": 0.0})
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="c",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
def test_worker_force_assign_with():
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
workers = Rota.get_workers()
for worker in workers:
worker.add_force_assign_with("a", "b")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[:4],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_force_assign_with", folder="tests")
for worker in Rota.get_workers():
shift_list = Rota.get_worker_shift_list(worker, search_multiple_assignments=True)
for shift in shift_list:
if shift == "a":
assert "b" in shift_list, f"Worker {worker.name} has 'a' but not 'b' assigned with it"
elif shift == "b":
assert "a" in shift_list, f"Worker {worker.name} has 'b' but not 'a' assigned with it"
assert shift_list.count({"a", "b"}) == 16
def test_worker_force_assign_with_and_hard_days():
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
workers = Rota.get_workers()
for worker in workers:
worker.add_force_assign_with("a", "b")
#worker.add_hard_day_dependency("Sat", "Sun")
worker.add_hard_day_dependency("Mon", "Tue")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:2],
workers_required=1,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[:2],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_force_assign_with_hard_days", folder="tests")
assert Rota.results.solver.status == "ok"
def test_worker_force_assign_with_and_hard_days_weekend():
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
workers = Rota.get_workers()
for worker in workers:
worker.add_force_assign_with("a", "b")
#worker.add_hard_day_dependency("Sat", "Sun")
worker.add_hard_day_dependency("Sat", "Sun")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[5:],
workers_required=1,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[5:],
workers_required=1,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_force_assign_with_hard_days", folder="tests")
assert Rota.results.solver.status == "ok"
def test_workers_double_shifts_on_mon_tue():
Rota = generate_basic_rota(workers=2, weeks_to_rota=16)
workers = Rota.get_workers()
for worker in workers:
worker.add_force_assign_with("a", "b")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:2],
workers_required=2,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[:2],
workers_required=2,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_double_shifts", folder="tests")
assert Rota.results.solver.status == "ok"
def test_workers_double_shifts_on_weekends():
Rota = generate_basic_rota(workers=2, weeks_to_rota=16)
workers = Rota.get_workers()
for worker in workers:
worker.add_force_assign_with("a", "b")
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[5:],
workers_required=2,
assign_as_block=False, # global setting is off
),
SingleShift(
sites=("group1",),
name="b",
length=8,
days=days[5:],
workers_required=2,
assign_as_block=False, # global setting is off
),
)
Rota.build_and_solve(options={"ratio": 0.0})
Rota.export_rota_to_html("test_worker_double_shifts", folder="tests")
assert Rota.results.solver.status == "ok"
def test_max_shifts_per_week_by_shift_name_basic():
Rota = RotaBuilder(weeks_to_rota=2, start_date=datetime.date(2025, 6, 16))
w = Worker(name="A01", site="site1", grade=1)
w.max_shifts_per_week_by_shift_name = {"a": 1}
Rota.add_worker(w)
Rota.add_shifts(
SingleShift(
sites=("site1",),
name="a",
length=8,
days=days[:5],
workers_required=1,
),
)
Rota.build_and_solve(options={"ratio": 0.0})
# Worker should not be assigned more than 1 "a" shift per week
for week in Rota.weeks:
count = sum(
Rota.model.works[w.id, week, day, "a"].value > 0.5
for day in days[:5]
)
assert count <= 1
def test_max_shifts_per_week_by_shift_name_multiple_weeks():
Rota =generate_basic_rota(workers=4, weeks_to_rota=2)
for worker in Rota.get_workers():
worker.set_max_shifts_per_week("a", 1)
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=8,
days=days[:5],
workers_required=1,
),
)
Rota.build_and_solve(options={"ratio": 0.0})
assert Rota.results.solver.status == "error"
Rota.add_worker(
Worker(name="extraworker", site="group1", grade=1, max_shifts_per_week_by_shift_name={"a": 1})
)
Rota.build_and_solve(options={"ratio": 0.0})
assert Rota.results.solver.status == "ok"
Rota.export_rota_to_html("test_worker_double_shifts", folder="tests")
# Worker should not be assigned more than 2 "a" shifts per week
for worker in Rota.get_workers():
for week in Rota.weeks:
count = sum(
Rota.model.works[worker.id, week, day, "a"].value > 0.5
for day in days[:5]
)
assert count <= 2
def test_max_shifts_per_week_by_shift_name_invalid_shift():
Rota = RotaBuilder(weeks_to_rota=1, start_date=datetime.date(2025, 6, 16))
w = Worker(name="A03", site="site1", grade=1)
# "b" does not exist
w.max_shifts_per_week_by_shift_name = {"b": 1}
Rota.add_worker(w)
Rota.add_shifts(
SingleShift(
sites=("site1",),
name="a",
length=8,
days=days[:5],
workers_required=1,
),
)
with pytest.raises(InvalidShift):
Rota.build_and_solve(options={"ratio": 0.0})