many improvements

This commit is contained in:
Ross
2022-01-27 23:39:22 +00:00
parent a34631bc22
commit 8d6208de73
4 changed files with 754 additions and 350 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ Rota.constraint_options["constrain_time_off_after_nights"] = True
Rota.constraint_options["balance_nights_across_sites"] = True Rota.constraint_options["balance_nights_across_sites"] = True
Rota.constraint_options["balance_blocks"] = True Rota.constraint_options["balance_blocks"] = True
Rota.constraint_options["balance_weekends"] = True Rota.constraint_options["balance_weekends"] = True
Rota.constraint_options["balance_shifts"] = True Rota.constraint_options["balance_shifts_over_workers"] = True
Rota.constraint_options["balance_bank_holidays"] = True Rota.constraint_options["balance_bank_holidays"] = True
Rota.constraint_options["avoid_st2_first_month"] = False Rota.constraint_options["avoid_st2_first_month"] = False
Rota.constraint_options["hard_constrain_pair_separation"] = True Rota.constraint_options["hard_constrain_pair_separation"] = True
+1 -1
View File
@@ -47,7 +47,7 @@ Rota.constraint_options["constrain_time_off_after_nights"] = True
Rota.constraint_options["balance_nights_across_sites"] = True Rota.constraint_options["balance_nights_across_sites"] = True
Rota.constraint_options["balance_blocks"] = True Rota.constraint_options["balance_blocks"] = True
Rota.constraint_options["balance_weekends"] = True Rota.constraint_options["balance_weekends"] = True
Rota.constraint_options["balance_shifts"] = True Rota.constraint_options["balance_shifts_over_workers"] = True
Rota.constraint_options["balance_bank_holidays"] = True Rota.constraint_options["balance_bank_holidays"] = True
Rota.constraint_options["avoid_st2_first_month"] = False Rota.constraint_options["avoid_st2_first_month"] = False
Rota.constraint_options["hard_constrain_pair_separation"] = True Rota.constraint_options["hard_constrain_pair_separation"] = True
+295 -221
View File
@@ -1,6 +1,6 @@
import datetime import datetime
import itertools import itertools
from typing import Iterable, List, Sequence, Tuple from typing import Iterable, List, Sequence, Tuple, Set
import datetime import datetime
@@ -104,12 +104,12 @@ class RotaBuilder(object):
def __init__( def __init__(
self, self,
start_date: datetime.datetime, start_date: datetime.datetime = datetime.datetime.now(),
weeks_to_rota: int = 26, weeks_to_rota: int = 26,
balance_offset_modifier: int = 1, balance_offset_modifier: int = 1,
ltft_balance_offset: int = 1, ltft_balance_offset: int = 1,
max_night_frequency: int = 2, max_night_frequency: int = 2,
max_weekend_frequency: int = 2, # Requires balance weekends max_weekend_frequency: int = 1, # Requires balance weekends
use_previous_shifts: bool = False, use_previous_shifts: bool = False,
use_shift_balance_extra: bool = False, use_shift_balance_extra: bool = False,
use_bank_holiday_extra: bool = False, use_bank_holiday_extra: bool = False,
@@ -171,11 +171,12 @@ class RotaBuilder(object):
"balance_bank_holidays": True, "balance_bank_holidays": True,
"balance_blocks": True, "balance_blocks": True,
"balance_shifts": True, "balance_shifts": True,
"minimise_shift_diffs": False, # less sophisticated version of balance_shifts "balance_shifts_over_workers": True,
"minimise_shift_diffs": False, # less sophisticated version of balance_shifts_over_workers
"balance_weekends": True, "balance_weekends": True,
"max_weekends": 100, "max_weekends": 100,
"max_shifts_per_week": 4, "max_shifts_per_week": 7,
"max_shifts_per_month": 12, "max_shifts_per_month": 40,
"prevent_monday_after_full_weekends": [], "prevent_monday_after_full_weekends": [],
"prevent_monday_and_tuesday_after_full_weekends": [], "prevent_monday_and_tuesday_after_full_weekends": [],
"prevent_fridays_before_full_weekends": [], "prevent_fridays_before_full_weekends": [],
@@ -226,6 +227,14 @@ class RotaBuilder(object):
if not results.solver.status: if not results.solver.status:
sys.exit(0) sys.exit(0)
def build_and_solve(self, options={"ratio": 0.1, "seconds": 1000, "threads": 10}):
self.build_shifts()
self.build_workers()
self.build_model()
self.solve_model(options=options)
def build_model(self): def build_model(self):
# Initialize model # Initialize model
self.model = ConcreteModel() self.model = ConcreteModel()
@@ -234,10 +243,9 @@ class RotaBuilder(object):
# NOTE: this will assign to all possible shift combinations (which we probably don't want) # NOTE: this will assign to all possible shift combinations (which we probably don't want)
self.model.works = Var( self.model.works = Var(
( (
(worker.id, week, day, shift) (worker.id, week, day, shiftname)
for worker in self.workers for worker in self.workers
for week, day in self.get_week_day_combinations() for week, day, shiftname in self.get_all_shiftname_combinations()
for shift in self.get_shift_names()
), ),
within=Binary, within=Binary,
initialize=0, initialize=0,
@@ -352,6 +360,7 @@ class RotaBuilder(object):
initialize=0, initialize=0,
) )
self.model.shift_count = Var( self.model.shift_count = Var(
( (
(worker.id, shift) (worker.id, shift)
@@ -379,20 +388,40 @@ class RotaBuilder(object):
initialize=0, initialize=0,
) )
if self.constraint_options["balance_shifts"]: if self.constraint_options["balance_shifts_over_workers"]:
self.model.worker_shift_count_t1 = Var(
self.model.shift_count_t1 = Var(
((worker.id) for worker in self.workers), ((worker.id) for worker in self.workers),
domain=NonNegativeReals, domain=NonNegativeReals,
initialize=0, initialize=0,
) )
self.model.shift_count_t2 = Var( self.model.worker_shift_count_t2 = Var(
((worker.id) for worker in self.workers), ((worker.id) for worker in self.workers),
domain=NonNegativeReals, domain=NonNegativeReals,
initialize=0, initialize=0,
) )
if self.constraint_options["balance_shifts"]:
self.model.shift_count_t1 = Var(
(
(worker.id, shift.name)
for worker in self.workers
for shift in self.get_shifts()
),
domain=NonNegativeReals,
initialize=0,
)
self.model.shift_count_t2 = Var(
(
(worker.id, shift.name)
for worker in self.workers
for shift in self.get_shifts()
),
domain=NonNegativeReals,
initialize=0,
)
if self.constraint_options["balance_nights"]: if self.constraint_options["balance_nights"]:
# We also try to even out the night shifts seperately # We also try to even out the night shifts seperately
self.model.night_shift_count = Var( self.model.night_shift_count = Var(
@@ -453,7 +482,6 @@ class RotaBuilder(object):
# initialize=0, # initialize=0,
# ) # )
if self.constraint_options["balance_weekends"]:
self.model.works_weekend = Var( self.model.works_weekend = Var(
((worker.id, week) for worker in self.workers for week in self.weeks), ((worker.id, week) for worker in self.workers for week in self.weeks),
@@ -468,6 +496,7 @@ class RotaBuilder(object):
bounds=SHIFT_BOUNDS["weekend_count"], bounds=SHIFT_BOUNDS["weekend_count"],
) )
if self.constraint_options["balance_weekends"]:
self.model.weekend_shift_count_t1 = Var( self.model.weekend_shift_count_t1 = Var(
((worker.id) for worker in self.workers), ((worker.id) for worker in self.workers),
domain=NonNegativeReals, domain=NonNegativeReals,
@@ -591,15 +620,16 @@ class RotaBuilder(object):
# self.model.works[worker.id, week, day, shift] # self.model.works[worker.id, week, day, shift]
# for worker in self.workers if worker.site not in site_required)) # for worker in self.workers if worker.site not in site_required))
# THIS SHOULD NO LONGER BE REQUIRED
# Ensure no workers are assigned to shifts that are not required # Ensure no workers are assigned to shifts that are not required
for week, day, shift in self.get_not_required_shifts(): #for week, day, shift in self.get_not_required_shifts():
self.model.constraints.add( # self.model.constraints.add(
0 # 0
== sum( # == sum(
self.model.works[worker.id, week, day, shift.name] # self.model.works[worker.id, week, day, shift.name]
for worker in self.workers # for worker in self.workers
) # )
) # )
# Constraint: total hours worked hours worked # Constraint: total hours worked hours worked
# for worker in self.workers: # for worker in self.workers:
@@ -820,7 +850,7 @@ class RotaBuilder(object):
] ]
for worker in self.workers for worker in self.workers
) )
<= 1 <= shift.workers_required
) )
# Most of our constraints apply per worker # Most of our constraints apply per worker
@@ -840,10 +870,9 @@ class RotaBuilder(object):
self.model.constraints.add( self.model.constraints.add(
self.constraint_options["max_shifts_per_month"] self.constraint_options["max_shifts_per_month"]
>= sum( >= sum(
self.model.works[worker.id, week, day, shift.name] self.model.works[worker.id, week, day, shiftname]
for week in week_blocks for week, day, shiftname in self.get_all_shiftname_combinations()
for day in self.days if week in week_blocks
for shift in self.get_shifts()
) )
) )
@@ -862,8 +891,9 @@ class RotaBuilder(object):
except ValueError as e: except ValueError as e:
print("Failure setting constraint", "avoid_st2_first_month") print("Failure setting constraint", "avoid_st2_first_month")
print(e) print(e)
# Count number of weekends an worker works # Count number of weekends an worker works
if self.constraint_options["balance_weekends"]: #if self.constraint_options["balance_weekends"]:
self.model.constraints.add( self.model.constraints.add(
self.model.worker_weekend_count[worker.id] self.model.worker_weekend_count[worker.id]
== sum( == sum(
@@ -933,7 +963,9 @@ class RotaBuilder(object):
min_shifts, min_shifts,
sum( sum(
self.model.works[worker.id, week, day, shift.name] self.model.works[worker.id, week, day, shift.name]
for week, day in self.get_week_day_combinations() for week, day in self.get_week_day_combinations_for_shift(shift)
#for week, day in self.get_week_day_combinations()
#if shift.name in self.get_shift_names_by_week_day(week, day)
), ),
max_shifts, max_shifts,
) )
@@ -944,7 +976,8 @@ class RotaBuilder(object):
0 0
== sum( == sum(
self.model.works[worker.id, week, day, shift.name] self.model.works[worker.id, week, day, shift.name]
for week, day in self.get_week_day_combinations() for week, day in self.get_week_day_combinations_for_shift(shift)
#for week, day in self.get_week_day_combinations()
) )
) )
@@ -954,7 +987,7 @@ class RotaBuilder(object):
self.model.shift_count[worker.id, shift.name] self.model.shift_count[worker.id, shift.name]
== sum( == sum(
self.model.works[worker.id, week, day, shift.name] self.model.works[worker.id, week, day, shift.name]
for week, day in self.get_week_day_combinations() for week, day in self.get_week_day_combinations_for_shift(shift)
) )
) )
@@ -964,16 +997,23 @@ class RotaBuilder(object):
- worker.shift_target_number[shift.name] - worker.shift_target_number[shift.name]
) )
# Define shift_count_t1 and shift_count_t2 constraints for the object if self.constraint_options["balance_shifts"]:
self.model.constraints.add(
self.model.shift_count_t1[worker.id, shift.name]
- self.model.shift_count_t2[worker.id, shift.name]
== self.model.shift_count_diff[worker.id, shift.name]
)
# Define worker_shift_count_t1 and worker_shift_count_t2 constraints for the object
# Thus bypassing the need for a quadratic solver # Thus bypassing the need for a quadratic solver
# t1-t2 is the target # t1-t2 is the target
# As the objective is to minimise t1+t2 and t1 and t2 are positive reals # 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) # t1+t2 approximates the absolute target (which otherwise requires a quadratic solver)
# TODO: quadratic implementation so perfect solutions will be chosen # TODO: quadratic implementation so perfect solutions will be chosen
if self.constraint_options["balance_shifts"]: if self.constraint_options["balance_shifts_over_workers"]:
self.model.constraints.add( self.model.constraints.add(
self.model.shift_count_t1[worker.id] self.model.worker_shift_count_t1[worker.id]
- self.model.shift_count_t2[worker.id] - self.model.worker_shift_count_t2[worker.id]
== sum( == sum(
(self.model.shift_count_diff[worker.id, shift.name]) (self.model.shift_count_diff[worker.id, shift.name])
* shift.balance_weighting * shift.balance_weighting
@@ -1081,7 +1121,6 @@ class RotaBuilder(object):
# We use a similar method to balance the number of weekends worked # We use a similar method to balance the number of weekends worked
# This works as long as weekend shifts are assigned as blocks! # This works as long as weekend shifts are assigned as blocks!
if self.constraint_options["balance_weekends"]:
weekend_shift_target_number = sum( weekend_shift_target_number = sum(
worker.shift_target_number[shift.name] / len(shift.shift_days) worker.shift_target_number[shift.name] / len(shift.shift_days)
for shift in self.get_shifts() for shift in self.get_shifts()
@@ -1090,6 +1129,8 @@ class RotaBuilder(object):
worker.weekend_shift_target_number = weekend_shift_target_number worker.weekend_shift_target_number = weekend_shift_target_number
if self.constraint_options["balance_weekends"]:
if weekend_shift_target_number > 0: if weekend_shift_target_number > 0:
self.model.constraints.add( self.model.constraints.add(
self.model.weekend_shift_count_t1[worker.id] self.model.weekend_shift_count_t1[worker.id]
@@ -1166,7 +1207,7 @@ class RotaBuilder(object):
) )
) )
if self.constraint_options["balance_weekends"]: #if self.constraint_options["balance_weekends"]:
for week_blocks in self.get_week_block_iterator( for week_blocks in self.get_week_block_iterator(
self.max_weekend_frequency self.max_weekend_frequency
): ):
@@ -1184,20 +1225,22 @@ class RotaBuilder(object):
self.model.constraints.add( self.model.constraints.add(
self.constraint_options["max_shifts_per_week"] self.constraint_options["max_shifts_per_week"]
>= sum( >= sum(
self.model.works[worker.id, week, day, shift.name] self.model.works[worker.id, week, day, shiftname]
for day in self.days #for shiftname in self.get_shift_names_by_week_day(week, day)
for shift in self.get_shifts() #for day in self.days
for day, shiftname in self.get_day_shiftname_combinations()
) )
) )
for shift in self.get_shifts_with_constraint("max_2_shifts_per_week"): # TODO: generic implementation
self.model.constraints.add( #for shift in self.get_shifts_with_constraint("max_2_shifts_per_week"):
2 # self.model.constraints.add(
>= sum( # 2
self.model.works[worker.id, week, day, shift.name] # >= sum(
for day in self.days # self.model.works[worker.id, week, day, shift.name]
) # for day in self.days
) # )
# )
if ( if (
worker.site worker.site
@@ -1305,22 +1348,24 @@ class RotaBuilder(object):
# self.model.works[worker.id, week, day, shift.name] # self.model.works[worker.id, week, day, shift.name]
# for day in self.days[5:] # for day in self.days[5:]
# for shift in self.get_shifts()) / 2) # for shift in self.get_shifts()) / 2)
if self.constraint_options["balance_weekends"]: #if self.constraint_options["balance_weekends"]:
self.model.constraints.add( self.model.constraints.add(
self.model.works_weekend[worker.id, week] self.model.works_weekend[worker.id, week]
>= sum( >= sum(
self.model.works[worker.id, week, day, shift.name] self.model.works[worker.id, week, day, shiftname]
for day in self.days[5:] for day, shiftname in self.get_day_shiftname_combinations() if day in self.days[5:]
for shift in self.get_shifts() #for day in self.days[5:]
#for shift in self.get_shifts()
) )
/ 2 / 2
) )
self.model.constraints.add( self.model.constraints.add(
self.model.works_weekend[worker.id, week] self.model.works_weekend[worker.id, week]
<= sum( <= sum(
self.model.works[worker.id, week, day, shift.name] self.model.works[worker.id, week, day, shiftname]
for day in self.days[5:] for day, shiftname in self.get_day_shiftname_combinations() if day in self.days[5:]
for shift in self.get_shifts() #for day in self.days[5:]
#for shift in self.get_shifts()
) )
) )
# self.model.constraints.add( # self.model.constraints.add(
@@ -1393,6 +1438,13 @@ class RotaBuilder(object):
n3week, n3day = weeks_days[n] n3week, n3day = weeks_days[n]
n3 = 0 n3 = 0
# IF paired we check the following against both workers
workers = [worker]
if self.constraint_options["hard_constrain_pair_separation"]:
for worker_pairs in self.worker_pairs:
if worker_pairs[0] == worker:
workers = worker_pairs
if self.get_shift_names_by_week_day(week, day): if self.get_shift_names_by_week_day(week, day):
# Unable to work (hard constraint not preference) # Unable to work (hard constraint not preference)
self.model.constraints.add( self.model.constraints.add(
@@ -1426,9 +1478,68 @@ class RotaBuilder(object):
) )
) )
for constraint_shift in self.get_shifts_with_constraints(
"preclear", "preclear2"
):
if day in constraint_shift.shift_days:
self.model.constraints.add(
1
>= self.model.works[worker.id, week, day, constraint_shift.name]
+ sum(
p1 * self.model.works[w.id, pweek, pday, shiftname]
for shiftname in self.get_shift_names_by_week_day(pweek, pday)
#for day, shiftname in self.get_day_shiftname_combinations() if day == pday
if shiftname != constraint_shift.name
for w in workers
)
)
for constraint_shift in self.get_shifts_with_constraint("preclear2"):
if day in constraint_shift.shift_days:
self.model.constraints.add(
1
>= self.model.works[worker.id, week, day, constraint_shift.name]
+ sum(
p2 * self.model.works[w.id, p2week, p2day, shiftname]
for shiftname in self.get_shift_names_by_week_day(p2week, p2day)
if shiftname != constraint_shift.name
for w in workers
)
)
for constraint_shift in self.get_shifts_with_constraints(
"postclear", "postclear2"
):
if day in constraint_shift.shift_days:
self.model.constraints.add(
1
>= self.model.works[worker.id, week, day, constraint_shift.name]
+ sum(
n1 * self.model.works[w.id, nweek, nday, shiftname]
for shiftname in self.get_shift_names_by_week_day(nweek, nday)
if shiftname != constraint_shift.name
for w in workers
)
)
for constraint_shift in self.get_shifts_with_constraints("postclear2"):
if day in constraint_shift.shift_days:
self.model.constraints.add(
1
>= self.model.works[worker.id, week, day, constraint_shift.name]
+ sum(
n2 * self.model.works[w.id, n2week, n2day, shiftname]
for shiftname in self.get_shift_names_by_week_day(n2week, n2day)
if shiftname != constraint_shift.name
for w in workers
)
)
# NOTE: use pre / post clear instead
# if working a night ensure preceeding (1) or subsequent (2) shifts can only be nights # if working a night ensure preceeding (1) or subsequent (2) shifts can only be nights
if self.constraint_options["constrain_time_off_after_nights"]: if self.constraint_options["constrain_time_off_after_nights"]:
for constraint_shift in self.get_shifts_with_constraint("night"): for constraint_shift in self.get_shifts_with_constraint("night"):
if day in constraint_shift.shift_days:
# Ensure night prior to unavalibity is not assigned # Ensure night prior to unavalibity is not assigned
self.model.constraints.add( self.model.constraints.add(
self.model.available[worker.id, week, day] self.model.available[worker.id, week, day]
@@ -1437,13 +1548,6 @@ class RotaBuilder(object):
] ]
) )
# IF paired we check the following against both workers
workers = [worker]
if self.constraint_options["hard_constrain_pair_separation"]:
for worker_pairs in self.worker_pairs:
if worker_pairs[0] == worker:
workers = worker_pairs
# print("Workers", workers) # print("Workers", workers)
self.model.constraints.add( self.model.constraints.add(
@@ -1521,14 +1625,27 @@ class RotaBuilder(object):
balance_modifier_constant = 1 balance_modifier_constant = 1
if self.constraint_options["balance_shifts_over_workers"]:
worker_shift_balancing = sum(
balance_modifier_constant
* (
self.model.worker_shift_count_t1[(worker.id)]
+ self.model.worker_shift_count_t2[(worker.id)]
)
for worker in self.workers
)
else:
worker_shift_balancing = 0
if self.constraint_options["balance_shifts"]: if self.constraint_options["balance_shifts"]:
shift_balancing = sum( shift_balancing = sum(
balance_modifier_constant balance_modifier_constant
* ( * (
self.model.shift_count_t1[(worker.id)] self.model.shift_count_t1[worker.id, shift.name]
+ self.model.shift_count_t2[(worker.id)] + self.model.shift_count_t2[worker.id, shift.name]
) )
for worker in self.workers for worker in self.workers
for shift in self.get_shifts()
) )
else: else:
shift_balancing = 0 shift_balancing = 0
@@ -1598,7 +1715,6 @@ class RotaBuilder(object):
) )
# # Spread nights # # Spread nights
if self.constraint_options["balance_nights_across_sites"]: if self.constraint_options["balance_nights_across_sites"]:
nights_site_balancing = sum( nights_site_balancing = sum(
( (
@@ -1631,14 +1747,10 @@ class RotaBuilder(object):
# print(worker_a, worker_b) # print(worker_a, worker_b)
# for week, day in self.get_week_day_combinations(): # for week, day in self.get_week_day_combinations():
# worker_pairs_balancing = worker_pairs_balancing + sum(self.model.works[worker_a.id, week, day, shift] for shift in self.get_shift_names_by_week_day(week, day)) * sum(self.model.works[worker_b.id, week, day, shift] for shift in self.get_shift_names_by_week_day(week, day)) * worker_pairs_constant # worker_pairs_balancing = worker_pairs_balancing + sum(self.model.works[worker_a.id, week, day, shift] for shift in self.get_shift_names_by_week_day(week, day)) * sum(self.model.works[worker_b.id, week, day, shift] for shift in self.get_shift_names_by_week_day(week, day)) * worker_pairs_constant
# 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 ( return (
weekend_shift_balancing weekend_shift_balancing
+ shift_balancing + shift_balancing
+ worker_shift_balancing
+ night_shift_balancing + night_shift_balancing
+ shift_diff_balancing + shift_diff_balancing
+ preferences + preferences
@@ -1712,7 +1824,7 @@ class RotaBuilder(object):
""" """
self.shifts.extend(shifts) self.shifts.extend(shifts)
def get_shift_names_by_week_day(self, week, day: DayStr) -> Set(): def get_shift_names_by_week_day(self, week, day: DayStr) -> set():
"""Returns the shifts required for a specific day """Returns the shifts required for a specific day
Returns: Returns:
@@ -1833,6 +1945,9 @@ class RotaBuilder(object):
""" """
return self.weeks_days_product return self.weeks_days_product
def get_week_day_combinations_for_shift(self, shift) -> list:
return [(week, day) for week, day in self.get_week_day_combinations() if shift.name in self.get_shift_names_by_week_day(week, day)]
def get_shifts(self) -> List[SingleShift]: def get_shifts(self) -> List[SingleShift]:
"""Returns a list of all the registered shifts """Returns a list of all the registered shifts
@@ -1844,6 +1959,15 @@ class RotaBuilder(object):
def get_shifts_with_constraint(self, constraint) -> List[SingleShift]: def get_shifts_with_constraint(self, constraint) -> List[SingleShift]:
return [shift for shift in self.shifts if constraint in shift.constraints] return [shift for shift in self.shifts if constraint in shift.constraints]
def get_shifts_with_constraints(self, *constraints) -> set[SingleShift]:
shifts = set()
for constraint in constraints:
shifts.update(
[shift for shift in self.shifts if constraint in shift.constraints]
)
return shifts
def get_shift_names(self) -> List[ShiftName]: def get_shift_names(self) -> List[ShiftName]:
"""Returns a list of all the registered shift names """Returns a list of all the registered shift names
@@ -1944,42 +2068,37 @@ class RotaBuilder(object):
if self.week_day_date_map[(week, day)] in bank_holiday_map if self.week_day_date_map[(week, day)] in bank_holiday_map
] ]
# RESULTS
class RotaResults(object):
def __init__(self, rota):
self.rota = rota
# self.results = results
def export_rota_to_html(self, filename: str = "rota"): def export_rota_to_html(self, filename: str = "rota"):
with open(f"{filename}.html", "w") as f: with open(f"{filename}.html", "w") as f:
f.write(self.get_worker_timetable_html(True)) f.write(self.get_worker_timetable_html(True))
def export_rota_to_csv(self, filename: str = "rota"): def export_rota_to_csv(self, filename: str = "rota"):
works = self.rota.model.works works = self.model.works
with open(f"{filename}.csv", "w", newline="") as f: with open(f"{filename}.csv", "w", newline="") as f:
wr = csv.writer(f, quoting=csv.QUOTE_ALL) wr = csv.writer(f, quoting=csv.QUOTE_ALL)
l = ["Name"] l = ["Name"]
l.extend([worker.name for worker in self.rota.workers]) l.extend([worker.name for worker in self.workers])
wr.writerow(l) wr.writerow(l)
l2 = ["Site"] l2 = ["Site"]
l2.extend([worker.site for worker in self.rota.workers]) l2.extend([worker.site for worker in self.workers])
wr.writerow(l2) wr.writerow(l2)
l3 = ["Grade"] l3 = ["Grade"]
l3.extend([worker.grade for worker in self.rota.workers]) l3.extend([worker.grade for worker in self.workers])
wr.writerow(l3) wr.writerow(l3)
l4 = ["FTE"] l4 = ["FTE"]
l4.extend([worker.fte for worker in self.rota.workers]) l4.extend([worker.fte for worker in self.workers])
wr.writerow(l4) wr.writerow(l4)
for week, day in self.rota.get_week_day_combinations(): for week, day in self.get_week_day_combinations():
d = [f"Week {week} Day {day}"] d = [f"Week {week} Day {day}"]
for worker in self.rota.workers: for worker in self.workers:
i = "" i = ""
for shift in self.rota.get_shift_names_by_week_day(week, day): for shift in self.get_shift_names_by_week_day(week, day):
if works[worker.id, week, day, shift].value == 1: if works[worker.id, week, day, shift].value == 1:
i = shift i = shift
@@ -1988,30 +2107,27 @@ class RotaResults(object):
def get_work_table(self): def get_work_table(self):
"""Build a timetable of the week as a dictionary from the model's optimal solution.""" """Build a timetable of the week as a dictionary from the model's optimal solution."""
works = self.rota.model.works works = self.model.works
week_table = { week_table = {
week: { week: {day: {shift: [] for shift in self.get_shift_names()} for day in days}
day: {shift: [] for shift in self.rota.get_shift_names()} for week in self.weeks
for day in days
} }
for week in self.rota.weeks for week in self.weeks:
} for worker in self.workers:
for week in self.rota.weeks: for day, shift in self.get_day_shiftname_combinations():
for worker in self.rota.workers:
for day, shift in self.rota.get_day_shiftname_combinations():
if works[worker.id, week, day, shift].value == 1: if works[worker.id, week, day, shift].value == 1:
week_table[week][day][shift].append(worker.get_details()) week_table[week][day][shift].append(worker.get_details())
return week_table return week_table
def get_worker_timetable(self): def get_worker_timetable(self):
works = self.rota.model.works works = self.model.works
timetable = { timetable = {
worker.name: {week: {day: "" for day in days} for week in self.rota.weeks} worker.name: {week: {day: "" for day in days} for week in self.weeks}
for worker in self.rota.workers for worker in self.workers
} }
for worker in self.rota.workers: for worker in self.workers:
for week in self.rota.weeks: for week in self.weeks:
for day, shift in self.rota.get_day_shiftname_combinations(): for day, shift in self.get_day_shiftname_combinations():
if works[worker.id, week, day, shift].value == 1: if works[worker.id, week, day, shift].value == 1:
timetable[worker.name][week][day] = shift timetable[worker.name][week][day] = shift
return timetable return timetable
@@ -2019,18 +2135,18 @@ class RotaResults(object):
def get_worker_timetable_brief( def get_worker_timetable_brief(
self, show_prefs=False, marker_every=30, show_unavailable=False self, show_prefs=False, marker_every=30, show_unavailable=False
): ):
model = self.rota.model model = self.model
week_string = f"{'-Week-':20}" + "".join( week_string = f"{'-Week-':20}" + "".join(
[7 * str(f"{w}")[-1:] for w in self.rota.weeks] [7 * str(f"{w}")[-1:] for w in self.weeks]
) )
days_string = f"{'-Day-':20}" + "".join("MTWTFSS" * len(self.rota.weeks)) days_string = f"{'-Day-':20}" + "".join("MTWTFSS" * len(self.weeks))
timetable = [] timetable = []
for worker in self.rota.workers: for worker in self.workers:
shifts = [] shifts = []
w = [f"{worker.name:20}"] w = [f"{worker.name:20}"]
for week, day in self.rota.get_week_day_combinations(): for week, day in self.get_week_day_combinations():
a = "-" a = "-"
for shift in self.rota.get_shift_names_by_week_day(week, day): for shift in self.get_shift_names_by_week_day(week, day):
if model.works[worker.id, week, day, shift].value > 0: if model.works[worker.id, week, day, shift].value > 0:
shifts.append(shift) shifts.append(shift)
a = shift[0] a = shift[0]
@@ -2049,7 +2165,7 @@ class RotaResults(object):
if show_prefs: if show_prefs:
# prefs # prefs
w = [f"{'Preferences':20}"] w = [f"{'Preferences':20}"]
for week, day in self.rota.get_week_day_combinations(): for week, day in self.get_week_day_combinations():
if model.pref_not_to_work[worker.id, week, day] > 0: if model.pref_not_to_work[worker.id, week, day] > 0:
w.append("Y") w.append("Y")
else: else:
@@ -2059,7 +2175,7 @@ class RotaResults(object):
if show_unavailable: if show_unavailable:
# prefs # prefs
w = [f"{'Unavailable':20}"] w = [f"{'Unavailable':20}"]
for week, day in self.rota.get_week_day_combinations(): for week, day in self.get_week_day_combinations():
if model.available[worker.id, week, day] > 0: if model.available[worker.id, week, day] > 0:
w.append("A") w.append("A")
else: else:
@@ -2080,30 +2196,30 @@ class RotaResults(object):
def get_worker_timetable_html( def get_worker_timetable_html(
self, include_html_tag=False, table_name="rota-table" self, include_html_tag=False, table_name="rota-table"
): ):
model = self.rota.model model = self.model
timetable = [] timetable = []
timetable.append( timetable.append(
f"<h2>Rota start date: {self.rota.start_date.isoformat()} ({self.rota.weeks[-1]} weeks)</h2>" f"<h2>Rota start date: {self.start_date.isoformat()} ({self.weeks[-1]} weeks)</h2>"
) )
date_row = ["<th class='worker'></th>"] date_row = ["<th class='worker'></th>"]
n = 0 n = 0
for week, day in self.rota.get_week_day_combinations(): for week, day in self.get_week_day_combinations():
d = self.rota.start_date + datetime.timedelta(n) d = self.start_date + datetime.timedelta(n)
date_row.append(f"<th title='{d}'>Week {week}: {day}</th>") date_row.append(f"<th title='{d}'>Week {week}: {day}</th>")
n = n + 1 n = n + 1
timetable.append(f"<tr class='data-row'>{''.join(date_row)}</tr>") timetable.append(f"<tr class='data-row'>{''.join(date_row)}</tr>")
current_site = "" current_site = ""
for worker in self.rota.workers: for worker in self.workers:
if worker.site != current_site: if worker.site != current_site:
try: try:
timetable.append( timetable.append(
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>" f"<tr><th class='site-title'>{worker.site} n={len(self.workers_at_sites[worker.site])} fte={self.full_time_equivalent_sites[worker.site]}</th><tr>"
) )
except KeyError as e: except KeyError as e:
print(e) print(e)
@@ -2123,14 +2239,14 @@ class RotaResults(object):
n = 0 n = 0
shift_tds = [] shift_tds = []
for week, day in self.rota.get_week_day_combinations(): for week, day in self.get_week_day_combinations():
d = self.rota.start_date + datetime.timedelta(n) d = self.start_date + datetime.timedelta(n)
n = n + 1 n = n + 1
a = "-" a = "-"
shift_name = "" shift_name = ""
for shift in self.rota.get_shift_names_by_week_day(week, day): for shift in self.get_shift_names_by_week_day(week, day):
if model.works[worker.id, week, day, shift].value > 0: if model.works[worker.id, week, day, shift].value > 0:
shifts.append(shift) shifts.append(shift)
a = shift[0] a = shift[0]
@@ -2146,7 +2262,7 @@ class RotaResults(object):
available = False available = False
css_class = "unavailable" css_class = "unavailable"
try: try:
unavailable_reason = self.rota.unavailable_to_work_reason[ unavailable_reason = self.unavailable_to_work_reason[
(worker.id, week, day) (worker.id, week, day)
] ]
except KeyError: except KeyError:
@@ -2162,10 +2278,10 @@ class RotaResults(object):
bank_holiday = f" data-bank-holiday='{bank_holiday_map[d]}'" bank_holiday = f" data-bank-holiday='{bank_holiday_map[d]}'"
requests = "" requests = ""
if (worker.id, week, day) in self.rota.work_requests_map: if (worker.id, week, day) in self.work_requests_map:
css_class = " ".join((css_class, "shift-requested")) css_class = " ".join((css_class, "shift-requested"))
title = " ".join((title, "[REQUESTED]")) title = " ".join((title, "[REQUESTED]"))
requests = f" data-shift-request='{self.rota.work_requests_map[worker.id, week, day]}'" requests = f" data-shift-request='{self.work_requests_map[worker.id, week, day]}'"
shift_tds.append( shift_tds.append(
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>" 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>"
@@ -2179,7 +2295,7 @@ class RotaResults(object):
shift_count = shift_count + f"{s}: {c}, " shift_count = shift_count + f"{s}: {c}, "
shift_diff_dict = {} shift_diff_dict = {}
for shift in self.rota.get_shifts(): for shift in self.get_shifts():
diff = model.shift_count_diff[worker.id, shift.name].value diff = model.shift_count_diff[worker.id, shift.name].value
shift_diff_dict[shift.name] = diff shift_diff_dict[shift.name] = diff
@@ -2219,8 +2335,15 @@ class RotaResults(object):
+ f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#" + f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#"
) )
if self.constraint_options["balance_bank_holidays"]:
bank_holiday_count = model.bank_holiday_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 bank_holiday_count_w = model.bank_holiday_count_w[worker.id].value - 1
else:
bank_holiday_count = -1
bank_holiday_count_w = -1
# print(worker.name, bank_holiday_count, bank_holiday_count_w) # print(worker.name, bank_holiday_count, bank_holiday_count_w)
timetable.append( timetable.append(
f"<tr class='worker-row'>{worker_td}{''.join(shift_tds)}</tr>" f"<tr class='worker-row'>{worker_td}{''.join(shift_tds)}</tr>"
@@ -2230,7 +2353,7 @@ class RotaResults(object):
# if show_prefs: # if show_prefs:
# # prefs # # prefs
# w = ["{:20}".format("Preferences")] # w = ["{:20}".format("Preferences")]
# for week, day in self.rota.get_week_day_combinations(): # for week, day in self.get_week_day_combinations():
# if model.pref_not_to_work[worker.id, week, day] > 0: # if model.pref_not_to_work[worker.id, week, day] > 0:
# w.append("Y") # w.append("Y")
# else: # else:
@@ -2240,7 +2363,7 @@ class RotaResults(object):
# if show_unavailable: # if show_unavailable:
# # prefs # # prefs
# w = ["{:20}".format("Unavailable")] # w = ["{:20}".format("Unavailable")]
# for week, day in self.rota.get_week_day_combinations(): # for week, day in self.get_week_day_combinations():
# if model.available[worker.id, week, day] > 0: # if model.available[worker.id, week, day] > 0:
# w.append("A") # w.append("A")
# else: # else:
@@ -2249,9 +2372,9 @@ class RotaResults(object):
result_stream = StringIO() result_stream = StringIO()
if self.rota.results is not None: if self.results is not None:
self.rota.results.write(ostream=result_stream) self.results.write(ostream=result_stream)
self.rota.results.write_json(ostream=result_stream) self.results.write_json(ostream=result_stream)
result_string = result_stream.getvalue() result_string = result_stream.getvalue()
else: else:
result_string = "Rota not run" result_string = "Rota not run"
@@ -2267,14 +2390,14 @@ class RotaResults(object):
<summary><h2>Rota settings</h2></summary> <summary><h2>Rota settings</h2></summary>
<div> <div>
<pre> <pre>
{json.dumps(self.rota.constraint_options, indent=4)} {json.dumps(self.constraint_options, indent=4)}
</pre> </pre>
</details> </details>
</div> </div>
<details> <details>
<summary><h2>Shifts settings</h2></summary> <summary><h2>Shifts settings</h2></summary>
<div id="shifts-container" data-shifts='{json.dumps([i.name for i in self.rota.shifts])}'> <div id="shifts-container" data-shifts='{json.dumps([i.name for i in self.shifts])}'>
{"<br/>".join(str(i) for i in self.rota.shifts)} {"<br/>".join(str(i) for i in self.shifts)}
</div> </div>
</details> </details>
<details> <details>
@@ -2299,54 +2422,79 @@ class RotaResults(object):
return html return html
def get_shift_summary(self): def get_shift_summary_dict(self):
works = self.rota.model.works """"""
timetable = { timetable = {
worker.get_details(): { worker.name: {shift: "" for shift in self.get_shift_names()}
shift[0]: "" for shift in self.rota.get_shift_names() for worker in self.workers
} }
for worker in self.rota.workers for worker in self.workers:
for shift in self.get_shift_names():
timetable[worker.name][shift] = self.model.shift_count[
worker.id, shift
].value
return timetable
def get_worker_shift_list(self, worker: Worker) -> List:
shifts = []
for week, day in self.get_week_day_combinations():
# d = self.start_date + datetime.timedelta(n)
# n = n + 1
temp = ""
for shift in self.get_shift_names_by_week_day(week, day):
if self.model.works[worker.id, week, day, shift].value > 0:
temp = shift
shifts.append(temp)
return shifts
def get_shift_summary(self):
works = self.model.works
timetable = {
worker.get_details(): {shift: "" for shift in self.get_shift_names()}
for worker in self.workers
} }
# timetable = { worker.get_details() : { shift : "" for shift in ["truro_twilight"] } for worker in workers } # timetable = { worker.get_details() : { shift : "" for shift in ["truro_twilight"] } for worker in workers }
t = [] t = []
for worker in self.rota.workers: for worker in self.workers:
l = [] l = []
total_shifts = 0 total_shifts = 0
for shift in self.rota.get_shift_names(): for shift in self.get_shift_names():
# for shift in ["truro_twilight"]: # for shift in ["truro_twilight"]:
c = [ c = [
works[worker.id, week, day, shift] works[worker.id, week, day, shift].value
for week in self.rota.weeks for week in self.weeks
for day in days for day in days
].count(1) ].count(1)
if c > 0: if c > 0:
l.append(f"{shift} ({c})") l.append(f"{shift} ({c})")
total_shifts = total_shifts + c total_shifts = total_shifts + c
# print(worker.id, shift) # print(worker.id, shift)
timetable[worker.get_details()][shift[0]] = c timetable[worker.get_details()][shift] = c
t.append(f"{worker.get_full_details()} [{total_shifts}]: {', '.join(l)}") t.append(f"{worker.get_full_details()} [{total_shifts}]: {', '.join(l)}")
return "\n".join(t) return "\n".join(t)
def get_shift_summary_html(self): def get_shift_summary_html(self):
works = self.rota.model.works works = self.model.works
timetable = { timetable = {
worker.get_details(): { worker.get_details(): {shift[0]: "" for shift in self.get_shift_names()}
shift[0]: "" for shift in self.rota.get_shift_names() for worker in self.workers
}
for worker in self.rota.workers
} }
# timetable = { worker.get_details() : { shift : "" for shift in ["truro_twilight"] } for worker in workers } # timetable = { worker.get_details() : { shift : "" for shift in ["truro_twilight"] } for worker in workers }
t = [] t = []
for worker in self.rota.workers: for worker in self.workers:
l = [] l = []
total_shifts = 0 total_shifts = 0
for shift in self.rota.get_shift_names(): for shift in self.get_shift_names():
# for shift in ["truro_twilight"]: # for shift in ["truro_twilight"]:
c = [ c = [
works[worker.id, week, day, shift] works[worker.id, week, day, shift]
for week in self.rota.weeks for week in self.weeks
for day in days for day in days
].count(1) ].count(1)
if c > 0: if c > 0:
@@ -2357,80 +2505,6 @@ class RotaResults(object):
t.append(f"{worker.get_full_details()} [{total_shifts}]: {', '.join(l)}") t.append(f"{worker.get_full_details()} [{total_shifts}]: {', '.join(l)}")
return "\n".join(t) 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]
# def get_night_blocks(self):
# nights = self.rota.model.nights
# timetable = {
# worker.name: {
# week: {block: ""
# for block in self.rota.night_blocks}
# for week in self.rota.weeks
# }
# for worker in self.rota.workers
# }
# for worker in self.rota.workers:
# for week in self.rota.weeks:
# for block in self.rota.night_blocks:
# if nights[worker.id, week, block].value == 1:
# timetable[worker.name][week][block] = "true"
# return timetable
def get_multinight(self):
for site in self.rota.sites:
for shift in self.get_shifts_with_constraint("night"):
block = shift.name
for week in self.rota.weeks:
print(
site,
week,
block,
self.rota.model.night_per_site[week, block, site].value,
self.rota.model.night_per_site_t1[week, block, site].value,
self.rota.model.night_per_site_t2[week, block, site].value,
)
"""
def get_night_details(self):
for worker in self.rota.workers:
print(
"{:20}".format(worker.name),
"worked: {},".format(
self.rota.model.night_shift_count[(worker.id)].value),
worker.shift_target_number["night_weekday"] +
worker.shift_target_number["night_weekend"]),
"target_diff: {},".format(
self.rota.model.night_shift_count_t1[(worker.id)].value +
self.rota.model.night_shift_count_t2[(worker.id)].value +
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),
)
"""
class NoActiveSites(Exception): class NoActiveSites(Exception):
"""Raised when there are no active sites""" """Raised when there are no active sites"""
+344 -14
View File
@@ -1,6 +1,6 @@
from black import main from black import main
import pytest import pytest
from shifts import NoWorkers, RotaBuilder, RotaResults, SingleShift, days from shifts import NoWorkers, RotaBuilder, SingleShift, days
import datetime import datetime
@@ -27,8 +27,10 @@ class TestDemoRota:
Rota.constraint_options["constrain_time_off_after_nights"] = False Rota.constraint_options["constrain_time_off_after_nights"] = False
Rota.constraint_options["balance_nights_across_site"] = False Rota.constraint_options["balance_nights_across_site"] = False
Rota.constraint_options["balance_shifts"] = True Rota.constraint_options["balance_shifts"] = True
Rota.constraint_options["balance_shifts_over_workers"] = True
Rota.constraint_options["balance_nights"] = False Rota.constraint_options["balance_nights"] = False
Rota.constraint_options["minimise_shift_diffs"] = True Rota.constraint_options["minimise_shift_diffs"] = False
Rota.constraint_options["balance_blocks"] = False
#Rota.constraint_options["balance_weekends"] = False #Rota.constraint_options["balance_weekends"] = False
# Add a few workers # Add a few workers
@@ -52,7 +54,7 @@ class TestDemoRota:
workers_required=1, workers_required=1,
# We use a different balance weighting for each shift as otherwise # We use a different balance weighting for each shift as otherwise
# the shifts can be treated equally (and no target diff) # the shifts can be treated equally (and no target diff)
balance_weighting=0.5, #balance_weighting=0.6,
), ),
SingleShift(("group1",), "weekend", 12.5, days[5:], balance_offset=40, SingleShift(("group1",), "weekend", 12.5, days[5:], balance_offset=40,
#balance_weighting=0.5 #balance_weighting=0.5
@@ -80,15 +82,17 @@ class TestDemoRota:
# result.Solver.Status = SolverStatus.warning # result.Solver.Status = SolverStatus.warning
# prob.solutions.load_from(result) # prob.solutions.load_from(result)
ResultsHolder = RotaResults(Rota) #worker_timetable_brief = Rota.get_worker_timetable_brief(
# show_prefs=False, show_unavailable=False
#)
#print(worker_timetable_brief)
#print(Rota.get_shift_summary_dict())
Rota.export_rota_to_html("test2")
worker_timetable_brief = ResultsHolder.get_worker_timetable_brief( def test_optimal_solution(self):
show_prefs=False, show_unavailable=False assert self.Rota.results.solver.status == "ok"
) assert self.Rota.results.solver.termination_condition == "optimal"
print(worker_timetable_brief)
ResultsHolder.export_rota_to_html("test")
def test_start_date(self): def test_start_date(self):
assert self.Rota.start_date == self.start_date assert self.Rota.start_date == self.start_date
@@ -145,23 +149,349 @@ class TestDemoRota:
case _: case _:
assert 1 == 0 assert 1 == 0
def test_worker_shift_targets(self):
shift_number = self.Rota.get_shift_summary_dict()
for worker in self.Rota.workers:
match worker.site:
case "group1":
assert shift_number[worker.name]["weekday"] == 10
assert shift_number[worker.name]["weekend"] == 5
case "group2":
assert shift_number[worker.name]["weekend"] == 0
match worker.fte:
case 60:
assert shift_number[worker.name]["weekday"] == 6
case 40:
assert shift_number[worker.name]["weekday"] == 4
case _:
assert 1 == 0
case _:
assert 1 == 0
def test_rota_sites(self): def test_rota_sites(self):
assert self.Rota.sites == {"group1", "group2"} assert self.Rota.sites == {"group1", "group2"}
class TestDemoRotaNights:
# Set up rota
weeks_to_rota = 12
start_date = datetime.date(2022, 3, 14)
class TestNoWorkerRota: Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
balance_offset_modifier=1,
max_weekend_frequency=1,
)
Rota.constraint_options["max_shifts_per_week"] = 5
Rota.constraint_options["max_shifts_per_month"] = 31
Rota.constraint_options["ensure_derriford_reg_for_nights"] = False
Rota.constraint_options["limit_to_1_st2_on_nights"] = False
Rota.constraint_options["ensure_1_st4_plus_on_nights"] = False
Rota.constraint_options["constrain_time_off_after_nights"] = False
Rota.constraint_options["balance_nights_across_site"] = False
Rota.constraint_options["balance_shifts"] = True
Rota.constraint_options["balance_shifts_over_workers"] = True
Rota.constraint_options["balance_nights"] = False
Rota.constraint_options["minimise_shift_diffs"] = False
Rota.constraint_options["balance_blocks"] = False
#Rota.constraint_options["balance_weekends"] = False
# Add a few workers
worker1 = Worker(Rota, "worker1", "group1", 1)
worker2 = Worker(Rota, "worker2", "group1", 1)
worker3 = Worker(Rota, "worker3", "group1", 1)
worker4 = Worker(Rota, "worker4", "group1", 1)
worker5 = Worker(Rota, "worker5", "group2", 1, fte=60)
worker6 = Worker(Rota, "worker6", "group2", 1, fte=40)
worker7 = Worker(Rota, "worker7", "group2", 1)
worker8 = Worker(Rota, "worker8", "group2", 1)
Rota.add_workers((worker1, worker2, worker3, worker4, worker5, worker6, worker7, worker8))
# Add a weekday and weekend shift
Rota.add_shifts(
SingleShift(
("group1", "group2"),
"night_weekday",
12.5,
days[:4],
balance_offset=10,
workers_required=1,
constraints=["preclear2", "postclear2"],
#constraints=["night"],
force_as_block=True
# We use a different balance weighting for each shift as otherwise
# the shifts can be treated equally (and no target diff)
#balance_weighting=0.6,
),
SingleShift(("group1","group2"), "night_weekend", 12.5, days[4:], balance_offset=40,
#balance_weighting=0.5
constraints=["preclear2", "postclear2"],
#constraints=["night"],
force_as_block=True
),
SingleShift(("group1","group2"), "twilight", 12.5, days[:5], balance_offset=40,
#balance_weighting=0.5
workers_required=2,
#constraints=["preclear2", "postclear2"],
),
#SingleShift(("group1","group2"), "atwilight", 12.5, days[:5], balance_offset=40,
##balance_weighting=0.5
#workers_required=2,
# #constraints=["postclear2"],
#),
)
Rota.build_shifts()
Rota.build_workers()
Rota.build_model()
print(Rota.get_worker_details())
for w in Rota.get_workers():
print(w)
print(w.get_shift_targets())
solver_options = {"ratio": 0.1, "seconds": 1000, "threads": 10}
Rota.solve_model(options=solver_options)
# print(Rota.get_worker_details())
# optimizer = SolverFactory('cbc')
# result = optimizer.solve(prob,tee=True)
# result.Solver.Status = SolverStatus.warning
# prob.solutions.load_from(result)
worker_timetable_brief = Rota.get_worker_timetable_brief(
show_prefs=False, show_unavailable=False
)
print(worker_timetable_brief)
print(Rota.get_shift_summary_dict())
Rota.export_rota_to_html("test3")
print(Rota.get_worker_shift_list(worker1))
def test_start_date(self):
assert self.Rota.start_date == self.start_date
class TestDemoRotaClear:
weeks_to_rota = 10 weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7) start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder( Rota = RotaBuilder(
start_date, start_date,
weeks_to_rota=weeks_to_rota, weeks_to_rota=weeks_to_rota,
balance_offset_modifier=1,
max_weekend_frequency=1,
)
#Rota.constraint_options["max_shifts_per_week"] = 5
Rota.constraint_options["max_shifts_per_month"] = 20
#Rota.constraint_options["ensure_derriford_reg_for_nights"] = False
#Rota.constraint_options["limit_to_1_st2_on_nights"] = False
#Rota.constraint_options["ensure_1_st4_plus_on_nights"] = False
#Rota.constraint_options["constrain_time_off_after_nights"] = False
#Rota.constraint_options["balance_nights_across_site"] = False
#Rota.constraint_options["balance_shifts"] = True
#Rota.constraint_options["balance_shifts_over_workers"] = True
#Rota.constraint_options["balance_nights"] = False
#Rota.constraint_options["minimise_shift_diffs"] = False
#Rota.constraint_options["balance_blocks"] = False
worker1 = Worker(Rota, "worker1", "group1", 1)
worker2 = Worker(Rota, "worker2", "group1", 1)
worker3 = Worker(Rota, "worker3", "group1", 1)
worker4 = Worker(Rota, "worker4", "group1", 1)
worker5 = Worker(Rota, "worker5", "group2", 1, fte=60)
worker6 = Worker(Rota, "worker6", "group2", 1, fte=40)
worker7 = Worker(Rota, "worker7", "group2", 1)
worker8 = Worker(Rota, "worker8", "group2", 1)
Rota.add_workers((worker1, worker2, worker3, worker4, worker5, worker6, worker7, worker8))
def test_preclear(self):
self.Rota.add_shifts(
SingleShift(("group1","group2"), "a", 12.5, days[:5], balance_offset=40,
workers_required=2,
constraints=["preclear"],
),
SingleShift(("group1","group2"), "b", 12.5, days[:5], balance_offset=40,
workers_required=2,
),
SingleShift(("group1","group2"), "c", 12.5, days[3], balance_offset=40,
workers_required=1,
constraints=["preclear2", "postclear2"],
),
) )
def test_no_workers(self): self.Rota.build_shifts()
with pytest.raises(NoWorkers):
self.Rota.build_workers() self.Rota.build_workers()
self.Rota.build_model()
solver_options = {"ratio": 0.1, "seconds": 1000, "threads": 10}
self.Rota.solve_model(options=solver_options)
self.Rota.export_rota_to_html("test4")
assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal"
for worker in self.Rota.workers:
shifts = self.Rota.get_worker_shift_list(worker)
# Convert shift to a string representation
shifts_string = "".join([i if i != "" else "-" for i in shifts])
# check for patterns that should not occur
assert "ba" not in shifts_string
assert "ca" not in shifts_string
assert "ac" not in shifts_string
assert "ca" not in shifts_string
assert "a-c" not in shifts_string
assert "c-a" not in shifts_string
assert "bc" not in shifts_string
assert "cb" not in shifts_string
assert "b-c" not in shifts_string
assert "c-b" not in shifts_string
class TestDemoRotaShiftConstraints:
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
balance_offset_modifier=1,
max_weekend_frequency=1,
)
#Rota.constraint_options["max_shifts_per_week"] = 5
#Rota.constraint_options["max_shifts_per_month"] = 20
worker1 = Worker(Rota, "worker1", "group1", 1)
worker2 = Worker(Rota, "worker2", "group1", 1)
Rota.add_workers((worker1, worker2))
Rota.add_shifts(
SingleShift(("group1",), "a", 12.5, days, balance_offset=40,
workers_required=1,
),
)
def test_max_shifts(self):
self.Rota.constraint_options["max_shifts_per_month"] = 14
self.Rota.build_and_solve()
assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal"
def test_max_shifts_fail(self):
self.Rota.constraint_options["max_shifts_per_month"] = 13
self.Rota.build_and_solve()
assert self.Rota.results.solver.status == "warning"
assert self.Rota.results.solver.termination_condition == "infeasible"
def test_max_shifts_extra_worker(self):
self.Rota.constraint_options["max_shifts_per_month"] = 12
worker3 = Worker(self.Rota, "worker3", "group1", 1)
self.Rota.add_worker(worker3)
self.Rota.build_and_solve()
assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal"
def test_max_shifts_extra_worker_wrong_group(self):
self.Rota.constraint_options["max_shifts_per_month"] = 12
worker3 = Worker(self.Rota, "worker3", "group2", 1)
self.Rota.add_worker(worker3)
self.Rota.build_and_solve()
assert self.Rota.results.solver.status == "warning"
assert self.Rota.results.solver.termination_condition == "infeasible"
def test_max_shifts_per_week_fail(self):
self.Rota.constraint_options["max_shifts_per_week"] = 3
self.Rota.build_and_solve()
assert self.Rota.results.solver.status == "warning"
assert self.Rota.results.solver.termination_condition == "infeasible"
def test_max_shifts_per_week_pass(self):
self.Rota.constraint_options["max_shifts_per_week"] = 4
self.Rota.build_and_solve()
assert self.Rota.results.solver.status == "ok"
def test_max_shifts_per_week_extra_worker_pass(self):
self.Rota.constraint_options["max_shifts_per_week"] = 3
worker3 = Worker(self.Rota, "worker3", "group1", 1)
self.Rota.add_worker(worker3)
self.Rota.build_and_solve()
assert self.Rota.results.solver.status == "ok"
class TestDemoRotaBalanceShiftSites:
weeks_to_rota = 8
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
#Rota.constraint_options["max_shifts_per_week"] = 5
#Rota.constraint_options["max_shifts_per_month"] = 20
worker1 = Worker(Rota, "worker1", "group1", 1)
worker2 = Worker(Rota, "worker2", "group1", 1)
worker3 = Worker(Rota, "worker3", "group2", 1)
worker4 = Worker(Rota, "worker4", "group2", 1)
worker5 = Worker(Rota, "worker3", "group2", 1)
worker6 = Worker(Rota, "worker4", "group2", 1)
Rota.add_workers((worker1, worker2, worker3, worker4))
#Rota.add_workers((worker5, worker6))
Rota.add_shifts(
SingleShift(("group1", "group2"), "weekday_night", 12.5, days[:4], balance_offset=40,
workers_required=2,
force_as_block=True,
constraints=["balance_across_groups"]
),
#SingleShift(("group1", "group2"), "weekend_night", 12.5, days[4:], balance_offset=40,
#workers_required=2,
#force_as_block=True,
#),
)
def test_balance_blocks_across_groups(self):
self.Rota.constraint_options["balance_nights_across_sites"] = True
#self.Rota.constraint_options["balance_bank_holidays"] = False
#self.Rota.constraint_options["balance_weekends"] = False
#self.Rota.constraint_options["balance_blocks"] = False
self.Rota.build_and_solve(options={"ratio": 0.0, "seconds": 1000, "threads": 10})
self.Rota.export_rota_to_html("test5")
assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal"
#class TestNoWorkerRota:
# weeks_to_rota = 10
# start_date = datetime.date(2022, 3, 7)
#
# Rota = RotaBuilder(
# start_date,
# weeks_to_rota=weeks_to_rota,
# )
#
# def test_no_workers(self):
# with pytest.raises(NoWorkers):
# self.Rota.build_workers()
if __name__ == "__main__": if __name__ == "__main__":
t = TestDemoRota() t = TestDemoRotaShiftConstraints()
t.test_balance_blocks_across_groups()