This commit is contained in:
Ross
2020-12-18 17:51:59 +00:00
parent 74484db6c6
commit eddb6a0f04
7 changed files with 1026 additions and 566 deletions
+210 -40
View File
@@ -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 = [
"<td title='Site: {}' class='worker {}' data-nwds='{}' data-site='{}' data-worker='{}' data-fte='{}' data-fte_adj='{}' data-end_date='{}'>{} ({}) [{}]</td>"
.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(
"<td title='{}' class='rota-day {}' data-shift='{}' data-available='{}' data-date='{}' data-week='{}' data-day='{}'{}{}>{}</td>"
.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 = "<td title='Site: {}' class='worker {}' data-nwds='{}' data-site='{}' data-worker='{}' data-fte='{}' data-fte_adj='{}' data-end_date='{}' data-worker-targets='{}' data-shift-counts='{}' data-weekend-target='{}' data-night-at-derriford='{}'>{} ({}) [{}]</td>".format(
worker.site, worker.site, nwds, worker.site, worker.name,
worker.fte, worker.fte_adj, worker.end_date, worker_targets,
json.dumps(shift_count_dict),
worker.weekend_shift_target_number, worker.night_at_derriford, worker.name, worker.grade,
worker.fte)
print(shift_count_dict)
shift_count = shift_count + "#weekends_worked: {}\\#".format(
model.worker_weekend_count[worker.id].value)
timetable.append("<tr class='worker-row'>{}</tr>".format(
"".join(w)))
bank_holiday_count = model.bank_holiday_count[worker.id].value
bank_holiday_count_w = model.bank_holiday_count_w[
worker.id].value - 1
print(worker.name, bank_holiday_count, bank_holiday_count_w)
timetable.append("<tr class='worker-row'>{}{}</tr>".format(
worker_td, "".join(shift_tds)))
#timetable.append("<tr>{}</tr>".format("".join(w), shift_count))
# if show_prefs: