From 108bb572a71edc5b854bc4a87600f4377e16bbf5 Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 16 Dec 2025 22:41:43 +0000 Subject: [PATCH] Enhance hard day dependency handling with ignore dates and improve warning visibility --- gen_cons.py | 3 ++- rota_generator/shifts.py | 25 +++++++++++++++++++++++-- rota_generator/workers.py | 5 +++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/gen_cons.py b/gen_cons.py index f6bafa6..de9b09c 100644 --- a/gen_cons.py +++ b/gen_cons.py @@ -1,3 +1,4 @@ +from pyomo.opt.results.container import ignore from collections import defaultdict import datetime import os @@ -299,7 +300,7 @@ def load_workers(): w.add_force_assign_with("weekend", "oncall") if w.name in ("DS",): - w.add_hard_day_dependency({"Fri", "Sat", "Sun"}) + w.add_hard_day_dependency({"Fri", "Sat", "Sun"}, ignore_dates=[datetime.date(2026, 5, 2)]) else: w.add_hard_day_dependency({"Fri", "Sun"}) w.add_hard_day_exclusion({"Sat", "Sun"}) diff --git a/rota_generator/shifts.py b/rota_generator/shifts.py index b2bf2f6..a01f867 100644 --- a/rota_generator/shifts.py +++ b/rota_generator/shifts.py @@ -1702,6 +1702,18 @@ class RotaBuilder(object): ) for week in weeks_to_apply: + # compute which days in the day_group should be ignored + ignored_days = set() + try: + for ignore_date in getattr(dep, "ignore_dates", []) or []: + try: + ww, dd = self.date_week_day_map.get(ignore_date, (None, None)) + except Exception: + ww = dd = None + if ww == week and dd in day_group: + ignored_days.add(dd) + except Exception: + ignored_days = set() # If the worker has any forced assignment in the dependency group, # emit a concise warning. For visibility include every day in the # group showing the worker's forced shifts (if any) and other @@ -1729,6 +1741,9 @@ class RotaBuilder(object): date_map_for_week = {} for day in day_group: + if day in ignored_days: + # skip ignored dates for warning/visibility + continue worker_forced_shifts = set() # week-based forced assignments for (fw, fd, fs) in getattr(worker, "forced_assignments", []): @@ -1799,8 +1814,14 @@ class RotaBuilder(object): "HardDayDependency forced assignment", f"Worker '{worker.name}' has hard_day_dependency {set(day_group)} in week {week}; forced assignments on dependency days: {details}. If the rota is not infeasible this is unlikely to be an issue.", ) + # Build constraints only for non-ignored days + effective_days = [d for d in day_group if d not in ignored_days] + if not effective_days: + # nothing to constrain for this dependency in this week + continue + assigned_any_shift = {} - for day in day_group: + for day in effective_days: shifts_today = self.get_shift_names_by_week_day(week, day) var_name = f"hard_day_dep_{worker.id}_{week}_{day}" if not hasattr(self.model, "hard_day_dep_vars"): @@ -1812,7 +1833,7 @@ class RotaBuilder(object): total_assigned = sum(self.model.works[worker.id, week, day, shift] for shift in shifts_today) self.model.constraints.add(total_assigned >= assigned_any_shift[day]) self.model.constraints.add(total_assigned <= len(shifts_today) * assigned_any_shift[day]) - # All-or-none: all days in the group must have the same assignment status + # All-or-none: all effective (non-ignored) days in the group must have the same assignment status vals = list(assigned_any_shift.values()) for i in range(1, len(vals)): self.model.constraints.add(vals[i] == vals[0]) diff --git a/rota_generator/workers.py b/rota_generator/workers.py index 3a5eec8..dface81 100644 --- a/rota_generator/workers.py +++ b/rota_generator/workers.py @@ -36,6 +36,7 @@ class HardDayDependency(BaseModel): weeks: Optional[List[int]] = None # If None, applies to all weeks start_date: Optional[datetime.date] = None # Optional start date for the dependency end_date: Optional[datetime.date] = None # Optional end date for the dependency + ignore_dates: list[datetime.date] = Field(default_factory=list, description="List of specific dates to ignore for this dependency") @field_validator("weeks") def weeks_none_if_dates_set(cls, weeks, values): @@ -437,13 +438,13 @@ class Worker(BaseModel): self.allowed_multi_shift_sets = [] self.allowed_multi_shift_sets.append(frozenset(shifts)) - def add_hard_day_dependency(self, days: set[str], weeks: Optional[List[int]] = None, start_date: Optional[datetime.date] = None, end_date: Optional[datetime.date] = None): + def add_hard_day_dependency(self, days: set[str], weeks: Optional[List[int]] = None, start_date: Optional[datetime.date] = None, end_date: Optional[datetime.date] = None, ignore_dates: Optional[List[datetime.date]] = None): """ Add a hard day dependency for this worker. days: List of days that must be grouped together. weeks: Optional list of weeks this dependency applies to. If None, applies to all weeks. """ - self.hard_day_dependencies.append(HardDayDependency(days=days, weeks=weeks, start_date=start_date, end_date=end_date)) + self.hard_day_dependencies.append(HardDayDependency(days=days, weeks=weeks, start_date=start_date, end_date=end_date, ignore_dates=ignore_dates or [])) def add_hard_day_exclusion(self, days: set[str], weeks: Optional[List[int]] = None, start_date: Optional[datetime.date] = None, end_date: Optional[datetime.date] = None): """