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