diff --git a/gen.py b/gen.py index 1c92363..5de37fa 100644 --- a/gen.py +++ b/gen.py @@ -11,7 +11,7 @@ sites = ( "torbay", "barnstaple", "plymouth", - "taunton", + #"taunton", "proc", ) @@ -25,26 +25,28 @@ from rota.workers import ( ) -suspend_on_finish = True +suspend_on_finish = False solve = True -time_to_run = 458500 +time_to_run = 508500 #time_to_run = 2660000 -#time_to_run = 458500 +time_to_run = 400 # allow = 5000 -ratio = 0.05 +ratio = 0.01 #ratio = 0.9 start_date = datetime.date(2023, 3, 6) +#start_date = datetime.date(2023, 5, 1) #start_date = start_date + datetime.timedelta(weeks=13) # 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["max_night_frequency"] = 3 # 3 Rota.constraint_options["max_night_frequency_week_exclusions"] = [] 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.add_shifts( @@ -57,7 +59,7 @@ Rota.add_shifts( constraint=[ { "name": "max_shifts_per_week", - "options": 1, + "options": 2, } ], ), @@ -77,7 +79,7 @@ Rota.add_shifts( assign_as_block=True, ), SingleShift( - sites=("plymouth", "plymouth twilights"), + sites=("plymouth", "plymouth twilights", "plymouth twilights and weekends"), name="plymouth_twilight", length=12.5, days=days[:5], @@ -93,7 +95,7 @@ Rota.add_shifts( balance_offset=3, rota_on_nwds=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},], ), SingleShift( @@ -119,33 +121,33 @@ Rota.add_shifts( # force_as_block_unless_nwd=True ), SingleShift( - sites=("plymouth",), + sites=("plymouth", "plymouth twilights and weekends"), name="weekend_plymouth1", length=8, days=days[5:], - balance_offset=3, + balance_offset=2.9, workers_required=1, rota_on_nwds=True, force_as_block=True, constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], ), SingleShift( - sites=("plymouth",), + sites=("plymouth","plymouth twilights and weekends"), name="weekend_plymouth2", length=8, days=days[5:], - balance_offset=3, + balance_offset=2.9, workers_required=1, rota_on_nwds=True, force_as_block=True, constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}], ), SingleShift( - sites=("plymouth", "plymouth twilights"), + sites=("plymouth", "plymouth twilights","plymouth twilights and weekends"), name="plymouth_bank_holidays", length=8, days=days[5:], - balance_offset=3, + balance_offset=1, workers_required=1, rota_on_nwds=True, force_as_block=False, @@ -156,7 +158,7 @@ Rota.add_shifts( name="night_weekday", length=12.25, days=days[:4], - balance_offset=4.9, + balance_offset=3.9, balance_weighting=1, # hard_constrain_shift=False, workers_required=3, @@ -176,7 +178,7 @@ Rota.add_shifts( name="night_weekend", length=12.25, days=days[4:], - balance_offset=3.9, + balance_offset=2.9, balance_weighting=1, # hard_constrain_shift=False, workers_required=3, @@ -236,7 +238,9 @@ if load_leave: nwd_end_date = Rota.rota_end_date if "[" in i: + print("Split nwd", worker_name, i) a, b = i.split("[")[1][:-1].split("-") + print(f"{a=} {b=}") nwd_start_date = datetime.datetime.strptime(a, "%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() Rota.build_and_solve(solver_options) +#Rota.solve_shifts_by_block(solver_options, block_length=10) #Rota.solve_shifts_individually(solver_options) # Rota.solve_model(options=solver_options) end_time = time.time() diff --git a/leave.py b/leave.py index 9d2e600..a5ee6eb 100644 --- a/leave.py +++ b/leave.py @@ -16,7 +16,12 @@ def load_leave(Rota): 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') reader = csv.reader(decoded_content.splitlines(), delimiter=',') diff --git a/output/block_shifts/timetable.css b/output/block_shifts/timetable.css new file mode 100644 index 0000000..8fda8b5 --- /dev/null +++ b/output/block_shifts/timetable.css @@ -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; +} \ No newline at end of file diff --git a/output/block_shifts/timetable.js b/output/block_shifts/timetable.js new file mode 100644 index 0000000..a980ff9 --- /dev/null +++ b/output/block_shifts/timetable.js @@ -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(`
+ Total shifts: ${total_shifts}, + Weekends: ${weekends_worked}, + Bank holidays: ${bank_holidays_worked}, + Summed shift diff: ${summed_shift_diff.toPrecision(2)}, + +
`) + + 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("
Average shifts for a 100% FTE trainee
"); + + // for(const site in sites) { + // $(table).find(".site-summary").append(`
${site}
`); + + // for(const shift in sites[site]) { + // average = mean(sites[site][shift]) + // $(table).find(`.site-${site}`).append(`
${shift}: ${average.toFixed(2)}
`); + // } + + // days_lost_avg = mean(sites_days_lost[site]); + // $(table).find(`.site-${site}`).append(`
Training days lost: ${days_lost_avg.toFixed(2)}
`); + // } + + summary_button = $("").click(() => { + $(table).find(".rota-day").toggleClass("hidden"); + $(table).find(".worker-summary").toggle(); + + }); + + + $(table).before($(`Total summed shift diff: ${total_summed_shift_diffs}
`)); + $(table).before($(`Total summed shift diff: ${total_summed_shift_diffs_abs}
`)); + $(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("
") +$("#show-colour-diff").on("click", (evt) => { + $("table.tsummary").find(`td.target-assigned`).toggleClass("no-colour"); +}); + +$("table.tsummary").append(""); +h = $("table.tsummary tr.header"); +h.append(`Worker`); +h.append(`Site`); +h.append(`FTE`); +h.append(`FTE (adj)`); + + +oshifts.forEach((s) => { + h.append(`${s}`); + +}); + + + +$(".table-div .worker-row .worker").each((n, tr) => { + //worker_td = $(tr).find("td").first(); + jtr = $(tr); + row = $(""); + row.append(`${jtr.data("worker")}`) + row.append(`${jtr.data("site")}`) + row.append(`${jtr.data("fte")}`) + row.append(`${parseFloat(jtr.data("fte_adj")).toFixed(2)}`) + + 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(`${c} (${shift_targets[s].toFixed(2)})`) + + } else { + row.append(`-`) + } + }) + + $("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.on("click", (evt) => { + console.log(evt.target.dataset.shift) + $("#shift-timetable-div").empty(); + table = $(""); + $("table#main-table th.date").each((n, th) => { + row = $("") + row.append(``); + $(`table#main-table td[data-date='${th.dataset.date}'][data-shift='${evt.target.dataset.shift}']`).each((n, td) => { + + //row.append(``) + 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("
Settings:
Customise training days lost per shift (you need to press recalculate for changes to take effect)
") + +// 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(``); +// } + + +// }) +// $("#global-settings").append($("").click(() => { +// generateExtra(); +// })) + +// generateExtra(); + +$(".table-div + pre").each((n, pre) => { + + $(pre).before($("").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 = $(`
+ Site: ${ds.site}
+ FTE: ${ds.fte} (${ds.fte_adj})
+ Start date: ${ds.start_date}
+ End date: ${ds.end_date}
+ Non working days: ${ds.nwds}
+ Remote site: ${ds.remoteSite}
+ Summed shift diff: ${ds.shiftDiffSummed} +
`) + $("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] = $(""); + } + while (newrows[colToRowIndex].find("td, th").length < rowToColIndex) { + newrows[colToRowIndex].append($("")); + } + 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(''); +}; \ No newline at end of file diff --git a/rota/shifts.py b/rota/shifts.py index 3e51d1f..38b4b06 100644 --- a/rota/shifts.py +++ b/rota/shifts.py @@ -3,6 +3,7 @@ import datetime from distutils.log import debug import itertools from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type +import time from pydantic import BaseModel, Extra, constr @@ -190,12 +191,6 @@ class RotaBuilder(object): self.use_shift_balance_extra = use_shift_balance_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] = [] @@ -243,7 +238,9 @@ class RotaBuilder(object): 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 @@ -273,6 +270,12 @@ class RotaBuilder(object): self.week_day_date_map[(week, day)] = d 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( self, @@ -282,7 +285,15 @@ class RotaBuilder(object): debug_if_fail: bool = False, ): 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") if use_neos: @@ -313,6 +324,35 @@ class RotaBuilder(object): if not results.solver.status: 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"): shifts = self.shifts @@ -2424,7 +2464,7 @@ class RotaBuilder(object): """ self.shifts.append(shift) - def add_shifts(self, *shifts: SingleShift) -> None: + def add_shifts(self, *shifts: SingleShift ) -> None: """Add multiple shifts Returns: @@ -2659,7 +2699,7 @@ class RotaBuilder(object): """ """ """ 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 e.g. block_length = 4 diff --git a/rota/workers.py b/rota/workers.py index 395dde6..962004d 100644 --- a/rota/workers.py +++ b/rota/workers.py @@ -128,7 +128,10 @@ class Worker(BaseModel): self.calculated_start_date = Rota.start_date else: # ? 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: self.calculated_end_date = Rota.rota_end_date @@ -271,6 +274,7 @@ class Worker(BaseModel): self.fte_adj = 0 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") def __lt__(self, other) -> bool: diff --git a/scip.set b/scip.set new file mode 100644 index 0000000..97df4a6 --- /dev/null +++ b/scip.set @@ -0,0 +1,6 @@ +parallel/minnthreads = 5 +parallel/maxnthreads = 10 + +limits/solutions = -1 + +limits/time = 345600 diff --git a/test/test_rota.py b/test/test_rota.py index 2f3cf3c..23976dc 100644 --- a/test/test_rota.py +++ b/test/test_rota.py @@ -353,7 +353,7 @@ class TestDemoRotaClear: self.Rota.build_workers() 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.export_rota_to_html("test4") @@ -682,7 +682,7 @@ class TestLimitConstraints: # Rota.constraint_options["balance_nights_across_sites"] = False # Rota.constraint_options["balance_shifts_over_workers"] = False #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")
${th.dataset.date}${}