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
+47 -86
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]
site = w["site"] try:
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()
+21 -9
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:
date = datetime.strptime(lower_item, "%d/%m/%y").date() try:
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(() => {
+723 -233
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"
+201 -258
View File
@@ -1,284 +1,227 @@
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():
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
balance_offset=2,
workers_required=1,
force_as_block=False,
),
SingleShift(
sites=("group1", "group2"),
name="b",
length=12.5,
days=days,
balance_offset=2,
workers_required=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.001})
Rota.export_rota_to_html("basic_balancing")
shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name]
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))
class TestBalancing: def test_weighted_shift_balancing():
def test_basic_balancing(self): Rota = generate_basic_rota(20)
Rota = generate_basic_rota() Rota.add_shifts(
SingleShift(
sites=["group1", "group2"],
name="a",
length=12.5,
days=days,
balance_weighting=10,
workers_required=1,
force_as_block=False,
constraint=[{"name": "pre","options": "2"}, {"name": "post","options": "2"}],
),
SingleShift(
sites=("group1", "group2"),
name="b",
length=12.5,
days=days,
workers_required=1,
force_as_block=False,
),
SingleShift(
sites=("group1", "group2"),
name="c",
length=12.5,
days=days[0],
workers_required=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.0001})
Rota.export_rota_to_html("basic_balancing_weighted_shifts")
shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name]
assert worker_shifts["a"] in (approx(46), approx(47), approx(48))
Rota.add_shifts( def test_weighted_shift_balancing2():
SingleShift( Rota = generate_basic_rota(23)
sites=("group1", "group2"), Rota.add_shifts(
name="a", SingleShift(
length=12.5, sites=("group1", "group2"),
days=days, name="a",
balance_offset=2, length=12.5,
workers_required=1, days=days,
force_as_block=False, workers_required=1,
), force_as_block=False,
SingleShift( ),
sites=("group1", "group2"), SingleShift(
name="b", sites=("group1", "group2"),
length=12.5, name="b",
days=days, length=12.5,
balance_offset=2, days=days,
workers_required=1, balance_weighting=8,
force_as_block=False, workers_required=1,
), force_as_block=False,
) ),
SingleShift(
sites=("group1", "group2"),
name="c",
length=12.5,
days=days[0],
workers_required=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.0001})
Rota.export_rota_to_html("basic_balancing_weighted_shifts2")
shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name]
assert worker_shifts["b"] in (approx(53), approx(54), approx(55))
Rota.build_and_solve(options={"ratio": 0.001}) def test_weighted_shift_balancing3():
Rota.export_rota_to_html("basic_balancing") Rota = generate_basic_rota(23)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
workers_required=1,
force_as_block=False,
),
SingleShift(
sites=("group1", "group2"),
name="b",
length=12.5,
days=days,
balance_weighting=1,
workers_required=1,
force_as_block=False,
),
SingleShift(
sites=("group1", "group2"),
name="c",
length=12.5,
days=days[0],
balance_weighting=8,
workers_required=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.0001})
Rota.export_rota_to_html("basic_balancing_weighted_shifts3")
shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name]
assert worker_shifts["c"] in (approx(7), approx(8))
shift_summary = Rota.get_shift_summary_dict() def test_weighted_shift_balancing4():
for worker_name in shift_summary: Rota = generate_basic_rota(10)
worker_shifts = shift_summary[worker_name] Rota.add_worker(
Worker(name="worker4", site="group3", grade=1),
)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
balance_offset=99,
balance_weighting=8,
workers_required=2,
force_as_block=False,
),
SingleShift(
sites=("group2", "group3"),
name="b",
length=12.5,
days=days,
balance_offset=99,
workers_required=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("basic_balancing_weighted_shifts4")
shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name]
assert worker_shifts["a"] in (0, 46, 47, 48)
#worker = Rota.get_worker_by_name(worker_name) def test_weighted_shift_balancing5():
assert worker_shifts["a"] in (approx(22), approx(23), approx(24), approx(25)) Rota = generate_basic_rota(10)
assert worker_shifts["b"] in (approx(22), approx(23), approx(24), approx(25)) Rota.add_worker(
Worker(name="worker4", site="group3", grade=1),
def test_weighted_shift_balancing(self): )
Rota = generate_basic_rota(20) Rota.add_shifts(
SingleShift(
Rota.add_shifts( sites=("group1", "group2"),
SingleShift( name="a",
sites=["group1", "group2"], length=12.5,
name="a", days=days,
length=12.5, balance_offset=99,
days=days, workers_required=2,
balance_weighting=10, force_as_block=False,
workers_required=1, ),
force_as_block=False, SingleShift(
constraint=[{"name": "pre","options": "2"}, {"name": "post","options": "2"}], sites=("group2", "group3"),
), name="b",
SingleShift( length=12.5,
sites=("group1", "group2"), days=days,
name="b", balance_offset=99,
length=12.5, balance_weighting=4,
days=days, workers_required=1,
workers_required=1, force_as_block=False,
force_as_block=False, ),
), )
SingleShift( Rota.build_and_solve(options={"ratio": 0.000})
sites=("group1", "group2"), Rota.export_rota_to_html("basic_balancing_weighted_shifts5")
name="c", shift_summary = Rota.get_shift_summary_dict()
length=12.5, for worker_name in shift_summary:
days=days[0], worker_shifts = shift_summary[worker_name]
workers_required=1, assert worker_shifts["b"] in (0, 35)
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.0001})
Rota.export_rota_to_html("basic_balancing_weighted_shifts")
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)
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):
Rota = generate_basic_rota(23)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
# balance_weighting=4,
workers_required=1,
force_as_block=False,
# constraint=["preclear2", "postclear2"],
),
SingleShift(
sites=("group1", "group2"),
name="b",
length=12.5,
days=days,
balance_weighting=8,
workers_required=1,
force_as_block=False,
),
SingleShift(
sites=("group1", "group2"),
name="c",
length=12.5,
days=days[0],
workers_required=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.0001})
Rota.export_rota_to_html("basic_balancing_weighted_shifts2")
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)
# 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))
def test_weighted_shift_balancing3(self):
Rota = generate_basic_rota(23)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
# balance_weighting=4,
workers_required=1,
force_as_block=False,
# constraint=["preclear2", "postclear2"],
),
SingleShift(
sites=("group1", "group2"),
name="b",
length=12.5,
days=days,
balance_weighting=1,
workers_required=1,
force_as_block=False,
),
SingleShift(
sites=("group1", "group2"),
name="c",
length=12.5,
days=days[0],
balance_weighting=8,
workers_required=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.0001})
Rota.export_rota_to_html("basic_balancing_weighted_shifts3")
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)
# assert worker_shifts["a"] in (46,47,48)
assert worker_shifts["c"] in (approx(7), approx(8))
#assert worker_shifts["b"] in (53, 54)
def test_weighted_shift_balancing4(self):
Rota = generate_basic_rota(10)
Rota.add_worker(
Worker(name="worker4", site="group3", grade=1),
)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
balance_offset=99,
balance_weighting=8,
workers_required=2,
force_as_block=False,
# constraint=["preclear2", "postclear2"],
),
SingleShift(
sites=("group2", "group3"),
name="b",
length=12.5,
days=days,
balance_offset=99,
# balance_weighting=4,
workers_required=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("basic_balancing_weighted_shifts4")
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)
assert worker_shifts["a"] in (0, 46, 47, 48)
def test_weighted_shift_balancing5(self):
Rota = generate_basic_rota(10)
Rota.add_worker(
Worker(name="worker4", site="group3", grade=1),
)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
balance_offset=99,
# balance_weighting=8,
workers_required=2,
force_as_block=False,
# constraint=["preclear2", "postclear2"],
),
SingleShift(
sites=("group2", "group3"),
name="b",
length=12.5,
days=days,
balance_offset=99,
balance_weighting=4,
workers_required=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("basic_balancing_weighted_shifts5")
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)
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
+325 -466
View File
@@ -1,512 +1,371 @@
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():
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}, {"name": "pre", "options": 2}],
workers_required=2,
),
SingleShift(
sites=("group1", "group2"),
name="b",
length=12.5,
days=days[:3],
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night", folder="tests")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
class TestNightShifts: def test_nights_pre3():
def test_nights(self): Rota = generate_basic_rota()
Rota = generate_basic_rota() Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
workers_required=2,
),
SingleShift(
sites=("group1", "group2"),
name="b",
length=12.5,
days=days[:2],
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night_pre3", folder="tests")
assert Rota.results.solver.status == "ok"
Rota.add_shifts( def test_nights_pre3_2():
SingleShift( Rota = generate_basic_rota()
sites=("group1", "group2"), Rota.add_shifts(
name="n", SingleShift(
length=12.5, sites=("group1", "group2"),
days=days[5:], name="n",
force_as_block=True, length=12.5,
constraint=[{"name": "night"}, {"name": "pre", "options": 2}], days=days[5:],
workers_required=2, force_as_block=True,
), constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
SingleShift( workers_required=1,
sites=("group1", "group2"), ),
name="b", SingleShift(
length=12.5, sites=("group1", "group2"),
days=days[:3], name="b",
force_as_block=False, length=12.5,
# constraint=[{"name": "night"}], days=days[:3],
), force_as_block=False,
) ),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night_pre3", folder="tests")
assert Rota.results.solver.status == "ok"
Rota.build_and_solve(options={"ratio": 0.000}) def test_nights_fail():
Rota.export_rota_to_html("night") Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
workers_required=2,
),
SingleShift(
sites=("group1", "group2"),
name="b",
length=12.5,
days=days[:3],
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status in ("warning", "error")
assert Rota.results.solver.status == "ok" def test_nights2():
assert Rota.results.solver.termination_condition == "optimal" Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 0
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=2,
),
SingleShift(
sites=("group1", "group2"),
name="m",
length=12.5,
days=days[:5],
force_as_block=True,
workers_required=2,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night", folder="tests")
assert Rota.results.solver.status == "ok"
def test_nights_pre3(self): def test_nights_pre_wrap_around():
Rota = generate_basic_rota() Rota = generate_basic_rota(weeks_to_rota=8)
Rota.constraint_options["max_night_frequency"] = 0
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
sites=("group1", "group2"), sites=("group1", "group2"),
name="n", name="n",
length=12.5, length=12.5,
days=days[5:], days=days[:2],
force_as_block=True, force_as_block=True,
constraint=[{"name": "night"}, {"name": "pre", "options": 3}], constraint=[
workers_required=2, {"name": "night"},
), {"name": "pre", "options": 5},
SingleShift( {"name": "post", "options": 5},
sites=("group1", "group2"), ],
name="b", workers_required=3,
length=12.5, ),
days=days[:2], )
force_as_block=False, Rota.add_workers(
# constraint=[{"name": "night"}], [
), Worker(
) name="worker3",
site="group1",
Rota.build_and_solve(options={"ratio": 0.000}) grade=1,
Rota.export_rota_to_html("night_pre3") not_available_to_work=[
{
assert Rota.results.solver.status == "ok" "date": Rota.rota_end_date - datetime.timedelta(days=1),
"reason": "no wanna",
def test_nights_pre3_2(self): },
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
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.export_rota_to_html("night_pre3")
assert Rota.results.solver.status == "ok"
def test_nights_fail(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
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})
assert Rota.results.solver.status in ("warning", "error")
def test_nights2(self):
Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 0
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
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.export_rota_to_html("night")
assert Rota.results.solver.status == "ok"
def test_nights_pre_wrap_around(self):
Rota = generate_basic_rota(weeks_to_rota=8)
Rota.constraint_options["max_night_frequency"] = 0
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[:2],
force_as_block=True,
constraint=[
{"name": "night"},
{"name": "pre", "options": 5},
{"name": "post", "options": 5},
], ],
workers_required=3,
), ),
# SingleShift( ]
# sites=("group1", "group2"), name="m", length= 12.5, days=days[:5], )
# force_as_block=True, Rota.build_and_solve(options={"ratio": 0.000})
# #constraint=[{"name": "night"}, {"name": "pre", "options": 3}], Rota.export_rota_to_html("night pre wrap around", folder="tests")
# #constraint=[{"name": "night"}], assert Rota.results.solver.status == "ok"
# 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( def test_nights_max_frequency():
[ Rota = generate_basic_rota()
Worker( Rota.constraint_options["max_night_frequency"] = 1
name="worker3", Rota.add_shifts(
site="group1", SingleShift(
grade=1, sites=("group1", "group2"),
not_available_to_work=[ name="n",
{ length=12.5,
"date": Rota.rota_end_date - datetime.timedelta(days=1), days=days[5:],
"reason": "no wanna", force_as_block=True,
}, constraint=[{"name": "night"}],
# { workers_required=2,
# "date": Rota.rota_end_date - datetime.timedelta(days=5), ),
# "reason": "no wanna", )
# }, Rota.build_and_solve(options={"ratio": 0.000})
], Rota.export_rota_to_html("night max frequency", folder="tests")
), assert Rota.results.solver.status == "ok"
]
)
Rota.build_and_solve(options={"ratio": 0.000}) def test_nights_max_frequency_fail():
Rota.export_rota_to_html("night") Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=2,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status in ("warning", "error")
assert Rota.results.solver.status == "ok" def test_nights_max_frequency3():
Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=1,
),
SingleShift(
sites=("group1", "group2"),
name="m",
length=12.5,
days=days[:5],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=1,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night max frequency 3", folder="tests")
assert Rota.results.solver.status in ("warning", "error")
def test_nights_max_frequency(self): def test_nights_max_frequency4():
Rota = generate_basic_rota() Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=1,
),
SingleShift(
sites=("group1", "group2"),
name="m",
length=12.5,
days=days[:5],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=1,
),
)
Rota.add_workers(
[
Worker(name="worker3", site="group1", grade=1),
Worker(name="worker4", site="group1", grade=1),
]
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night max frequency 4", folder="tests")
assert Rota.results.solver.status == "ok"
Rota.constraint_options["max_night_frequency"] = 1 def test_nights_max_frequency_exclusions():
Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2
Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(10))
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=2,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night max frequency exclusions", folder="tests")
assert Rota.results.solver.status == "ok"
Rota.add_shifts( def test_nights_max_frequency_exclusions2():
SingleShift( Rota = generate_basic_rota(weeks_to_rota=8)
sites=("group1", "group2"), Rota.constraint_options["max_night_frequency"] = 2
name="n", Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(1,4))
length=12.5, Rota.add_shifts(
days=days[5:], SingleShift(
force_as_block=True, sites=("group1", "group2"),
constraint=[{"name": "night"}], name="n",
workers_required=2, length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=2,
),
)
Rota.add_workers(
[
Worker(
name="worker3",
site="group1",
grade=1,
start_date=Rota.start_date + datetime.timedelta(weeks=4)
), ),
# SingleShift( Worker(
# sites=("group1", "group2"), name="worker4",
# name="m", site="group1",
# length=12.5, grade=1,
# days=days[:5], start_date=Rota.start_date + datetime.timedelta(weeks=4)
# 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.export_rota_to_html("night")
assert Rota.results.solver.status == "ok"
def test_nights_max_frequency_fail(self):
Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=2,
), ),
# SingleShift( ]
# sites=("group1", "group2"), )
# name="m", Rota.build_and_solve(options={"ratio": 0.000})
# length=12.5, Rota.export_rota_to_html("night max frequency exclusions 2", folder="tests")
# days=days[:5], assert Rota.results.solver.status == "ok"
# 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}) def test_nights_max_frequency_exclusions3():
Rota.export_rota_to_html("night") Rota = generate_basic_rota(weeks_to_rota=9)
Rota.constraint_options["max_night_frequency"] = 2
assert Rota.results.solver.status in ("warning", "error") Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(1,5))
Rota.add_shifts(
def test_nights_max_frequency3(self): SingleShift(
Rota = generate_basic_rota() sites=("group1", "group2"),
name="n",
Rota.constraint_options["max_night_frequency"] = 2 length=12.5,
days=days[5:],
Rota.add_shifts( force_as_block=True,
SingleShift( balance_offset=10,
sites=("group1", "group2"), constraint=[{"name": "night"}],
name="n", workers_required=2,
length=12.5, ),
days=days[5:], )
force_as_block=True, Rota.add_workers(
constraint=[{"name": "night"}], [
workers_required=1, Worker(
name="worker3",
site="group1",
grade=1,
start_date=Rota.start_date + datetime.timedelta(weeks=4)
), ),
SingleShift( Worker(
sites=("group1", "group2"), name="worker4",
name="m", site="group1",
length=12.5, grade=1,
days=days[:5], start_date=Rota.start_date + datetime.timedelta(weeks=4)
force_as_block=True,
# constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
constraint=[{"name": "night"}],
workers_required=1,
), ),
# SingleShift( ]
# sites=("group1", "group2"), name="b", length= 12.5, days=days[:3], )
# force_as_block=False, Rota.build_and_solve(options={"ratio": 0.000})
# #constraint=[{"name": "night"}], Rota.export_rota_to_html("night max frequency exclusions 3", folder="tests")
# ), assert Rota.results.solver.status == "ok"
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night")
assert Rota.results.solver.status in ("warning", "error")
def test_nights_max_frequency4(self):
Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=1,
),
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=1,
),
# SingleShift(
# sites=("group1", "group2"), name="b", length= 12.5, days=days[:3],
# force_as_block=False,
# #constraint=[{"name": "night"}],
# ),
)
Rota.add_workers(
[
Worker(
name="worker3",
site="group1",
grade=1,
),
Worker(
name="worker4",
site="group1",
grade=1,
),
]
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night")
assert Rota.results.solver.status == "ok"
def test_nights_max_frequency_exclusions(self):
Rota = generate_basic_rota()
Rota.constraint_options["max_night_frequency"] = 2
Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(10))
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=2,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night")
assert Rota.results.solver.status == "ok"
def test_nights_max_frequency_exclusions2(self):
Rota = generate_basic_rota(weeks_to_rota=8)
Rota.constraint_options["max_night_frequency"] = 2
Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(1,4))
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
constraint=[{"name": "night"}],
workers_required=2,
),
)
Rota.add_workers(
[
Worker(
name="worker3",
site="group1",
grade=1,
start_date=Rota.start_date + datetime.timedelta(weeks=4)
),
Worker(
name="worker4",
site="group1",
grade=1,
start_date=Rota.start_date + datetime.timedelta(weeks=4)
),
]
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night")
assert Rota.results.solver.status == "ok"
def test_nights_max_frequency_exclusions3(self):
# TODO: find out the issues with odd rotas and exclusions
Rota = generate_basic_rota(weeks_to_rota=9)
Rota.constraint_options["max_night_frequency"] = 2
Rota.constraint_options["max_night_frequency_week_exclusions"] = list(range(1,5))
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="n",
length=12.5,
days=days[5:],
force_as_block=True,
balance_offset=10,
constraint=[{"name": "night"}],
workers_required=2,
),
)
Rota.add_workers(
[
Worker(
name="worker3",
site="group1",
grade=1,
start_date=Rota.start_date + datetime.timedelta(weeks=4)
),
Worker(
name="worker4",
site="group1",
grade=1,
start_date=Rota.start_date + datetime.timedelta(weeks=4)
),
]
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("night")
assert Rota.results.solver.status == "ok"
+195 -353
View File
@@ -1,374 +1,216 @@
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, worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}])
) worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Fri"}])
Rota.add_workers((worker1, worker2))
Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length=12.5, days=days[:5], workers_required=1,
),
SingleShift(
sites=("group1",), name="w", length=12.5, days=days[5:], workers_required=2,
),
)
Rota.build_and_solve(options={"ratio": 0.10})
Rota.export_rota_to_html("nwd1", folder="tests")
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])
if worker.name == "worker1":
assert "wwd" not in shifts_string
if worker.name == "worker2":
assert "dww" not in shifts_string
# Add a few workers def test_nwd_simple_fail():
worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}]) Rota = setup_rota()
worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Fri"}]) worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}])
worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Mon"}])
Rota.add_workers((worker1, worker2))
Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length=12.5, days=days[:5], 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.add_workers((worker1, worker2)) def test_nwd_partial_rota():
Rota = setup_rota()
start_date = Rota.start_date
d = start_date + datetime.timedelta(weeks=5)
worker1 = Worker(
name="worker1", site="group1", grade=1,
nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}],
)
worker2 = Worker(
name="worker2", site="group1", grade=1,
nwds=[{"day": "Mon", "start_date": d, "end_date": Rota.rota_end_date}],
)
Rota.add_workers((worker1, worker2))
Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length=12.5, days=days[:5], 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 == "ok"
for worker in Rota.workers:
shifts = Rota.get_worker_shift_list(worker)
shifts_string = "".join([i if i != "" else "-" for i in shifts])
if worker.name == "worker1":
for n in range(0, 4):
assert shifts_string[0 + 7 * n] == "-"
if worker.name == "worker2":
for n in range(5, 9):
assert shifts_string[0 + 7 * n] == "-"
# Add a weekday and weekend shift def test_nwd_partial_overlay():
self.Rota.add_shifts( Rota = setup_rota()
SingleShift( start_date = Rota.start_date
sites=("group1",), name="d", length= 12.5, days=days[:5], d = start_date + datetime.timedelta(weeks=5)
# balance_offset=10, d2 = start_date + datetime.timedelta(weeks=6)
workers_required=1, worker1 = Worker(
), name="worker1", site="group1", grade=1,
SingleShift( nwds=[{"day": "Mon", "start_date": start_date, "end_date": d2}],
sites=("group1",), name="w", length= 12.5, days=days[5:], )
workers_required=2, worker2 = Worker(
), name="worker2", site="group1", grade=1,
) nwds=[{"day": "Mon", "start_date": d, "end_date": Rota.rota_end_date}],
)
Rota.add_workers((worker1, worker2))
Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length=12.5, days=days[:5], 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.10}) def test_nwd_force_as_block():
Rota = setup_rota()
start_date = Rota.start_date
d = start_date + datetime.timedelta(weeks=4)
worker1 = Worker(
name="worker1", site="group1", grade=1,
nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}],
)
worker2 = Worker(name="worker2", 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))
Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length=12.5, days=days[:5],
balance_offset=10, workers_required=2,
force_as_block_unless_nwd=True, assign_as_block=True,
),
)
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("nwd", 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])
for week in weeks_from_list(shifts_string):
assert week in ("-------", "ddddd--")
assert shifts_string.count("d") == 25
self.Rota.export_rota_to_html("nwd1") def test_nwd_force_as_block_force_split():
Rota = setup_rota()
start_date = Rota.start_date
d = start_date + datetime.timedelta(weeks=5)
worker1 = Worker(
name="worker1", site="group1", grade=1,
nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}],
)
worker2 = Worker(name="worker2", 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))
Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length=12.5, days=days[:5],
workers_required=2, force_as_block_unless_nwd=True, assign_as_block=True,
),
SingleShift(
sites=("group1",), name="w", length=12.5, days=days[5:],
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")
assert self.Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal" for worker in Rota.workers:
shifts = Rota.get_worker_shift_list(worker)
for worker in self.Rota.workers: shifts_string = "".join([i if i != "" else "-" for i in shifts])
shifts = self.Rota.get_worker_shift_list(worker) for week in weeks_from_list(shifts_string[7 * 5 :]):
assert week in ("-----ww", "dddddww")
# Convert shift to a string representation if worker.name == "worker1":
shifts_string = "".join([i if i != "" else "-" for i in shifts]) assert shifts_string[: 7 * 5].count("ww-") == 4
assert shifts_string.count("d") == 25
assert shifts_string.count("w") == 20
def test_nwd_testing():
Rota = setup_rota()
start_date = Rota.start_date
d = start_date + datetime.timedelta(weeks=5)
worker1 = Worker(
name="worker1", site="group1", grade=1,
nwds=[
{"day": "Mon", "start_date": d, "end_date": Rota.rota_end_date},
{"day": "Tue", "start_date": d, "end_date": Rota.rota_end_date},
{"day": "Wed", "start_date": d, "end_date": Rota.rota_end_date},
{"day": "Thu", "start_date": d, "end_date": Rota.rota_end_date},
{"day": "Fri", "start_date": d, "end_date": Rota.rota_end_date},
],
)
worker2 = Worker(name="worker2", site="group1", grade=1)
Rota.add_workers((worker1, worker2))
Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length=12.5, days=days[:5],
balance_offset=10, workers_required=1,
force_as_block_unless_nwd=True, assign_as_block=True,
),
SingleShift(
sites=("group1",), name="w", length=12.5, days=days[5:],
balance_offset=10, workers_required=2,
force_as_block_unless_nwd=True, assign_as_block=True,
),
)
Rota.build_and_solve(options={"ratio": 0.00})
Rota.export_rota_to_html("nwd", 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])
for week in weeks_from_list(shifts_string[7 * 5 :]):
if worker.name == "worker1": if worker.name == "worker1":
assert "wwd" not in shifts_string assert week == "-----ww"
else:
if worker.name == "worker2": assert week == "dddddww"
assert "dww" not in shifts_string for week in weeks_from_list(shifts_string[: 7 * 5]):
def test_nwd_simple_fail(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,
)
# Add a few workers
worker1 = Worker(name="worker1", site="group1", grade=1, nwds=[{"day": "Mon"}])
worker2 = Worker(name="worker2", site="group1", grade=1, nwds=[{"day": "Mon"}])
self.Rota.add_workers((worker1, worker2))
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5],
balance_offset=10,
workers_required=1,
),
)
self.Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.export_rota_to_html("nwd")
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)
# Add a few workers
worker1 = Worker(
name="worker1",
site="group1",
grade=1,
nwds=[{"day": "Mon", "start_date": start_date, "end_date": d}],
)
worker2 = Worker(
name="worker2",
site="group1",
grade=1,
nwds=[{"day": "Mon", "start_date": d, "end_date": self.Rota.rota_end_date}],
)
self.Rota.add_workers((worker1, worker2))
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5],
balance_offset=10,
workers_required=1,
),
)
self.Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.export_rota_to_html("nwd")
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])
if worker.name == "worker1": if worker.name == "worker1":
for n in range(0,4): assert week == "dddddww"
assert shifts_string[0 + 7*n] == "-" else:
assert week == "-----ww"
if worker.name == "worker2":
for n in range(5,9):
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,
)
d = start_date + datetime.timedelta(weeks=5)
d2 = start_date + datetime.timedelta(weeks=6)
# Add a few workers
worker1 = Worker(
name="worker1",
site="group1",
grade=1,
nwds=[{"day":"Mon", "start_date":start_date, "end_date":d2}],
)
worker2 = Worker(
name="worker2",
site="group1",
grade=1,
nwds=[{"day":"Mon", "start_date":d, "end_date":self.Rota.rota_end_date}],
)
self.Rota.add_workers((worker1, worker2))
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5],
balance_offset=10,
workers_required=1,
),
)
self.Rota.build_and_solve(options={"ratio": 0.00})
assert self.Rota.results.solver.status in ("warning", "error")
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)
worker1 = Worker(
name="worker1",
site="group1",
grade=1,
nwds=[{"day": "Mon", "start_date":start_date, "end_date":d}],
)
worker2 = Worker(name="worker2", site="group1", grade=1,
)
worker3 = Worker(name="worker3", site="group1", grade=1,
)
worker4 = Worker(name="worker4", site="group1", grade=1,
)
self.Rota.add_workers((worker1, worker2, worker3, worker4))
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5],
balance_offset=10,
workers_required=2,
force_as_block_unless_nwd=True,
assign_as_block=True,
),
)
self.Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.export_rota_to_html("nwd")
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])
for week in weeks_from_list(shifts_string):
assert week in ("-------", "ddddd--")
assert shifts_string.count("d") == 25
def test_nwd_force_as_block_force_split(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)
worker1 = Worker(
name="worker1",
site="group1",
grade=1,
nwds=[{"day": "Mon", "start_date":start_date, "end_date":d}],
)
worker2 = Worker(name="worker2", site="group1", grade=1,
)
worker3 = Worker(name="worker3", site="group1", grade=1,
)
worker4 = Worker(name="worker4", site="group1", grade=1,
)
self.Rota.add_workers((worker1, worker2, worker3, worker4))
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift(
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,
),
SingleShift(
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,
),
)
self.Rota.build_and_solve()
self.Rota.export_rota_to_html("nwd_block_force_split")
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])
for week in weeks_from_list(shifts_string[7 * 5 :]):
assert week in ("-----ww", "dddddww")
if worker.name == "worker1":
assert shifts_string[: 7 * 5].count("ww-") == 4
assert shifts_string.count("d") == 25
assert shifts_string.count("w") == 20
def test_nwd_testing(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)
worker1 = Worker(name="worker1", site="group1", grade=1,
nwds=[
{"day": "Mon", "start_date":d, "end_date":self.Rota.rota_end_date},
{"day": "Tue", "start_date":d, "end_date":self.Rota.rota_end_date},
{"day": "Wed", "start_date":d, "end_date":self.Rota.rota_end_date},
{"day": "Thu", "start_date":d, "end_date":self.Rota.rota_end_date},
{"day": "Fri", "start_date":d, "end_date":self.Rota.rota_end_date},
],
)
worker2 = Worker(name="worker2", site="group1", grade=1,
)
self.Rota.add_workers((worker1, worker2))
# Add a weekday and weekend shift
self.Rota.add_shifts(
SingleShift(
sites=("group1",), name="d", length= 12.5, days=days[:5],
balance_offset=10,
workers_required=1,
force_as_block_unless_nwd=True,
assign_as_block=True,
),
SingleShift(
sites=("group1",), name="w", length= 12.5, days=days[5:],
balance_offset=10,
workers_required=2,
force_as_block_unless_nwd=True,
assign_as_block=True,
),
)
self.Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.export_rota_to_html("nwd")
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])
for week in weeks_from_list(shifts_string[7 * 5 :]):
if worker.name == "worker1":
assert week == "-----ww"
else:
assert week == "dddddww"
for week in weeks_from_list(shifts_string[: 7 * 5]):
if worker.name == "worker1":
assert week == "dddddww"
else:
assert week == "-----ww"
+91 -1195
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"
+1406 -867
View File
File diff suppressed because it is too large Load Diff