This commit is contained in:
Ross
2022-01-15 08:45:43 +00:00
parent 2040499145
commit 85c1344737
6 changed files with 305 additions and 197 deletions
+134 -91
View File
@@ -89,12 +89,7 @@ class SingleShift(object):
Returns a text summary of the shift
"""
return "{}: {} workers for sites ({}) on days ({})".format(
self.name,
self.workers_required,
", ".join(self.site),
", ".join(self.shift_days),
)
return f"{self.name}: {self.workers_required} workers for sites ({', '.join(self.site)}) on days ({', '.join(self.shift_days)})"
class RotaBuilder(object):
@@ -102,18 +97,21 @@ class RotaBuilder(object):
def __init__(
self,
start_date,
weeks_to_rota=26,
balance_offset_modifier=1,
ltft_balance_offset=2,
max_night_frequency=2,
max_weekend_frequency=2,
use_previous_shifts=False,
start_date: datetime.datetime,
weeks_to_rota: int = 26,
balance_offset_modifier: int = 1,
ltft_balance_offset: int = 1,
max_night_frequency: int = 2,
max_weekend_frequency: int = 2,
use_previous_shifts: bool = False,
use_shift_balance_extra: bool = False,
use_bank_holiday_extra: bool = False,
):
print("Start time: {}".format(datetime.datetime.now()))
print("Weeks to rota: {}".format(weeks_to_rota))
print("Use previous shifts {}".format(use_previous_shifts))
print(f"Start time: {datetime.datetime.now()}")
print(f"Weeks to rota: {weeks_to_rota}")
print(f"Use previous shifts {use_previous_shifts}")
print(f"Use previous shifts (balance_extra) {use_shift_balance_extra}")
self.shifts = [] # type List[SingleShift]
@@ -124,12 +122,12 @@ class RotaBuilder(object):
self.weeks_days_product = list(itertools.product(self.weeks, days))
self.use_previous_shifts = use_previous_shifts
self.use_shift_balance_extra = use_shift_balance_extra
self.use_bank_holiday_extra = use_bank_holiday_extra
if start_date.weekday() != 0:
raise ValueError(
"Start date {} must be a Mon (not a {})".format(
start_date.isoformat(), days[start_date.weekday()]
)
f"Start date {start_date.isoformat()} must be a Mon (not a {days[start_date.weekday()]})"
)
self.start_date = start_date
@@ -166,6 +164,7 @@ class RotaBuilder(object):
"balance_bank_holidays": True,
"balance_blocks": True,
"balance_shifts": True,
"minimise_shift_diffs": True,
"balance_weekends": True,
"max_weekends": False,
"max_shifts_per_week": 4,
@@ -353,6 +352,22 @@ class RotaBuilder(object):
initialize=0,
)
self.model.shift_count_diff = Var(
(
(worker.id, shift)
for worker in self.workers
for shift in self.get_shift_names()
),
within=Reals,
initialize=0,
)
self.model.shift_count_diff_summed = Var(
(worker.id for worker in self.workers),
within=Reals,
initialize=0,
)
if self.constraint_options["balance_shifts"]:
self.model.shift_count_t1 = Var(
@@ -799,6 +814,15 @@ class RotaBuilder(object):
# Most of our constraints apply per worker
for worker in self.workers:
if self.constraint_options["minimise_shift_diffs"]:
self.model.constraints.add(
self.model.shift_count_diff_summed[worker.id]
== sum(
self.model.shift_count_diff[worker.id, shift.name]
for shift in self.get_shifts()
)
)
for week_blocks in self.get_week_block_iterator(4):
# Prevent weekend shifts more than once every n weeks
self.model.constraints.add(
@@ -868,6 +892,11 @@ class RotaBuilder(object):
target_shifts + float(allocated) - float(worked)
)
if self.use_shift_balance_extra:
if shift.name in worker.shift_balance_extra:
extra = worker.shift_balance_extra[shift.name]
target_shifts = target_shifts + extra
# print(worker.name, shift.name, target_shifts)
worker.shift_target_number[shift.name] = target_shifts
@@ -917,6 +946,12 @@ class RotaBuilder(object):
)
)
self.model.constraints.add(
self.model.shift_count_diff[worker.id, shift.name]
== self.model.shift_count[worker.id, shift.name]
- worker.shift_target_number[shift.name]
)
# 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
@@ -928,8 +963,7 @@ class RotaBuilder(object):
- self.model.shift_count_t2[worker.id]
== sum(
(
self.model.shift_count[worker.id, shift.name]
- worker.shift_target_number[shift.name]
self.model.shift_count_diff[worker.id, shift.name]
)
* shift.balance_weighting
for shift in self.get_shifts()
@@ -938,6 +972,12 @@ class RotaBuilder(object):
# if "night" not in shift.constraints))
if self.constraint_options["balance_bank_holidays"]:
extra_bank_holiday = 0
if self.use_bank_holiday_extra:
print(worker.name, worker.bank_holiday_extra)
extra_bank_holiday = worker.bank_holiday_extra
self.model.constraints.add(
self.model.bank_holiday_count[worker.id]
== sum(
@@ -945,6 +985,7 @@ class RotaBuilder(object):
for week, day, shift in self.get_all_shiftname_combinations()
if self.week_day_date_map[(week, day)] in bank_holiday_map
)
+ extra_bank_holiday
)
xU = len(self.get_bank_holiday_week_days()) + 1
@@ -1501,6 +1542,16 @@ class RotaBuilder(object):
else:
shift_balancing = 0
if self.constraint_options["minimise_shift_diffs"]:
shift_diff_modifier_constant = 10
shift_diff_balancing = sum(
shift_diff_modifier_constant
* self.model.shift_count_diff_summed[(worker.id)]
for worker in self.workers
)
else:
shift_diff_balancing = 0
if self.constraint_options["balance_nights"]:
night_balance_modifier_constant = 10
night_shift_balancing = sum(
@@ -1598,6 +1649,7 @@ class RotaBuilder(object):
weekend_shift_balancing
+ shift_balancing
+ night_shift_balancing
+ shift_diff_balancing
+ preferences
+ nights_site_balancing
+ bank_holiday_balancing
@@ -1882,11 +1934,9 @@ class RotaBuilder(object):
l = []
for site in w:
l.append(
"{}: {}".format(site, sum(worker.fte_adj for worker in w[site]) / 100)
)
l.append(f"{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)
return f"Full time equivalent trainees by site:\n{t}"
def get_bank_holiday_week_days(self):
return [
@@ -1902,12 +1952,12 @@ class RotaResults(object):
# self.results = results
def export_rota_to_html(self, filename="rota"):
with open("{}.html".format(filename), "w") as f:
with open(f"{filename}.html", "w") as f:
f.write(self.get_worker_timetable_html(True))
def export_rota_to_csv(self, filename="rota"):
works = self.rota.model.works
with open("{}.csv".format(filename), "w", newline="") as f:
with open(f"{filename}.csv", "w", newline="") as f:
wr = csv.writer(f, quoting=csv.QUOTE_ALL)
l = ["Name"]
l.extend([worker.name for worker in self.rota.workers])
@@ -1926,7 +1976,7 @@ class RotaResults(object):
wr.writerow(l4)
for week, day in self.rota.get_week_day_combinations():
d = ["Week {} Day {}".format(week, day)]
d = [f"Week {week} Day {day}"]
for worker in self.rota.workers:
i = ""
@@ -1971,16 +2021,14 @@ class RotaResults(object):
self, 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]
)
days_string = "{:20}".format("-Day-") + "".join(
"MTWTFSS" * len(self.rota.weeks)
week_string = f"{'-Week-':20}" + "".join(
[7 * str(f"{w}")[-1:] for w in self.rota.weeks]
)
days_string = f"{'-Day-':20}" + "".join("MTWTFSS" * len(self.rota.weeks))
timetable = []
for worker in self.rota.workers:
shifts = []
w = ["{:20}".format(worker.name)]
w = [f"{worker.name:20}"]
for week, day in self.rota.get_week_day_combinations():
a = "-"
for shift in self.rota.get_shift_names_by_week_day(week, day):
@@ -1991,16 +2039,17 @@ class RotaResults(object):
shift_count = ""
for s in set(shifts):
shift_count = shift_count + "{}: {}, ".format(s, shifts.count(s))
shift_count = shift_count + f"{s}: {shifts.count(s)}, "
shift_count = shift_count + "#weekends_worked: {}\\#".format(
model.worker_weekend_count[worker.id].value
shift_count = (
shift_count
+ f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#"
)
timetable.append("{} {}".format("".join(w), shift_count))
timetable.append(f"{''.join(w)} {shift_count}")
if show_prefs:
# prefs
w = ["{:20}".format("Preferences")]
w = [f"{'Preferences':20}"]
for week, day in self.rota.get_week_day_combinations():
if model.pref_not_to_work[worker.id, week, day] > 0:
w.append("Y")
@@ -2010,7 +2059,7 @@ class RotaResults(object):
if show_unavailable:
# prefs
w = ["{:20}".format("Unavailable")]
w = [f"{'Unavailable':20}"]
for week, day in self.rota.get_week_day_combinations():
if model.available[worker.id, week, day] > 0:
w.append("A")
@@ -2037,9 +2086,7 @@ class RotaResults(object):
timetable = []
timetable.append(
"<h2>Rota start date: {} ({} weeks)</h2>".format(
self.rota.start_date.isoformat(), self.rota.weeks[-1]
)
f"<h2>Rota start date: {self.rota.start_date.isoformat()} ({self.rota.weeks[-1]} weeks)</h2>"
)
date_row = ["<th class='worker'></th>"]
@@ -2047,9 +2094,9 @@ class RotaResults(object):
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))
date_row.append(f"<th title='{d}'>Week {week}: {day}</th>")
n = n + 1
timetable.append("<tr class='data-row'>{}</tr>".format("".join(date_row)))
timetable.append(f"<tr class='data-row'>{''.join(date_row)}</tr>")
current_site = ""
@@ -2057,11 +2104,7 @@ class RotaResults(object):
if worker.site != current_site:
try:
timetable.append(
"<tr><th class='site-title'>{} n={} fte={}</th><tr>".format(
worker.site,
len(self.rota.workers_at_sites[worker.site]),
self.rota.full_time_equivalent_sites[worker.site],
)
f"<tr><th class='site-title'>{worker.site} n={len(self.rota.workers_at_sites[worker.site])} fte={self.rota.full_time_equivalent_sites[worker.site]}</th><tr>"
)
except KeyError as e:
print(e)
@@ -2095,7 +2138,7 @@ class RotaResults(object):
shift_name = shift
break
# If we haven't run just add the shift
title = "{} ({})".format(shift_name, d)
title = f"{shift_name} ({d})"
css_class = day
unavailable_reason = ""
if model.available[worker.id, week, day] > 0:
@@ -2110,37 +2153,23 @@ class RotaResults(object):
except KeyError:
print("Error getting reason")
print(worker.id, worker.name)
print("Week {}, Day {}".format(week, day))
print(f"Week {week}, Day {day}")
unavailable_reason = "????"
title = "{} / {}".format(title, unavailable_reason)
title = f"{title} / {unavailable_reason}"
bank_holiday = ""
if d in bank_holiday_map:
css_class = " ".join((css_class, "bank-holiday"))
bank_holiday = " data-bank-holiday='{}'".format(bank_holiday_map[d])
bank_holiday = f" data-bank-holiday='{bank_holiday_map[d]}'"
requests = ""
if (worker.id, week, day) in self.rota.work_requests_map:
css_class = " ".join((css_class, "shift-requested"))
title = " ".join((title, "[REQUESTED]"))
requests = " data-shift-request='{}'".format(
self.rota.work_requests_map[(worker.id, week, day)]
)
requests = f" data-shift-request='{self.rota.work_requests_map[worker.id, week, day]}'"
shift_tds.append(
"<td title='{}' class='rota-day {}' data-shift='{}' data-available='{}' data-unavailable_reason='{}' data-date='{}' data-week='{}' data-day='{}'{}{}>{}</td>".format(
title,
css_class,
shift_name,
available,
unavailable_reason,
d,
week,
day,
requests,
bank_holiday,
a,
)
f"<td title='{title}' class='rota-day {css_class}' data-shift='{shift_name}' data-available='{available}' data-unavailable_reason='{unavailable_reason}' data-date='{d}' data-week='{week}' data-day='{day}'{requests}{bank_holiday}>{a}</td>"
)
shift_count = ""
@@ -2148,9 +2177,28 @@ class RotaResults(object):
for s in set(shifts):
c = shifts.count(s)
shift_count_dict[s] = c
shift_count = shift_count + "{}: {}, ".format(s, c)
shift_count = shift_count + f"{s}: {c}, "
worker_td = "<td title='Site: {site}' class='worker {site}' data-nwds='{nwds}' data-site='{site}' data-worker='{name}' data-fte='{fte}' data-fte_adj='{fte_adj}' data-end_date='{end_date}' data-worker-targets='{targets}' data-shift-counts='{shift_counts}' data-weekend-target='{weekend_target}' data-night-at-derriford='{nights_at_derriford}' data-pair='{pair}'><span class='name' title='{name}'>{name}</span> ({grade}) [{fte}]</td>".format(
shift_diff_dict = {}
for shift in self.rota.get_shifts():
diff = model.shift_count_diff[worker.id, shift.name].value
shift_diff_dict[shift.name] = diff
worker_td = """<td title='Site: {site}' class='worker {site}'
data-nwds='{nwds}' data-site='{site}' data-worker='{name}'
data-fte='{fte}' data-fte_adj='{fte_adj}' data-end_date='{end_date}'
data-worker-targets='{targets}' data-shift-counts='{shift_counts}'
data-weekend-target='{weekend_target}'
data-night-at-derriford='{nights_at_derriford}'
data-pair='{pair}'
data-shift-balance-extra='{shift_balance_extra}'
data-bank-holiday-extra='{bank_holiday_extra}'
data-shift-diff='{shift_diff}'
data-shift-diff_summed='{shift_diff_summed}'
>
<span class='name' title='{name}'>{name}</span> ({grade}) [{fte}]</td>""".format(
site=worker.site,
nwds=nwds,
name=worker.name,
@@ -2163,17 +2211,22 @@ class RotaResults(object):
nights_at_derriford=worker.night_at_derriford,
grade=worker.grade,
pair=worker.pair,
shift_balance_extra=json.dumps(worker.shift_balance_extra),
shift_diff=json.dumps(shift_diff_dict),
shift_diff_summed=model.shift_count_diff_summed[worker.id].value,
bank_holiday_extra=worker.bank_holiday_extra,
)
shift_count = shift_count + "#weekends_worked: {}\\#".format(
model.worker_weekend_count[worker.id].value
shift_count = (
shift_count
+ f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#"
)
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))
f"<tr class='worker-row'>{worker_td}{''.join(shift_tds)}</tr>"
)
# timetable.append("<tr>{}</tr>".format("".join(w), shift_count))
@@ -2237,15 +2290,13 @@ class RotaResults(object):
"""
if include_html_tag:
html = """<html>
html = f"""<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="timetable.js" defer></script>
</head>
{}</html>""".format(
html
)
{html}</html>"""
return html
@@ -2271,15 +2322,11 @@ class RotaResults(object):
for day in days
].count(1)
if c > 0:
l.append("{} ({})".format(shift, c))
l.append(f"{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)
)
)
t.append(f"{worker.get_full_details()} [{total_shifts}]: {', '.join(l)}")
return "\n".join(t)
def get_shift_summary_html(self):
@@ -2304,15 +2351,11 @@ class RotaResults(object):
for day in days
].count(1)
if c > 0:
l.append("{} ({})".format(shift, c))
l.append(f"{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)
)
)
t.append(f"{worker.get_full_details()} [{total_shifts}]: {', '.join(l)}")
return "\n".join(t)
# def get_no_preference(no_pref):