This commit is contained in:
Ross
2023-01-23 14:37:09 +00:00
parent aaa10d5fb8
commit 0179ed410a
8 changed files with 961 additions and 32 deletions
+23 -18
View File
@@ -11,7 +11,7 @@ sites = (
"torbay", "torbay",
"barnstaple", "barnstaple",
"plymouth", "plymouth",
"taunton", #"taunton",
"proc", "proc",
) )
@@ -25,26 +25,28 @@ from rota.workers import (
) )
suspend_on_finish = True suspend_on_finish = False
solve = True solve = True
time_to_run = 458500 time_to_run = 508500
#time_to_run = 2660000 #time_to_run = 2660000
#time_to_run = 458500 time_to_run = 400
# allow = 5000 # allow = 5000
ratio = 0.05 ratio = 0.01
#ratio = 0.9 #ratio = 0.9
start_date = datetime.date(2023, 3, 6) start_date = datetime.date(2023, 3, 6)
#start_date = datetime.date(2023, 5, 1)
#start_date = start_date + datetime.timedelta(weeks=13) #start_date = start_date + datetime.timedelta(weeks=13)
# start_date = datetime.date(2022, 10, 10) # start_date = datetime.date(2022, 10, 10)
Rota = RotaBuilder(start_date, weeks_to_rota=26, balance_offset_modifier=1) Rota = RotaBuilder(start_date, weeks_to_rota=6, balance_offset_modifier=1)
#Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
Rota.constraint_options["balance_weekends"] = True Rota.constraint_options["balance_weekends"] = True
Rota.constraint_options["max_night_frequency"] = 3 # 3 Rota.constraint_options["max_night_frequency"] = 3 # 3
Rota.constraint_options["max_night_frequency_week_exclusions"] = [] Rota.constraint_options["max_night_frequency_week_exclusions"] = []
Rota.constraint_options["max_weekend_frequency"] = 2 Rota.constraint_options["max_weekend_frequency"] = 2
Rota.constraint_options["hard_constrain_pair_separation"] = False Rota.constraint_options["hard_constrain_pair_separation"] = True
# Rota.constraint_options["avoid_st2_first_month"] = True # Rota.constraint_options["avoid_st2_first_month"] = True
Rota.add_shifts( Rota.add_shifts(
@@ -57,7 +59,7 @@ Rota.add_shifts(
constraint=[ constraint=[
{ {
"name": "max_shifts_per_week", "name": "max_shifts_per_week",
"options": 1, "options": 2,
} }
], ],
), ),
@@ -77,7 +79,7 @@ Rota.add_shifts(
assign_as_block=True, assign_as_block=True,
), ),
SingleShift( SingleShift(
sites=("plymouth", "plymouth twilights"), sites=("plymouth", "plymouth twilights", "plymouth twilights and weekends"),
name="plymouth_twilight", name="plymouth_twilight",
length=12.5, length=12.5,
days=days[:5], days=days[:5],
@@ -93,7 +95,7 @@ Rota.add_shifts(
balance_offset=3, balance_offset=3,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
constraint=[{"name": "post", "options": 2}, {"name": "pre", "options": 5}], constraint=[{"name": "post", "options": 2}, {"name": "pre", "options": 2}],
# constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},], # constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},],
), ),
SingleShift( SingleShift(
@@ -119,33 +121,33 @@ Rota.add_shifts(
# force_as_block_unless_nwd=True # force_as_block_unless_nwd=True
), ),
SingleShift( SingleShift(
sites=("plymouth",), sites=("plymouth", "plymouth twilights and weekends"),
name="weekend_plymouth1", name="weekend_plymouth1",
length=8, length=8,
days=days[5:], days=days[5:],
balance_offset=3, balance_offset=2.9,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
), ),
SingleShift( SingleShift(
sites=("plymouth",), sites=("plymouth","plymouth twilights and weekends"),
name="weekend_plymouth2", name="weekend_plymouth2",
length=8, length=8,
days=days[5:], days=days[5:],
balance_offset=3, balance_offset=2.9,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
), ),
SingleShift( SingleShift(
sites=("plymouth", "plymouth twilights"), sites=("plymouth", "plymouth twilights","plymouth twilights and weekends"),
name="plymouth_bank_holidays", name="plymouth_bank_holidays",
length=8, length=8,
days=days[5:], days=days[5:],
balance_offset=3, balance_offset=1,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=False, force_as_block=False,
@@ -156,7 +158,7 @@ Rota.add_shifts(
name="night_weekday", name="night_weekday",
length=12.25, length=12.25,
days=days[:4], days=days[:4],
balance_offset=4.9, balance_offset=3.9,
balance_weighting=1, balance_weighting=1,
# hard_constrain_shift=False, # hard_constrain_shift=False,
workers_required=3, workers_required=3,
@@ -176,7 +178,7 @@ Rota.add_shifts(
name="night_weekend", name="night_weekend",
length=12.25, length=12.25,
days=days[4:], days=days[4:],
balance_offset=3.9, balance_offset=2.9,
balance_weighting=1, balance_weighting=1,
# hard_constrain_shift=False, # hard_constrain_shift=False,
workers_required=3, workers_required=3,
@@ -236,7 +238,9 @@ if load_leave:
nwd_end_date = Rota.rota_end_date nwd_end_date = Rota.rota_end_date
if "[" in i: if "[" in i:
print("Split nwd", worker_name, i)
a, b = i.split("[")[1][:-1].split("-") a, b = i.split("[")[1][:-1].split("-")
print(f"{a=} {b=}")
nwd_start_date = datetime.datetime.strptime(a, "%d/%m/%y").date() nwd_start_date = datetime.datetime.strptime(a, "%d/%m/%y").date()
nwd_end_date = datetime.datetime.strptime(b, "%d/%m/%y").date() nwd_end_date = datetime.datetime.strptime(b, "%d/%m/%y").date()
@@ -326,6 +330,7 @@ solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
start_time = time.time() start_time = time.time()
Rota.build_and_solve(solver_options) Rota.build_and_solve(solver_options)
#Rota.solve_shifts_by_block(solver_options, block_length=10)
#Rota.solve_shifts_individually(solver_options) #Rota.solve_shifts_individually(solver_options)
# Rota.solve_model(options=solver_options) # Rota.solve_model(options=solver_options)
end_time = time.time() end_time = time.time()
+6 -1
View File
@@ -16,7 +16,12 @@ def load_leave(Rota):
workers = {} workers = {}
download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vR18crGluN4wrL9n1sBdZ2u-JsvaLtQ6hr-R5CPUBOaKpL7_hBbhemyPSrFOhDuWxKlrjh-QUM0HrcD/pub?gid=0&single=true&output=csv") headers = {
"Cache-Control": "no-cache",
"Pragma": "no-cache"
}
download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vR18crGluN4wrL9n1sBdZ2u-JsvaLtQ6hr-R5CPUBOaKpL7_hBbhemyPSrFOhDuWxKlrjh-QUM0HrcD/pub?gid=0&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=',')
+320
View File
@@ -0,0 +1,320 @@
html {
/* color: red; */
}
table {
/* margin-top: 50px; */
text-align: center;
/* table-layout : fixed; */
/* width:150px */
/* width: 200%; */
/* overflow-x: auto;
display: block;
position: relative; */
border-collapse: separate;
border-spacing: 0;
}
#main-table {
padding-top: 100px;
}
.shift-table {
padding-top: 10px;
}
.shift-table thead {
font-weight: bold;
}
.table-div {
/* overflow-x: auto; */
width: 80%;
overflow-x: scroll;
margin-left: 200px;
overflow-y: visible;
padding: 0;
}
.table-div th {
text-align: center;
white-space: nowrap;
transform: rotate(90deg) translateX(-100px);
position: sticky;
top: 0;
}
.table-div th p {
margin: 0 -100%;
display: inline-block;
}
.table-div th p:before {
content: '';
width: 0;
padding-top: 110%;
/* takes width as reference, + 10% for faking some extra padding */
display: inline-block;
vertical-align: middle;
}
.table-div td,
th {
max-width: 10px;
padding: 0px;
}
.table-div tr {
padding: 0px;
}
.table-div tr:hover {
background-color: lightblue;
}
.table-div tr:hover .unavailable {
background-color: lightgray;
}
.Sat {
/* opacity: 30%; */
/* background-color: lightsteelblue; */
border-left: solid 1px lightsteelblue;
}
.Sun {
border-right: solid 1px lightsteelblue;
}
.bank-holiday {
border-bottom: dotted 1px blue;
}
th.bank-holiday {
color: darkblue;
}
/* th.worker {
max-width: 100%;
width: 200px;
} */
.plymouth {
color: #9169FF;
}
.exeter {
color: #5F60E8;
}
.truro {
color: #0074D9;
}
.torquay, .torbay {
color: #5FA8E8;
}
.taunton {
color: #e85fdf;
}
.unavailable {
color: lightgray;
}
.shift-requested {
color: green;
}
.worker-summary {
position: absolute;
display: none;
overflow-x: visible;
width: 100%;
text-align: left;
}
.site-summary {
display: inline-flex;
}
.site-summary .site {
border: 1px solid black;
padding: 5px;
margin: 20px;
}
.site-name {
font-weight: bold;
}
.worker-summary span {
min-width: 160px;
display: inline-flex;
}
.training-days-lost {
padding-top: 5px;
}
.tdl-settings {
padding: 2px;
}
.table-div+pre {
display: none;
}
.shift-breakdown-button+pre {
display: none;
}
.shift-breakdown-button {
display: block;
}
.nwd {
color: lightcoral;
}
.th {}
.table-div th.worker,
.table-div td.worker {
position: absolute;
width: 200px;
max-width: 200px;
left: 0;
top: auto;
border-top-width: 1px;
/*only relevant for first row*/
margin-top: -1px;
/*compensate for top border*/
}
.hidden {
opacity: 0%;
}
.tsummary {
max-width: auto;
}
.header th {
max-width: 200px;
}
/* [data-night-at-derriford="0"] {
border: 1px dotted orange;
} */
th.site-title {
transform: unset;
}
.name {
max-width: 90px;
display: inline-flex;
white-space: nowrap;
overflow: hidden;
/*text-overflow: ellipsis;*/
}
.shift-highlight {
background-color: palegreen;
}
.pair-match {
background-color: red;
}
summary h2 {
display: inline;
}
.remote-site-match.shift-highlight {
background-color: yellow;
}
td.large-shift-diff-pos::before {
content: "*";
color: red;
}
td.large-shift-diff-neg::before {
content: "*";
color: green;
}
tr.large-shift-diff-pos td {
border-bottom: solid 1px red;
border-top: solid 1px red;
}
tr.large-shift-diff-neg td {
border-bottom: solid 1px green;
border-top: solid 1px green;
}
/* table.transposed {
border-collapse: collapse;
}
table.transposed tr {
display: block;
float: left;
}
table.transposed th,
table.transposed td {
display: block;
border: 1px solid black;
max-width: 100px;
} */
table.transposed {
}
table.transposed tr {
}
table.transposed th,
table.transposed td {
width: fit-content;
max-width: unset;
position: relative;
border-top: solid 0.5px black;
border-right: solid 0.5px black;
}
table.transposed .Sat {
/* opacity: 30%; */
/* background-color: lightsteelblue; */
border-top: solid 2px lightsteelblue !important;
}
table.transposed .Sun {
border-bottom: solid 2px lightsteelblue !important;
}
table.transposed .bank-holiday {
border-bottom: dotted 2px blue !important;
}
table.transposed th.bank-holiday {
color: darkblue;
}
.target-assigned.no-colour {
background-color: white !important;
}
+549
View File
@@ -0,0 +1,549 @@
Object.defineProperties(Array.prototype, {
count: {
value: function (query) {
/*
Counts number of occurrences of query in array, an integer >= 0
Uses the javascript == notion of equality.
*/
var count = 0;
for (let i = 0; i < this.length; i++)
if (this[i] == query)
count++;
return count;
}
}
});
const mean = arr => arr.reduce((p, c) => p + c, 0) / arr.length;
tables = $(".table-div");
function generateExtra() {
$(".auto-generated").remove();
global_shifts = new Set();
total_summed_shift_diffs = 0;
total_summed_shift_diffs_abs = 0;
tables.each((n, table) => {
rows = $(table).find(".worker-row");
workers = {};
rows.each((n, tr) => {
worker_td = $(tr).find("td:first");
worker = worker_td.attr("data-worker");
shift_tds = $(tr).find(".rota-day");
shifts = []
shift_tds.each((n, td) => {
shifts.push($(td).attr("data-shift"));
})
shifts_unique = new Set(shifts);
shifts_unique.delete("");
global_shifts = new Set([...global_shifts, ...shifts_unique]);
weekends_worked = 0;
weeks = shift_tds.length / 7;
whole_weekends_worked = 0;
for (var i = 1; i <= weeks; i++) {
if ($(tr).find(`[data-week='${i}'][data-day='Sat']`).attr("data-shift") != "" || $(tr).find(`[data-week='${i}'][data-day='Sun']`).attr("data-shift") != "") {
weekends_worked = weekends_worked + 1;
}
if ($(tr).find(`[data-week='${i}'][data-day='Sat']`).attr("data-shift") != "" && $(tr).find(`[data-week='${i}'][data-day='Sun']`).attr("data-shift") != "") {
whole_weekends_worked = whole_weekends_worked + 1;
}
}
shift_counts = {};
total_shifts = 0;
days_lost = 0;
shifts_unique.forEach(el => {
shift_counts[el] = shifts.count(el);
total_shifts = total_shifts + shifts.count(el);
// why like this?
if (el == "night_weekend") {
whole_weekends_worked = whole_weekends_worked - shifts.count(el) / 3
}
if ($("#global-settings").length == 1) {
n = $(`#${el}-tdl`).val();
if (!isNaN(n)) {
days_lost = days_lost + shifts.count(el) * n;
}
}
// // 5 days for weekday nights
// if(el == "night_weekday") { days_lost = days_lost + shifts.count(el) / 4 * 5 }
// // 4 days for weekend nights
// if(el == "night_weekend") {
// days_lost = days_lost + shifts.count(el) / 3 * 4
// //whole_weekends_worked = whole_weekends_worked - shifts.count(el) / 3
// }
// // 0.5 for twilights
// if(el.includes("twilight")) {
// twilight_days_lost = shifts.count(el) / 2
// days_lost = days_lost + twilight_days_lost
// }
});
// Also 1 day for a whole weekend
days_lost = days_lost + whole_weekends_worked
// Highlight non working days
nwds = JSON.parse(worker_td.attr("data-nwds"));
nwds.forEach(element => {
// Restrict to date ranges
start_date = new Date(Date.parse(element[1]))
end_date = new Date(Date.parse(element[2]))
$(shift_tds).filter(`.${element[0]}`).each(
(n, td_el) => {
td_date = new Date(Date.parse(td_el.dataset.date));
if (td_date >= start_date && td_date < end_date) {
$(td_el).addClass("nwd");
}
}
)
});
workers[worker] = {
"site": worker_td.attr("data-site"),
"nwds": worker_td.attr("data-nwds"),
"fte": worker_td.attr("data-fte_adj"),
"end_date": worker_td.attr("data-end_date"),
"shift_diff": worker_td.attr("data-shift-diff"),
"shifts": shifts,
"shift_types": shifts_unique,
"weekends_worked": weekends_worked,
"shift_counts": shift_counts,
"days_lost": days_lost,
//"shifts"
}
sc = JSON.stringify(shift_counts);
bank_holidays = shift_tds.filter(".bank-holiday").filter(function (i) {
return $(this).attr('data-shift') != "";
});
bank_holidays_worked = bank_holidays.length;
shift_diff = JSON.parse(worker_td.attr("data-shift-diff"));
summed_shift_diff = Object.values(shift_diff).reduce((a, b) => a + b);
total_summed_shift_diffs = total_summed_shift_diffs + summed_shift_diff;
total_summed_shift_diffs_abs = total_summed_shift_diffs_abs + Math.abs(summed_shift_diff);
worker_td.get(0).dataset.shiftDiffSummed = summed_shift_diff.toPrecision(2);
worker_td.after(`<div class='worker-summary auto-generated'>
<span>Total shifts: ${total_shifts}, </span>
<span>Weekends: ${weekends_worked}, </span>
<span>Bank holidays: ${bank_holidays_worked}, </span>
<span class='shift-diff-span'>Summed shift diff: ${summed_shift_diff.toPrecision(2)}, </span>
</div>`)
if (summed_shift_diff >= 1) {
worker_td.addClass("large-shift-diff-pos")
$(tr).find(".shift-diff-span").addClass("large-shift-diff-pos")
} else if (summed_shift_diff <= -1) {
worker_td.addClass("large-shift-diff-neg")
$(tr).find(".shift-diff-span").addClass("large-shift-diff-neg")
}
})
sites = {};
sites_days_lost = {}
for (const worker in workers) {
w = workers[worker];
if (w.fte == 100) {
for (let shift of w.shift_types) {
if (!(w.site in sites)) {
sites[w.site] = {};
}
if (!(shift in sites[w.site])) {
sites[w.site][shift] = [w.shift_counts[shift]];
} else {
sites[w.site][shift].push(w.shift_counts[shift]);
}
}
if (!(w.site in sites_days_lost)) {
sites_days_lost[w.site] = [w.days_lost];
} else {
sites_days_lost[w.site].push(w.days_lost);
}
}
}
// Calculate site shift summaries
// $(table).append("<div class='site-summary auto-generated'>Average shifts for a 100% FTE trainee</div>");
// for(const site in sites) {
// $(table).find(".site-summary").append(`<div class='site site-${site}'><span class='site-name'>${site}</span></div>`);
// for(const shift in sites[site]) {
// average = mean(sites[site][shift])
// $(table).find(`.site-${site}`).append(`<div>${shift}: ${average.toFixed(2)}</div>`);
// }
// days_lost_avg = mean(sites_days_lost[site]);
// $(table).find(`.site-${site}`).append(`<div class='training-days-lost'>Training days lost: ${days_lost_avg.toFixed(2)}</div>`);
// }
summary_button = $("<button class='auto-generated'>Toggle Summary</button>").click(() => {
$(table).find(".rota-day").toggleClass("hidden");
$(table).find(".worker-summary").toggle();
});
$(table).before($(`<span>Total summed shift diff: ${total_summed_shift_diffs}</span><br/>`));
$(table).before($(`<span>Total summed shift diff: ${total_summed_shift_diffs_abs}</span><br/>`));
$(table).before(summary_button);
});
return global_shifts
}
shifts = generateExtra();
if (shifts.size < 1) {
shifts = $("#shifts-container").data("shifts");
}
oshifts = Array.from(shifts).sort()
$("body").append("<div><button id='show-colour-diff'>Show colour diff</button><table class='tsummary'></table></div>")
$("#show-colour-diff").on("click", (evt) => {
$("table.tsummary").find(`td.target-assigned`).toggleClass("no-colour");
});
$("table.tsummary").append("<tr class='header'></tr>");
h = $("table.tsummary tr.header");
h.append(`<th>Worker</th>`);
h.append(`<th>Site</th>`);
h.append(`<th>FTE</th>`);
h.append(`<th>FTE (adj)</th>`);
oshifts.forEach((s) => {
h.append(`<th>${s}</th>`);
});
$(".table-div .worker-row .worker").each((n, tr) => {
//worker_td = $(tr).find("td").first();
jtr = $(tr);
row = $("<tr></tr>");
row.append(`<td>${jtr.data("worker")}</td>`)
row.append(`<td>${jtr.data("site")}</td>`)
row.append(`<td>${jtr.data("fte")}</td>`)
row.append(`<td>${parseFloat(jtr.data("fte_adj")).toFixed(2)}</td>`)
if (jtr.data("shift-diff-summed") >= 1) {
row.addClass("large-shift-diff-pos")
} else if (jtr.data("shift-diff-summed") <= -1) {
row.addClass("large-shift-diff-neg")
}
shift_counts = jtr.data("shift-counts")
shift_targets = jtr.data("worker-targets")
oshifts.forEach((s) => {
if (s in shift_targets && shift_targets[s] > 0) {
if (s in shift_counts) {
c = shift_counts[s];
} else {
c = 0;
}
row.append(`<td class="target-assigned ${s}" data-assigned=${c} data-target=${shift_targets[s].toFixed(2)} data-diff=${shift_targets[s].toFixed(2)-c}>${c} (${shift_targets[s].toFixed(2)})</td>`)
} else {
row.append(`<td>-</td>`)
}
})
$("table.tsummary").append(row)
})
shift_max_diff_map = {};
shift_min_diff_map = {};
oshifts.forEach((s) => {
shift_max_diff_map[s] = 0;
shift_min_diff_map[s] = 0;
$("table.tsummary").find(`td.target-assigned.${s}`).each((n, td) => {
shift_max_diff_map[s] = Math.max(shift_max_diff_map[s], td.dataset.diff)
shift_min_diff_map[s] = Math.min(shift_min_diff_map[s], td.dataset.diff)
})
});
oshifts.forEach((shift) => {
$("table.tsummary").find(`td.target-assigned.${shift}`).each((n, td) => {
diff = td.dataset.diff;
if (diff > 0) {
max_diff = shift_max_diff_map[shift];
var h = 120 - Math.floor((max_diff - diff) * 120 / max_diff);
var s = diff / max_diff;
//var s = 0.5;
var v = 1;
$(td).css({
backgroundColor: hsv2rgb(h, s, 1)
})
} else {
min_diff = shift_min_diff_map[shift]
var h = Math.floor((min_diff - diff) * 120 / min_diff);
var s = diff / min_diff;
//var s = 0.5;
var v = 1;
$(td).css({
backgroundColor: hsv2rgb(h, s, 1)
})
}
$(td).addClass("no-colour")
//s = Math.max(shift_max_diff_map[s], td.dataset.diff)
//shift_min_diff_map[s] = Math.min(shift_max_diff_map[s], td.dataset.diff)
})
});
oshifts.forEach((shift) => {
button = $(`<button data-shift='${shift}'>${shift}</button>`)
button.on("click", (evt) => {
console.log(evt.target.dataset.shift)
$("#shift-timetable-div").empty();
table = $("<table>");
$("table#main-table th.date").each((n, th) => {
row = $("<tr>")
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) => {
//row.append(`<td>${}</td>`)
row.append($(td).closest("tr").children("td:first").clone())
});
table.append(row)
});
$("#shift-timetable-div").append(table);
})
$("#shift-timetable-options").append(button)
});
//$("table.summary")
// $("body").prepend("<div id='global-settings'>Settings:<br />Customise training days lost per shift (you need to press recalculate for changes to take effect)<form></form></div>")
// shifts.forEach((s) => {
// disable = false;
// if(s.includes("twilight")) {
// value = 0.5;
// } else if(s == "night_weekend") {
// value = 4 / 3;
// } else if(s == "night_weekday") {
// value = 5 / 4;
// } else {
// disable = true;
// value = 0;
// }
// if(!disable) {
// $("#global-settings form").append(`<span class='tdl-settings'><label for="${s}-tdl">${s}</label><input type="number" id="${s}-tdl" name="${s}-tdl" value='${value}'></span>`);
// }
// })
// $("#global-settings").append($("<button >Recalculate</button>").click(() => {
// generateExtra();
// }))
// generateExtra();
$(".table-div + pre").each((n, pre) => {
$(pre).before($("<button class='shift-breakdown-button'>Show shift breakdown</button>").click(() => {
$(pre).toggle();
}));
})
$("td").hover((e) => {
//start hover
worker_td = $(e.target).closest('tr').find('td:first')[0]
pair = worker_td.dataset.pair;
index = $(e.target).index() - 1;
if (pair > 0) {
$(`td[data-pair=${pair}]`).each((n, el) => {
$($(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")
$(`td[data-remote-site='${e.target.dataset.shiftRemoteSite}']`).closest('tr').find(`td[data-shift=${e.target.dataset.shift}]`).addClass("remote-site-match")
// if (e.target.dataset.shiftRemoteSite == worker_td.dataset.remoteSite) {
// $(`td[data-shift-remote-site=${e.target.dataset.shiftRemoteSite}]`).addClass("remote-site-match")
// }
}
}, (e) => {
//end hover
$("td.pair-match").removeClass("pair-match")
$("td.shift-highlight").removeClass("shift-highlight")
$("td.remote-site-match").removeClass("remote-site-match")
})
$("#rota-table tr td:first-child").each((n, td) => {
$(td).click(() => {
viewWorker(td);
})
})
function viewWorker(worker) {
ds = worker.dataset;
dlg = $(`<div class='dialog' title='${ds.worker}'>
Site: ${ds.site}</br>
FTE: ${ds.fte} (${ds.fte_adj})</br>
Start date: ${ds.start_date}</br>
End date: ${ds.end_date}</br>
Non working days: ${ds.nwds}</br>
Remote site: ${ds.remoteSite}</br>
Summed shift diff: ${ds.shiftDiffSummed}
</div>`)
$("body").append(dlg)
shifts = JSON.parse(ds.shiftCounts)
shift_targets = JSON.parse(ds.workerTargets)
shift_diffs = JSON.parse(ds.shiftDiff)
let shift_count_table = document.createElement("table")
shift_count_table.classList.add("shift-table")
let header_row = shift_count_table.createTHead().insertRow();
header_row.insertCell().appendChild(document.createTextNode("Shift"))
header_row.insertCell().appendChild(document.createTextNode("Count"))
header_row.insertCell().appendChild(document.createTextNode("Target"))
header_row.insertCell().appendChild(document.createTextNode("Diffs"))
table_body = shift_count_table.createTBody()
for (const shift in shifts) {
let row = table_body.insertRow();
row.insertCell().appendChild(document.createTextNode(shift))
row.insertCell().appendChild(document.createTextNode(shifts[shift]))
row.insertCell().appendChild(document.createTextNode(shift_targets[shift].toPrecision(2)))
row.insertCell().appendChild(document.createTextNode(shift_diffs[shift].toPrecision(2)))
}
dlg.get(0).appendChild(shift_count_table)
dlg.dialog()
}
$("#export-div").append($("#main-table").clone().attr("id", "gen-table").addClass("transposed"))
$("#gen-table").each(function () {
var $this = $(this);
var newrows = [];
//$this.find("th.site-title").parent().remove();
$this.find("tr").each(function (rowToColIndex) {
$(this).find("td, th").each(function (colToRowIndex) {
if (newrows[colToRowIndex] === undefined) {
newrows[colToRowIndex] = $("<tr></tr>");
}
while (newrows[colToRowIndex].find("td, th").length < rowToColIndex) {
newrows[colToRowIndex].append($("<td></td>"));
}
newrows[colToRowIndex].append($(this));
});
});
$this.find("tr").remove();
$.each(newrows, function () {
$this.append(this);
});
$this.find("td.rota-day").each((n, el) => {
$(el).text(el.dataset.shift)
});
});
function hsv2rgb(h, s, v) {
// adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/
var rgb, i, data = [];
if (s === 0) {
rgb = [v, v, v];
} else {
h = h / 60;
i = Math.floor(h);
data = [v * (1 - s), v * (1 - s * (h - i)), v * (1 - s * (1 - (h - i)))];
switch (i) {
case 0:
rgb = [v, data[2], data[0]];
break;
case 1:
rgb = [data[1], v, data[0]];
break;
case 2:
rgb = [data[0], v, data[2]];
break;
case 3:
rgb = [data[0], data[1], v];
break;
case 4:
rgb = [data[2], data[0], v];
break;
default:
rgb = [v, data[0], data[1]];
break;
}
}
return '#' + rgb.map(function (x) {
return ("0" + Math.round(x * 255).toString(16)).slice(-2);
}).join('');
};
+50 -10
View File
@@ -3,6 +3,7 @@ import datetime
from distutils.log import debug from distutils.log import debug
import itertools import itertools
from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type
import time
from pydantic import BaseModel, Extra, constr from pydantic import BaseModel, Extra, constr
@@ -190,12 +191,6 @@ class RotaBuilder(object):
self.use_shift_balance_extra = use_shift_balance_extra self.use_shift_balance_extra = use_shift_balance_extra
self.use_bank_holiday_extra = use_bank_holiday_extra self.use_bank_holiday_extra = use_bank_holiday_extra
self.unavailable_to_work = set()
self.unavailable_to_work_reason = {}
self.pref_not_to_work = {}
self.pref_not_to_work_reason = {}
self.work_requests: set[Tuple[str, WeekInt, DayStr, ShiftName]] = set()
self.work_requests_map = {}
self.workers: list[Worker] = [] self.workers: list[Worker] = []
@@ -243,7 +238,9 @@ class RotaBuilder(object):
self.set_rota_dates(start_date, weeks_to_rota) self.set_rota_dates(start_date, weeks_to_rota)
def set_rota_dates(self, start_date, weeks_to_rota): def set_rota_dates(self, start_date: datetime.datetime, weeks_to_rota: int):
self.weeks_to_rota = weeks_to_rota
self.days = days self.days = days
@@ -273,6 +270,12 @@ class RotaBuilder(object):
self.week_day_date_map[(week, day)] = d self.week_day_date_map[(week, day)] = d
n = n + 1 n = n + 1
self.unavailable_to_work = set()
self.unavailable_to_work_reason = {}
self.pref_not_to_work = {}
self.pref_not_to_work_reason = {}
self.work_requests: set[Tuple[str, WeekInt, DayStr, ShiftName]] = set()
self.work_requests_map = {}
def solve_model( def solve_model(
self, self,
@@ -282,7 +285,15 @@ class RotaBuilder(object):
debug_if_fail: bool = False, debug_if_fail: bool = False,
): ):
console.print("Setting up solver") console.print("Setting up solver")
self.opt = SolverFactory(solver)
solver = "scip"
if solver == "scip":
self.opt = SolverFactory(solver, executable="scip")
else:
self.opt = SolverFactory(solver)
console.print("Solving") console.print("Solving")
if use_neos: if use_neos:
@@ -313,6 +324,35 @@ class RotaBuilder(object):
if not results.solver.status: if not results.solver.status:
sys.exit(0) sys.exit(0)
def solve_shifts_by_block(self, options, block_length: int, folder="block_shifts"):
shifts = self.shifts
outcomes = {}
no_blocks = self.weeks_to_rota - block_length
original_start_date = self.start_date
for block in range(0, no_blocks):
start_time = time.time()
start_date = original_start_date + datetime.timedelta(weeks=block)
self.set_rota_dates(start_date = start_date,weeks_to_rota=block_length)
console.print(f"Testing block: start_date - {self.start_date} , length {self.weeks_to_rota} weeks")
self.clear_shifts()
self.add_shifts(*shifts)
self.build_and_solve(options)
self.export_rota_to_html(filename=f"{start_date}_{self.weeks_to_rota}", folder=folder)
end_time = time.time()
time_taken = end_time - start_time
outcomes[f"{start_date}_{self.weeks}"] = (
self.results.solver.status,
self.results.solver.termination_condition,
time_taken
)
console.print(outcomes)
sys.exit(0)
def solve_shifts_individually(self, options, folder="individual_shifts"): def solve_shifts_individually(self, options, folder="individual_shifts"):
shifts = self.shifts shifts = self.shifts
@@ -2424,7 +2464,7 @@ class RotaBuilder(object):
""" """
self.shifts.append(shift) self.shifts.append(shift)
def add_shifts(self, *shifts: SingleShift) -> None: def add_shifts(self, *shifts: SingleShift ) -> None:
"""Add multiple shifts """Add multiple shifts
Returns: Returns:
@@ -2659,7 +2699,7 @@ class RotaBuilder(object):
""" """ """ """ """ """
return self.shift_names return self.shift_names
def get_week_block_iterator(self, block_length): def get_week_block_iterator(self, block_length: int):
"""Gets a two dimensional list of week blocks in specified length """Gets a two dimensional list of week blocks in specified length
e.g. block_length = 4 e.g. block_length = 4
+5 -1
View File
@@ -128,7 +128,10 @@ class Worker(BaseModel):
self.calculated_start_date = Rota.start_date self.calculated_start_date = Rota.start_date
else: else:
# ? test if start date is valid # ? test if start date is valid
self.calculated_start_date = self.start_date if self.start_date < Rota.start_date:
self.calculated_start_date = self.start_date
else:
self.calculated_start_date = self.start_date
if self.end_date is None: if self.end_date is None:
self.calculated_end_date = Rota.rota_end_date self.calculated_end_date = Rota.rota_end_date
@@ -271,6 +274,7 @@ class Worker(BaseModel):
self.fte_adj = 0 self.fte_adj = 0
if days_to_work > 0 and (days_to_work - len(unavailable_set)) / days_to_work < 0.3: if days_to_work > 0 and (days_to_work - len(unavailable_set)) / days_to_work < 0.3:
console.print(f"{ self.name }: {days_to_work}, {Rota.rota_days_length}")
console.print(f"Warning {self.name} has excessive unavailability [{len(unavailable_set)}/{days_to_work}, adjusted fte={self.fte_adj}] (this may be infeasible)", style="red") console.print(f"Warning {self.name} has excessive unavailability [{len(unavailable_set)}/{days_to_work}, adjusted fte={self.fte_adj}] (this may be infeasible)", style="red")
def __lt__(self, other) -> bool: def __lt__(self, other) -> bool:
+6
View File
@@ -0,0 +1,6 @@
parallel/minnthreads = 5
parallel/maxnthreads = 10
limits/solutions = -1
limits/time = 345600
+2 -2
View File
@@ -353,7 +353,7 @@ class TestDemoRotaClear:
self.Rota.build_workers() self.Rota.build_workers()
self.Rota.build_model() self.Rota.build_model()
solver_options = {"ratio": 0.1, "seconds": 1000, "threads": 10} solver_options = {"seconds": 1000, "threads": 10}
self.Rota.solve_model(options=solver_options) self.Rota.solve_model(options=solver_options)
self.Rota.export_rota_to_html("test4") self.Rota.export_rota_to_html("test4")
@@ -682,7 +682,7 @@ class TestLimitConstraints:
# Rota.constraint_options["balance_nights_across_sites"] = False # Rota.constraint_options["balance_nights_across_sites"] = False
# Rota.constraint_options["balance_shifts_over_workers"] = False # Rota.constraint_options["balance_shifts_over_workers"] = False
#Rota.constraint_options["balance_shifts_quadratic"] = True #Rota.constraint_options["balance_shifts_quadratic"] = True
Rota.build_and_solve(options={"ratio": 0.1}) Rota.build_and_solve(options={})
Rota.export_rota_to_html("constraint_limit_grades") Rota.export_rota_to_html("constraint_limit_grades")