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