diff --git a/gen_cons.py b/gen_cons.py
index dcb4e21..0a32e7f 100644
--- a/gen_cons.py
+++ b/gen_cons.py
@@ -121,6 +121,14 @@ def main(
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"])
diff --git a/output/timetable.css b/output/timetable.css
index e9d27c8..0adbba2 100644
--- a/output/timetable.css
+++ b/output/timetable.css
@@ -321,4 +321,8 @@ table.transposed th.bank-holiday {
.locum-shift {
background-color: lightgreen;
+}
+
+.multi-shift {
+ line-height: 1;
}
\ No newline at end of file
diff --git a/output/timetable.js b/output/timetable.js
index b240ee1..2d23acd 100644
--- a/output/timetable.js
+++ b/output/timetable.js
@@ -295,7 +295,10 @@ $(".table-div .worker-row .worker").each((n, tr) => {
locum_shift_counts = jtr.data("locum-shift-counts")
shift_targets = jtr.data("worker-targets")
+ console.log(shift_counts, locum_shift_counts, shift_targets)
+
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_counts) {
c = shift_counts[s];
@@ -426,6 +429,7 @@ $(".table-div + pre").each((n, pre) => {
})
+
$("td").hover((e) => {
// start hover
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")
})
}
- 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() {
let shifts = $(this).attr("data-shift");
- let remote = $(this).attr("data-remote-site");
if (!shifts) return false;
- return shifts.split(",").map(s => s.trim()).includes(e.target.dataset.shift) &&
- remote == e.target.dataset.shiftRemoteSite;
- }).addClass("remote-site-match");
+ let cell_shifts = shifts.split(",").map(s => s.trim());
+ return hovered_shifts.some(s => cell_shifts.includes(s));
+ }).addClass("shift-highlight");
}
}, (e) => {
// end hover
$("td.pair-match").removeClass("pair-match")
$("td.shift-highlight").removeClass("shift-highlight")
+ $("span.shift-highlight").removeClass("shift-highlight")
$("td.remote-site-match").removeClass("remote-site-match")
});
+
$("#rota-table tr td:first-child").each((n, td) => {
$(td).click(() => {
viewWorker(td);
diff --git a/rota/shifts.py b/rota/shifts.py
index fc764ef..b50ff7a 100644
--- a/rota/shifts.py
+++ b/rota/shifts.py
@@ -257,7 +257,7 @@ class RotaBuilder(object):
self.SHIFT_BOUNDS = SHIFT_BOUNDS
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_shift_balance_extra = use_shift_balance_extra
@@ -603,6 +603,16 @@ class RotaBuilder(object):
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
# if we force a binary it will in effect hard constrain to < 2
# from a single site
@@ -1398,6 +1408,25 @@ class RotaBuilder(object):
)
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():
@@ -2416,55 +2445,40 @@ class RotaBuilder(object):
# single shift per day (unless multi-shift allowed)
# 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)
- if shifts_today:
- # Build all allowed sets that are subsets of today's shifts
- allowed_sets_today = [
- allowed_set for allowed_set in self.allowed_multi_shift_sets
- if allowed_set.issubset(shifts_today)
- ]
+ 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]
+ worker_allowed_sets = getattr(worker, "allowed_multi_shift_sets", [])
+ allowed_sets_today = [
+ allowed_set for allowed_set in worker_allowed_sets
+ if allowed_set.issubset(shifts_today)
+ ]
- # For each possible assignment, only allow:
- # - a single shift
- # - OR exactly one of the allowed sets
- assigned_vars = [self.model.works[worker.id, week, day, shift] for shift in shifts_today]
- # The sum of assigned shifts must be 1 (single shift) or exactly len(allowed_set) for one allowed set
- allowed_sums = [1] + [len(allowed_set) for allowed_set in allowed_sets_today]
- # Enforce: sum in allowed_sums
+ if allowed_sets_today:
+ # Forbid any multi-shift assignment that includes shifts from different allowed sets
+ # Build all possible multi-shift assignments
+ all_multi = [set(c) for i in range(2, len(shifts_today)+1) for c in itertools.combinations(shifts_today, i)]
+ # Build all allowed multi-shift patterns (all non-empty subsets of each allowed set)
+ allowed_patterns = [{shift} for shift in shifts_today]
+ 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(
- 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(
- 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:
+ # Still, at most all shifts per day
self.model.constraints.add(
- 1
- >= sum(
- self.model.works[worker.id, week, day, shift]
- for shift in self.get_shift_names_by_week_day(week, day)
- )
+ sum(self.model.works[worker.id, week, day, shift] for shift in shifts_today)
+ <= len(shifts_today)
)
+ else:
+ # Only one shift per day if no allowed sets
+ self.model.constraints.add(
+ sum(assigned_vars) <= 1
+ )
+
# This applies to locums as well
if self.get_workers_who_require_locums():
self.model.constraints.add(
@@ -2647,6 +2661,14 @@ class RotaBuilder(object):
# Define an objective function with model as input, to pass later
def obj_rule(m):
# 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_quadratic_shift_modifier_constant = 3
@@ -2840,16 +2862,17 @@ class RotaBuilder(object):
- work_requests
+ locum_shift_balancing
+ true_quadratic_shift_balancing
+ - prefer_multi_shift_expr
)
# add objective function to the model. rule (pass function) or expr (pass expression directly)
self.model.obj = Objective(rule=obj_rule, sense=minimize)
- def allow_shifts_together(self, *shift_names: str):
- """
- Allow the given set of shift names to be assigned together on a single day.
- """
- self.allowed_multi_shift_sets.append(set(shift_names))
+ #def allow_shifts_together(self, *shift_names: str):
+ # """
+ # Allow the given set of shift names to be assigned together on a single day.
+ # """
+ # self.allowed_multi_shift_sets.append(set(shift_names))
def add_warning(self, warning_type: str, message: str):
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):
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):
"""
Process the added shifts
@@ -3151,6 +3181,16 @@ class RotaBuilder(object):
for shift in self.get_shifts_with_constraint("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
# self.paired_shift_map = {}
# for shifts in self.paired_shifts:
@@ -3414,6 +3454,18 @@ class RotaBuilder(object):
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]]:
group_workers: dict[int, list[Worker]] = defaultdict(list)
for worker in self.workers:
@@ -3466,7 +3518,7 @@ class RotaBuilder(object):
# RESULTS
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:
filename = f"{filename}_{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
@@ -3562,15 +3614,15 @@ class RotaBuilder(object):
shifts = []
w = [f"{worker.name:20}"]
for week, day in self.get_week_day_combinations():
- a = "-"
+ shift_display_char = "-"
shift_names = []
for shift in self.get_shift_names_by_week_day(week, day):
if model.works[worker.id, week, day, shift].value > 0.5:
shift_names.append(self.get_shift_by_name(shift).get_display_char())
if shift_names:
- a = "".join(shift_names)
+ shift_display_char = "".join(shift_names)
shifts.extend(shift_names)
- w.append(a)
+ w.append(shift_display_char)
shift_count = ""
for s in set(shifts):
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():
d = self.start_date + datetime.timedelta(n)
n = n + 1
- a = "-"
+ shift_display_char = "-"
shift_names = []
assigned_shift_names = []
locum = False
@@ -3693,7 +3745,7 @@ class RotaBuilder(object):
assigned_shift_names.append(shift)
locum = True
if shift_names:
- a = "
".join(shift_names)
+ shift_display_char = "
".join([""+name+"" for name in shift_names])
shifts.extend(assigned_shift_names)
shift_name = assigned_shift_names[0] if assigned_shift_names else ""
@@ -3704,7 +3756,6 @@ class RotaBuilder(object):
css_class += " multi-shift"
title = f"{shift_name} ({d})"
- css_class = day
unavailable_reason = ""
if model.available[worker.id, week, day] > 0:
available = True
@@ -3751,7 +3802,13 @@ class RotaBuilder(object):
css_class = " ".join((css_class, "night-shift"))
shift_tds.append(
- f"