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();
+ }));
+
+})
\ No newline at end of file
diff --git a/shifts.py b/shifts.py
index 2fd1c2b..82ad588 100644
--- a/shifts.py
+++ b/shifts.py
@@ -2,11 +2,15 @@ import datetime
import itertools
from typing import List, Tuple
+import datetime
+
from pyomo.environ import *
from pyomo.opt import SolverFactory
from workers import Worker
+import json
+
import csv
from collections import defaultdict
@@ -89,6 +93,9 @@ class RotaBuilder(object):
ltft_balance_offset=4,
max_night_frequency=2,
max_weekend_frequency=2):
+
+ print("Start time: {}".format(datetime.datetime.now()))
+
self.shifts = [] # type List[SingleShift]
self.days = days
@@ -98,9 +105,8 @@ class RotaBuilder(object):
self.weeks_days_product = list(itertools.product(self.weeks, days))
if start_date.weekday() != 0:
- raise ValueError(
- "Start date {} must be a Mon (not a {})".format(
- start_date.isoformat(), days[start_date.weekday()]))
+ raise ValueError("Start date {} must be a Mon (not a {})".format(
+ start_date.isoformat(), days[start_date.weekday()]))
self.start_date = start_date
self.rota_days_length = len(self.weeks) * 7
@@ -128,16 +134,31 @@ class RotaBuilder(object):
self.constraint_options = {
"limit_to_1_st1_on_nights": True,
"ensure_1_st4_plus_on_nights": True,
+ "ensure_derriford_reg_for_nights": True,
"balance_nights": True,
"constrain_time_off_after_nights": True,
"balance_nights_across_sites": True,
+ "balance_bank_holidays": True,
"balance_blocks": True,
"balance_shifts": True,
"balance_weekends": True,
- "prevent_monday_after_full_weekends": False,
- "prevent_fridays_before_full_weekends": True,
+ "max_weekends": False,
+ "prevent_monday_after_full_weekends": [],
+ "prevent_monday_and_tuesday_after_full_weekends": [],
+ "prevent_fridays_before_full_weekends": [],
+ "prevent_thursdays_before_full_weekends": [],
+ "avoid_st1_first_month": False,
}
+ # Generate a map for week, day combinations to a given date
+ self.week_day_date_map = {}
+
+ n = 0
+ for week, day in self.get_week_day_combinations():
+ d = self.start_date + datetime.timedelta(n)
+ self.week_day_date_map[(week, day)] = d
+ n = n + 1
+
def build_model(self):
# Initialize model
self.model = ConcreteModel()
@@ -191,6 +212,26 @@ class RotaBuilder(object):
initialize=0,
)
+ if self.constraint_options["balance_bank_holidays"]:
+ # Bank holidays
+ self.model.bank_holiday_count = Var(
+ ((worker.id) for worker in self.workers),
+ within=NonNegativeIntegers,
+ initialize=0,
+ )
+ self.model.bank_holiday_count_w = Var(
+ ((worker.id) for worker in self.workers),
+ within=NonNegativeIntegers,
+ initialize=0,
+ )
+ # self.model.bank_holiday_count = Var(
+ # ((worker.id, week, day) for worker in self.workers
+ # for week, day in self.get_week_day_combinations() if self.week_day_date_map[(week, day)] in bank_holiday_map
+ # ),
+ # within=Binary,
+ # initialize=0,
+ # )
+
# Used to limit number of workers on night shift per site
# if we force a binary it will in effect hard constrain to < 2
# from a single site
@@ -362,7 +403,6 @@ class RotaBuilder(object):
def work_request_init(model, wid, week, day, shift):
if (wid, week, day, shift) in self.work_requests:
self.work_requests_map[(wid, week, day)] = shift
- print((wid, week, day))
return 1
return 0
@@ -478,7 +518,6 @@ class RotaBuilder(object):
def nightShiftMinST4Rule(model, week, shift):
single_workers = [w for w in self.workers if w.grade >= 4]
if not single_workers:
- print(single_workers)
return Constraint.Skip
return sum(model.shift_week_worker_assigned[shift, week, w.id]
for w in single_workers) >= 1
@@ -493,6 +532,23 @@ class RotaBuilder(object):
rule=nightShiftMinST4Rule,
)
+ def nightShiftDerrifordRule(model, week, shift):
+ derriford_workers = [w for w in self.workers if w.night_at_derriford >= 1]
+ #if not derriford_workers:
+ #return Constraint.Skip
+ return sum(model.shift_week_worker_assigned[shift, week, w.id]
+ for w in derriford_workers) >= 1
+
+ if self.constraint_options["ensure_derriford_reg_for_nights"]:
+ self.model.night_shifts_derriford_rule = Constraint(
+ [week for week in self.weeks],
+ [
+ shift.name
+ for shift in self.get_shifts_with_constraint("night")
+ ],
+ rule=nightShiftDerrifordRule,
+ )
+
# # Count the number of workers from each site on each night shift
# # As 1 or 0 is optimum we can simply subtract 1 from the number
if self.constraint_options["balance_nights_across_sites"]:
@@ -548,6 +604,14 @@ class RotaBuilder(object):
# Most of our constraints apply per worker
for worker in self.workers:
+ if self.constraint_options[
+ "avoid_st1_first_month"] and worker.grade == 2:
+ # Avoid ST1s on the first month
+ self.model.constraints.add(0 == sum(
+ self.model.works[worker.id, week, day, shift] for week,
+ day, shift in self.get_all_shiftname_combinations()
+ if week in (1, 2, 3, 4) and shift in ("night_weekday", "night_weekend")))
+
# Count number of weekends an worker works
if self.constraint_options["balance_weekends"]:
self.model.constraints.add(
@@ -555,6 +619,11 @@ class RotaBuilder(object):
self.model.works_weekend[worker.id, week]
for week in self.weeks))
+ if self.constraint_options["max_weekends"]:
+ self.model.constraints.add(
+ self.constraint_options["max_weekends"] >=
+ self.model.worker_weekend_count[worker.id])
+
# Balance shifts within required limits (set by balance_offset)
# If balance_offset is too restrictive (for the rota length)
# no solution will be possible
@@ -606,13 +675,6 @@ class RotaBuilder(object):
self.model.works[worker.id, week, day, shift.name]
for week, day in self.get_week_day_combinations()))
- if self.constraint_options["balance_nights"]:
- self.model.constraints.add(
- self.model.night_shift_count[worker.id] == sum(
- self.model.works[worker.id, week, day, shift.name]
- for week, day in self.get_week_day_combinations()
- for shift in self.get_shifts_with_constraint("night")))
-
# Define shift_count_t1 and shift_count_t2 constraints for the object
# Thus bypassing the need for a quadratic solver
# t1-t2 is the target
@@ -628,7 +690,40 @@ class RotaBuilder(object):
for shift in self.get_shifts()))
#if "night" not in shift.constraints))
+ if self.constraint_options["balance_bank_holidays"]:
+ self.model.constraints.add(
+ self.model.bank_holiday_count[worker.id] == sum(
+ self.model.works[worker.id, week, day, shift] for week,
+ day, shift in self.get_all_shiftname_combinations()
+ if self.week_day_date_map[(week,
+ day)] in bank_holiday_map))
+
+ xU = len(self.get_bank_holiday_week_days()) + 1
+ xL = 1
+ self.model.constraints.add(
+ inequality(
+ xL,
+ self.model.bank_holiday_count[worker.id] + 1,
+ xU,
+ ))
+
+ self.model.constraints.add(
+ self.model.bank_holiday_count_w[worker.id] >= xL *
+ (self.model.bank_holiday_count[worker.id] + 1) * 2 -
+ xL * xL)
+
+ self.model.constraints.add(
+ self.model.bank_holiday_count_w[worker.id] >= xU *
+ (self.model.bank_holiday_count[worker.id] + 1) * 2 -
+ xU * xU)
+
if self.constraint_options["balance_nights"]:
+ self.model.constraints.add(
+ self.model.night_shift_count[worker.id] == sum(
+ self.model.works[worker.id, week, day, shift.name]
+ for week, day in self.get_week_day_combinations()
+ for shift in self.get_shifts_with_constraint("night")))
+
night_shift_target_number = sum(
worker.shift_target_number[shift.name]
for shift in self.get_shifts_with_constraint("night"))
@@ -676,12 +771,14 @@ class RotaBuilder(object):
# self.model.night_shift_count_w[worker.id] >= 0)
# We use a similar method to balance the number of weekends worked
- # I'm not entirely sure how split shifts will affect this
+ # This works as long as weekend shifts are assigned as blocks!
if self.constraint_options["balance_weekends"]:
weekend_shift_target_number = sum(
- worker.shift_target_number[shift.name]
- for shift in self.get_shifts()
- if not set(("Sat", "Sun")).isdisjoint(shift.shift_days))
+ worker.shift_target_number[shift.name] /
+ len(shift.shift_days) for shift in self.get_shifts()
+ if "Sat" in shift.shift_days or "Sun" in shift.shift_days)
+
+ worker.weekend_shift_target_number = weekend_shift_target_number
# min_shifts = weekend_shift_target_number - 20
# max_shifts = weekend_shift_target_number + 20
@@ -699,7 +796,8 @@ class RotaBuilder(object):
self.model.worker_weekend_count[worker.id] -
weekend_shift_target_number)
- xU = 10
+ xU = self.constraint_options["max_weekends"]
+
xL = 1
self.model.constraints.add(
inequality(
@@ -750,7 +848,13 @@ class RotaBuilder(object):
for week in week_blocks))
for week in self.weeks:
- if self.constraint_options[
+ for shift in self.get_shifts_with_constraint(
+ "max_2_consecutive_shifts_per_week"):
+ self.model.constraints.add(2 >= sum(
+ self.model.works[worker.id, week, day, shift.name]
+ for day in self.days))
+
+ if worker.site in self.constraint_options[
"prevent_monday_after_full_weekends"]:
# Ignore last week
if week + 1 in self.weeks:
@@ -764,21 +868,53 @@ class RotaBuilder(object):
sum(self.model.works[worker.id, week + 1, "Mon",
shift.name]
for shift in self.get_shifts()))
- if self.constraint_options[
+ if worker.site in self.constraint_options[
+ "prevent_monday_and_tuesday_after_full_weekends"]:
+ # Ignore last week
+ if week + 1 in self.weeks:
+ self.model.constraints.add(
+ 2 >= sum(self.model.works[worker.id, week, "Sat",
+ shift.name]
+ for shift in self.get_shifts()) +
+ sum(self.model.works[worker.id, week, "Sun",
+ shift.name]
+ for shift in self.get_shifts()) +
+ sum(self.model.works[worker.id, week + 1, "Mon",
+ shift.name]
+ for shift in self.get_shifts()) +
+ sum(self.model.works[worker.id, week + 1, "Tue",
+ shift.name]
+ for shift in self.get_shifts()))
+ if worker.site in self.constraint_options[
"prevent_fridays_before_full_weekends"]:
self.model.constraints.add(
2 >= sum(self.model.works[worker.id, week, "Sat",
shift.name]
for shift in self.get_shifts()
- if shift.name not in ("night_weekend")) +
+ if "night" not in shift.constraints) +
sum(self.model.works[worker.id, week, "Sun",
shift.name]
- for shift in self.get_shifts() if shift.name not in
- ("night_weekend")) +
+ for shift in self.get_shifts()
+ if "night" not in shift.constraints) +
sum(self.model.works[worker.id, week, "Fri",
shift.name]
- for shift in self.get_shifts() if shift.name not in
- ("night_weekend")))
+ for shift in self.get_shifts()
+ if "night" not in shift.constraints))
+ if worker.site in self.constraint_options[
+ "prevent_thursdays_before_full_weekends"]:
+ self.model.constraints.add(
+ 2 >= sum(self.model.works[worker.id, week, "Sat",
+ shift.name]
+ for shift in self.get_shifts()
+ if "night" not in shift.constraints) +
+ sum(self.model.works[worker.id, week, "Sun",
+ shift.name]
+ for shift in self.get_shifts()
+ if "night" not in shift.constraints) +
+ sum(self.model.works[worker.id, week, "Thu",
+ shift.name]
+ for shift in self.get_shifts()
+ if "night" not in shift.constraints))
# # model.weekend_count stores the number of weekend shifts that a worker is assigned to wor
# # this is used (by the objective) to balance the total number of weekend shifts worked
@@ -959,6 +1095,17 @@ class RotaBuilder(object):
else:
night_shift_balancing = 0
+ if self.constraint_options["balance_bank_holidays"]:
+ bank_holiday_balance_modifier_constant = 1000
+ bank_holiday_balancing = sum(
+ bank_holiday_balance_modifier_constant *
+ (self.model.bank_holiday_count_w[(worker.id)] - 1)
+ # (self.model.night_shift_count_t1[(worker.id)] +
+ # self.model.night_shift_count_t2[(worker.id)])
+ for worker in self.workers)
+ else:
+ bank_holiday_balancing = 0
+
if self.constraint_options["balance_weekends"]:
weekend_balance_modifier_constant = 5
weekend_shift_balancing = sum(
@@ -977,7 +1124,7 @@ class RotaBuilder(object):
for week, day, shift in self.get_all_shiftname_combinations())
# Work requsets
- work_request_constant = 1000
+ work_request_constant = 10000 # Needs to be very big to override loss from bank holiday requests
work_requests = sum(
self.model.work_requests[worker.id, week, day, shift] *
self.model.works[worker.id, week, day, shift] *
@@ -1006,9 +1153,10 @@ class RotaBuilder(object):
blocks_balancing = 0
#return weekend_shift_balancing + blocks_balancing
+ #return bank_holiday_balancing
#return shift_balancing + preferences + blocks_balancing
#return shift_balancing + preferences + nights_site_balancing + blocks_balancing
- return shift_balancing + night_shift_balancing + preferences + nights_site_balancing + blocks_balancing - work_requests
+ return weekend_shift_balancing + shift_balancing + night_shift_balancing + preferences + nights_site_balancing + bank_holiday_balancing + blocks_balancing - work_requests
# add objective function to the model. rule (pass function) or expr (pass expression directly)
self.model.obj = Objective(rule=obj_rule, sense=minimize)
@@ -1273,6 +1421,10 @@ class RotaBuilder(object):
t = "\n".join(l)
return "Full time equivalent trainees by site:\n{}".format(t)
+ def get_bank_holiday_week_days(self):
+ return ([(week, day) for week, day in self.get_week_day_combinations()
+ if self.week_day_date_map[(week, day)] in bank_holiday_map])
+
class RotaResults(object):
def __init__(self, rota, results):
@@ -1431,13 +1583,13 @@ class RotaResults(object):
else:
nwds = None
- w = [
- "
{} ({}) [{}]
"
- .format(worker.site, worker.site, nwds, worker.site,
- worker.name, worker.fte, worker.fte_adj,
- worker.end_date, worker.name, worker.grade, worker.fte)
- ]
+ worker_targets = json.dumps(worker.shift_target_number)
+
+ tds = []
+
n = 0
+
+ shift_tds = []
for week, day in self.rota.get_week_day_combinations():
d = self.rota.start_date + datetime.timedelta(n)
@@ -1445,19 +1597,21 @@ class RotaResults(object):
a = "-"
shift_name = ""
- title = "{} ({})".format(shift_name, d)
for shift in self.rota.get_shift_names_by_day(day):
if model.works[worker.id, week, day, shift].value == 1:
shifts.append(shift)
a = shift[0]
shift_name = shift
break
+ title = "{} ({})".format(shift_name, d)
css_class = day
if model.available[worker.id, week, day] > 0:
available = True
+ unavailable_reason = ""
else:
available = False
css_class = "unavailable"
+ unavailable_reason = self.rota.unavailable_to_work
bank_holiday = ""
if d in bank_holiday_map:
@@ -1472,20 +1626,36 @@ class RotaResults(object):
requests = " data-shift-request='{}'".format(
self.rota.work_requests_map[(worker.id, week, day)])
- w.append(
+ shift_tds.append(
"
{}
"
.format(title, css_class, shift_name, available, d, week,
day, requests, bank_holiday, a))
shift_count = ""
+ shift_count_dict = {}
for s in set(shifts):
- shift_count = shift_count + "{}: {}, ".format(
- s, shifts.count(s))
+ c = shifts.count(s)
+ shift_count_dict[s] = c
+ shift_count = shift_count + "{}: {}, ".format(s, c)
+
+ worker_td = "