This commit is contained in:
Ross
2020-05-24 10:16:25 +01:00
parent 63dcc7ce78
commit a6326bcaf6
15 changed files with 1138 additions and 809 deletions
+328 -54
View File
@@ -119,6 +119,8 @@ class RotaBuilder(object):
"balance_blocks": True,
"balance_shifts": True,
"balance_weekends": True,
"prevent_monday_after_full_weekends": False,
"prevent_fridays_before_full_weekends": True,
}
def build_model(self):
@@ -209,17 +211,19 @@ class RotaBuilder(object):
initialize=0,
)
self.model.shift_count_t1 = Var(
((worker.id) for worker in self.workers),
domain=NonNegativeReals,
initialize=0,
)
if self.constraint_options["balance_shifts"]:
self.model.shift_count_t2 = Var(
((worker.id) for worker in self.workers),
domain=NonNegativeReals,
initialize=0,
)
self.model.shift_count_t1 = Var(
((worker.id) for worker in self.workers),
domain=NonNegativeReals,
initialize=0,
)
self.model.shift_count_t2 = Var(
((worker.id) for worker in self.workers),
domain=NonNegativeReals,
initialize=0,
)
if self.constraint_options["balance_nights"]:
# We also try to even out the night shifts seperately
@@ -284,7 +288,7 @@ class RotaBuilder(object):
self.model.works_weekend = Var(
((worker.id, week) for worker in self.workers
for week in self.weeks),
for week in self.weeks),
domain=Binary,
initialize=0,
)
@@ -295,7 +299,6 @@ class RotaBuilder(object):
initialize=0,
)
self.model.weekend_shift_count_t1 = Var(
((worker.id) for worker in self.workers),
domain=NonNegativeReals,
@@ -331,10 +334,12 @@ class RotaBuilder(object):
def availability_init(model, wid, week, day):
if (wid, week, day) in self.unavailable_to_work:
# print((wid, week, day))
#print((wid, week, day))
return 0
return 1
#print(self.unavailable_to_work)
self.model.available = Param(
((worker.id, week, day) for worker in self.workers
for week, day in self.get_week_day_combinations()),
@@ -545,9 +550,6 @@ class RotaBuilder(object):
worker.fte_adj)
worker.shift_target_number[shift.name] = target_shifts
if worker.name == "Jean Sukumar":
print(worker.name, shift.name, target_shifts,
total_shifts, worker.fte_adj)
if shift.hard_constrain_shift:
adjusted_balance_offset = shift.balance_offset
@@ -590,12 +592,14 @@ class RotaBuilder(object):
# t1-t2 is the target
# As the objective is to minimise t1+t2 and t1 and t2 are positive reals
# t1+t2 approximates the absolute target (which otherwise requires a quadratic solver)
self.model.constraints.add(
self.model.shift_count_t1[worker.id] -
self.model.shift_count_t2[worker.id] == sum(
(self.model.shift_count[worker.id, shift.name] -
worker.shift_target_number[shift.name]) *
shift.balance_weighting for shift in self.get_shifts()))
if self.constraint_options["balance_shifts"]:
self.model.constraints.add(
self.model.shift_count_t1[worker.id] -
self.model.shift_count_t2[worker.id] == sum(
(self.model.shift_count[worker.id, shift.name] -
worker.shift_target_number[shift.name]) *
shift.balance_weighting
for shift in self.get_shifts()))
#if "night" not in shift.constraints))
if self.constraint_options["balance_nights"]:
@@ -603,15 +607,15 @@ class RotaBuilder(object):
worker.shift_target_number[shift.name]
for shift in self.get_shifts_with_constraint("night"))
min_shifts = night_shift_target_number - 20
max_shifts = night_shift_target_number + 20
# min_shifts = night_shift_target_number - 20
# max_shifts = night_shift_target_number + 20
self.model.constraints.add(
inequality(
min_shifts,
self.model.night_shift_count[worker.id],
max_shifts,
))
# self.model.constraints.add(
# inequality(
# min_shifts,
# self.model.night_shift_count[worker.id],
# max_shifts,
# ))
self.model.constraints.add(
self.model.night_shift_count_t1[worker.id] -
@@ -619,6 +623,7 @@ class RotaBuilder(object):
self.model.night_shift_count[worker.id] -
night_shift_target_number)
# This may need to be updated
xU = 6
xL = 1
self.model.constraints.add(
@@ -644,6 +649,52 @@ class RotaBuilder(object):
# self.model.constraints.add(
# 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
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))
# min_shifts = weekend_shift_target_number - 20
# max_shifts = weekend_shift_target_number + 20
# self.model.constraints.add(
# inequality(
# min_shifts,
# self.model.worker_weekend_count[worker.id],
# max_shifts,
# ))
self.model.constraints.add(
self.model.weekend_shift_count_t1[worker.id] -
self.model.weekend_shift_count_t2[worker.id] ==
self.model.worker_weekend_count[worker.id] -
weekend_shift_target_number)
xU = 10
xL = 1
self.model.constraints.add(
inequality(
xL,
self.model.weekend_shift_count_t1[worker.id] +
self.model.weekend_shift_count_t2[worker.id] + 1,
xU,
))
self.model.constraints.add(
self.model.weekend_shift_count_w[worker.id] >= xL *
(self.model.weekend_shift_count_t1[worker.id] +
self.model.weekend_shift_count_t2[worker.id] + 1) * 2 -
xL * xL)
self.model.constraints.add(
self.model.weekend_shift_count_w[worker.id] >= xU *
(self.model.weekend_shift_count_t1[worker.id] +
self.model.weekend_shift_count_t2[worker.id] + 1) * 2 -
xU * xU)
# Ensure worker is not allocated shifts on non working days
if worker.nwd:
for week, day, shift in self.get_all_shiftclass_combinations():
@@ -652,24 +703,56 @@ class RotaBuilder(object):
0 == self.model.works[worker.id, week, day,
shift.name])
# for week_blocks in self.get_week_block_iterator(
# self.max_night_frequency):
# if self.get_shifts_with_constraint("night"):
# # Prevent nights more than once every n weeks
# self.model.constraints.add(1 >= sum(
# self.model.shift_week_worker_assigned[shift.name, week,
# worker.id]
# for week in week_blocks
# for shift in self.get_shifts_with_constraint("night")))
if self.constraint_options["balance_blocks"]:
for week_blocks in self.get_week_block_iterator(
self.max_night_frequency):
if self.get_shifts_with_constraint("night"):
# Prevent nights more than once every n weeks
self.model.constraints.add(1 >= sum(
self.model.shift_week_worker_assigned[shift.name,
week,
worker.id]
for week in week_blocks
for shift in self.get_shifts_with_constraint(
"night")))
# for week_blocks in self.get_week_block_iterator(
# self.max_weekend_frequency):
# # Prevent weekend shifts more than once every n weeks
# self.model.constraints.add(
# 1 >= sum(self.model.works_weekend[worker.id, week]
# for week in week_blocks))
for week_blocks in self.get_week_block_iterator(
self.max_weekend_frequency):
# Prevent weekend shifts more than once every n weeks
self.model.constraints.add(
1 >= sum(self.model.works_weekend[worker.id, week]
for week in week_blocks))
for week in self.weeks:
if self.constraint_options[
"prevent_monday_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()))
if 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")) +
sum(self.model.works[worker.id, week, "Sun",
shift.name]
for shift in self.get_shifts() if shift.name not in
("night_weekend")) +
sum(self.model.works[worker.id, week, "Fri",
shift.name]
for shift in self.get_shifts() if shift.name not in
("night_weekend")))
# # 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
@@ -755,6 +838,12 @@ class RotaBuilder(object):
n2week, n2day = weeks_days[n]
n2 = 0
try:
n3week, n3day = weeks_days[n + 3]
except IndexError:
n3week, n3day = weeks_days[n]
n3 = 0
if self.get_shift_names_by_day(day):
# Unable to work (hard constraint not preference)
self.model.constraints.add(
@@ -793,6 +882,13 @@ class RotaBuilder(object):
shift]
for shift in self.get_shift_names_by_day(n2day)
if shift != constraint_shift.name))
# self.model.constraints.add(
# 1 >= self.model.works[worker.id, week, day,
# constraint_shift.name] +
# sum(n3 * self.model.works[worker.id, n3week, n3day,
# shift]
# for shift in self.get_shift_names_by_day(n3day)
# if shift != constraint_shift.name))
self.model.constraints.add(
1 >= self.model.works[worker.id, week, day,
constraint_shift.name] +
@@ -816,7 +912,7 @@ class RotaBuilder(object):
# c = len(workers)
balance_modifier_constant = 500
balance_modifier_constant = 1
if self.constraint_options["balance_shifts"]:
shift_balancing = sum(balance_modifier_constant *
@@ -827,7 +923,7 @@ class RotaBuilder(object):
shift_balancing = 0
if self.constraint_options["balance_nights"]:
night_balance_modifier_constant = 1
night_balance_modifier_constant = 10
night_shift_balancing = sum(
night_balance_modifier_constant *
self.model.night_shift_count_w[(worker.id)]
@@ -837,6 +933,15 @@ class RotaBuilder(object):
else:
night_shift_balancing = 0
if self.constraint_options["balance_weekends"]:
weekend_balance_modifier_constant = 5
weekend_shift_balancing = sum(
weekend_balance_modifier_constant *
self.model.weekend_shift_count_w[(worker.id)]
for worker in self.workers)
else:
weekend_shift_balancing = 0
preference_constant = 10
# Preferences
preferences = sum(
@@ -860,16 +965,16 @@ class RotaBuilder(object):
if self.constraint_options["balance_blocks"]:
blocks_balancing = sum(
100 * self.model.blocks_assigned[week, shift]
1 * self.model.blocks_assigned[week, shift]
for week in self.weeks
for shift in self.shifts_to_assign_as_blocks())
else:
blocks_balancing = 0
return night_shift_balancing
#return weekend_shift_balancing + blocks_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
return shift_balancing + night_shift_balancing + preferences + nights_site_balancing + blocks_balancing
# add objective function to the model. rule (pass function) or expr (pass expression directly)
self.model.obj = Objective(rule=obj_rule, sense=minimize)
@@ -1120,6 +1225,20 @@ class RotaBuilder(object):
def get_workers_total_fte(self):
return self.full_time_equivalent
def get_worker_details(self):
w = defaultdict(list)
for worker in self.workers:
w[worker.site].append(worker)
l = []
for site in w:
l.append("{}: {}".format(
site,
sum([worker.fte_adj for worker in w[site]]) / 100))
t = "\n".join(l)
return "Full time equivalent trainees by site:\n{}".format(t)
class RotaResults(object):
def __init__(self, rota, results):
@@ -1193,9 +1312,9 @@ class RotaResults(object):
return timetable
def get_worker_timetable_brief(self,
show_prefs=True,
marker_every=19,
show_unavailable=True):
show_prefs=False,
marker_every=30,
show_unavailable=False):
model = self.rota.model
week_string = "{:20}".format("-Week-") + "".join(
[7 * str("{}".format(w))[-1:] for w in self.rota.weeks])
@@ -1253,6 +1372,112 @@ class RotaResults(object):
timetable.insert(0, days_string)
return "\n".join(timetable)
def get_worker_timetable_html(self,
include_html_tag=False,
table_name="Test"):
model = self.rota.model
timetable = []
date_row = ["<th class='worker'></th>"]
n = 0
for week, day in self.rota.get_week_day_combinations():
d = self.rota.start_date + datetime.timedelta(n)
date_row.append("<th title='{}'>Week {}: {}</th>".format(
d, week, day))
n = n + 1
timetable.append("<tr class='data-row'>{}</tr>".format(
"".join(date_row)))
for worker in self.rota.workers:
shifts = []
if worker.nwd is not None:
nwds = ", ".join(worker.nwd)
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)
]
n = 0
for week, day in self.rota.get_week_day_combinations():
d = self.rota.start_date + datetime.timedelta(n)
n = n + 1
a = "-"
shift_name = ""
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
css_class = ""
if model.available[worker.id, week, day] > 0:
available = True
else:
available = False
css_class = "unavailable"
if worker.name == "Delilah Trimmer":
pass
w.append(
"<td title='{} ({})' class='rota-day {}' data-shift='{}' data-available='{}' data-date='{}' data-week='{}' data-day='{}'>{}</td>"
.format(shift_name, d, css_class, shift_name, available, d,
week, day, a))
shift_count = ""
for s in set(shifts):
shift_count = shift_count + "{}: {}, ".format(
s, shifts.count(s))
shift_count = shift_count + "#weekends_worked: {}\\#".format(
model.worker_weekend_count[worker.id].value)
timetable.append("<tr class='worker-row'>{}</tr>".format(
"".join(w)))
#timetable.append("<tr>{}</tr>".format("".join(w), shift_count))
# if show_prefs:
# # prefs
# w = ["{:20}".format("Preferences")]
# for week, day in self.rota.get_week_day_combinations():
# if model.pref_not_to_work[worker.id, week, day] > 0:
# w.append("Y")
# else:
# w.append("N")
# timetable.append("".join(w))
# if show_unavailable:
# # prefs
# w = ["{:20}".format("Unavailable")]
# for week, day in self.rota.get_week_day_combinations():
# if model.available[worker.id, week, day] > 0:
# w.append("A")
# else:
# w.append("U")
# timetable.append("".join(w))
html = """
<body>
<div class="table-div" id="{}">
<table>{}</table>
</div>
</body>
""".format(table_name, "\n".join(timetable))
if include_html_tag:
html = """<html>
<head>
<link rel="stylesheet" type="text/css" href="timetable.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="s.js" defer></script>
</head>
{}</html>""".format(html)
return html
def get_shift_summary(self):
works = self.rota.model.works
timetable = {
@@ -1282,6 +1507,35 @@ class RotaResults(object):
total_shifts, ", ".join(l)))
return "\n".join(t)
def get_shift_summary_html(self):
works = self.rota.model.works
timetable = {
worker.get_details():
{shift[0]: ""
for shift in self.rota.get_shift_names()}
for worker in self.rota.workers
}
#timetable = { worker.get_details() : { shift : "" for shift in ["truro_twilight"] } for worker in workers }
t = []
for worker in self.rota.workers:
l = []
total_shifts = 0
for shift in self.rota.get_shift_names():
#for shift in ["truro_twilight"]:
c = [
works[worker.id, week, day, shift]
for week in self.rota.weeks for day in days
].count(1)
if c > 0:
l.append("{} ({})".format(shift, c))
total_shifts = total_shifts + c
#print(worker.id, shift)
timetable[worker.get_details()][shift[0]] = c
t.append("{} [{}]: {}".format(worker.get_full_details(),
total_shifts, ", ".join(l)))
return "\n".join(t)
#def get_no_preference(no_pref):
# """Extract to a list the workers not satisfied with their weekend preference."""
# return [worker.id for worker in workers if no_pref[worker.id].value == 1]
@@ -1332,4 +1586,24 @@ class RotaResults(object):
1),
"balance: {},".format(
self.rota.model.night_shift_count_w[worker.id].value),
)
)
def get_weekend_details(self):
for worker in self.rota.workers:
print(
"{:20}".format(worker.name),
"worked: {},".format(
self.rota.model.worker_weekend_count[(worker.id)].value),
"target: {},".format(
sum([
worker.shift_target_number[shift.name]
for shift in self.rota.get_shifts()
if not set(("Sat", "Sun")).isdisjoint(shift.shift_days)
])),
"target_diff: {},".format(
self.rota.model.weekend_shift_count_t1[(worker.id)].value +
self.rota.model.weekend_shift_count_t2[(worker.id)].value +
1),
"balance: {},".format(
self.rota.model.weekend_shift_count_w[worker.id].value),
)