go
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user