numerous improvemts
This commit is contained in:
@@ -121,6 +121,14 @@ def main(
|
|||||||
constraint=[],
|
constraint=[],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
Rota.allow_shifts_together(
|
||||||
|
|
||||||
|
"oncall weekday",
|
||||||
|
"Oncall weekend",
|
||||||
|
"weekend a",
|
||||||
|
"evening",
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
# Rota.add_grade_constraint_by_week([2], [1, 2], ["night_weekday", "night_weekend"])
|
# Rota.add_grade_constraint_by_week([2], [1, 2], ["night_weekday", "night_weekend"])
|
||||||
|
|
||||||
|
|||||||
@@ -321,4 +321,8 @@ table.transposed th.bank-holiday {
|
|||||||
|
|
||||||
.locum-shift {
|
.locum-shift {
|
||||||
background-color: lightgreen;
|
background-color: lightgreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-shift {
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
+22
-12
@@ -295,7 +295,10 @@ $(".table-div .worker-row .worker").each((n, tr) => {
|
|||||||
locum_shift_counts = jtr.data("locum-shift-counts")
|
locum_shift_counts = jtr.data("locum-shift-counts")
|
||||||
shift_targets = jtr.data("worker-targets")
|
shift_targets = jtr.data("worker-targets")
|
||||||
|
|
||||||
|
console.log(shift_counts, locum_shift_counts, shift_targets)
|
||||||
|
|
||||||
oshifts.forEach((s) => {
|
oshifts.forEach((s) => {
|
||||||
|
console.log(s, shift_counts, shift_targets, locum_shift_counts)
|
||||||
if (s in shift_targets && shift_targets[s] > 0) {
|
if (s in shift_targets && shift_targets[s] > 0) {
|
||||||
if (s in shift_counts) {
|
if (s in shift_counts) {
|
||||||
c = shift_counts[s];
|
c = shift_counts[s];
|
||||||
@@ -426,6 +429,7 @@ $(".table-div + pre").each((n, pre) => {
|
|||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
$("td").hover((e) => {
|
$("td").hover((e) => {
|
||||||
// start hover
|
// start hover
|
||||||
worker_td = $(e.target).closest('tr').find('td:first')[0]
|
worker_td = $(e.target).closest('tr').find('td:first')[0]
|
||||||
@@ -436,31 +440,37 @@ $("td").hover((e) => {
|
|||||||
$($(el).closest('tr').find('td').get(index)).addClass("pair-match")
|
$($(el).closest('tr').find('td').get(index)).addClass("pair-match")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (e.target.dataset.shift != undefined && e.target.dataset.shift.length > 0) {
|
|
||||||
// Highlight all cells containing this shift, even if multiple shifts per day
|
|
||||||
$("td").filter(function() {
|
|
||||||
let shifts = $(this).attr("data-shift");
|
|
||||||
if (!shifts) return false;
|
|
||||||
return shifts.split(",").map(s => s.trim()).includes(e.target.dataset.shift);
|
|
||||||
}).addClass("shift-highlight");
|
|
||||||
|
|
||||||
// For remote site, similar logic if needed
|
// Find the span under the mouse, if any
|
||||||
|
let targetSpan = $(e.target).closest("span.multi-shift-shift").get(0) ||
|
||||||
|
($(e.target).hasClass("multi-shift-shift") ? e.target : null);
|
||||||
|
|
||||||
|
if (targetSpan) {
|
||||||
|
// Only highlight spans with the same text
|
||||||
|
let shiftDisplay = $(targetSpan).text().trim();
|
||||||
|
$("span.multi-shift-shift").filter(function() {
|
||||||
|
return $(this).text().trim() === shiftDisplay;
|
||||||
|
}).addClass("shift-highlight");
|
||||||
|
} else if (e.target.dataset.shift != undefined && e.target.dataset.shift.length > 0) {
|
||||||
|
// Fallback: highlight all cells containing ANY of the hovered shifts (legacy)
|
||||||
|
let hovered_shifts = e.target.dataset.shift.split(",").map(s => s.trim());
|
||||||
$("td").filter(function() {
|
$("td").filter(function() {
|
||||||
let shifts = $(this).attr("data-shift");
|
let shifts = $(this).attr("data-shift");
|
||||||
let remote = $(this).attr("data-remote-site");
|
|
||||||
if (!shifts) return false;
|
if (!shifts) return false;
|
||||||
return shifts.split(",").map(s => s.trim()).includes(e.target.dataset.shift) &&
|
let cell_shifts = shifts.split(",").map(s => s.trim());
|
||||||
remote == e.target.dataset.shiftRemoteSite;
|
return hovered_shifts.some(s => cell_shifts.includes(s));
|
||||||
}).addClass("remote-site-match");
|
}).addClass("shift-highlight");
|
||||||
}
|
}
|
||||||
|
|
||||||
}, (e) => {
|
}, (e) => {
|
||||||
// end hover
|
// end hover
|
||||||
$("td.pair-match").removeClass("pair-match")
|
$("td.pair-match").removeClass("pair-match")
|
||||||
$("td.shift-highlight").removeClass("shift-highlight")
|
$("td.shift-highlight").removeClass("shift-highlight")
|
||||||
|
$("span.shift-highlight").removeClass("shift-highlight")
|
||||||
$("td.remote-site-match").removeClass("remote-site-match")
|
$("td.remote-site-match").removeClass("remote-site-match")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
$("#rota-table tr td:first-child").each((n, td) => {
|
$("#rota-table tr td:first-child").each((n, td) => {
|
||||||
$(td).click(() => {
|
$(td).click(() => {
|
||||||
viewWorker(td);
|
viewWorker(td);
|
||||||
|
|||||||
+137
-59
@@ -257,7 +257,7 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
self.SHIFT_BOUNDS = SHIFT_BOUNDS
|
self.SHIFT_BOUNDS = SHIFT_BOUNDS
|
||||||
self.shifts: List[SingleShift] = [] # type List[SingleShift]
|
self.shifts: List[SingleShift] = [] # type List[SingleShift]
|
||||||
self.allowed_multi_shift_sets: list[set[str]] = []
|
#self.allowed_multi_shift_sets: list[set[str]] = []
|
||||||
|
|
||||||
self.use_previous_shifts = use_previous_shifts
|
self.use_previous_shifts = use_previous_shifts
|
||||||
self.use_shift_balance_extra = use_shift_balance_extra
|
self.use_shift_balance_extra = use_shift_balance_extra
|
||||||
@@ -603,6 +603,16 @@ class RotaBuilder(object):
|
|||||||
initialize=0,
|
initialize=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.model.multi_shift_together = Var(
|
||||||
|
((worker.id, week, day, idx)
|
||||||
|
for worker in self.workers
|
||||||
|
for week, day in self.get_week_day_combinations()
|
||||||
|
for idx, allowed_set in enumerate(getattr(worker, "allowed_multi_shift_sets", []))
|
||||||
|
if allowed_set.issubset(self.get_shift_names_by_week_day(week, day))),
|
||||||
|
domain=Binary,
|
||||||
|
initialize=0,
|
||||||
|
)
|
||||||
|
|
||||||
# Used to limit number of workers on night shift per site
|
# Used to limit number of workers on night shift per site
|
||||||
# if we force a binary it will in effect hard constrain to < 2
|
# if we force a binary it will in effect hard constrain to < 2
|
||||||
# from a single site
|
# from a single site
|
||||||
@@ -1398,6 +1408,25 @@ class RotaBuilder(object):
|
|||||||
)
|
)
|
||||||
logging.debug(f"Generate worker constraints: {worker.name}")
|
logging.debug(f"Generate worker constraints: {worker.name}")
|
||||||
|
|
||||||
|
for week, day in self.get_week_day_combinations():
|
||||||
|
shifts_today = self.get_shift_names_by_week_day(week, day)
|
||||||
|
worker_allowed_sets = getattr(worker, "allowed_multi_shift_sets", [])
|
||||||
|
#for idx, allowed_set in enumerate(worker_allowed_sets):
|
||||||
|
# if allowed_set.issubset(shifts_today):
|
||||||
|
# var = self.model.multi_shift_together[worker.id, week, day, idx]
|
||||||
|
# self.model.constraints.add(
|
||||||
|
# sum(self.model.works[worker.id, week, day, shift] for shift in allowed_set)
|
||||||
|
# == len(allowed_set) * var
|
||||||
|
# )
|
||||||
|
for idx, allowed_set in enumerate(worker_allowed_sets):
|
||||||
|
if allowed_set.issubset(shifts_today):
|
||||||
|
var = self.model.multi_shift_together[worker.id, week, day, idx]
|
||||||
|
# var is 1 iff all shifts in allowed_set are assigned
|
||||||
|
for shift in allowed_set:
|
||||||
|
self.model.constraints.add(var <= self.model.works[worker.id, week, day, shift])
|
||||||
|
self.model.constraints.add(
|
||||||
|
var >= sum(self.model.works[worker.id, week, day, shift] for shift in allowed_set) - len(allowed_set) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if self.get_workers_who_require_locums():
|
if self.get_workers_who_require_locums():
|
||||||
@@ -2416,55 +2445,40 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
# single shift per day (unless multi-shift allowed)
|
# single shift per day (unless multi-shift allowed)
|
||||||
# This is signifantly slower so only enable if required
|
# This is signifantly slower so only enable if required
|
||||||
if self.allowed_multi_shift_sets:
|
shifts_today = self.get_shift_names_by_week_day(week, day)
|
||||||
shifts_today = self.get_shift_names_by_week_day(week, day)
|
assigned_vars = [self.model.works[worker.id, week, day, shift] for shift in shifts_today]
|
||||||
if shifts_today:
|
worker_allowed_sets = getattr(worker, "allowed_multi_shift_sets", [])
|
||||||
# Build all allowed sets that are subsets of today's shifts
|
allowed_sets_today = [
|
||||||
allowed_sets_today = [
|
allowed_set for allowed_set in worker_allowed_sets
|
||||||
allowed_set for allowed_set in self.allowed_multi_shift_sets
|
if allowed_set.issubset(shifts_today)
|
||||||
if allowed_set.issubset(shifts_today)
|
]
|
||||||
]
|
|
||||||
|
|
||||||
# For each possible assignment, only allow:
|
if allowed_sets_today:
|
||||||
# - a single shift
|
# Forbid any multi-shift assignment that includes shifts from different allowed sets
|
||||||
# - OR exactly one of the allowed sets
|
# Build all possible multi-shift assignments
|
||||||
assigned_vars = [self.model.works[worker.id, week, day, shift] for shift in shifts_today]
|
all_multi = [set(c) for i in range(2, len(shifts_today)+1) for c in itertools.combinations(shifts_today, i)]
|
||||||
# The sum of assigned shifts must be 1 (single shift) or exactly len(allowed_set) for one allowed set
|
# Build all allowed multi-shift patterns (all non-empty subsets of each allowed set)
|
||||||
allowed_sums = [1] + [len(allowed_set) for allowed_set in allowed_sets_today]
|
allowed_patterns = [{shift} for shift in shifts_today]
|
||||||
# Enforce: sum in allowed_sums
|
for allowed_set in allowed_sets_today:
|
||||||
|
allowed_patterns += [set(s) for i in range(2, len(allowed_set)+1) for s in itertools.combinations(allowed_set, i)]
|
||||||
|
# Forbid all other multi-shift combinations
|
||||||
|
forbidden = [s for s in all_multi if s not in allowed_patterns]
|
||||||
|
for forbidden_set in forbidden:
|
||||||
self.model.constraints.add(
|
self.model.constraints.add(
|
||||||
sum(assigned_vars) <= max(allowed_sums)
|
sum(self.model.works[worker.id, week, day, shift] for shift in forbidden_set)
|
||||||
|
<= len(forbidden_set) - 1
|
||||||
)
|
)
|
||||||
self.model.constraints.add(
|
# Still, at most all shifts per day
|
||||||
sum(assigned_vars) >= min(allowed_sums)
|
|
||||||
)
|
|
||||||
# Now, for each allowed set, prevent partial assignment of the set
|
|
||||||
for allowed_set in allowed_sets_today:
|
|
||||||
# If any shift in allowed_set is assigned, all must be assigned
|
|
||||||
for shift in allowed_set:
|
|
||||||
self.model.constraints.add(
|
|
||||||
sum(self.model.works[worker.id, week, day, s] for s in allowed_set)
|
|
||||||
>= len(allowed_set) * self.model.works[worker.id, week, day, shift]
|
|
||||||
)
|
|
||||||
# No other shifts can be assigned together with the allowed set
|
|
||||||
for shift in shifts_today:
|
|
||||||
if shift not in allowed_set:
|
|
||||||
self.model.constraints.add(
|
|
||||||
sum(self.model.works[worker.id, week, day, s] for s in allowed_set)
|
|
||||||
+ self.model.works[worker.id, week, day, shift]
|
|
||||||
<= len(allowed_set)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Otherwise only one shift per day
|
|
||||||
else:
|
|
||||||
self.model.constraints.add(
|
self.model.constraints.add(
|
||||||
1
|
sum(self.model.works[worker.id, week, day, shift] for shift in shifts_today)
|
||||||
>= sum(
|
<= len(shifts_today)
|
||||||
self.model.works[worker.id, week, day, shift]
|
|
||||||
for shift in self.get_shift_names_by_week_day(week, day)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Only one shift per day if no allowed sets
|
||||||
|
self.model.constraints.add(
|
||||||
|
sum(assigned_vars) <= 1
|
||||||
|
)
|
||||||
|
|
||||||
# This applies to locums as well
|
# This applies to locums as well
|
||||||
if self.get_workers_who_require_locums():
|
if self.get_workers_who_require_locums():
|
||||||
self.model.constraints.add(
|
self.model.constraints.add(
|
||||||
@@ -2647,6 +2661,14 @@ class RotaBuilder(object):
|
|||||||
# Define an objective function with model as input, to pass later
|
# Define an objective function with model as input, to pass later
|
||||||
def obj_rule(m):
|
def obj_rule(m):
|
||||||
# c = len(workers)
|
# c = len(workers)
|
||||||
|
prefer_multi_shift_expr = sum(
|
||||||
|
getattr(worker, "prefer_multi_shift_together", 0) *
|
||||||
|
self.model.multi_shift_together[worker.id, week, day, idx]
|
||||||
|
for worker in self.workers
|
||||||
|
for week, day in self.get_week_day_combinations()
|
||||||
|
for idx, allowed_set in enumerate(getattr(worker, "allowed_multi_shift_sets", []))
|
||||||
|
if allowed_set.issubset(self.get_shift_names_by_week_day(week, day))
|
||||||
|
)
|
||||||
|
|
||||||
balance_modifier_constant = 10
|
balance_modifier_constant = 10
|
||||||
balance_quadratic_shift_modifier_constant = 3
|
balance_quadratic_shift_modifier_constant = 3
|
||||||
@@ -2840,16 +2862,17 @@ class RotaBuilder(object):
|
|||||||
- work_requests
|
- work_requests
|
||||||
+ locum_shift_balancing
|
+ locum_shift_balancing
|
||||||
+ true_quadratic_shift_balancing
|
+ true_quadratic_shift_balancing
|
||||||
|
- prefer_multi_shift_expr
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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)
|
||||||
self.model.obj = Objective(rule=obj_rule, sense=minimize)
|
self.model.obj = Objective(rule=obj_rule, sense=minimize)
|
||||||
|
|
||||||
def allow_shifts_together(self, *shift_names: str):
|
#def allow_shifts_together(self, *shift_names: str):
|
||||||
"""
|
# """
|
||||||
Allow the given set of shift names to be assigned together on a single day.
|
# Allow the given set of shift names to be assigned together on a single day.
|
||||||
"""
|
# """
|
||||||
self.allowed_multi_shift_sets.append(set(shift_names))
|
# self.allowed_multi_shift_sets.append(set(shift_names))
|
||||||
|
|
||||||
def add_warning(self, warning_type: str, message: str):
|
def add_warning(self, warning_type: str, message: str):
|
||||||
print(f"[bold red]WARNING:[/bold red] {warning_type} - {message}")
|
print(f"[bold red]WARNING:[/bold red] {warning_type} - {message}")
|
||||||
@@ -3032,6 +3055,13 @@ class RotaBuilder(object):
|
|||||||
for n in range(int((end_date - start_date).days) + 1):
|
for n in range(int((end_date - start_date).days) + 1):
|
||||||
yield start_date + datetime.timedelta(n)
|
yield start_date + datetime.timedelta(n)
|
||||||
|
|
||||||
|
def allow_shifts_together_for_all_workers(self, *shift_names: str):
|
||||||
|
"""
|
||||||
|
Allow the given set of shift names to be assigned together on a single day for all workers.
|
||||||
|
"""
|
||||||
|
for worker in self.workers:
|
||||||
|
worker.allowed_multi_shift_sets.append(frozenset(shift_names))
|
||||||
|
|
||||||
def build_shifts(self):
|
def build_shifts(self):
|
||||||
"""
|
"""
|
||||||
Process the added shifts
|
Process the added shifts
|
||||||
@@ -3151,6 +3181,16 @@ class RotaBuilder(object):
|
|||||||
for shift in self.get_shifts_with_constraint("post"):
|
for shift in self.get_shifts_with_constraint("post"):
|
||||||
self.max_post = max(shift.constraint_options["post"], self.max_post)
|
self.max_post = max(shift.constraint_options["post"], self.max_post)
|
||||||
|
|
||||||
|
# --- Check allowed_multi_shift_sets validity ---
|
||||||
|
all_shift_names = set(s.name for s in self.shifts)
|
||||||
|
for worker in self.workers:
|
||||||
|
for allowed_set in getattr(worker, "allowed_multi_shift_sets", []):
|
||||||
|
missing = set(allowed_set) - all_shift_names
|
||||||
|
if missing:
|
||||||
|
raise InvalidShift(
|
||||||
|
f"Worker '{worker.name}' has allowed_multi_shift_set(s) with non-existent shift(s): {missing}"
|
||||||
|
)
|
||||||
|
|
||||||
## For paired shifts we create proxy shifts
|
## For paired shifts we create proxy shifts
|
||||||
# self.paired_shift_map = {}
|
# self.paired_shift_map = {}
|
||||||
# for shifts in self.paired_shifts:
|
# for shifts in self.paired_shifts:
|
||||||
@@ -3414,6 +3454,18 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
return group_workers
|
return group_workers
|
||||||
|
|
||||||
|
def get_workers_by_site(self, site: str) -> list[Worker]:
|
||||||
|
"""
|
||||||
|
Returns a list of workers assigned to a specific site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site (str): The site name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[Worker]: List of workers at the given site.
|
||||||
|
"""
|
||||||
|
return [worker for worker in self.workers if worker.site == site]
|
||||||
|
|
||||||
def get_workers_by_grade(self) -> Dict[int, list[Worker]]:
|
def get_workers_by_grade(self) -> Dict[int, list[Worker]]:
|
||||||
group_workers: dict[int, list[Worker]] = defaultdict(list)
|
group_workers: dict[int, list[Worker]] = defaultdict(list)
|
||||||
for worker in self.workers:
|
for worker in self.workers:
|
||||||
@@ -3466,7 +3518,7 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
# RESULTS
|
# RESULTS
|
||||||
def export_rota_to_html(
|
def export_rota_to_html(
|
||||||
self, filename: str = "rota", folder=None, timestamp_filename=True
|
self, filename: str = "rota", folder=None, timestamp_filename=False
|
||||||
):
|
):
|
||||||
if timestamp_filename:
|
if timestamp_filename:
|
||||||
filename = f"{filename}_{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
filename = f"{filename}_{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||||
@@ -3562,15 +3614,15 @@ class RotaBuilder(object):
|
|||||||
shifts = []
|
shifts = []
|
||||||
w = [f"{worker.name:20}"]
|
w = [f"{worker.name:20}"]
|
||||||
for week, day in self.get_week_day_combinations():
|
for week, day in self.get_week_day_combinations():
|
||||||
a = "-"
|
shift_display_char = "-"
|
||||||
shift_names = []
|
shift_names = []
|
||||||
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.5:
|
||||||
shift_names.append(self.get_shift_by_name(shift).get_display_char())
|
shift_names.append(self.get_shift_by_name(shift).get_display_char())
|
||||||
if shift_names:
|
if shift_names:
|
||||||
a = "".join(shift_names)
|
shift_display_char = "".join(shift_names)
|
||||||
shifts.extend(shift_names)
|
shifts.extend(shift_names)
|
||||||
w.append(a)
|
w.append(shift_display_char)
|
||||||
shift_count = ""
|
shift_count = ""
|
||||||
for s in set(shifts):
|
for s in set(shifts):
|
||||||
shift_count = shift_count + f"{s}: {shifts.count(s)}, "
|
shift_count = shift_count + f"{s}: {shifts.count(s)}, "
|
||||||
@@ -3676,7 +3728,7 @@ class RotaBuilder(object):
|
|||||||
for week, day in self.get_week_day_combinations():
|
for week, day in self.get_week_day_combinations():
|
||||||
d = self.start_date + datetime.timedelta(n)
|
d = self.start_date + datetime.timedelta(n)
|
||||||
n = n + 1
|
n = n + 1
|
||||||
a = "-"
|
shift_display_char = "-"
|
||||||
shift_names = []
|
shift_names = []
|
||||||
assigned_shift_names = []
|
assigned_shift_names = []
|
||||||
locum = False
|
locum = False
|
||||||
@@ -3693,7 +3745,7 @@ class RotaBuilder(object):
|
|||||||
assigned_shift_names.append(shift)
|
assigned_shift_names.append(shift)
|
||||||
locum = True
|
locum = True
|
||||||
if shift_names:
|
if shift_names:
|
||||||
a = "<br/>".join(shift_names)
|
shift_display_char = "<br/>".join(["<span class='multi-shift-shift'>"+name+"</span>" for name in shift_names])
|
||||||
shifts.extend(assigned_shift_names)
|
shifts.extend(assigned_shift_names)
|
||||||
shift_name = assigned_shift_names[0] if assigned_shift_names else ""
|
shift_name = assigned_shift_names[0] if assigned_shift_names else ""
|
||||||
|
|
||||||
@@ -3704,7 +3756,6 @@ class RotaBuilder(object):
|
|||||||
css_class += " multi-shift"
|
css_class += " multi-shift"
|
||||||
|
|
||||||
title = f"{shift_name} ({d})"
|
title = f"{shift_name} ({d})"
|
||||||
css_class = day
|
|
||||||
unavailable_reason = ""
|
unavailable_reason = ""
|
||||||
if model.available[worker.id, week, day] > 0:
|
if model.available[worker.id, week, day] > 0:
|
||||||
available = True
|
available = True
|
||||||
@@ -3751,7 +3802,13 @@ class RotaBuilder(object):
|
|||||||
css_class = " ".join((css_class, "night-shift"))
|
css_class = " ".join((css_class, "night-shift"))
|
||||||
|
|
||||||
shift_tds.append(
|
shift_tds.append(
|
||||||
f"<td title='{','.join(shift_names)} ({d})' class='rota-day {css_class}' data-shift='{','.join(shift_names)}' data-available='{available}' data-unavailable_reason='{unavailable_reason}' data-date='{d}' data-week='{week}' data-day='{day}'{remote_site}{requests}{bank_holiday}>{a}</td>"
|
f"<td title='{','.join(shift_names)} ({d})' class='rota-day {css_class}'"
|
||||||
|
f" data-shift='{','.join(assigned_shift_names)}'"
|
||||||
|
f" data-shift-display='{','.join(shift_names)}'"
|
||||||
|
f" data-available='{available}'"
|
||||||
|
f" data-unavailable_reason='{unavailable_reason}'"
|
||||||
|
f" data-date='{d}' data-week='{week}' data-day='{day}'"
|
||||||
|
f"{remote_site}{requests}{bank_holiday}>{shift_display_char}</td>"
|
||||||
)
|
)
|
||||||
|
|
||||||
shift_count = ""
|
shift_count = ""
|
||||||
@@ -3961,7 +4018,7 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
temp = ""
|
temp = ""
|
||||||
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:
|
if self.model.works[worker.id, week, day, shift].value > 0.5:
|
||||||
shifts[d] = shift
|
shifts[d] = shift
|
||||||
|
|
||||||
return shifts
|
return shifts
|
||||||
@@ -3994,6 +4051,27 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
return shifts
|
return shifts
|
||||||
|
|
||||||
|
def get_worker_shift_count(self, worker: Worker, shift_name:str) -> int:
|
||||||
|
"""Returns the number of shifts of a specific type assigned to a worker."""
|
||||||
|
count = 0
|
||||||
|
for week, day in self.get_week_day_combinations():
|
||||||
|
if shift_name in self.get_shift_names_by_week_day(week, day):
|
||||||
|
if self.model.works[worker.id, week, day, shift_name].value > 0.5:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_worker_days_worked(self, worker: Worker) -> int:
|
||||||
|
"""
|
||||||
|
Returns the number of unique days on which the worker is assigned at least one shift.
|
||||||
|
"""
|
||||||
|
days_worked = 0
|
||||||
|
for week, day in self.get_week_day_combinations():
|
||||||
|
for shift in self.get_shift_names_by_week_day(week, day):
|
||||||
|
if self.model.works[worker.id, week, day, shift].value > 0.5:
|
||||||
|
days_worked += 1
|
||||||
|
break # Only count each day once, even if multiple shifts
|
||||||
|
return days_worked
|
||||||
|
|
||||||
def get_worker_shift_list_string(self, worker: Worker) -> str:
|
def get_worker_shift_list_string(self, worker: Worker) -> str:
|
||||||
shifts = self.get_worker_shift_list(worker)
|
shifts = self.get_worker_shift_list(worker)
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ class Worker(BaseModel):
|
|||||||
bank_holiday_extra: int = 0
|
bank_holiday_extra: int = 0
|
||||||
pair: int | str | None = None
|
pair: int | str | None = None
|
||||||
locum: bool = False
|
locum: bool = False
|
||||||
|
allowed_multi_shift_sets: list[set] = [] # or list/set of frozensets
|
||||||
|
prefer_multi_shift_together: int = 0 # Default: no preference
|
||||||
|
# Set to a positive integer to prefer, negative to discourage, 0 for neutral
|
||||||
|
|
||||||
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
|
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
|
||||||
|
|
||||||
@@ -389,3 +392,8 @@ class Worker(BaseModel):
|
|||||||
if shift in self.fte_adj_shifts:
|
if shift in self.fte_adj_shifts:
|
||||||
return self.fte_adj_shifts[shift]
|
return self.fte_adj_shifts[shift]
|
||||||
return self.fte_adj
|
return self.fte_adj
|
||||||
|
|
||||||
|
def allow_shifts_together(self, *shifts):
|
||||||
|
if not hasattr(self, "allowed_multi_shift_sets"):
|
||||||
|
self.allowed_multi_shift_sets = []
|
||||||
|
self.allowed_multi_shift_sets.append(frozenset(shifts))
|
||||||
@@ -12,6 +12,50 @@ def basic_rota():
|
|||||||
Rota.add_worker(worker)
|
Rota.add_worker(worker)
|
||||||
return Rota
|
return Rota
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def long_rota():
|
||||||
|
start_date = datetime.date(2022, 4, 4) # must be a Monday!
|
||||||
|
Rota = RotaBuilder(start_date=start_date, weeks_to_rota=10)
|
||||||
|
worker = Worker(name="worker1", site="site1", grade=1)
|
||||||
|
Rota.add_worker(worker)
|
||||||
|
return Rota
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cons_rota():
|
||||||
|
start_date = datetime.date(2022, 4, 4) # must be a Monday!
|
||||||
|
Rota = RotaBuilder(start_date=start_date, weeks_to_rota=10)
|
||||||
|
worker1 = Worker(name="worker1", site="a")
|
||||||
|
worker2 = Worker(name="worker2", site="a")
|
||||||
|
worker3 = Worker(name="worker3", site="a")
|
||||||
|
worker4 = Worker(name="worker4", site="a")
|
||||||
|
worker5 = Worker(name="worker5", site="a")
|
||||||
|
worker6 = Worker(name="worker6", site="a")
|
||||||
|
worker7 = Worker(name="worker7", site="a")
|
||||||
|
worker8 = Worker(name="worker8", site="a")
|
||||||
|
worker9 = Worker(name="worker9", site="a")
|
||||||
|
worker10 = Worker(name="worker10", site="a")
|
||||||
|
worker11 = Worker(name="worker11", site="a")
|
||||||
|
|
||||||
|
worker12 = Worker(name="worker12", site="b")
|
||||||
|
worker13 = Worker(name="worker13", site="b")
|
||||||
|
worker14 = Worker(name="worker14", site="b")
|
||||||
|
worker15 = Worker(name="worker15", site="b")
|
||||||
|
worker16 = Worker(name="worker16", site="b")
|
||||||
|
worker17 = Worker(name="worker17", site="b")
|
||||||
|
worker18 = Worker(name="worker18", site="b")
|
||||||
|
worker19 = Worker(name="worker19", site="b")
|
||||||
|
worker20 = Worker(name="worker20", site="b")
|
||||||
|
|
||||||
|
Rota.add_workers([
|
||||||
|
worker1, worker2, worker3, worker4, worker5,
|
||||||
|
worker6, worker7, worker8, worker9, worker10,
|
||||||
|
worker11, worker12, worker13, worker14, worker15,
|
||||||
|
worker16, worker17, worker18, worker19, worker20
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
return Rota
|
||||||
|
|
||||||
def test_single_shift_per_day_default(basic_rota):
|
def test_single_shift_per_day_default(basic_rota):
|
||||||
Rota = basic_rota
|
Rota = basic_rota
|
||||||
Rota.add_shifts(
|
Rota.add_shifts(
|
||||||
@@ -21,12 +65,6 @@ def test_single_shift_per_day_default(basic_rota):
|
|||||||
Rota.build_and_solve()
|
Rota.build_and_solve()
|
||||||
assert Rota.results.solver.status == "error"
|
assert Rota.results.solver.status == "error"
|
||||||
# Only one shift per day should be assigned
|
# Only one shift per day should be assigned
|
||||||
for week, day in Rota.get_week_day_combinations():
|
|
||||||
assigned = [
|
|
||||||
shift for shift in Rota.get_shift_names_by_week_day(week, day)
|
|
||||||
if Rota.model.works[Rota.workers[0].id, week, day, shift].value > 0.5
|
|
||||||
]
|
|
||||||
assert len(assigned) <= 1
|
|
||||||
|
|
||||||
def test_allow_two_shifts_per_day(basic_rota):
|
def test_allow_two_shifts_per_day(basic_rota):
|
||||||
Rota = basic_rota
|
Rota = basic_rota
|
||||||
@@ -34,7 +72,8 @@ def test_allow_two_shifts_per_day(basic_rota):
|
|||||||
SingleShift(sites=["site1"], name="a", length=8, days=days[:2], workers_required=1),
|
SingleShift(sites=["site1"], name="a", length=8, days=days[:2], workers_required=1),
|
||||||
SingleShift(sites=["site1"], name="b", length=8, days=days[:2], workers_required=1),
|
SingleShift(sites=["site1"], name="b", length=8, days=days[:2], workers_required=1),
|
||||||
)
|
)
|
||||||
Rota.allow_shifts_together("a", "b")
|
for worker in Rota.workers:
|
||||||
|
worker.allow_shifts_together("a", "b")
|
||||||
Rota.build_and_solve()
|
Rota.build_and_solve()
|
||||||
Rota.export_rota_to_html("test_allow_two_shifts", folder="tests")
|
Rota.export_rota_to_html("test_allow_two_shifts", folder="tests")
|
||||||
assert Rota.results.solver.status == "ok"
|
assert Rota.results.solver.status == "ok"
|
||||||
@@ -58,7 +97,8 @@ def test_only_specified_shifts_can_be_double_assigned(basic_rota):
|
|||||||
SingleShift(sites=["site1"], name="b", length=8, days=days[:3], workers_required=1),
|
SingleShift(sites=["site1"], name="b", length=8, days=days[:3], workers_required=1),
|
||||||
SingleShift(sites=["site1"], name="c", length=8, days=days[:3], workers_required=1),
|
SingleShift(sites=["site1"], name="c", length=8, days=days[:3], workers_required=1),
|
||||||
)
|
)
|
||||||
Rota.allow_shifts_together("a", "b")
|
for worker in Rota.workers:
|
||||||
|
worker.allow_shifts_together("a", "b")
|
||||||
Rota.build_and_solve()
|
Rota.build_and_solve()
|
||||||
Rota.export_rota_to_html("test_only_specified_shifts_can_be_double_assigned", folder="tests")
|
Rota.export_rota_to_html("test_only_specified_shifts_can_be_double_assigned", folder="tests")
|
||||||
assert Rota.results.solver.status == "ok"
|
assert Rota.results.solver.status == "ok"
|
||||||
@@ -79,8 +119,111 @@ def test_triple_shifts_allowed(basic_rota):
|
|||||||
SingleShift(sites=["site1"], name="b", length=8, days=days[2:4], workers_required=1),
|
SingleShift(sites=["site1"], name="b", length=8, days=days[2:4], workers_required=1),
|
||||||
SingleShift(sites=["site1"], name="c", length=8, days=days[2:4], workers_required=1),
|
SingleShift(sites=["site1"], name="c", length=8, days=days[2:4], workers_required=1),
|
||||||
)
|
)
|
||||||
Rota.allow_shifts_together("a", "b", "c")
|
Rota.allow_shifts_together_for_all_workers("a", "b", "c")
|
||||||
Rota.build_and_solve()
|
Rota.build_and_solve()
|
||||||
Rota.export_rota_to_html("test_triple_shifts_allowed", folder="tests")
|
Rota.export_rota_to_html("test_triple_shifts_allowed", folder="tests")
|
||||||
assert Rota.results.solver.status == "ok"
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
def test_triple_shifts_allowed2(long_rota):
|
||||||
|
Rota = long_rota
|
||||||
|
worker2 = Worker(name="worker2", site="site1", grade=1)
|
||||||
|
worker3 = Worker(name="worker3", site="site1", grade=1)
|
||||||
|
Rota.add_worker(worker2)
|
||||||
|
Rota.add_worker(worker3)
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(sites=["site1"], name="a", length=8, days=days[2:4], workers_required=1, balance_offset=1),
|
||||||
|
SingleShift(sites=["site1"], name="b", length=8, days=days[2:4], workers_required=1, balance_offset=1),
|
||||||
|
SingleShift(sites=["site1"], name="c", length=8, days=days[2:4], workers_required=1, balance_offset=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
worker2.allow_shifts_together("a", "b", "c")
|
||||||
|
worker2.prefer_multi_shift_together = 10
|
||||||
|
worker3.allow_shifts_together("a", "b", "c")
|
||||||
|
worker3.prefer_multi_shift_together = -10
|
||||||
|
|
||||||
|
Rota.build_and_solve()
|
||||||
|
Rota.export_rota_to_html("test_triple_shifts_allowed2", folder="tests")
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
def test_multi_shift_prefs(long_rota):
|
||||||
|
Rota = long_rota
|
||||||
|
#Rota.constraint_options["max_shifts_per_week"] = 10
|
||||||
|
worker2 = Worker(name="worker2", site="site1", grade=1)
|
||||||
|
worker3 = Worker(name="worker3", site="site1", grade=1)
|
||||||
|
worker4 = Worker(name="worker4", site="site1", grade=1)
|
||||||
|
#worker5 = Worker(name="worker5", site="site1", grade=1)
|
||||||
|
Rota.add_worker(worker2)
|
||||||
|
Rota.add_worker(worker3)
|
||||||
|
Rota.add_worker(worker4)
|
||||||
|
#Rota.add_worker(worker5)
|
||||||
|
Rota.add_worker
|
||||||
|
for worker in Rota.workers:
|
||||||
|
worker.allow_shifts_together("a", "b")
|
||||||
|
worker.prefer_multi_shift_together = 0
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(sites=["site1"], name="a", length=8, days=days[:1], workers_required=1),
|
||||||
|
SingleShift(sites=["site1"], name="b", length=8, days=days[:1], workers_required=1),
|
||||||
|
SingleShift(sites=["site1"], name="c", length=8, days=days[:1], workers_required=1),
|
||||||
|
SingleShift(sites=["site1"], name="d", length=8, days=days[:1], workers_required=1),
|
||||||
|
SingleShift(sites=["site1"], name="e", length=8, days=days[:1], workers_required=1),
|
||||||
|
#SingleShift(sites=["site1"], name="d", length=8, days=days[:1], workers_required=1),
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0})
|
||||||
|
Rota.export_rota_to_html("test_multi_shift_prefs", folder="tests")
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
def test_cons_rota(cons_rota):
|
||||||
|
Rota = cons_rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(sites=["a"], name="oncall", length=8, days=days[:5], workers_required=1),
|
||||||
|
SingleShift(sites=["a", "b"], name="evening", length=8, days=days[:5], workers_required=1),
|
||||||
|
)
|
||||||
|
print("Adding shifts")
|
||||||
|
for worker in Rota.get_workers_by_site("a"):
|
||||||
|
print(worker.name)
|
||||||
|
worker.allow_shifts_together("oncall", "evening")
|
||||||
|
worker.prefer_multi_shift_together = 1
|
||||||
|
Rota.build_and_solve()
|
||||||
|
Rota.export_rota_to_html("test_cons_rota", folder="tests")
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
# Check that everyone at site "a" is assigned 2 or 3 evening shifts
|
||||||
|
for worker in Rota.get_workers_by_site("a"):
|
||||||
|
evening_count = Rota.get_worker_shift_count(worker, "evening")
|
||||||
|
assert 2 <= evening_count <= 3, f"{worker.name} assigned {evening_count} evening shifts"
|
||||||
|
|
||||||
|
if worker.site == "a":
|
||||||
|
oncall_count = Rota.get_worker_shift_count(worker, "oncall")
|
||||||
|
assert oncall_count == 4 or oncall_count == 5, f"{worker.name} assigned {oncall_count} oncall shifts"
|
||||||
|
|
||||||
|
# Check that days worked is no more than oncall count
|
||||||
|
days_worked = Rota.get_worker_days_worked(worker)
|
||||||
|
assert days_worked <= oncall_count, f"{worker.name} worked {days_worked} days, more than oncall shifts {oncall_count}"
|
||||||
|
|
||||||
|
def test_cons_rota2(cons_rota):
|
||||||
|
Rota = cons_rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(sites=["a"], name="oncall", length=8, days=days[:5], workers_required=1),
|
||||||
|
SingleShift(sites=["a", "b"], name="evening", length=8, days=days[:5], workers_required=1),
|
||||||
|
)
|
||||||
|
print("Adding shifts")
|
||||||
|
for worker in Rota.get_workers_by_site("a"):
|
||||||
|
print(worker.name)
|
||||||
|
worker.allow_shifts_together("oncall", "evening")
|
||||||
|
worker.prefer_multi_shift_together = -1
|
||||||
|
Rota.build_and_solve()
|
||||||
|
Rota.export_rota_to_html("test_cons_rota", folder="tests")
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
# Check that everyone at site "a" is assigned 2 or 3 evening shifts
|
||||||
|
for worker in Rota.get_workers_by_site("a"):
|
||||||
|
evening_count = Rota.get_worker_shift_count(worker, "evening")
|
||||||
|
assert 2 <= evening_count <= 3, f"{worker.name} assigned {evening_count} evening shifts"
|
||||||
|
|
||||||
|
if worker.site == "a":
|
||||||
|
oncall_count = Rota.get_worker_shift_count(worker, "oncall")
|
||||||
|
assert oncall_count == 4 or oncall_count == 5, f"{worker.name} assigned {oncall_count} oncall shifts"
|
||||||
|
|
||||||
|
# Check that days worked is now more than oncall count
|
||||||
|
days_worked = Rota.get_worker_days_worked(worker)
|
||||||
|
assert days_worked > oncall_count, f"{worker.name} worked {days_worked} days, more than oncall shifts {oncall_count}"
|
||||||
|
|||||||
Reference in New Issue
Block a user