...
This commit is contained in:
@@ -318,3 +318,7 @@ table.transposed th.bank-holiday {
|
|||||||
.target-assigned.no-colour {
|
.target-assigned.no-colour {
|
||||||
background-color: white !important;
|
background-color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.locum-shift {
|
||||||
|
background-color: lightgreen;
|
||||||
|
}
|
||||||
+6
-1
@@ -283,6 +283,7 @@ $(".table-div .worker-row .worker").each((n, tr) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shift_counts = jtr.data("shift-counts")
|
shift_counts = jtr.data("shift-counts")
|
||||||
|
locum_shift_counts = jtr.data("locum-shift-counts")
|
||||||
shift_targets = jtr.data("worker-targets")
|
shift_targets = jtr.data("worker-targets")
|
||||||
|
|
||||||
oshifts.forEach((s) => {
|
oshifts.forEach((s) => {
|
||||||
@@ -292,7 +293,11 @@ $(".table-div .worker-row .worker").each((n, tr) => {
|
|||||||
} else {
|
} else {
|
||||||
c = 0;
|
c = 0;
|
||||||
}
|
}
|
||||||
row.append(`<td class="target-assigned ${s}" data-assigned=${c} data-target=${shift_targets[s].toFixed(2)} data-diff=${shift_targets[s].toFixed(2)-c}>${c} (${shift_targets[s].toFixed(2)})</td>`)
|
locum_shifts = ""
|
||||||
|
if (s in locum_shift_counts) {
|
||||||
|
locum_shifts = `+${locum_shift_counts[s]}`
|
||||||
|
}
|
||||||
|
row.append(`<td class="target-assigned ${s}" data-assigned=${c} data-target=${shift_targets[s].toFixed(2)} data-diff=${shift_targets[s].toFixed(2)-c}>${c}${locum_shifts} (${shift_targets[s].toFixed(2)})</td>`)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
row.append(`<td>-</td>`)
|
row.append(`<td>-</td>`)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# pytest.ini
|
||||||
|
[pytest]
|
||||||
|
testpaths = test
|
||||||
|
python_files = test_*.py
|
||||||
+325
-93
@@ -268,9 +268,11 @@ class RotaBuilder(object):
|
|||||||
"balance_bank_holidays": True,
|
"balance_bank_holidays": True,
|
||||||
"balance_blocks": True,
|
"balance_blocks": True,
|
||||||
"balance_shifts": True, # Does not use a quadratic function
|
"balance_shifts": True, # Does not use a quadratic function
|
||||||
|
"balance_shifts_true_quadratic": False, # Does not use a quadratic function
|
||||||
"balance_shifts_quadratic": False, # Will prevent spreading of spreading across different shifts
|
"balance_shifts_quadratic": False, # Will prevent spreading of spreading across different shifts
|
||||||
"balance_shifts_over_workers": True,
|
"balance_shifts_over_workers": True,
|
||||||
"minimise_shift_diffs": False, # less sophisticated version of balance_shifts_over_workers
|
"minimise_shift_diffs": False, # less sophisticated version of balance_shifts_over_workers
|
||||||
|
"maximum_allowed_shift_diff": None,
|
||||||
"balance_weekends": True,
|
"balance_weekends": True,
|
||||||
"max_weekends": 100,
|
"max_weekends": 100,
|
||||||
# Don't assign multiple shifts every (n) weeks
|
# Don't assign multiple shifts every (n) weeks
|
||||||
@@ -289,6 +291,11 @@ class RotaBuilder(object):
|
|||||||
"hard_constrain_pair_separation": False,
|
"hard_constrain_pair_separation": False,
|
||||||
"avoid_shifts_by_grades": [],
|
"avoid_shifts_by_grades": [],
|
||||||
"avoid_shifts_by_worker_names": [],
|
"avoid_shifts_by_worker_names": [],
|
||||||
|
"allocate_locum_shifts": True,
|
||||||
|
"distribute_locum_shifts": True,
|
||||||
|
"balance_locum_shifts": False,
|
||||||
|
"maximum_allowed_locum_shifts_per_worker": None,
|
||||||
|
"minimum_allowed_locum_shifts_per_worker": None
|
||||||
}
|
}
|
||||||
|
|
||||||
self.terminate_on_warning = [
|
self.terminate_on_warning = [
|
||||||
@@ -297,6 +304,8 @@ class RotaBuilder(object):
|
|||||||
"Worker/no valid shifts",
|
"Worker/no valid shifts",
|
||||||
"Shift/invalid start date",
|
"Shift/invalid start date",
|
||||||
"Shift/invalid end date",
|
"Shift/invalid end date",
|
||||||
|
"Locum/no locum availability",
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
self.results = None
|
self.results = None
|
||||||
@@ -864,6 +873,8 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
|
|
||||||
def locum_request_init(model, wid, week, day, shift):
|
def locum_request_init(model, wid, week, day, shift):
|
||||||
|
print("locum rquest init")
|
||||||
|
print(locals())
|
||||||
if (wid, week, day, shift) in self.locum_availability:
|
if (wid, week, day, shift) in self.locum_availability:
|
||||||
self.locum_availability_map[(wid, week, day)] = shift
|
self.locum_availability_map[(wid, week, day)] = shift
|
||||||
return 1
|
return 1
|
||||||
@@ -873,6 +884,7 @@ class RotaBuilder(object):
|
|||||||
(
|
(
|
||||||
(worker.id, week, day, shift)
|
(worker.id, week, day, shift)
|
||||||
for worker in self.workers
|
for worker in self.workers
|
||||||
|
if not worker.locum
|
||||||
for week, day, shift in self.get_all_shiftname_combinations()
|
for week, day, shift in self.get_all_shiftname_combinations()
|
||||||
),
|
),
|
||||||
initialize=locum_request_init,
|
initialize=locum_request_init,
|
||||||
@@ -931,6 +943,59 @@ class RotaBuilder(object):
|
|||||||
# binary variables representing if a worker worked on sunday but not on saturday (avoid if possible)
|
# binary variables representing if a worker worked on sunday but not on saturday (avoid if possible)
|
||||||
# self.model.no_pref = Var([worker.id for worker in self.workers], within=Binary, initialize=0)
|
# self.model.no_pref = Var([worker.id for worker in self.workers], within=Binary, initialize=0)
|
||||||
|
|
||||||
|
if self.get_workers_who_require_locums():
|
||||||
|
# We use this as a time check that their is some valid locum ability
|
||||||
|
if not self.get_all_locum_availability():
|
||||||
|
self.add_warning("Locum/no locum availability", "No locum availability set")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
self.model.locum_required = Var(
|
||||||
|
(
|
||||||
|
(week, day, shiftname)
|
||||||
|
for week, day, shiftname in self.get_all_shiftname_combinations()
|
||||||
|
),
|
||||||
|
within=Binary,
|
||||||
|
initialize=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.model.locum_works = Var(
|
||||||
|
(
|
||||||
|
(worker.id, week, day, shiftname)
|
||||||
|
for worker in self.workers
|
||||||
|
for week, day, shiftname in self.get_all_shiftname_combinations()
|
||||||
|
),
|
||||||
|
within=Binary,
|
||||||
|
initialize=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.model.locum_shifts_by_worker = Var(
|
||||||
|
(
|
||||||
|
worker.id
|
||||||
|
for worker in self.workers
|
||||||
|
),
|
||||||
|
within=Integers,
|
||||||
|
initialize=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.model.locum_shifts_t1 = Var(
|
||||||
|
(
|
||||||
|
worker.id
|
||||||
|
for worker in self.workers
|
||||||
|
),
|
||||||
|
domain=NonNegativeReals,
|
||||||
|
initialize=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.model.locum_shifts_t2 = Var(
|
||||||
|
(
|
||||||
|
worker.id
|
||||||
|
for worker in self.workers
|
||||||
|
),
|
||||||
|
domain=NonNegativeReals,
|
||||||
|
initialize=0,
|
||||||
|
)
|
||||||
|
|
||||||
self.build_model_constraints()
|
self.build_model_constraints()
|
||||||
|
|
||||||
def build_model_constraints(self):
|
def build_model_constraints(self):
|
||||||
@@ -953,6 +1018,36 @@ class RotaBuilder(object):
|
|||||||
if worker.site in site_required
|
if worker.site in site_required
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.get_workers_who_require_locums():
|
||||||
|
self.model.constraints.add(
|
||||||
|
self.model.locum_required[week, day, shift]
|
||||||
|
== sum(
|
||||||
|
self.model.works[worker.id, week, day, shift]
|
||||||
|
for worker in self.workers
|
||||||
|
if worker.locum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.model.constraints.add(
|
||||||
|
self.model.locum_required[week, day, shift]
|
||||||
|
== sum(
|
||||||
|
self.model.locum_works[worker.id, week, day, shift]
|
||||||
|
for worker in self.workers
|
||||||
|
#if not worker.locum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.model.constraints.add(
|
||||||
|
0
|
||||||
|
== sum(
|
||||||
|
self.model.locum_works[worker.id, week, day, shift]
|
||||||
|
for worker in self.workers
|
||||||
|
if worker.locum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# And it is not assigned if the worker is from the wrong site
|
# And it is not assigned if the worker is from the wrong site
|
||||||
if [worker for worker in self.workers if worker.site not in site_required]:
|
if [worker for worker in self.workers if worker.site not in site_required]:
|
||||||
self.model.constraints.add(
|
self.model.constraints.add(
|
||||||
@@ -1190,20 +1285,6 @@ class RotaBuilder(object):
|
|||||||
for shift_name in self.shifts_to_assign_or_force_as_blocks():
|
for shift_name in self.shifts_to_assign_or_force_as_blocks():
|
||||||
shift = self.get_shift_by_name(shift_name)
|
shift = self.get_shift_by_name(shift_name)
|
||||||
|
|
||||||
print(shift_name)
|
|
||||||
print(
|
|
||||||
week,
|
|
||||||
self.get_week_start_date(week),
|
|
||||||
shift.start_date,
|
|
||||||
shift.end_date,
|
|
||||||
)
|
|
||||||
|
|
||||||
# if self.get_week_start_date(week) <= shift.start_date:
|
|
||||||
# continue
|
|
||||||
|
|
||||||
# if self.get_week_start_date(week) > shift.end_date:
|
|
||||||
# continue
|
|
||||||
|
|
||||||
for worker in self.get_workers_for_shift(shift):
|
for worker in self.get_workers_for_shift(shift):
|
||||||
try:
|
try:
|
||||||
self.model.constraints.add(
|
self.model.constraints.add(
|
||||||
@@ -1231,7 +1312,6 @@ class RotaBuilder(object):
|
|||||||
workers_required = shift.get_worker_requirement_by_date(
|
workers_required = shift.get_worker_requirement_by_date(
|
||||||
self.get_week_start_date(week)
|
self.get_week_start_date(week)
|
||||||
)
|
)
|
||||||
print(worker.name, week, workers_required, self.get_week_start_date(week))
|
|
||||||
|
|
||||||
if shift.force_as_block_unless_nwd:
|
if shift.force_as_block_unless_nwd:
|
||||||
workers = self.get_workers_for_shift(shift)
|
workers = self.get_workers_for_shift(shift)
|
||||||
@@ -1304,7 +1384,70 @@ class RotaBuilder(object):
|
|||||||
)
|
)
|
||||||
logging.debug(f"Generate worker constraints: {worker.name}")
|
logging.debug(f"Generate worker constraints: {worker.name}")
|
||||||
|
|
||||||
if self.constraint_options["minimise_shift_diffs"]:
|
|
||||||
|
|
||||||
|
if self.get_workers_who_require_locums():
|
||||||
|
for week, day, shift in self.get_all_shiftclass_combinations():
|
||||||
|
if not worker.locum:
|
||||||
|
self.model.constraints.add(
|
||||||
|
self.get_locum_availability(worker, week, day, shift)
|
||||||
|
>=
|
||||||
|
self.model.locum_works[worker.id, week, day, shift.name]
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
self.model.constraints.add(
|
||||||
|
self.model.locum_shifts_by_worker[worker.id] ==
|
||||||
|
sum(
|
||||||
|
self.model.locum_works[worker.id, week, day, shift]
|
||||||
|
for week, day, shift in self.get_all_shiftname_combinations()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
self.model.constraints.add(
|
||||||
|
self.model.locum_shifts_t1[worker.id]
|
||||||
|
- self.model.locum_shifts_t2[worker.id]
|
||||||
|
== self.model.locum_shifts_by_worker[worker.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if worker.locum_max_shifts:
|
||||||
|
self.model.constraints.add(
|
||||||
|
worker.locum_max_shifts
|
||||||
|
>=
|
||||||
|
sum(self.model.locum_works[worker.id, week, day, shiftname] for week, day, shiftname in self.get_all_shiftname_combinations())
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
if worker.locum_max_shifts_per_week:
|
||||||
|
for week_to_check in self.weeks:
|
||||||
|
self.model.constraints.add(
|
||||||
|
worker.locum_max_shifts_per_week
|
||||||
|
>=
|
||||||
|
sum(self.model.locum_works[worker.id, week, day, shiftname] for week, day, shiftname in self.get_all_shiftname_combinations(week=week_to_check))
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.constraint_options["distribute_locum_shifts"]:
|
||||||
|
self.model.constraints.add(
|
||||||
|
sum(self.model.locum_required[week, day, shift] for week, day, shift in self.get_all_shiftname_combinations()) / (len(self.get_locum_workers())-1)
|
||||||
|
>= self.model.locum_shifts_by_worker[worker.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.constraint_options["maximum_allowed_locum_shifts_per_worker"] is not None:
|
||||||
|
self.model.constraints.add(
|
||||||
|
self.constraint_options["maximum_allowed_locum_shifts_per_worker"]
|
||||||
|
>= self.model.locum_shifts_by_worker[worker.id]
|
||||||
|
)
|
||||||
|
if worker.locum_availability:
|
||||||
|
if self.constraint_options["minimum_allowed_locum_shifts_per_worker"] is not None:
|
||||||
|
self.model.constraints.add(
|
||||||
|
self.constraint_options["minimum_allowed_locum_shifts_per_worker"]
|
||||||
|
<= self.model.locum_shifts_by_worker[worker.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if self.constraint_options["minimise_shift_diffs"] or self.constraint_options["maximum_allowed_shift_diff"] is not None:
|
||||||
self.model.constraints.add(
|
self.model.constraints.add(
|
||||||
self.model.shift_count_diff_summed[worker.id]
|
self.model.shift_count_diff_summed[worker.id]
|
||||||
== sum(
|
== sum(
|
||||||
@@ -1313,6 +1456,16 @@ class RotaBuilder(object):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.constraint_options["maximum_allowed_shift_diff"] is not None:
|
||||||
|
self.model.constraints.add(
|
||||||
|
self.model.shift_count_diff_summed[worker.id]
|
||||||
|
<= self.constraint_options["maximum_allowed_shift_diff"]
|
||||||
|
)
|
||||||
|
self.model.constraints.add(
|
||||||
|
self.model.shift_count_diff_summed[worker.id]
|
||||||
|
>= -self.constraint_options["maximum_allowed_shift_diff"]
|
||||||
|
)
|
||||||
|
|
||||||
for week_blocks in self.get_week_block_iterator(4):
|
for week_blocks in self.get_week_block_iterator(4):
|
||||||
# Prevent more than n number shifts per 4 weeks
|
# Prevent more than n number shifts per 4 weeks
|
||||||
try:
|
try:
|
||||||
@@ -1743,6 +1896,14 @@ class RotaBuilder(object):
|
|||||||
worker.id, week, day, shift.name
|
worker.id, week, day, shift.name
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
if not worker.locum_on_nwds and day == n:
|
||||||
|
if start <= self.week_day_date_map[(week, day)] < end:
|
||||||
|
self.model.constraints.add(
|
||||||
|
0
|
||||||
|
== self.model.locum_works[
|
||||||
|
worker.id, week, day, shift.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
if self.constraint_options["balance_blocks"]:
|
if self.constraint_options["balance_blocks"]:
|
||||||
if self.constraint_options["max_night_frequency"]:
|
if self.constraint_options["max_night_frequency"]:
|
||||||
@@ -2231,6 +2392,14 @@ class RotaBuilder(object):
|
|||||||
for shift in self.get_shift_names_by_week_day(week, day)
|
for shift in self.get_shift_names_by_week_day(week, day)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if self.get_locum_workers():
|
||||||
|
self.model.constraints.add(
|
||||||
|
self.model.available[worker.id, week, day]
|
||||||
|
>= sum(
|
||||||
|
self.model.locum_works[worker.id, week, day, shift]
|
||||||
|
for shift in self.get_shift_names_by_week_day(week, day)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# single shift per day
|
# single shift per day
|
||||||
self.model.constraints.add(
|
self.model.constraints.add(
|
||||||
@@ -2240,6 +2409,22 @@ class RotaBuilder(object):
|
|||||||
for shift in self.get_shift_names_by_week_day(week, day)
|
for shift in self.get_shift_names_by_week_day(week, day)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# This applies to locums as well
|
||||||
|
if self.get_workers_who_require_locums():
|
||||||
|
self.model.constraints.add(
|
||||||
|
1
|
||||||
|
>= sum(
|
||||||
|
self.model.locum_works[worker.id, week, day, shift]
|
||||||
|
for shift in self.get_shift_names_by_week_day(week, day)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.model.constraints.add(
|
||||||
|
1
|
||||||
|
>= sum(
|
||||||
|
self.model.works[worker.id, week, day, shift] + self.model.locum_works[worker.id, week, day, shift]
|
||||||
|
for shift in self.get_shift_names_by_week_day(week, day)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if self.constraint_options["hard_constrain_pair_separation"]:
|
if self.constraint_options["hard_constrain_pair_separation"]:
|
||||||
for worker_pairs in self.worker_pairs:
|
for worker_pairs in self.worker_pairs:
|
||||||
@@ -2349,78 +2534,6 @@ class RotaBuilder(object):
|
|||||||
# except KeyError:
|
# except KeyError:
|
||||||
# pass
|
# pass
|
||||||
|
|
||||||
# for constraint_shift in self.get_shifts_with_constraints(
|
|
||||||
# "preclear", "preclear2"
|
|
||||||
# ):
|
|
||||||
# if day in constraint_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
|
|
||||||
# )
|
|
||||||
# if shiftname != constraint_shift.name
|
|
||||||
# for w in workers
|
|
||||||
# )
|
|
||||||
# )
|
|
||||||
#
|
|
||||||
# for constraint_shift in self.get_shifts_with_constraint("preclear2"):
|
|
||||||
# if day in constraint_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.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.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
|
|
||||||
# )
|
|
||||||
# )
|
|
||||||
|
|
||||||
# Night constraint means we won't assign a shift the day before
|
# Night constraint means we won't assign a shift the day before
|
||||||
# an unavailability
|
# an unavailability
|
||||||
for constraint_shift in self.get_shifts_with_constraint("night"):
|
for constraint_shift in self.get_shifts_with_constraint("night"):
|
||||||
@@ -2444,6 +2557,32 @@ class RotaBuilder(object):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
## Ensure each shift has the requisit number of workers assigned
|
||||||
|
#for (
|
||||||
|
# week,
|
||||||
|
# day,
|
||||||
|
# shift,
|
||||||
|
# workers_required,
|
||||||
|
# site_required,
|
||||||
|
#) in self.get_required_workers_and_site_combinations():
|
||||||
|
# # print(week, day, shift, workers_required, site_required)
|
||||||
|
# self.model.constraints.add(
|
||||||
|
# workers_required
|
||||||
|
# == sum(
|
||||||
|
# self.model.works[worker.id, week, day, shift]
|
||||||
|
# for worker in self.workers
|
||||||
|
# if worker.locum
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
#for worker in self.workers:
|
||||||
|
# if worker.locum:
|
||||||
|
# for week, day, shift in self.get_all_shiftname_combinations():
|
||||||
|
# self.model.constraints.add(
|
||||||
|
# self.model.locum_works[worker.id, week, day, shift]
|
||||||
|
# == self.model.works[worker.id, week, day, shift]
|
||||||
|
# )
|
||||||
|
|
||||||
self.define_objectives()
|
self.define_objectives()
|
||||||
|
|
||||||
print("Building model completed")
|
print("Building model completed")
|
||||||
@@ -2453,9 +2592,20 @@ class RotaBuilder(object):
|
|||||||
def obj_rule(m):
|
def obj_rule(m):
|
||||||
# c = len(workers)
|
# c = len(workers)
|
||||||
|
|
||||||
balance_modifier_constant = 1
|
balance_modifier_constant = 10
|
||||||
balance_quadratic_shift_modifier_constant = 3
|
balance_quadratic_shift_modifier_constant = 3
|
||||||
block_shift_balancing_constant = 1
|
block_shift_balancing_constant = 1
|
||||||
|
locum_shift_balance_modifier_constant = 40
|
||||||
|
|
||||||
|
locum_shift_balancing = 0
|
||||||
|
if self.get_workers_who_require_locums():
|
||||||
|
if self.constraint_options["balance_locum_shifts"]:
|
||||||
|
locum_shift_balancing = sum(
|
||||||
|
locum_shift_balance_modifier_constant *
|
||||||
|
(self.model.locum_shifts_t1[(worker.id)]
|
||||||
|
+ self.model.locum_shifts_t2[(worker.id)])
|
||||||
|
for worker in self.workers
|
||||||
|
)
|
||||||
|
|
||||||
if self.constraint_options["balance_shifts_over_workers"]:
|
if self.constraint_options["balance_shifts_over_workers"]:
|
||||||
worker_shift_balancing = sum(
|
worker_shift_balancing = sum(
|
||||||
@@ -2495,6 +2645,16 @@ class RotaBuilder(object):
|
|||||||
else:
|
else:
|
||||||
quadratic_shift_balancing = 0
|
quadratic_shift_balancing = 0
|
||||||
|
|
||||||
|
true_quadratic_shift_balancing = 0
|
||||||
|
if self.constraint_options["balance_shifts_true_quadratic"]:
|
||||||
|
shift_diff_modifier_constant = 10
|
||||||
|
true_quadratic_shift_balancing = sum(
|
||||||
|
shift_diff_modifier_constant
|
||||||
|
* self.model.shift_count_diff_summed[(worker.id)]
|
||||||
|
for worker in self.workers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if self.constraint_options["minimise_shift_diffs"]:
|
if self.constraint_options["minimise_shift_diffs"]:
|
||||||
shift_diff_modifier_constant = 10
|
shift_diff_modifier_constant = 10
|
||||||
shift_diff_balancing = sum(
|
shift_diff_balancing = sum(
|
||||||
@@ -2622,6 +2782,8 @@ class RotaBuilder(object):
|
|||||||
+ bank_holiday_balancing
|
+ bank_holiday_balancing
|
||||||
+ blocks_balancing
|
+ blocks_balancing
|
||||||
- work_requests
|
- work_requests
|
||||||
|
+ locum_shift_balancing
|
||||||
|
+ true_quadratic_shift_balancing
|
||||||
)
|
)
|
||||||
|
|
||||||
# add objective function to the model. rule (pass function) or expr (pass expression directly)
|
# add objective function to the model. rule (pass function) or expr (pass expression directly)
|
||||||
@@ -2796,6 +2958,18 @@ class RotaBuilder(object):
|
|||||||
raise ValueError(f"Must pair at least two shifts: {shifts}")
|
raise ValueError(f"Must pair at least two shifts: {shifts}")
|
||||||
self.paired_shifts.append([*shifts])
|
self.paired_shifts.append([*shifts])
|
||||||
|
|
||||||
|
def get_date_range(self, start_date: datetime.date=None, end_date: datetime.date = None):
|
||||||
|
"""Gets a range of dates
|
||||||
|
|
||||||
|
If either start_date or end_date are not provided defaults to the rota dates"""
|
||||||
|
if start_date is None:
|
||||||
|
start_date = self.start_date
|
||||||
|
if end_date is None:
|
||||||
|
end_date = self.rota_end_date
|
||||||
|
|
||||||
|
for n in range(int((end_date - start_date).days) + 1):
|
||||||
|
yield start_date + datetime.timedelta(n)
|
||||||
|
|
||||||
def build_shifts(self):
|
def build_shifts(self):
|
||||||
"""
|
"""
|
||||||
Process the added shifts
|
Process the added shifts
|
||||||
@@ -3145,6 +3319,16 @@ class RotaBuilder(object):
|
|||||||
s.extend(self.shifts_to_force_as_blocks())
|
s.extend(self.shifts_to_force_as_blocks())
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
def get_all_locum_availability(self):
|
||||||
|
return self.locum_availability_map
|
||||||
|
|
||||||
|
def get_locum_availability(self, worker: Worker, week: int, day: DayStr, shift):
|
||||||
|
|
||||||
|
if (worker.id, week, day) in self.locum_availability_map:
|
||||||
|
if shift.name in self.locum_availability_map[(worker.id, week, day)]:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
def get_all_workers(self) -> List[Worker]:
|
def get_all_workers(self) -> List[Worker]:
|
||||||
return self.workers
|
return self.workers
|
||||||
|
|
||||||
@@ -3200,6 +3384,12 @@ class RotaBuilder(object):
|
|||||||
def get_worker_grades(self) -> set[int]:
|
def get_worker_grades(self) -> set[int]:
|
||||||
return set([worker.grade for worker in self.workers])
|
return set([worker.grade for worker in self.workers])
|
||||||
|
|
||||||
|
def get_workers_who_require_locums(self):
|
||||||
|
return [worker for worker in self.get_all_workers() if worker.locum]
|
||||||
|
|
||||||
|
def get_locum_workers(self):
|
||||||
|
return [worker for worker in self.get_all_workers() if not worker.locum and worker.locum_availability]
|
||||||
|
|
||||||
def get_bank_holiday_week_days(self):
|
def get_bank_holiday_week_days(self):
|
||||||
return [
|
return [
|
||||||
(week, day)
|
(week, day)
|
||||||
@@ -3400,6 +3590,7 @@ class RotaBuilder(object):
|
|||||||
current_site = worker.site
|
current_site = worker.site
|
||||||
|
|
||||||
shifts = []
|
shifts = []
|
||||||
|
locum_shifts = []
|
||||||
nwds = json.dumps(worker.non_working_day_list, default=str)
|
nwds = json.dumps(worker.non_working_day_list, default=str)
|
||||||
# if worker.nwd:
|
# if worker.nwd:
|
||||||
# # TODO: limit to dates
|
# # TODO: limit to dates
|
||||||
@@ -3423,12 +3614,22 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
# Loop through all the days possible shifts and see
|
# Loop through all the days possible shifts and see
|
||||||
# if the worker has been assigned
|
# if the worker has been assigned
|
||||||
|
locum = False
|
||||||
for shift in self.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.5:
|
if model.works[worker.id, week, day, shift].value > 0.8:
|
||||||
shifts.append(shift)
|
shifts.append(shift)
|
||||||
a = shift[0]
|
a = shift[0]
|
||||||
shift_name = shift
|
shift_name = shift
|
||||||
break
|
break
|
||||||
|
if self.get_workers_who_require_locums():
|
||||||
|
if model.locum_works[worker.id, week, day, shift].value > 0.8:
|
||||||
|
locum_shifts.append(shift)
|
||||||
|
a = shift[0]
|
||||||
|
shift_name = shift
|
||||||
|
locum = True
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
title = f"{shift_name} ({d})"
|
title = f"{shift_name} ({d})"
|
||||||
css_class = day
|
css_class = day
|
||||||
unavailable_reason = ""
|
unavailable_reason = ""
|
||||||
@@ -3460,6 +3661,14 @@ class RotaBuilder(object):
|
|||||||
title = " ".join((title, "[REQUESTED]"))
|
title = " ".join((title, "[REQUESTED]"))
|
||||||
requests = f" data-shift-request='{self.work_requests_map[worker.id, week, day]}'"
|
requests = f" data-shift-request='{self.work_requests_map[worker.id, week, day]}'"
|
||||||
|
|
||||||
|
if (worker.id, week, day) in self.locum_availability_map:
|
||||||
|
css_class = " ".join((css_class, "locum-availability"))
|
||||||
|
title = " ".join((title, "[LOCUM AVAILABILITY]"))
|
||||||
|
requests = f" data-locum-request='{self.locum_availability_map[worker.id, week, day]}'"
|
||||||
|
|
||||||
|
if locum:
|
||||||
|
css_class = " ".join((css_class, "locum-shift"))
|
||||||
|
|
||||||
remote_site = ""
|
remote_site = ""
|
||||||
if shift_name:
|
if shift_name:
|
||||||
shift = self.get_shift_by_name(shift_name)
|
shift = self.get_shift_by_name(shift_name)
|
||||||
@@ -3479,6 +3688,13 @@ class RotaBuilder(object):
|
|||||||
shift_count_dict[s] = c
|
shift_count_dict[s] = c
|
||||||
shift_count = shift_count + f"{s}: {c}, "
|
shift_count = shift_count + f"{s}: {c}, "
|
||||||
|
|
||||||
|
locum_shift_count = ""
|
||||||
|
locum_shift_count_dict = {}
|
||||||
|
for s in set(locum_shifts):
|
||||||
|
c = locum_shifts.count(s)
|
||||||
|
locum_shift_count_dict[s] = c
|
||||||
|
locum_shift_count = locum_shift_count + f"{s}: {c}, "
|
||||||
|
|
||||||
shift_diff_dict = {}
|
shift_diff_dict = {}
|
||||||
for shift in self.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
|
||||||
@@ -3491,7 +3707,9 @@ class RotaBuilder(object):
|
|||||||
data-start_date='{start_date}'
|
data-start_date='{start_date}'
|
||||||
data-end_date='{end_date}'
|
data-end_date='{end_date}'
|
||||||
data-oops='{oops}'
|
data-oops='{oops}'
|
||||||
data-worker-targets='{targets}' data-shift-counts='{worker_shift_counts}'
|
data-worker-targets='{targets}'
|
||||||
|
data-shift-counts='{worker_shift_counts}'
|
||||||
|
data-locum-shift-counts='{locum_shift_counts}'
|
||||||
data-weekend-target='{weekend_target}'
|
data-weekend-target='{weekend_target}'
|
||||||
data-remote-site='{remote_site}'
|
data-remote-site='{remote_site}'
|
||||||
data-pair='{pair}'
|
data-pair='{pair}'
|
||||||
@@ -3500,7 +3718,7 @@ class RotaBuilder(object):
|
|||||||
data-shift-diff='{shift_diff}'
|
data-shift-diff='{shift_diff}'
|
||||||
data-shift-diff-summed='{shift_diff_summed}'
|
data-shift-diff-summed='{shift_diff_summed}'
|
||||||
>
|
>
|
||||||
<span class='name' title='{name}'>{name}</span> ({grade}) [{fte}]</td>""".format(
|
<span class='name' title='{name} ({worker_id})'>{name}</span> ({grade}) [{fte}]</td>""".format(
|
||||||
site=worker.site,
|
site=worker.site,
|
||||||
nwds=nwds,
|
nwds=nwds,
|
||||||
worker_id=worker.id,
|
worker_id=worker.id,
|
||||||
@@ -3512,6 +3730,7 @@ class RotaBuilder(object):
|
|||||||
oops=json.dumps(json.dumps(worker.oop, default=str)),
|
oops=json.dumps(json.dumps(worker.oop, default=str)),
|
||||||
targets=worker_targets,
|
targets=worker_targets,
|
||||||
worker_shift_counts=json.dumps(shift_count_dict),
|
worker_shift_counts=json.dumps(shift_count_dict),
|
||||||
|
locum_shift_counts=json.dumps(locum_shift_count_dict),
|
||||||
weekend_target=worker.weekend_shift_target_number,
|
weekend_target=worker.weekend_shift_target_number,
|
||||||
remote_site=worker.remote_site,
|
remote_site=worker.remote_site,
|
||||||
grade=worker.grade,
|
grade=worker.grade,
|
||||||
@@ -3617,6 +3836,17 @@ class RotaBuilder(object):
|
|||||||
<div id="shift-timetable-div">
|
<div id="shift-timetable-div">
|
||||||
<div>
|
<div>
|
||||||
</details>
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><h2>Extra</h2></summary>
|
||||||
|
<div id="extra-options">
|
||||||
|
</div>
|
||||||
|
<div id="extra-div">
|
||||||
|
<h3>Locum availability</h3>
|
||||||
|
{"TEST"}
|
||||||
|
{self.locum_availability_map}
|
||||||
|
<br/>
|
||||||
|
<div>
|
||||||
|
</details>
|
||||||
</div
|
</div
|
||||||
</body>
|
</body>
|
||||||
"""
|
"""
|
||||||
@@ -3674,7 +3904,7 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
return shifts
|
return shifts
|
||||||
|
|
||||||
def get_worker_shift_list(self, worker: Worker) -> List:
|
def get_worker_shift_list(self, worker: Worker, include_locums=False) -> List:
|
||||||
shifts = []
|
shifts = []
|
||||||
|
|
||||||
for week, day in self.get_week_day_combinations():
|
for week, day in self.get_week_day_combinations():
|
||||||
@@ -3685,6 +3915,8 @@ class RotaBuilder(object):
|
|||||||
for shift in self.get_shift_names_by_week_day(week, day):
|
for shift in self.get_shift_names_by_week_day(week, day):
|
||||||
if self.model.works[worker.id, week, day, shift].value > 0.90:
|
if self.model.works[worker.id, week, day, shift].value > 0.90:
|
||||||
temp = shift
|
temp = shift
|
||||||
|
if include_locums and self.model.locum_works[worker.id, week, day, shift].value > 0.90:
|
||||||
|
temp = shift
|
||||||
shifts.append(temp)
|
shifts.append(temp)
|
||||||
|
|
||||||
return shifts
|
return shifts
|
||||||
|
|||||||
+8
-1
@@ -67,6 +67,9 @@ class NonWorkingDays(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class WorkRequests(BaseModel):
|
class WorkRequests(BaseModel):
|
||||||
|
"""
|
||||||
|
Shift is the name of the shift requested, * is a wildcard for any shift
|
||||||
|
"""
|
||||||
date: datetime.date
|
date: datetime.date
|
||||||
shift: str = ""
|
shift: str = ""
|
||||||
|
|
||||||
@@ -99,6 +102,9 @@ class Worker(BaseModel):
|
|||||||
pref_not_to_work: list[PreferenceNotToWork] = []
|
pref_not_to_work: list[PreferenceNotToWork] = []
|
||||||
work_requests: list[WorkRequests] = []
|
work_requests: list[WorkRequests] = []
|
||||||
locum_availability: list[WorkRequests] = []
|
locum_availability: list[WorkRequests] = []
|
||||||
|
locum_max_shifts: int = 0
|
||||||
|
locum_max_shifts_per_week: int = 0
|
||||||
|
locum_on_nwds: bool = False
|
||||||
remote_site: str = "plymouth" # We set a default proc_site
|
remote_site: str = "plymouth" # We set a default proc_site
|
||||||
previous_shifts: dict = {}
|
previous_shifts: dict = {}
|
||||||
shift_balance_extra: dict = {}
|
shift_balance_extra: dict = {}
|
||||||
@@ -293,7 +299,8 @@ class Worker(BaseModel):
|
|||||||
week = days_from_start // 7 + 1
|
week = days_from_start // 7 + 1
|
||||||
day = Rota.days[(days_from_start % 7)]
|
day = Rota.days[(days_from_start % 7)]
|
||||||
if request.shift == "*":
|
if request.shift == "*":
|
||||||
for shift in Rota.get_shifts_for_worker_site(self.site):
|
# We may want to add a limit to shifts that can be filled by locums?
|
||||||
|
for shift in Rota.get_shifts():
|
||||||
Rota.locum_availability.add((self.id, week, day, shift.name))
|
Rota.locum_availability.add((self.id, week, day, shift.name))
|
||||||
else:
|
else:
|
||||||
Rota.locum_availability.add((self.id, week, day, request.shift))
|
Rota.locum_availability.add((self.id, week, day, request.shift))
|
||||||
|
|||||||
+4
-4
@@ -591,7 +591,7 @@ class TestShiftWorkerRequirements:
|
|||||||
SingleShift(
|
SingleShift(
|
||||||
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
||||||
assign_as_block=True,
|
assign_as_block=True,
|
||||||
force_as_block=True,
|
#force_as_block=True,
|
||||||
workers_required=wr,
|
workers_required=wr,
|
||||||
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
||||||
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
||||||
@@ -619,7 +619,7 @@ class TestShiftWorkerRequirements:
|
|||||||
SingleShift(
|
SingleShift(
|
||||||
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
||||||
assign_as_block=True,
|
assign_as_block=True,
|
||||||
force_as_block=True,
|
#force_as_block=True,
|
||||||
workers_required=wr,
|
workers_required=wr,
|
||||||
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
||||||
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
||||||
@@ -686,7 +686,7 @@ class TestShiftWorkerRequirements:
|
|||||||
SingleShift(
|
SingleShift(
|
||||||
sites=("group1", "group2"), name="b", length= 12.5, days=days[4:],
|
sites=("group1", "group2"), name="b", length= 12.5, days=days[4:],
|
||||||
assign_as_block=True,
|
assign_as_block=True,
|
||||||
force_as_block=True,
|
#force_as_block=True,
|
||||||
workers_required=2,
|
workers_required=2,
|
||||||
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
||||||
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
||||||
@@ -694,7 +694,7 @@ class TestShiftWorkerRequirements:
|
|||||||
SingleShift(
|
SingleShift(
|
||||||
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
sites=("group1", "group2"), name="a", length= 12.5, days=days[:4],
|
||||||
assign_as_block=True,
|
assign_as_block=True,
|
||||||
force_as_block=True,
|
#force_as_block=True,
|
||||||
workers_required=wr,
|
workers_required=wr,
|
||||||
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
#start_date=(Rota.start_date + datetime.timedelta(days=7)),
|
||||||
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
#end_date=(Rota.start_date + datetime.timedelta(days=((7*6)-1))),
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class TestWorkerRequests:
|
|||||||
)
|
)
|
||||||
|
|
||||||
Rota.build_and_solve(options={"ratio": 0.000})
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
Rota.export_rota_to_html("preferences_not_to_work2")
|
Rota.export_rota_to_html("preferences_not_to_work2", timestamp_filename=False)
|
||||||
|
|
||||||
shift_summary = Rota.get_shift_summary_dict()
|
shift_summary = Rota.get_shift_summary_dict()
|
||||||
for worker_name in shift_summary:
|
for worker_name in shift_summary:
|
||||||
|
|||||||
+184
-11
@@ -2,7 +2,8 @@ import pytest
|
|||||||
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days
|
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from rota.workers import NotAvailableToWork, Worker, generate_not_available_to_works
|
from rota.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works
|
||||||
|
from rota.workers import WorkRequests
|
||||||
|
|
||||||
|
|
||||||
def generate_basic_rota(
|
def generate_basic_rota(
|
||||||
@@ -619,6 +620,7 @@ class TestComplexWorkers:
|
|||||||
# assert worker.get_fte("b") == 50
|
# assert worker.get_fte("b") == 50
|
||||||
# assert total_shifts[worker.name] == 20
|
# assert total_shifts[worker.name] == 20
|
||||||
#total_shifts = Rota.get_workers_total_shifts()
|
#total_shifts = Rota.get_workers_total_shifts()
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
for worker in Rota.get_workers():
|
for worker in Rota.get_workers():
|
||||||
shift_string = Rota.get_worker_shift_list_string(worker)
|
shift_string = Rota.get_worker_shift_list_string(worker)
|
||||||
assert shift_string.count("cccc") == shift_string.count("c") / 4
|
assert shift_string.count("cccc") == shift_string.count("c") / 4
|
||||||
@@ -674,30 +676,198 @@ class TestComplexWorkers:
|
|||||||
Rota.build_and_solve()
|
Rota.build_and_solve()
|
||||||
|
|
||||||
Rota.export_rota_to_html("test_split")
|
Rota.export_rota_to_html("test_split")
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
class TestLocums:
|
class TestLocums:
|
||||||
def test_locums(self):
|
def test_locums_basic(self):
|
||||||
Rota = generate_basic_rota(workers=9, weeks_to_rota=22)
|
Rota = generate_basic_rota(workers=2, weeks_to_rota=6)
|
||||||
|
|
||||||
Rota.set_rota_constraint("max_shifts_per_week", 2)
|
la = []
|
||||||
Rota.set_rota_constraint("max_weekend_frequency", 3)
|
for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
||||||
Rota.set_rota_constraint("max_shifts_per_month", 4)
|
#for date in Rota.get_date_range():
|
||||||
|
la.append(WorkRequests(date=date, shift="*"))
|
||||||
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
la2 = []
|
||||||
|
for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
||||||
|
#for date in Rota.get_date_range():
|
||||||
|
la2.append(WorkRequests(date=date, shift="*"))
|
||||||
|
|
||||||
Rota.add_workers(
|
Rota.add_workers(
|
||||||
[
|
[
|
||||||
|
Worker(name="worker03", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1),
|
||||||
|
Worker(name="worker04", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2),
|
||||||
Worker(name="locum1", site="group1", locum=True),
|
Worker(name="locum1", site="group1", locum=True),
|
||||||
Worker(
|
Worker(
|
||||||
name="locum2", site="group1", locum=True
|
name="locum2", site="group1", locum=True
|
||||||
),
|
),
|
||||||
Worker(
|
]
|
||||||
name="extra1", site="locum"
|
)
|
||||||
|
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days[:4],
|
||||||
|
workers_required=1,
|
||||||
|
|
||||||
|
constraint=[
|
||||||
|
#{"name": "pre", "options": 1},
|
||||||
|
#{"name": "post", "options": 1},
|
||||||
|
#{"name": "max_shifts_per_week", "options": 1},
|
||||||
|
]
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
Rota.set_rota_constraint("distribute_locum_shifts", True)
|
||||||
|
Rota.set_rota_constraint("maximum_allowed_shift_diff", 0)
|
||||||
|
Rota.set_rota_constraint("maximum_allowed_locum_shifts_per_worker", 4)
|
||||||
|
|
||||||
|
Rota.build_and_solve()
|
||||||
|
|
||||||
|
Rota.export_rota_to_html("test_locums_basic", timestamp_filename=False)
|
||||||
|
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
assert Rota.get_worker_shift_list(worker).count("a") == 4
|
||||||
|
|
||||||
|
def test_locums_nwds(self):
|
||||||
|
Rota = generate_basic_rota(workers=2, weeks_to_rota=6)
|
||||||
|
|
||||||
|
la = []
|
||||||
|
for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
||||||
|
la.append(WorkRequests(date=date, shift="*"))
|
||||||
|
la2 = []
|
||||||
|
for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
||||||
|
la2.append(WorkRequests(date=date, shift="*"))
|
||||||
|
|
||||||
|
Rota.add_workers(
|
||||||
|
[
|
||||||
|
Worker(name="worker03", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1, nwds=[NonWorkingDays(day="Tue")]),
|
||||||
|
Worker(name="worker04", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2, nwds=[NonWorkingDays(day="Wed"),NonWorkingDays(day="Thu")]),
|
||||||
|
#Worker(name="worker03", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1, nwds=[NonWorkingDays(day="Mon"),NonWorkingDays(day="Tue")]),
|
||||||
|
#Worker(name="worker04", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2, nwds=[NonWorkingDays(day="Wed"),NonWorkingDays(day="Thu")]),
|
||||||
|
Worker(name="locum1", site="group1", locum=True),
|
||||||
Worker(
|
Worker(
|
||||||
name="extra2", site="locum"
|
name="locum2", site="group1", locum=True
|
||||||
),
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days[:4],
|
||||||
|
workers_required=1,
|
||||||
|
|
||||||
|
constraint=[
|
||||||
|
#{"name": "pre", "options": 1},
|
||||||
|
#{"name": "post", "options": 1},
|
||||||
|
#{"name": "max_shifts_per_week", "options": 1},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
#Rota.set_rota_constraint("distribute_locum_shifts", True)
|
||||||
|
Rota.set_rota_constraint("maximum_allowed_shift_diff", 0)
|
||||||
|
Rota.set_rota_constraint("maximum_allowed_locum_shifts_per_worker", 4)
|
||||||
|
#Rota.set_rota_constraint("minimum_allowed_locum_shifts_per_worker", 3)
|
||||||
|
|
||||||
|
Rota.build_and_solve()
|
||||||
|
|
||||||
|
Rota.export_rota_to_html("test_locums_basic", timestamp_filename=False)
|
||||||
|
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
print( Rota.get_worker_shift_list(worker))
|
||||||
|
assert Rota.get_worker_shift_list(worker).count("a") == 4
|
||||||
|
|
||||||
|
if worker.name in ("worker03", "worker04"):
|
||||||
|
assert Rota.get_worker_shift_list(worker, include_locums=True).count("a") > 6
|
||||||
|
|
||||||
|
def test_locums_no_availablity(self):
|
||||||
|
Rota = generate_basic_rota(workers=2, weeks_to_rota=5)
|
||||||
|
|
||||||
|
la = []
|
||||||
|
#for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
||||||
|
# la.append(WorkRequests(date=date, shift="*"))
|
||||||
|
la2 = []
|
||||||
|
#for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
||||||
|
# la2.append(WorkRequests(date=date, shift="*"))
|
||||||
|
|
||||||
|
Rota.add_workers(
|
||||||
|
[
|
||||||
|
Worker(name="worker03", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1),
|
||||||
|
Worker(name="worker04", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2),
|
||||||
|
Worker(name="locum1", site="group1", locum=True),
|
||||||
|
Worker(
|
||||||
|
name="locum2", site="group1", locum=True
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days[:4],
|
||||||
|
workers_required=1,
|
||||||
|
|
||||||
|
constraint=[
|
||||||
|
#{"name": "pre", "options": 1},
|
||||||
|
#{"name": "post", "options": 1},
|
||||||
|
#{"name": "max_shifts_per_week", "options": 1},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.set_rota_constraint("distribute_locum_shifts", False)
|
||||||
|
|
||||||
|
with pytest.raises(WarningTermination):
|
||||||
|
Rota.build_and_solve()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_locums(self):
|
||||||
|
Rota = generate_basic_rota(workers=7, weeks_to_rota=10)
|
||||||
|
|
||||||
|
Rota.set_rota_constraint("max_shifts_per_week", 2)
|
||||||
|
Rota.set_rota_constraint("max_weekend_frequency", 3)
|
||||||
|
Rota.set_rota_constraint("max_shifts_per_month", 4)
|
||||||
|
#Rota.set_rota_constraint("balance_shifts", False)
|
||||||
|
Rota.set_rota_constraint("distribute_locum_shifts", True)
|
||||||
|
Rota.set_rota_constraint("maximum_allowed_shift_diff", 1)
|
||||||
|
Rota.set_rota_constraint("maximum_allowed_locum_shifts_per_worker", 6)
|
||||||
|
|
||||||
|
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||||
|
|
||||||
|
|
||||||
|
la = []
|
||||||
|
for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
||||||
|
la.append(WorkRequests(date=date, shift="*"))
|
||||||
|
la2 = []
|
||||||
|
for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
||||||
|
la2.append(WorkRequests(date=date, shift="*"))
|
||||||
|
|
||||||
|
|
||||||
|
Rota.add_workers(
|
||||||
|
[
|
||||||
|
Worker(name="worker08", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1),
|
||||||
|
Worker(name="worker09", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2),
|
||||||
|
Worker(name="locum1", site="group1", locum=True),
|
||||||
|
Worker(
|
||||||
|
name="locum2", site="group1", locum=True
|
||||||
|
),
|
||||||
|
#Worker(
|
||||||
|
# name="extra1", site="locum", locum_availability=la,
|
||||||
|
# locum_max_shifts=40
|
||||||
|
#),
|
||||||
|
#Worker(
|
||||||
|
# name="extra2", site="locum", locum_availability=la,
|
||||||
|
# locum_max_shifts=4, locum_max_shifts_per_week=1
|
||||||
|
#),
|
||||||
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -710,6 +880,7 @@ class TestLocums:
|
|||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[:4],
|
days=days[:4],
|
||||||
workers_required=1,
|
workers_required=1,
|
||||||
|
|
||||||
constraint=[
|
constraint=[
|
||||||
{"name": "pre", "options": 2},
|
{"name": "pre", "options": 2},
|
||||||
{"name": "post", "options": 3},
|
{"name": "post", "options": 3},
|
||||||
@@ -744,3 +915,5 @@ class TestLocums:
|
|||||||
Rota.build_and_solve()
|
Rota.build_and_solve()
|
||||||
|
|
||||||
Rota.export_rota_to_html("test_locums", timestamp_filename=False)
|
Rota.export_rota_to_html("test_locums", timestamp_filename=False)
|
||||||
|
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
Reference in New Issue
Block a user