many changes

This commit is contained in:
Ross
2022-01-24 18:25:36 +00:00
parent 85c1344737
commit a34631bc22
15 changed files with 1821 additions and 738 deletions
+125 -114
View File
@@ -1,6 +1,6 @@
import datetime
import itertools
from typing import List, Tuple
from typing import Iterable, List, Sequence, Tuple
import datetime
@@ -31,30 +31,37 @@ from govuk_bank_holidays.bank_holidays import BankHolidays
bank_holidays = BankHolidays()
bank_holiday_map = {}
for bank_holiday in bank_holidays.get_holidays():
for bank_holiday in bank_holidays.get_holidays(division="england-and-wales"):
bank_holiday_map[bank_holiday["date"]] = bank_holiday["title"]
SHIFT_BOUNDS = {
"bank_holiday": (0, 4),
"shift_count": (0, 400),
"night_shift_count": (0, 19),
"weekend_count": (0, 60),
}
class SingleShift(object):
"""Class to hold all details for a shift"""
def __init__(
self,
sites,
name,
length,
shift_days,
sites: Iterable[str],
name: str,
length: float,
shift_days: Sequence[str],
# balance_by_site=True,
balance_offset=2, # this could be generated dynamically
balance_weighting=1,
workers_required=1,
rota_on_nwds=False,
assign_as_block=False,
force_as_block=False,
force_as_block_unless_nwd=False,
hard_constrain_shift=True,
bank_holidays_only=False,
constraints=[],
balance_offset: float = 2, # this could be generated dynamically
balance_weighting: float = 1,
workers_required: float = 1,
rota_on_nwds: bool = False,
assign_as_block: bool = False,
force_as_block: bool = False,
force_as_block_unless_nwd: bool = False,
hard_constrain_shift: bool = True,
bank_holidays_only: bool = False,
constraints: Iterable[str] = [],
):
self.site = sites
self.name = name
@@ -102,7 +109,7 @@ class RotaBuilder(object):
balance_offset_modifier: int = 1,
ltft_balance_offset: int = 1,
max_night_frequency: int = 2,
max_weekend_frequency: int = 2,
max_weekend_frequency: int = 2, # Requires balance weekends
use_previous_shifts: bool = False,
use_shift_balance_extra: bool = False,
use_bank_holiday_extra: bool = False,
@@ -143,7 +150,7 @@ class RotaBuilder(object):
self.workers = []
self.worker_pairs = []
self.night_blocks = ["weekday", "weekend", "none"]
# self.night_blocks = ["weekday", "weekend", "none"]
self.sites = None
@@ -164,9 +171,9 @@ class RotaBuilder(object):
"balance_bank_holidays": True,
"balance_blocks": True,
"balance_shifts": True,
"minimise_shift_diffs": True,
"minimise_shift_diffs": False, # less sophisticated version of balance_shifts
"balance_weekends": True,
"max_weekends": False,
"max_weekends": 100,
"max_shifts_per_week": 4,
"max_shifts_per_month": 12,
"prevent_monday_after_full_weekends": [],
@@ -188,13 +195,15 @@ class RotaBuilder(object):
self.results = None
def solve_model(self, solver="cbc", use_neos=False, options={}):
def solve_model(
self, solver: str = "cbc", use_neos: bool = False, options: dict = {}
):
print("Setting up solver")
self.opt = SolverFactory(solver)
print("Solving")
if use_neos:
solver_manager = SolverManagerFactory("neos") # Solve in neos server
solver_manager = SolverManagerFactory("neos") # Solve using neos server
# results = solver_manager.solve(Rota.model, opt=opt, logfile="test.log")
results = solver_manager.solve(
self.model, keepfiles=True, tee=True, opt=self.opt, logfile="test.log"
@@ -291,6 +300,7 @@ class RotaBuilder(object):
((worker.id) for worker in self.workers),
within=NonNegativeIntegers,
initialize=0,
bounds=SHIFT_BOUNDS["bank_holiday"],
)
self.model.bank_holiday_count_w = Var(
((worker.id) for worker in self.workers),
@@ -350,6 +360,7 @@ class RotaBuilder(object):
),
domain=NonNegativeReals,
initialize=0,
bounds=SHIFT_BOUNDS["shift_count"],
)
self.model.shift_count_diff = Var(
@@ -388,6 +399,7 @@ class RotaBuilder(object):
((worker.id) for worker in self.workers),
domain=NonNegativeReals,
initialize=0,
bounds=SHIFT_BOUNDS["night_shift_count"],
)
self.model.night_shift_count_t1 = Var(
@@ -453,6 +465,7 @@ class RotaBuilder(object):
((worker.id) for worker in self.workers),
domain=NonNegativeIntegers,
initialize=0,
bounds=SHIFT_BOUNDS["weekend_count"],
)
self.model.weekend_shift_count_t1 = Var(
@@ -554,7 +567,7 @@ class RotaBuilder(object):
workers_required,
site_required,
) in self.get_required_workers_and_site_combinations():
print(week, day, shift, workers_required, site_required)
# print(week, day, shift, workers_required, site_required)
self.model.constraints.add(
workers_required
== sum(
@@ -564,14 +577,15 @@ class RotaBuilder(object):
)
)
# And it is not assigned if the worker is from the wrong site
self.model.constraints.add(
0
== sum(
self.model.works[worker.id, week, day, shift]
for worker in self.workers
if worker.site not in site_required
if [worker for worker in self.workers if worker.site not in site_required]:
self.model.constraints.add(
0
== sum(
self.model.works[worker.id, week, day, shift]
for worker in self.workers
if worker.site not in site_required
)
)
)
# # Ensure shifts are only assigned by workers from the allowed sites
# self.model.constraints.add(0 == sum(
# self.model.works[worker.id, week, day, shift]
@@ -754,7 +768,6 @@ class RotaBuilder(object):
else:
full_workers.append(w)
print(shift.name, [n.name for n in nwd_workers])
if not nwd_workers: # Just do the usual
self.model.constraints.add(
@@ -768,7 +781,6 @@ class RotaBuilder(object):
)
else:
print(shift.name)
self.model.constraints.add(
sum(
self.model.blocks_worker_shift_assigned[
@@ -824,7 +836,7 @@ class RotaBuilder(object):
)
for week_blocks in self.get_week_block_iterator(4):
# Prevent weekend shifts more than once every n weeks
# Prevent more than n number shifts per 4 weeks
self.model.constraints.add(
self.constraint_options["max_shifts_per_month"]
>= sum(
@@ -859,7 +871,7 @@ class RotaBuilder(object):
)
)
if self.constraint_options["max_weekends"]:
if self.constraint_options["max_weekends"] > -1:
self.model.constraints.add(
self.constraint_options["max_weekends"]
>= self.model.worker_weekend_count[worker.id]
@@ -957,25 +969,22 @@ 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)
# TODO: quadratic implementation so perfect solutions will be chosen
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_diff[worker.id, shift.name]
)
(self.model.shift_count_diff[worker.id, shift.name])
* shift.balance_weighting
for shift in self.get_shifts()
)
)
# 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(
@@ -1023,16 +1032,6 @@ class RotaBuilder(object):
for shift in self.get_shifts_with_constraint("night")
)
# 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(
self.model.night_shift_count_t1[worker.id]
- self.model.night_shift_count_t2[worker.id]
@@ -1091,59 +1090,50 @@ class RotaBuilder(object):
worker.weekend_shift_target_number = weekend_shift_target_number
# 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 = self.constraint_options["max_weekends"]
xL = 1
self.model.constraints.add(
inequality(
xL,
if weekend_shift_target_number > 0:
self.model.constraints.add(
self.model.weekend_shift_count_t1[worker.id]
+ self.model.weekend_shift_count_t2[worker.id]
+ 1,
xU,
- self.model.weekend_shift_count_t2[worker.id]
== self.model.worker_weekend_count[worker.id]
- weekend_shift_target_number
)
)
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
)
xU = self.constraint_options["max_weekends"]
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
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
)
* 2
- xU * xU
)
# Ensure worker is not allocated shifts on non working days
if worker.nwd:
@@ -1176,6 +1166,7 @@ class RotaBuilder(object):
)
)
if self.constraint_options["balance_weekends"]:
for week_blocks in self.get_week_block_iterator(
self.max_weekend_frequency
):
@@ -1660,7 +1651,7 @@ class RotaBuilder(object):
# add objective function to the model. rule (pass function) or expr (pass expression directly)
self.model.obj = Objective(rule=obj_rule, sense=minimize)
def add_worker(self, worker: Worker):
def add_worker(self, worker: Worker) -> None:
"""Add a worker to the rota
Args:
@@ -1668,7 +1659,7 @@ class RotaBuilder(object):
"""
self.workers.append(worker)
def add_workers(self, workers: List):
def add_workers(self, workers: List) -> None:
"""Add multiple worker to the rota
Args:
@@ -1676,12 +1667,14 @@ class RotaBuilder(object):
"""
self.workers.extend(workers)
def build_shifts_and_workers(self):
def build_workers(self) -> None:
"""Process loaded shifts and workers
Must be called prior to attempting to solve
"""
# self.build_shifts()
if not self.workers:
raise NoWorkers("Workers must be added prior to calling build_workers")
self.workers = sorted(self.workers)
@@ -1703,7 +1696,7 @@ class RotaBuilder(object):
for p in pairs:
self.worker_pairs.append(tuple(pairs[p]))
def add_shift(self, shift):
def add_shift(self, shift) -> None:
"""Add a shift to the collection
:param SingleShift shift: Shift object
@@ -1711,7 +1704,7 @@ class RotaBuilder(object):
"""
self.shifts.append(shift)
def add_shifts(self, *shifts: SingleShift):
def add_shifts(self, *shifts: SingleShift) -> None:
"""Add multiple shifts
Returns:
@@ -1719,7 +1712,7 @@ class RotaBuilder(object):
"""
self.shifts.extend(shifts)
def get_shift_names_by_week_day(self, week, day: DayStr):
def get_shift_names_by_week_day(self, week, day: DayStr) -> Set():
"""Returns the shifts required for a specific day
Returns:
@@ -1748,7 +1741,9 @@ class RotaBuilder(object):
return self.shifts_by_name[name].length
def build_shifts(self):
""" """
"""
Process the added shifts
"""
self.shifts_by_name = {}
self.shift_names = [] # type: List[ShiftName]
@@ -1874,8 +1869,9 @@ class RotaBuilder(object):
list: two dimensional list containing weeks in blocks
"""
blocks = []
for i in range(len(self.weeks)):
blocks.append(self.weeks[i : i + block_length])
for i in range(len(self.weeks) - block_length + 1):
block = self.weeks[i : i + block_length]
blocks.append(block)
return blocks
def get_required_workers_and_site_combinations(self):
@@ -1919,10 +1915,10 @@ class RotaBuilder(object):
s.extend(self.shifts_to_force_as_blocks())
return s
def get_workers_for_shift(self, shift):
def get_workers_for_shift(self, shift: SingleShift) -> List[Worker]:
return [worker for worker in self.workers if worker.site in shift.site]
def get_workers_total_fte(self):
def get_workers_total_fte(self) -> float:
return self.full_time_equivalent
def get_worker_details(self):
@@ -1938,6 +1934,9 @@ class RotaBuilder(object):
t = "\n".join(l)
return f"Full time equivalent trainees by site:\n{t}"
def get_workers(self):
return self.workers
def get_bank_holiday_week_days(self):
return [
(week, day)
@@ -1951,11 +1950,11 @@ class RotaResults(object):
self.rota = rota
# self.results = results
def export_rota_to_html(self, filename="rota"):
def export_rota_to_html(self, filename: str = "rota"):
with open(f"{filename}.html", "w") as f:
f.write(self.get_worker_timetable_html(True))
def export_rota_to_csv(self, filename="rota"):
def export_rota_to_csv(self, filename: str = "rota"):
works = self.rota.model.works
with open(f"{filename}.csv", "w", newline="") as f:
wr = csv.writer(f, quoting=csv.QUOTE_ALL)
@@ -2179,17 +2178,15 @@ class RotaResults(object):
shift_count_dict[s] = c
shift_count = shift_count + f"{s}: {c}, "
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-worker-targets='{targets}' data-shift-counts='{worker_shift_counts}'
data-weekend-target='{weekend_target}'
data-night-at-derriford='{nights_at_derriford}'
data-pair='{pair}'
@@ -2206,7 +2203,7 @@ class RotaResults(object):
fte_adj=worker.fte_adj,
end_date=worker.end_date,
targets=worker_targets,
shift_counts=json.dumps(shift_count_dict),
worker_shift_counts=json.dumps(shift_count_dict),
weekend_target=worker.weekend_shift_target_number,
nights_at_derriford=worker.night_at_derriford,
grade=worker.grade,
@@ -2293,7 +2290,9 @@ class RotaResults(object):
html = f"""<html>
<head>
<link rel="stylesheet" type="text/css" href="timetable.css">
<link rel="stylesheet" type="text/css" href="https://code.jquery.com/ui/1.13.0/themes/base/jquery-ui.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://code.jquery.com/ui/1.13.0/jquery-ui.min.js"></script>
<script src="timetable.js" defer></script>
</head>
{html}</html>"""
@@ -2431,3 +2430,15 @@ class RotaResults(object):
)
"""
class NoActiveSites(Exception):
"""Raised when there are no active sites"""
pass
class NoWorkers(Exception):
"""Raised when there are no active sites"""
pass