diff --git a/output/timetable.css b/output/timetable.css
index 8fda8b5..e9d27c8 100644
--- a/output/timetable.css
+++ b/output/timetable.css
@@ -317,4 +317,8 @@ table.transposed th.bank-holiday {
.target-assigned.no-colour {
background-color: white !important;
+}
+
+.locum-shift {
+ background-color: lightgreen;
}
\ No newline at end of file
diff --git a/output/timetable.js b/output/timetable.js
index 670eaf7..5ecc2d9 100644
--- a/output/timetable.js
+++ b/output/timetable.js
@@ -283,6 +283,7 @@ $(".table-div .worker-row .worker").each((n, tr) => {
}
shift_counts = jtr.data("shift-counts")
+ locum_shift_counts = jtr.data("locum-shift-counts")
shift_targets = jtr.data("worker-targets")
oshifts.forEach((s) => {
@@ -292,7 +293,11 @@ $(".table-div .worker-row .worker").each((n, tr) => {
} else {
c = 0;
}
- row.append(`
${c} (${shift_targets[s].toFixed(2)}) | `)
+ locum_shifts = ""
+ if (s in locum_shift_counts) {
+ locum_shifts = `+${locum_shift_counts[s]}`
+ }
+ row.append(`${c}${locum_shifts} (${shift_targets[s].toFixed(2)}) | `)
} else {
row.append(`- | `)
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..e1d4a46
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+# pytest.ini
+[pytest]
+testpaths = test
+python_files = test_*.py
\ No newline at end of file
diff --git a/rota/shifts.py b/rota/shifts.py
index 16ee541..619686f 100644
--- a/rota/shifts.py
+++ b/rota/shifts.py
@@ -268,9 +268,11 @@ class RotaBuilder(object):
"balance_bank_holidays": True,
"balance_blocks": True,
"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_over_workers": True,
"minimise_shift_diffs": False, # less sophisticated version of balance_shifts_over_workers
+ "maximum_allowed_shift_diff": None,
"balance_weekends": True,
"max_weekends": 100,
# Don't assign multiple shifts every (n) weeks
@@ -289,6 +291,11 @@ class RotaBuilder(object):
"hard_constrain_pair_separation": False,
"avoid_shifts_by_grades": [],
"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 = [
@@ -297,6 +304,8 @@ class RotaBuilder(object):
"Worker/no valid shifts",
"Shift/invalid start date",
"Shift/invalid end date",
+ "Locum/no locum availability",
+
]
self.results = None
@@ -864,6 +873,8 @@ class RotaBuilder(object):
def locum_request_init(model, wid, week, day, shift):
+ print("locum rquest init")
+ print(locals())
if (wid, week, day, shift) in self.locum_availability:
self.locum_availability_map[(wid, week, day)] = shift
return 1
@@ -873,6 +884,7 @@ class RotaBuilder(object):
(
(worker.id, week, day, shift)
for worker in self.workers
+ if not worker.locum
for week, day, shift in self.get_all_shiftname_combinations()
),
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)
# 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()
def build_model_constraints(self):
@@ -953,6 +1018,36 @@ class RotaBuilder(object):
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
if [worker for worker in self.workers if worker.site not in site_required]:
self.model.constraints.add(
@@ -1190,20 +1285,6 @@ class RotaBuilder(object):
for shift_name in self.shifts_to_assign_or_force_as_blocks():
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):
try:
self.model.constraints.add(
@@ -1231,7 +1312,6 @@ class RotaBuilder(object):
workers_required = shift.get_worker_requirement_by_date(
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:
workers = self.get_workers_for_shift(shift)
@@ -1304,7 +1384,70 @@ class RotaBuilder(object):
)
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.shift_count_diff_summed[worker.id]
== 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):
# Prevent more than n number shifts per 4 weeks
try:
@@ -1743,6 +1896,14 @@ class RotaBuilder(object):
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["max_night_frequency"]:
@@ -2231,6 +2392,14 @@ class RotaBuilder(object):
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
self.model.constraints.add(
@@ -2240,6 +2409,22 @@ class RotaBuilder(object):
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"]:
for worker_pairs in self.worker_pairs:
@@ -2349,78 +2534,6 @@ class RotaBuilder(object):
# except KeyError:
# 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
# an unavailability
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()
print("Building model completed")
@@ -2453,9 +2592,20 @@ class RotaBuilder(object):
def obj_rule(m):
# c = len(workers)
- balance_modifier_constant = 1
+ balance_modifier_constant = 10
balance_quadratic_shift_modifier_constant = 3
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"]:
worker_shift_balancing = sum(
@@ -2495,6 +2645,16 @@ class RotaBuilder(object):
else:
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"]:
shift_diff_modifier_constant = 10
shift_diff_balancing = sum(
@@ -2622,6 +2782,8 @@ class RotaBuilder(object):
+ bank_holiday_balancing
+ blocks_balancing
- work_requests
+ + locum_shift_balancing
+ + true_quadratic_shift_balancing
)
# 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}")
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):
"""
Process the added shifts
@@ -3144,6 +3318,16 @@ class RotaBuilder(object):
s = self.shifts_to_assign_as_blocks()
s.extend(self.shifts_to_force_as_blocks())
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]:
return self.workers
@@ -3200,6 +3384,12 @@ class RotaBuilder(object):
def get_worker_grades(self) -> set[int]:
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):
return [
(week, day)
@@ -3400,6 +3590,7 @@ class RotaBuilder(object):
current_site = worker.site
shifts = []
+ locum_shifts = []
nwds = json.dumps(worker.non_working_day_list, default=str)
# if worker.nwd:
# # TODO: limit to dates
@@ -3423,12 +3614,22 @@ class RotaBuilder(object):
# Loop through all the days possible shifts and see
# if the worker has been assigned
+ locum = False
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)
a = shift[0]
shift_name = shift
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})"
css_class = day
unavailable_reason = ""
@@ -3460,6 +3661,14 @@ class RotaBuilder(object):
title = " ".join((title, "[REQUESTED]"))
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 = ""
if shift_name:
shift = self.get_shift_by_name(shift_name)
@@ -3479,6 +3688,13 @@ class RotaBuilder(object):
shift_count_dict[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 = {}
for shift in self.get_shifts():
diff = model.shift_count_diff[worker.id, shift.name].value
@@ -3491,7 +3707,9 @@ class RotaBuilder(object):
data-start_date='{start_date}'
data-end_date='{end_date}'
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-remote-site='{remote_site}'
data-pair='{pair}'
@@ -3500,7 +3718,7 @@ class RotaBuilder(object):
data-shift-diff='{shift_diff}'
data-shift-diff-summed='{shift_diff_summed}'
>
- {name} ({grade}) [{fte}]""".format(
+ {name} ({grade}) [{fte}]""".format(
site=worker.site,
nwds=nwds,
worker_id=worker.id,
@@ -3512,6 +3730,7 @@ class RotaBuilder(object):
oops=json.dumps(json.dumps(worker.oop, default=str)),
targets=worker_targets,
worker_shift_counts=json.dumps(shift_count_dict),
+ locum_shift_counts=json.dumps(locum_shift_count_dict),
weekend_target=worker.weekend_shift_target_number,
remote_site=worker.remote_site,
grade=worker.grade,
@@ -3617,6 +3836,17 @@ class RotaBuilder(object):