From 15896639924b062e7f0ea60774cef88a67134c65 Mon Sep 17 00:00:00 2001 From: xXxEmilioxXx Date: Fri, 26 Jun 2026 14:50:18 +0200 Subject: [PATCH 01/17] implemented all my objectives save for rotate_shitfs_forward --- src/scheduling/solver/config.py | 16 +++- src/scheduling/solver/cp_sat/builder.py | 9 ++- .../cp_sat/objectives/minimize_overtime.py | 55 ++++++++++++++ .../not_too_many_consecutive_days.py | 75 +++++++++++++++++++ .../objectives/preferred_block_length.py | 75 +++++++++++++++++++ .../cp_sat/objectives/rotate_shits_foward.py | 57 ++++++++++++++ 6 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 src/scheduling/solver/cp_sat/objectives/minimize_overtime.py create mode 100644 src/scheduling/solver/cp_sat/objectives/not_too_many_consecutive_days.py create mode 100644 src/scheduling/solver/cp_sat/objectives/preferred_block_length.py create mode 100644 src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py diff --git a/src/scheduling/solver/config.py b/src/scheduling/solver/config.py index 8acb5c2a..6d86ea1a 100644 --- a/src/scheduling/solver/config.py +++ b/src/scheduling/solver/config.py @@ -7,7 +7,9 @@ from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( TemporaryBalanceGeneratedAssignments, ) - +from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime +from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays +from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength class ConstraintConfig(SchedulingBaseModel): enabled: bool @@ -40,5 +42,17 @@ def create_base_solver_config() -> SolverConfig: enabled=True, weight=1, ), + MinimizeOvertime.id: ObjectiveConfig( + enabled=True, + weight=100, + ), + NotTooManyConsecutiveDays.id: ObjectiveConfig( + enabled=True, + weight=1, + ), + PreferredBlockLength.id: ObjectiveConfig( + enabled=True, + weight=1, + ), }, ) diff --git a/src/scheduling/solver/cp_sat/builder.py b/src/scheduling/solver/cp_sat/builder.py index eec7296d..69dd8419 100644 --- a/src/scheduling/solver/cp_sat/builder.py +++ b/src/scheduling/solver/cp_sat/builder.py @@ -11,11 +11,18 @@ from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( TemporaryBalanceGeneratedAssignments, ) +from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime +from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays +from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength + from scheduling.solver.cp_sat.variables import create_assignment_variables CP_SAT_CONSTRAINTS: tuple[Constraint, ...] = (MinimumStaffing(),) -CP_SAT_OBJECTIVES: tuple[Objective, ...] = (TemporaryBalanceGeneratedAssignments(),) +CP_SAT_OBJECTIVES: tuple[Objective, ...] = (TemporaryBalanceGeneratedAssignments(), + MinimizeOvertime(), + NotTooManyConsecutiveDays(), + PreferredBlockLength()) @dataclass(frozen=True, slots=True) diff --git a/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py b/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py new file mode 100644 index 00000000..986a8af3 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py @@ -0,0 +1,55 @@ +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.cp_sat.objective import Penalty + + +class MinimizeOvertime: + """ + Adds a penalty to the solver for including assigning overtime to employees. + """ + + id: ClassVar[str] = "minimize_overtime" + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + overtime: int = 0 + for account in ctx.dataset.monthly_work_accounts: + employee_id = account.employee_id + target_minutes = account.target_minutes + actual_minutes = actual_minutes + overtime += max(0, actual_minutes - target_minutes) + + total_overtime = ctx.model.new_int_var( + 0, + overtime, + "minimize_overtime__total_overtime" + ) + + ctx.model.add(total_overtime == overtime).with_name("minimize_overtime__define_total_overtime") + + return ( + Penalty( + objective_id=self.id, + name="total_overtime", + expression=total_overtime, + ), + ) + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + return () diff --git a/src/scheduling/solver/cp_sat/objectives/not_too_many_consecutive_days.py b/src/scheduling/solver/cp_sat/objectives/not_too_many_consecutive_days.py new file mode 100644 index 00000000..41531c86 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/not_too_many_consecutive_days.py @@ -0,0 +1,75 @@ +from datetime import date, timedelta +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.cp_sat.objective import Penalty + + +class NotTooManyConsecutiveDays: + """ + Adds a penalty to the solver for each consecutive day that an employee works. + """ + + id: ClassVar[str] = "not_too_many_consecutive_days" + + #This seems to be a hard coded variable in the legacy version + MAX_CONSECUTIVE_DAYS: int = 5 + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + #First check which days every employee is assigned to + days_by_employee: defaultdict[int, date] = defaultdict(list) + for key, variable in ctx.assignment_variables.items(): + employee_id, _, date, _, _ = key + days_by_employee[employee_id].append(date) + + #Find out how many times an employee works five days or more consecutively + too_many_consecutive_days: int = 0 + for employee_id in days_by_employee.keys(): + #Make sure the lsit of days is sorted + days_by_employee[employee_id] = sorted(days_by_employee[employee_id]) + block_length: int = 1 + for i in range(len(days_by_employee[employee_id]) - 1): + if days_by_employee[employee_id][i+1] - days_by_employee[employee_id][i] == timedelta(days=1): + block_length += 1 + else: + if block_length > self.MAX_CONSECUTIVE_DAYS: + too_many_consecutive_days +=1 + block_length = 1 + + + + total_too_many_consecutive_days = ctx.model.new_int_var( + 0, + too_many_consecutive_days, + "not_too_many_consecutive_days" + ) + + ctx.model.add(total_too_many_consecutive_days == too_many_consecutive_days).with_name( + "not_too_many_consecutive_days__total_too_many_consecutive_daays") + + return ( + Penalty( + objective_id=self.id, + name="total_too_many_consecutive_days", + expression=total_too_many_consecutive_days, + ), + ) + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + return () diff --git a/src/scheduling/solver/cp_sat/objectives/preferred_block_length.py b/src/scheduling/solver/cp_sat/objectives/preferred_block_length.py new file mode 100644 index 00000000..70c1bff2 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/preferred_block_length.py @@ -0,0 +1,75 @@ +from datetime import date, timedelta +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.cp_sat.objective import Penalty + + +class PreferredBlockLength: + """ + Adds a reward for each time an employee works exactly three days in a row. + """ + + id: ClassVar[str] = "preferred_block_length" + + #This seems to be a hard coded variable in the legacy version + PREFERRED_BLOCK_LENGTH: int = 3 + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + #First check which days every employee is assigned to + days_by_employee: defaultdict[int, date] = defaultdict(list) + for key, variable in ctx.assignment_variables.items(): + employee_id, _, date, _, _ = key + days_by_employee[employee_id].append(date) + + #Find out how many times an employee works exactly three days consecutively + num_preferred_blocks: int = 0 + for employee_id in days_by_employee.keys(): + #Make sure the lsit of days is sorted + days_by_employee[employee_id] = sorted(days_by_employee[employee_id]) + block_length: int = 1 + for i in range(len(days_by_employee[employee_id]) - 1): + if days_by_employee[employee_id][i+1] - days_by_employee[employee_id][i] == timedelta(days=1): + block_length += 1 + else: + if block_length == self.PREFERRED_BLOCK_LENGTH: + num_preferred_blocks +=1 + block_length = 1 + + + + total_preferred_blocks = ctx.model.new_int_var( + 0, + num_preferred_blocks, + "total_preferred_blocks" + ) + + ctx.model.add(total_preferred_blocks == num_preferred_blocks).with_name("preferred_block_length__total_preferred_blocks") + + return ( + Penalty( + objective_id=self.id, + name="total_preferred_blocks", + expression=total_preferred_blocks, + multiplier=-1 #This should make sure that this objective gives a reward instead of a penalty + ), + ) + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + return () diff --git a/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py b/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py new file mode 100644 index 00000000..8b49be63 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py @@ -0,0 +1,57 @@ +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.cp_sat.objective import Penalty + + +class RotateShiftsForward: + """ + Adds a reward for each time an employee works forward rotating shifts and a + penalty for backwards rotating shifts + """ + + id: ClassVar[str] = "rotate_shifts_forward" + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + #First check which shifts every employee is assigned to + days_by_employee: defaultdict[int, date] = defaultdict(list) + for key, variable in ctx.assignment_variables.items(): + employee_id, _, _, shift_id, _ = key + days_by_employee[employee_id].append((date, shift_id)) + + #Find out how the shifts rotate for each employee + num_forward_rotations: int = 0 + num_backward_rotations: int = 0 + for employee_id in days_by_employee.keys(): + #Again make sure that the shifts are properly sorted + days_by_employee[employee_id] = sorted(days_by_employee[employee_id]) + + for i in range(len(days_by_employee[employee_id]) - 1): + shift_type_before = ctx.dataset.shifts + + return ( + Penalty( + objective_id=self.id, + name="total_preferred_blocks", + expression=total_preferred_blocks, + ), + ) + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + return () From 904f897a5ce9f41501b6440d0a2d14d931ff4227 Mon Sep 17 00:00:00 2001 From: xXxEmilioxXx Date: Mon, 29 Jun 2026 10:57:48 +0200 Subject: [PATCH 02/17] all my objectives implemented --- src/scheduling/solver/config.py | 5 +++ src/scheduling/solver/cp_sat/builder.py | 4 ++- .../cp_sat/objectives/rotate_shits_foward.py | 35 ++++++++++++++++--- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/scheduling/solver/config.py b/src/scheduling/solver/config.py index 6d86ea1a..fc937658 100644 --- a/src/scheduling/solver/config.py +++ b/src/scheduling/solver/config.py @@ -10,6 +10,7 @@ from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength +from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward class ConstraintConfig(SchedulingBaseModel): enabled: bool @@ -54,5 +55,9 @@ def create_base_solver_config() -> SolverConfig: enabled=True, weight=1, ), + RotateShiftsForward.id: ObjectiveConfig( + enabled=True, + weight=1, + ), }, ) diff --git a/src/scheduling/solver/cp_sat/builder.py b/src/scheduling/solver/cp_sat/builder.py index 69dd8419..435c4ff6 100644 --- a/src/scheduling/solver/cp_sat/builder.py +++ b/src/scheduling/solver/cp_sat/builder.py @@ -14,6 +14,7 @@ from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength +from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward from scheduling.solver.cp_sat.variables import create_assignment_variables @@ -22,7 +23,8 @@ CP_SAT_OBJECTIVES: tuple[Objective, ...] = (TemporaryBalanceGeneratedAssignments(), MinimizeOvertime(), NotTooManyConsecutiveDays(), - PreferredBlockLength()) + PreferredBlockLength(), + RotateShiftsForward()) @dataclass(frozen=True, slots=True) diff --git a/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py b/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py index 8b49be63..dc80bdcf 100644 --- a/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py +++ b/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py @@ -7,6 +7,7 @@ from scheduling.solver.audit import AuditFinding from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty +from scheduling.domain.shift import ShiftType class RotateShiftsForward: @@ -14,6 +15,16 @@ class RotateShiftsForward: Adds a reward for each time an employee works forward rotating shifts and a penalty for backwards rotating shifts """ + FORWARD_ROTATIONS = ( + (ShiftType("early"), ShiftType("late")), + (ShiftType("late"), ShiftType("night")) + ) + BACKWARD_ROTATIONS = ( + (ShiftType("late"), ShiftType("early")), + (ShiftType("night"), ShiftType("late")) + #In the legacy version, they also penalize going from night -> early, which makes no sense in my eyes + #Also, they only consider a timeframe of 3 days per shift, I do not understand why + ) id: ClassVar[str] = "rotate_shifts_forward" @@ -28,7 +39,7 @@ def add_to_model( #First check which shifts every employee is assigned to days_by_employee: defaultdict[int, date] = defaultdict(list) for key, variable in ctx.assignment_variables.items(): - employee_id, _, _, shift_id, _ = key + employee_id, _, date, shift_id, _ = key days_by_employee[employee_id].append((date, shift_id)) #Find out how the shifts rotate for each employee @@ -39,13 +50,29 @@ def add_to_model( days_by_employee[employee_id] = sorted(days_by_employee[employee_id]) for i in range(len(days_by_employee[employee_id]) - 1): - shift_type_before = ctx.dataset.shifts + for shift in ctx.dataset.shifts: + if shift.shift_id == days_by_employee[employee_id][i][1]: + shift_type_before = shift.type + if shift.shift_id == days_by_employee[employee_id][i+1][1]: + shift_type_after = shift.type + if (shift_type_before, shift_type_after) in self.FORWARD_ROTATIONS: + num_forward_rotations += 1 + elif (shift_type_before, shift_type_after) in self.BACKWARD_ROTATIONS: + num_backward_rotations += 1 + + rotations = ctx.model.new_int_var( + -1000000, + 1000000, + "rotations" + ) + + ctx.model.add(rotations == num_backward_rotations - num_forward_rotations).with_name("rotate_shifts_forward__rotations") return ( Penalty( objective_id=self.id, - name="total_preferred_blocks", - expression=total_preferred_blocks, + name="rotations", + expression=rotations, ), ) From d1d26d57afbccc24a8f9d2ea5d17ea48a6f4adb6 Mon Sep 17 00:00:00 2001 From: halaalasas Date: Sat, 4 Jul 2026 11:30:37 +0200 Subject: [PATCH 03/17] all objectives --- src/scheduling/solver/config.py | 4 + src/scheduling/solver/cp_sat/builder.py | 10 +- .../objectives/every_second_weekend_free.py | 72 ++++++++ .../free_day_after_night_shift_phase.py | 81 +++++++++ .../objectives/free_days_near_weekend.py | 165 ++++++++++++++++++ .../minimize_consecutive_night_shifts.py | 73 ++++++++ 6 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py create mode 100644 src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py create mode 100644 src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py create mode 100644 src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py diff --git a/src/scheduling/solver/config.py b/src/scheduling/solver/config.py index 8acb5c2a..64341ca5 100644 --- a/src/scheduling/solver/config.py +++ b/src/scheduling/solver/config.py @@ -7,6 +7,10 @@ from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( TemporaryBalanceGeneratedAssignments, ) +from scheduling.solver.cp_sat.objectives.every_second_weekend_free import EverySecondWeekendFree +from scheduling.solver.cp_sat.objectives.free_day_after_night_shift_phase import FreeDaysAfterNightShiftPhase +from scheduling.solver.cp_sat.objectives.free_days_near_weekend import FreeDaysNearWeekend +from scheduling.solver.cp_sat.objectives.minimize_consecutive_night_shifts import MinimizeConsecutiveNightShifts class ConstraintConfig(SchedulingBaseModel): diff --git a/src/scheduling/solver/cp_sat/builder.py b/src/scheduling/solver/cp_sat/builder.py index eec7296d..038e8ebb 100644 --- a/src/scheduling/solver/cp_sat/builder.py +++ b/src/scheduling/solver/cp_sat/builder.py @@ -11,11 +11,19 @@ from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( TemporaryBalanceGeneratedAssignments, ) +from scheduling.solver.cp_sat.objectives.every_second_weekend_free import EverySecondWeekendFree +from scheduling.solver.cp_sat.objectives.free_day_after_night_shift_phase import FreeDaysAfterNightShiftPhase +from scheduling.solver.cp_sat.objectives.free_days_near_weekend import FreeDaysNearWeekend +from scheduling.solver.cp_sat.objectives.minimize_consecutive_night_shifts import MinimizeConsecutiveNightShifts + from scheduling.solver.cp_sat.variables import create_assignment_variables CP_SAT_CONSTRAINTS: tuple[Constraint, ...] = (MinimumStaffing(),) -CP_SAT_OBJECTIVES: tuple[Objective, ...] = (TemporaryBalanceGeneratedAssignments(),) +CP_SAT_OBJECTIVES: tuple[Objective, ...] = (TemporaryBalanceGeneratedAssignments(), + EverySecondWeekendFree(), + FreeDaysAfterNightShiftPhase(), + MinimizeConsecutiveNightShifts()) @dataclass(frozen=True, slots=True) diff --git a/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py b/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py new file mode 100644 index 00000000..488aa777 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py @@ -0,0 +1,72 @@ +from collections import defaultdict +from collections.abc import Mapping +from datetime import date, timedelta +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.cp_sat.objective import Penalty + + +class EverySecondWeekendFree: + """ + Adds a penalty if two consecutive weekends have the same status (both free or both worked). + A weekend is free only if both Saturday and Sunday are free. + """ + + id: ClassVar[str] = "every_second_weekend_free" + + def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + weekends: list[tuple[date, date]] = [] + current = ctx.dataset.planning_month.start + while current <= ctx.dataset.planning_month.end: + if current.isoweekday() == 6: + sunday = current + timedelta(days=1) + if sunday <= ctx.dataset.planning_month.end: + weekends.append((current, sunday)) + current += timedelta(days=1) + + if len(weekends) < 2: + return () + + vars_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) + for (employee_id, _unit, d, _shift, _level), var in ctx.assignment_variables.items(): + vars_by_employee_date[(employee_id, d)].append(var) + + employee_ids = {key[0] for key in ctx.assignment_variables} + penalties: list[cp_model.IntVar] = [] + + for employee_id in employee_ids: + for i in range(len(weekends) - 1): + sat1, sun1 = weekends[i] + sat2, sun2 = weekends[i + 1] + + w1_free = ctx.model.new_bool_var(f"esw_w1_free_e{employee_id}_i{i}") + w2_free = ctx.model.new_bool_var(f"esw_w2_free_e{employee_id}_i{i}") + + w1_sum = cp_model.LinearExpr.sum(vars_by_employee_date[(employee_id, sat1)] + vars_by_employee_date[(employee_id, sun1)]) + w2_sum = cp_model.LinearExpr.sum(vars_by_employee_date[(employee_id, sat2)] + vars_by_employee_date[(employee_id, sun2)]) + + ctx.model.add(w1_sum == 0).only_enforce_if(w1_free) + ctx.model.add(w1_sum >= 1).only_enforce_if(w1_free.Not()) + ctx.model.add(w2_sum == 0).only_enforce_if(w2_free) + ctx.model.add(w2_sum >= 1).only_enforce_if(w2_free.Not()) + + same_status = ctx.model.new_bool_var(f"esw_same_status_e{employee_id}_i{i}") + ctx.model.add(same_status == 1).only_enforce_if([w1_free, w2_free]) + ctx.model.add(same_status == 1).only_enforce_if([w1_free.Not(), w2_free.Not()]) + ctx.model.add(same_status == 0).only_enforce_if([w1_free, w2_free.Not()]) + ctx.model.add(same_status == 0).only_enforce_if([w1_free.Not(), w2_free]) + penalties.append(same_status) + + total = ctx.model.new_int_var(0, len(penalties), "esw_total") + ctx.model.add(total == cp_model.LinearExpr.sum(penalties)) + return (Penalty(objective_id=self.id, name="total", expression=total),) + + def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: + return () \ No newline at end of file diff --git a/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py b/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py new file mode 100644 index 00000000..9af3d0bd --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py @@ -0,0 +1,81 @@ +from collections import defaultdict +from collections.abc import Mapping +from datetime import date, timedelta +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.domain.shift import ShiftType +from scheduling.solver.audit import AuditFinding +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.cp_sat.objective import Penalty + + +class FreeDaysAfterNightShiftPhase: + """ + Adds a penalty if an employee works on day+2 after a night shift on day, + while day+1 is free. Encourages two consecutive free days after night shifts. + """ + + id: ClassVar[str] = "free_days_after_night_shift_phase" + + def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + night_shift_ids = {s.shift_id for s in ctx.dataset.shifts if s.type == ShiftType.NIGHT} + if not night_shift_ids: + return () + + vars_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) + night_vars_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) + + for (employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): + vars_by_employee_date[(employee_id, d)].append(var) + if shift_id in night_shift_ids: + night_vars_by_employee_date[(employee_id, d)].append(var) + + planning_dates = sorted({key[2] for key in ctx.assignment_variables}) + employee_ids = {key[0] for key in ctx.assignment_variables} + penalties: list[cp_model.IntVar] = [] + + for employee_id in employee_ids: + for day in planning_dates[:-2]: + next_day = day + timedelta(days=1) + after_next = day + timedelta(days=2) + + night_vars = night_vars_by_employee_date[(employee_id, day)] + if not night_vars: + continue + + worked_night = ctx.model.new_bool_var(f"fdansp_night_e{employee_id}_d{day}") + ctx.model.add(cp_model.LinearExpr.sum(night_vars) >= 1).only_enforce_if(worked_night) + ctx.model.add(cp_model.LinearExpr.sum(night_vars) == 0).only_enforce_if(worked_night.Not()) + + next_vars = vars_by_employee_date[(employee_id, next_day)] + after_vars = vars_by_employee_date[(employee_id, after_next)] + + next_free = ctx.model.new_bool_var(f"fdansp_next_free_e{employee_id}_d{day}") + ctx.model.add(cp_model.LinearExpr.sum(next_vars) == 0).only_enforce_if(next_free) + ctx.model.add(cp_model.LinearExpr.sum(next_vars) >= 1).only_enforce_if(next_free.Not()) + + after_worked = ctx.model.new_bool_var(f"fdansp_after_worked_e{employee_id}_d{day}") + ctx.model.add(cp_model.LinearExpr.sum(after_vars) >= 1).only_enforce_if(after_worked) + ctx.model.add(cp_model.LinearExpr.sum(after_vars) == 0).only_enforce_if(after_worked.Not()) + + penalty_var = ctx.model.new_bool_var(f"fdansp_penalty_e{employee_id}_d{day}") + ctx.model.add(penalty_var == 1).only_enforce_if([worked_night, next_free, after_worked]) + ctx.model.add(penalty_var == 0).only_enforce_if(worked_night.Not()) + ctx.model.add(penalty_var == 0).only_enforce_if(next_free.Not()) + ctx.model.add(penalty_var == 0).only_enforce_if(after_worked.Not()) + penalties.append(penalty_var) + + if not penalties: + return () + + total = ctx.model.new_int_var(0, len(penalties), "fdansp_total") + ctx.model.add(total == cp_model.LinearExpr.sum(penalties)) + return (Penalty(objective_id=self.id, name="total", expression=total),) + + def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: + return () \ No newline at end of file diff --git a/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py b/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py new file mode 100644 index 00000000..6ba09c12 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py @@ -0,0 +1,165 @@ +from collections import defaultdict +from collections.abc import Mapping +from datetime import date, timedelta +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.cp_sat.objective import Penalty + +# Weekdays adjacent to a weekend. +# ISO weekday: Monday = 1, Friday = 5 +_NEAR_WEEKEND_DAYS = frozenset({1, 5}) + + +class FreeDaysNearWeekend: + """ + Rewards employees for having free days adjacent to a weekend. + + A free Friday or Monday is rewarded. + An additional reward is given if the adjacent weekend day is also free. + """ + + id: ClassVar[str] = "free_days_near_weekend" + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + # Group assignment variables by employee and date. + vars_by_employee_date: defaultdict[ + tuple[int, date], + list[cp_model.IntVar], + ] = defaultdict(list) + + for (employee_id, _, assignment_date, _, _), variable in ctx.assignment_variables.items(): + vars_by_employee_date[(employee_id, assignment_date)].append(variable) + + # Build planning dates from the planning month. + planning_dates: list[date] = [] + + current_date = ctx.dataset.planning_month.start + while current_date <= ctx.dataset.planning_month.end: + planning_dates.append(current_date) + current_date += timedelta(days=1) + + planning_dates_set = set(planning_dates) + + employee_ids = { + key[0] + for key in ctx.assignment_variables.keys() + } + + reward_variables: list[cp_model.IntVar] = [] + reward_weights: list[int] = [] + + for employee_id in employee_ids: + for current_date in planning_dates: + + if current_date.isoweekday() not in _NEAR_WEEKEND_DAYS: + continue + + adjacent_date = ( + current_date + timedelta(days=1) + if current_date.isoweekday() == 5 + else current_date - timedelta(days=1) + ) + + if adjacent_date not in planning_dates_set: + continue + + today_assignment_vars = vars_by_employee_date[(employee_id, current_date)] + adjacent_assignment_vars = vars_by_employee_date[(employee_id, adjacent_date)] + + free_today = ctx.model.new_bool_var( + f"free_today_e{employee_id}_{current_date}" + ) + + ctx.model.add( + cp_model.LinearExpr.sum(today_assignment_vars) == 0 + ).only_enforce_if(free_today) + + ctx.model.add( + cp_model.LinearExpr.sum(today_assignment_vars) >= 1 + ).only_enforce_if(free_today.Not()) + + free_adjacent = ctx.model.new_bool_var( + f"free_adjacent_e{employee_id}_{current_date}" + ) + + ctx.model.add( + cp_model.LinearExpr.sum(adjacent_assignment_vars) == 0 + ).only_enforce_if(free_adjacent) + + ctx.model.add( + cp_model.LinearExpr.sum(adjacent_assignment_vars) >= 1 + ).only_enforce_if(free_adjacent.Not()) + + free_both = ctx.model.new_bool_var( + f"free_both_e{employee_id}_{current_date}" + ) + + ctx.model.add_bool_and( + [free_today, free_adjacent] + ).only_enforce_if(free_both) + + ctx.model.add_bool_or( + [free_today.Not(), free_adjacent.Not()] + ).only_enforce_if(free_both.Not()) + + reward_variables.extend( + [ + free_today, + free_adjacent, + free_both, + ] + ) + + reward_weights.extend( + [ + 1, + 1, + 4, + ] + ) + + if not reward_variables: + return () + + total_reward = ctx.model.new_int_var( + 0, + sum(reward_weights), + "free_days_near_weekend__total_reward", + ) + + ctx.model.add( + total_reward + == cp_model.LinearExpr.weighted_sum( + reward_variables, + reward_weights, + ) + ).with_name( + "free_days_near_weekend__define_total_reward" + ) + + return ( + Penalty( + objective_id=self.id, + name="total_reward", + expression=total_reward, + multiplier=-1, + ), + ) + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + return () diff --git a/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py b/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py new file mode 100644 index 00000000..1614b071 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py @@ -0,0 +1,73 @@ +from collections import defaultdict +from collections.abc import Mapping +from datetime import date, timedelta +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.domain.shift import ShiftType +from scheduling.solver.audit import AuditFinding +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.cp_sat.objective import Penalty + + +class MinimizeConsecutiveNightShifts: + """ + Penalizes consecutive night shift phases of length 2, 3, and 4. + Longer phases are penalized more heavily via the multiplier. + """ + + id: ClassVar[str] = "minimize_consecutive_night_shifts" + + def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + night_shift_ids = {s.shift_id for s in ctx.dataset.shifts if s.type == ShiftType.NIGHT} + if not night_shift_ids: + return () + + night_vars_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) + for (employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): + if shift_id in night_shift_ids: + night_vars_by_employee_date[(employee_id, d)].append(var) + + planning_dates = sorted({key[2] for key in ctx.assignment_variables}) + employee_ids = {key[0] for key in ctx.assignment_variables} + result: list[Penalty] = [] + + for phase_length in (2, 3, 4): + phase_vars: list[cp_model.IntVar] = [] + + for employee_id in employee_ids: + for i, day in enumerate(planning_dates[: -(phase_length - 1)]): + window_days = [planning_dates[i + offset] for offset in range(phase_length)] + + # One bool per day: did employee work a night shift? + per_day: list[cp_model.IntVar] = [] + for wd in window_days: + day_night_vars = night_vars_by_employee_date[(employee_id, wd)] + if not day_night_vars: + break + if len(day_night_vars) == 1: + per_day.append(day_night_vars[0]) + else: + b = ctx.model.new_bool_var(f"mcns_night_e{employee_id}_d{wd}_l{phase_length}") + ctx.model.add(cp_model.LinearExpr.sum(day_night_vars) >= 1).only_enforce_if(b) + ctx.model.add(cp_model.LinearExpr.sum(day_night_vars) == 0).only_enforce_if(b.Not()) + per_day.append(b) + else: + phase_var = ctx.model.new_bool_var(f"mcns_phase_e{employee_id}_d{day}_l{phase_length}") + ctx.model.add_bool_and(per_day).only_enforce_if(phase_var) + ctx.model.add_bool_or([v.Not() for v in per_day]).only_enforce_if(phase_var.Not()) + phase_vars.append(phase_var) + + if phase_vars: + total = ctx.model.new_int_var(0, len(phase_vars), f"mcns_total_l{phase_length}") + ctx.model.add(total == cp_model.LinearExpr.sum(phase_vars)) + result.append(Penalty(objective_id=self.id, name=f"total_l{phase_length}", expression=total, multiplier=phase_length)) + + return tuple(result) + + def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: + return () \ No newline at end of file From 6f7d1322038825f5d618e895a426921863b3a36c Mon Sep 17 00:00:00 2001 From: xXxEmilioxXx Date: Mon, 6 Jul 2026 14:32:45 +0200 Subject: [PATCH 04/17] implemented prefer_own_planning_units objective --- launch.json | 21 ++++++ src/scheduling/solver/config.py | 5 ++ src/scheduling/solver/cp_sat/builder.py | 4 +- .../objectives/prefer_own_planning_unit.py | 73 +++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 launch.json create mode 100644 src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py diff --git a/launch.json b/launch.json new file mode 100644 index 00000000..cb8162b2 --- /dev/null +++ b/launch.json @@ -0,0 +1,21 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Attach to Docker debugpy", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/app" + } + ], + "justMyCode": false + } + ] +} \ No newline at end of file diff --git a/src/scheduling/solver/config.py b/src/scheduling/solver/config.py index fc937658..de682ca2 100644 --- a/src/scheduling/solver/config.py +++ b/src/scheduling/solver/config.py @@ -11,6 +11,7 @@ from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward +from scheduling.solver.cp_sat.objectives.prefer_own_planning_unit import PreferOwnPlanningUnit class ConstraintConfig(SchedulingBaseModel): enabled: bool @@ -59,5 +60,9 @@ def create_base_solver_config() -> SolverConfig: enabled=True, weight=1, ), + PreferOwnPlanningUnit.id: ObjectiveConfig( + enabled=True, + weight=1, + ), }, ) diff --git a/src/scheduling/solver/cp_sat/builder.py b/src/scheduling/solver/cp_sat/builder.py index 435c4ff6..1bc25c21 100644 --- a/src/scheduling/solver/cp_sat/builder.py +++ b/src/scheduling/solver/cp_sat/builder.py @@ -15,6 +15,7 @@ from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward +from scheduling.solver.cp_sat.objectives.prefer_own_planning_unit import PreferOwnPlanningUnit from scheduling.solver.cp_sat.variables import create_assignment_variables @@ -24,7 +25,8 @@ MinimizeOvertime(), NotTooManyConsecutiveDays(), PreferredBlockLength(), - RotateShiftsForward()) + RotateShiftsForward(), + PreferOwnPlanningUnit()) @dataclass(frozen=True, slots=True) diff --git a/src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py b/src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py new file mode 100644 index 00000000..d9e2da87 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py @@ -0,0 +1,73 @@ +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.cp_sat.objective import Penalty + +from scheduling.domain.planning_unit import PlanningUnitMembership, PlanningUnitType, PlanningUnit, PlanningUnitId + +class PreferOwnPlanningUnit: + """ + Adds a penalty every time an employee is assigned to the planning unit that is not his + preferred planning unit. + """ + + id: ClassVar[str] = "prefer_own_planning_unit" + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + #Dictionary with employee as key and planning unit + employees_PU_dict: defaultdict[int, PlanningUnitId] = defaultdict(list) + #First assign a planning unit to every employee, read from the dataset + for membership in ctx.dataset.planning_unit_memberships: + employee_id = membership.employee_id + planning_unit_id = membership.planning_unit_id + + + #Then find out the id of any shared pool stations + shared_pool_type = PlanningUnitType("shared_pool") + shared_pool_ids: list[PlanningUnitId] = list[PlanningUnitId] + for pu in ctx.dataset.planning_units: + if pu.type == shared_pool_ids: + shared_pool_ids.append(pu.planning_unit_id) + + #Now check in the assignments whether an employee (who is not in the shared pool!!!) + #was assigned to a planning unit that is not his own + num_not_preferred_planning_unit: int = 0 + for key, variable in ctx.assignment_variables.items(): + employee_id, planning_unit_id, _, _, _ = key + if employees_PU_dict[employee_id] != planning_unit_id and employees_PU_dict[employee_id] not in shared_pool_ids: + num_not_preferred_planning_unit += 1 + + prefer_own_planning_unit_penalty = ctx.model.new_int_var( + 0, + 100000, + "not_preferred_planning_unit" + ) + + ctx.model.add(prefer_own_planning_unit_penalty == num_not_preferred_planning_unit).with_name("prefer_own_planning_unit__penalty") + + return ( + Penalty( + objective_id=self.id, + name="prefer_own_planning_unit_penalty", + expression=prefer_own_planning_unit_penalty, + ), + ) + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + return () From 81308f9cc5a7e957fa707f37bd463a7243c6a0a7 Mon Sep 17 00:00:00 2001 From: halaalasas Date: Tue, 7 Jul 2026 17:20:06 +0200 Subject: [PATCH 05/17] Improved objectives and added tests --- src/scheduling/solver/config.py | 4 + src/scheduling/solver/cp_sat/builder.py | 3 +- .../objectives/every_second_weekend_free.py | 41 ++-- .../free_day_after_night_shift_phase.py | 36 +++- .../objectives/free_days_near_weekend.py | 184 ++++++------------ .../minimize_consecutive_night_shifts.py | 30 ++- tests/cp/objectives/__init__.py | 0 .../test_every_second_weekend_free.py | 130 +++++++++++++ .../test_free_day_after_night_shift_phase.py | 127 ++++++++++++ .../objectives/test_free_days_near_weekend.py | 126 ++++++++++++ .../test_minimize_consecutive_night_shifts.py | 151 ++++++++++++++ 11 files changed, 673 insertions(+), 159 deletions(-) create mode 100644 tests/cp/objectives/__init__.py create mode 100644 tests/cp/objectives/test_every_second_weekend_free.py create mode 100644 tests/cp/objectives/test_free_day_after_night_shift_phase.py create mode 100644 tests/cp/objectives/test_free_days_near_weekend.py create mode 100644 tests/cp/objectives/test_minimize_consecutive_night_shifts.py diff --git a/src/scheduling/solver/config.py b/src/scheduling/solver/config.py index 64341ca5..2f98dc61 100644 --- a/src/scheduling/solver/config.py +++ b/src/scheduling/solver/config.py @@ -44,5 +44,9 @@ def create_base_solver_config() -> SolverConfig: enabled=True, weight=1, ), + EverySecondWeekendFree.id: ObjectiveConfig(enabled=True, weight=1), + FreeDaysAfterNightShiftPhase.id: ObjectiveConfig(enabled=True, weight=1), + FreeDaysNearWeekend.id: ObjectiveConfig(enabled=True, weight=1), + MinimizeConsecutiveNightShifts.id: ObjectiveConfig(enabled=True, weight=1), }, ) diff --git a/src/scheduling/solver/cp_sat/builder.py b/src/scheduling/solver/cp_sat/builder.py index 038e8ebb..bc8f7fd1 100644 --- a/src/scheduling/solver/cp_sat/builder.py +++ b/src/scheduling/solver/cp_sat/builder.py @@ -23,7 +23,8 @@ CP_SAT_OBJECTIVES: tuple[Objective, ...] = (TemporaryBalanceGeneratedAssignments(), EverySecondWeekendFree(), FreeDaysAfterNightShiftPhase(), - MinimizeConsecutiveNightShifts()) + MinimizeConsecutiveNightShifts(), + FreeDaysNearWeekend(),) @dataclass(frozen=True, slots=True) diff --git a/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py b/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py index 488aa777..6096b015 100644 --- a/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py +++ b/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py @@ -12,8 +12,9 @@ class EverySecondWeekendFree: """ - Adds a penalty if two consecutive weekends have the same status (both free or both worked). - A weekend is free only if both Saturday and Sunday are free. + Penalizes consecutive weekends with the same status (both worked or both free). + Encourages alternating free weekends. A weekend is only free if both Saturday + and Sunday are unassigned. """ id: ClassVar[str] = "every_second_weekend_free" @@ -22,6 +23,7 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P if not ctx.assignment_variables: return () + # Collect all complete Saturday-Sunday pairs within the planning month weekends: list[tuple[date, date]] = [] current = ctx.dataset.planning_month.start while current <= ctx.dataset.planning_month.end: @@ -46,17 +48,31 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P sat1, sun1 = weekends[i] sat2, sun2 = weekends[i + 1] - w1_free = ctx.model.new_bool_var(f"esw_w1_free_e{employee_id}_i{i}") - w2_free = ctx.model.new_bool_var(f"esw_w2_free_e{employee_id}_i{i}") + w1_vars = vars_by_employee_date[(employee_id, sat1)] + vars_by_employee_date[(employee_id, sun1)] + w2_vars = vars_by_employee_date[(employee_id, sat2)] + vars_by_employee_date[(employee_id, sun2)] - w1_sum = cp_model.LinearExpr.sum(vars_by_employee_date[(employee_id, sat1)] + vars_by_employee_date[(employee_id, sun1)]) - w2_sum = cp_model.LinearExpr.sum(vars_by_employee_date[(employee_id, sat2)] + vars_by_employee_date[(employee_id, sun2)]) + # Skip if employee has no assignments on either weekend at all + if not w1_vars and not w2_vars: + continue - ctx.model.add(w1_sum == 0).only_enforce_if(w1_free) - ctx.model.add(w1_sum >= 1).only_enforce_if(w1_free.Not()) - ctx.model.add(w2_sum == 0).only_enforce_if(w2_free) - ctx.model.add(w2_sum >= 1).only_enforce_if(w2_free.Not()) + w1_free = ctx.model.new_bool_var(f"esw_w1_free_e{employee_id}_i{i}") + w2_free = ctx.model.new_bool_var(f"esw_w2_free_e{employee_id}_i{i}") + # w1_free iff no assignment on Saturday or Sunday of weekend 1 + if w1_vars: + ctx.model.add(sum(w1_vars) == 0).only_enforce_if(w1_free) + ctx.model.add(sum(w1_vars) >= 1).only_enforce_if(w1_free.Not()) + else: + ctx.model.add(w1_free == 1) + + # w2_free iff no assignment on Saturday or Sunday of weekend 2 + if w2_vars: + ctx.model.add(sum(w2_vars) == 0).only_enforce_if(w2_free) + ctx.model.add(sum(w2_vars) >= 1).only_enforce_if(w2_free.Not()) + else: + ctx.model.add(w2_free == 1) + + # Penalize if both weekends have the same free/worked status same_status = ctx.model.new_bool_var(f"esw_same_status_e{employee_id}_i{i}") ctx.model.add(same_status == 1).only_enforce_if([w1_free, w2_free]) ctx.model.add(same_status == 1).only_enforce_if([w1_free.Not(), w2_free.Not()]) @@ -64,8 +80,11 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P ctx.model.add(same_status == 0).only_enforce_if([w1_free.Not(), w2_free]) penalties.append(same_status) + if not penalties: + return () + total = ctx.model.new_int_var(0, len(penalties), "esw_total") - ctx.model.add(total == cp_model.LinearExpr.sum(penalties)) + ctx.model.add(total == sum(penalties)) return (Penalty(objective_id=self.id, name="total", expression=total),) def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: diff --git a/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py b/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py index 9af3d0bd..e3a0a83e 100644 --- a/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py +++ b/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py @@ -13,8 +13,8 @@ class FreeDaysAfterNightShiftPhase: """ - Adds a penalty if an employee works on day+2 after a night shift on day, - while day+1 is free. Encourages two consecutive free days after night shifts. + Penalizes the pattern: night shift on day D, free on D+1, but working on D+2. + The goal is to encourage two full consecutive rest days after a night shift. """ id: ClassVar[str] = "free_days_after_night_shift_phase" @@ -35,34 +35,50 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P if shift_id in night_shift_ids: night_vars_by_employee_date[(employee_id, d)].append(var) + # Use the actual set of planning dates to avoid gaps when checking day+1 and day+2 planning_dates = sorted({key[2] for key in ctx.assignment_variables}) + planning_dates_set = set(planning_dates) employee_ids = {key[0] for key in ctx.assignment_variables} penalties: list[cp_model.IntVar] = [] for employee_id in employee_ids: - for day in planning_dates[:-2]: + for day in planning_dates: next_day = day + timedelta(days=1) after_next = day + timedelta(days=2) + # Only proceed if both following days exist in the planning period + if next_day not in planning_dates_set or after_next not in planning_dates_set: + continue + night_vars = night_vars_by_employee_date[(employee_id, day)] if not night_vars: continue + # worked_night: employee worked a night shift on this day worked_night = ctx.model.new_bool_var(f"fdansp_night_e{employee_id}_d{day}") - ctx.model.add(cp_model.LinearExpr.sum(night_vars) >= 1).only_enforce_if(worked_night) - ctx.model.add(cp_model.LinearExpr.sum(night_vars) == 0).only_enforce_if(worked_night.Not()) + ctx.model.add(sum(night_vars) >= 1).only_enforce_if(worked_night) + ctx.model.add(sum(night_vars) == 0).only_enforce_if(worked_night.Not()) next_vars = vars_by_employee_date[(employee_id, next_day)] after_vars = vars_by_employee_date[(employee_id, after_next)] + # next_free: employee has no assignment the day after the night shift next_free = ctx.model.new_bool_var(f"fdansp_next_free_e{employee_id}_d{day}") - ctx.model.add(cp_model.LinearExpr.sum(next_vars) == 0).only_enforce_if(next_free) - ctx.model.add(cp_model.LinearExpr.sum(next_vars) >= 1).only_enforce_if(next_free.Not()) + if next_vars: + ctx.model.add(sum(next_vars) == 0).only_enforce_if(next_free) + ctx.model.add(sum(next_vars) >= 1).only_enforce_if(next_free.Not()) + else: + ctx.model.add(next_free == 1) + # after_worked: employee works two days after the night shift after_worked = ctx.model.new_bool_var(f"fdansp_after_worked_e{employee_id}_d{day}") - ctx.model.add(cp_model.LinearExpr.sum(after_vars) >= 1).only_enforce_if(after_worked) - ctx.model.add(cp_model.LinearExpr.sum(after_vars) == 0).only_enforce_if(after_worked.Not()) + if after_vars: + ctx.model.add(sum(after_vars) >= 1).only_enforce_if(after_worked) + ctx.model.add(sum(after_vars) == 0).only_enforce_if(after_worked.Not()) + else: + ctx.model.add(after_worked == 0) + # Penalty fires when: night on D, free on D+1, but working on D+2 penalty_var = ctx.model.new_bool_var(f"fdansp_penalty_e{employee_id}_d{day}") ctx.model.add(penalty_var == 1).only_enforce_if([worked_night, next_free, after_worked]) ctx.model.add(penalty_var == 0).only_enforce_if(worked_night.Not()) @@ -74,7 +90,7 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P return () total = ctx.model.new_int_var(0, len(penalties), "fdansp_total") - ctx.model.add(total == cp_model.LinearExpr.sum(penalties)) + ctx.model.add(total == sum(penalties)) return (Penalty(objective_id=self.id, name="total", expression=total),) def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: diff --git a/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py b/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py index 6ba09c12..ebcaa18c 100644 --- a/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py +++ b/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py @@ -9,157 +9,83 @@ from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty -# Weekdays adjacent to a weekend. -# ISO weekday: Monday = 1, Friday = 5 +# Fridays (5) and Mondays (1) are the days adjacent to weekends _NEAR_WEEKEND_DAYS = frozenset({1, 5}) class FreeDaysNearWeekend: """ - Rewards employees for having free days adjacent to a weekend. + Rewards employees for being free on Fridays and Mondays (days adjacent to weekends). + An additional bonus is given if the neighboring weekend day is also free, + effectively rewarding a three-day weekend stretch. - A free Friday or Monday is rewarded. - An additional reward is given if the adjacent weekend day is also free. + Weights: free near-weekend day = 1, free adjacent weekend day = 1, + both free (bonus) = 4. + Since this is a reward, the penalty is returned with multiplier=-1. """ id: ClassVar[str] = "free_days_near_weekend" - def add_to_model( - self, - ctx: SolverContext, - params: Mapping[str, Any], - ) -> tuple[Penalty, ...]: + def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: if not ctx.assignment_variables: return () - # Group assignment variables by employee and date. - vars_by_employee_date: defaultdict[ - tuple[int, date], - list[cp_model.IntVar], - ] = defaultdict(list) + vars_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) + for (employee_id, _unit, d, _shift, _level), var in ctx.assignment_variables.items(): + vars_by_employee_date[(employee_id, d)].append(var) - for (employee_id, _, assignment_date, _, _), variable in ctx.assignment_variables.items(): - vars_by_employee_date[(employee_id, assignment_date)].append(variable) + planning_dates_set = {key[2] for key in ctx.assignment_variables} + employee_ids = {key[0] for key in ctx.assignment_variables} - # Build planning dates from the planning month. - planning_dates: list[date] = [] + free_today_vars: list[cp_model.IntVar] = [] + free_adjacent_vars: list[cp_model.IntVar] = [] + free_both_vars: list[cp_model.IntVar] = [] - current_date = ctx.dataset.planning_month.start - while current_date <= ctx.dataset.planning_month.end: - planning_dates.append(current_date) - current_date += timedelta(days=1) - - planning_dates_set = set(planning_dates) - - employee_ids = { - key[0] - for key in ctx.assignment_variables.keys() - } + for employee_id in employee_ids: + for d in sorted(planning_dates_set): + if d.isoweekday() not in _NEAR_WEEKEND_DAYS: + continue - reward_variables: list[cp_model.IntVar] = [] - reward_weights: list[int] = [] + today_vars = vars_by_employee_date[(employee_id, d)] - for employee_id in employee_ids: - for current_date in planning_dates: + # Reward: near-weekend day (Friday/Monday) is free + free_today = ctx.model.new_bool_var(f"fdnw_free_today_e{employee_id}_d{d}") + if today_vars: + ctx.model.add(sum(today_vars) == 0).only_enforce_if(free_today) + ctx.model.add(sum(today_vars) >= 1).only_enforce_if(free_today.Not()) + else: + ctx.model.add(free_today == 1) + free_today_vars.append(free_today) - if current_date.isoweekday() not in _NEAR_WEEKEND_DAYS: + # Adjacent weekend day: Saturday for Friday, Sunday for Monday + adjacent = d + timedelta(days=1) if d.isoweekday() == 5 else d - timedelta(days=1) + if adjacent not in planning_dates_set: continue - adjacent_date = ( - current_date + timedelta(days=1) - if current_date.isoweekday() == 5 - else current_date - timedelta(days=1) - ) + adjacent_vars = vars_by_employee_date[(employee_id, adjacent)] - if adjacent_date not in planning_dates_set: - continue + # Reward: the neighboring weekend day is also free + free_adj = ctx.model.new_bool_var(f"fdnw_free_adj_e{employee_id}_d{d}") + if adjacent_vars: + ctx.model.add(sum(adjacent_vars) == 0).only_enforce_if(free_adj) + ctx.model.add(sum(adjacent_vars) >= 1).only_enforce_if(free_adj.Not()) + else: + ctx.model.add(free_adj == 1) + free_adjacent_vars.append(free_adj) - today_assignment_vars = vars_by_employee_date[(employee_id, current_date)] - adjacent_assignment_vars = vars_by_employee_date[(employee_id, adjacent_date)] - - free_today = ctx.model.new_bool_var( - f"free_today_e{employee_id}_{current_date}" - ) - - ctx.model.add( - cp_model.LinearExpr.sum(today_assignment_vars) == 0 - ).only_enforce_if(free_today) - - ctx.model.add( - cp_model.LinearExpr.sum(today_assignment_vars) >= 1 - ).only_enforce_if(free_today.Not()) - - free_adjacent = ctx.model.new_bool_var( - f"free_adjacent_e{employee_id}_{current_date}" - ) - - ctx.model.add( - cp_model.LinearExpr.sum(adjacent_assignment_vars) == 0 - ).only_enforce_if(free_adjacent) - - ctx.model.add( - cp_model.LinearExpr.sum(adjacent_assignment_vars) >= 1 - ).only_enforce_if(free_adjacent.Not()) - - free_both = ctx.model.new_bool_var( - f"free_both_e{employee_id}_{current_date}" - ) - - ctx.model.add_bool_and( - [free_today, free_adjacent] - ).only_enforce_if(free_both) - - ctx.model.add_bool_or( - [free_today.Not(), free_adjacent.Not()] - ).only_enforce_if(free_both.Not()) - - reward_variables.extend( - [ - free_today, - free_adjacent, - free_both, - ] - ) - - reward_weights.extend( - [ - 1, - 1, - 4, - ] - ) - - if not reward_variables: + # Bonus reward: both the near-weekend day and adjacent weekend day are free + free_both = ctx.model.new_bool_var(f"fdnw_free_both_e{employee_id}_d{d}") + ctx.model.add_bool_and([free_today, free_adj]).only_enforce_if(free_both) + ctx.model.add_bool_or([free_today.Not(), free_adj.Not()]).only_enforce_if(free_both.Not()) + free_both_vars.append(free_both) + + if not free_today_vars: return () - total_reward = ctx.model.new_int_var( - 0, - sum(reward_weights), - "free_days_near_weekend__total_reward", - ) - - ctx.model.add( - total_reward - == cp_model.LinearExpr.weighted_sum( - reward_variables, - reward_weights, - ) - ).with_name( - "free_days_near_weekend__define_total_reward" - ) - - return ( - Penalty( - objective_id=self.id, - name="total_reward", - expression=total_reward, - multiplier=-1, - ), - ) - - def audit( - self, - ctx: AuditContext, - params: Mapping[str, Any], - ) -> tuple[AuditFinding, ...]: - return () + max_total = len(free_today_vars) + len(free_adjacent_vars) + 4 * len(free_both_vars) + total = ctx.model.new_int_var(0, max_total, "fdnw_total") + ctx.model.add(total == sum(free_today_vars) + sum(free_adjacent_vars) + 4 * sum(free_both_vars)) + return (Penalty(objective_id=self.id, name="total", expression=total, multiplier=-1),) + + def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: + return () \ No newline at end of file diff --git a/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py b/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py index 1614b071..3801d603 100644 --- a/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py +++ b/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py @@ -1,6 +1,6 @@ from collections import defaultdict from collections.abc import Mapping -from datetime import date, timedelta +from datetime import date from typing import Any, ClassVar from ortools.sat.python import cp_model @@ -13,8 +13,13 @@ class MinimizeConsecutiveNightShifts: """ - Penalizes consecutive night shift phases of length 2, 3, and 4. - Longer phases are penalized more heavily via the multiplier. + Penalizes windows of consecutive night shifts of length 2, 3, and 4. + Each phase length produces a separate Penalty with multiplier=phase_length, + so longer phases are penalized more heavily by the model builder. + + For each window: a bool variable is True iff the employee worked a night + shift on every day in the window. If an employee can work multiple night + shift types, a per-day aggregation bool is created first. """ id: ClassVar[str] = "minimize_consecutive_night_shifts" @@ -43,20 +48,24 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P for i, day in enumerate(planning_dates[: -(phase_length - 1)]): window_days = [planning_dates[i + offset] for offset in range(phase_length)] - # One bool per day: did employee work a night shift? + # Build one bool per day in the window indicating a night shift was worked. + # If there is only one night shift type, reuse its variable directly. per_day: list[cp_model.IntVar] = [] for wd in window_days: day_night_vars = night_vars_by_employee_date[(employee_id, wd)] if not day_night_vars: + # No night shift possible on this day; window can never be active break if len(day_night_vars) == 1: per_day.append(day_night_vars[0]) else: + # Aggregate multiple night shift variables into one bool b = ctx.model.new_bool_var(f"mcns_night_e{employee_id}_d{wd}_l{phase_length}") - ctx.model.add(cp_model.LinearExpr.sum(day_night_vars) >= 1).only_enforce_if(b) - ctx.model.add(cp_model.LinearExpr.sum(day_night_vars) == 0).only_enforce_if(b.Not()) + ctx.model.add(sum(day_night_vars) >= 1).only_enforce_if(b) + ctx.model.add(sum(day_night_vars) == 0).only_enforce_if(b.Not()) per_day.append(b) else: + # for/else: only reached if no break occurred (all days have night vars) phase_var = ctx.model.new_bool_var(f"mcns_phase_e{employee_id}_d{day}_l{phase_length}") ctx.model.add_bool_and(per_day).only_enforce_if(phase_var) ctx.model.add_bool_or([v.Not() for v in per_day]).only_enforce_if(phase_var.Not()) @@ -64,8 +73,13 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P if phase_vars: total = ctx.model.new_int_var(0, len(phase_vars), f"mcns_total_l{phase_length}") - ctx.model.add(total == cp_model.LinearExpr.sum(phase_vars)) - result.append(Penalty(objective_id=self.id, name=f"total_l{phase_length}", expression=total, multiplier=phase_length)) + ctx.model.add(total == sum(phase_vars)) + result.append(Penalty( + objective_id=self.id, + name=f"total_l{phase_length}", + expression=total, + multiplier=phase_length, # longer phases cost more + )) return tuple(result) diff --git a/tests/cp/objectives/__init__.py b/tests/cp/objectives/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cp/objectives/test_every_second_weekend_free.py b/tests/cp/objectives/test_every_second_weekend_free.py new file mode 100644 index 00000000..4b3fa546 --- /dev/null +++ b/tests/cp/objectives/test_every_second_weekend_free.py @@ -0,0 +1,130 @@ +from datetime import date + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.every_second_weekend_free import EverySecondWeekendFree +from scheduling.solver.cp_sat.variables import create_assignment_variables + + +# --- Shared test data --- + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _dataset() -> SchedulingDataset: + # November 2024: weekends are 2-3, 9-10, 16-17, 23-24, 30 + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT,), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + ) + + +def _solve_with_forced_days(forced_work_dates: list[date]): + """ + Build a model where the employee is forced to work on forced_work_dates, + apply the objective, and solve. Returns the solver status. + """ + dataset = _dataset() + ctx = create_context(dataset=dataset) + create_assignment_variables(ctx) + + # Force the employee to work (or not) on the given dates + for (_employee_id, _unit, d, _shift, _level), var in ctx.assignment_variables.items(): + if d in forced_work_dates: + ctx.model.add(var == 1) + else: + ctx.model.add(var == 0) + + penalties = EverySecondWeekendFree().add_to_model(ctx, params={}) + assert penalties, "Objective should return at least one penalty" + + ctx.model.minimize(penalties[0].expression) + + solver = cp_model.CpSolver() + return solver.solve(ctx.model) + + +@pytest.mark.integration +def test_penalty_when_both_weekends_worked() -> None: + # Employee works both weekend 1 (Sat 2, Sun 3) and weekend 2 (Sat 9, Sun 10) + # → same status (both worked) → penalty expected + status = _solve_with_forced_days([date(2024, 11, 2), date(2024, 11, 3), date(2024, 11, 9), date(2024, 11, 10)]) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_no_penalty_when_weekends_alternate() -> None: + # Employee works weekends 1 and 3 (Sat/Sun), is free on weekends 2 and 4. + # Every consecutive pair alternates → no same-status penalty expected. + # November 2024 weekends: (2,3), (9,10), (16,17), (23,24) + worked_weekends = {date(2024, 11, 2), date(2024, 11, 3), date(2024, 11, 16), date(2024, 11, 17)} + free_weekends = {date(2024, 11, 9), date(2024, 11, 10), date(2024, 11, 23), date(2024, 11, 24)} + + dataset = _dataset() + ctx = create_context(dataset=dataset) + create_assignment_variables(ctx) + + for (_employee_id, _unit, d, _shift, _level), var in ctx.assignment_variables.items(): + if d in worked_weekends: + ctx.model.add(var == 1) + else: + ctx.model.add(var == 0) + + penalties = EverySecondWeekendFree().add_to_model(ctx, params={}) + assert penalties + + ctx.model.minimize(penalties[0].expression) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + # Alternating weekends (worked, free, worked, free) → penalty should be 0 + assert solver.objective_value == 0 \ No newline at end of file diff --git a/tests/cp/objectives/test_free_day_after_night_shift_phase.py b/tests/cp/objectives/test_free_day_after_night_shift_phase.py new file mode 100644 index 00000000..27cc6954 --- /dev/null +++ b/tests/cp/objectives/test_free_day_after_night_shift_phase.py @@ -0,0 +1,127 @@ +from datetime import date + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.free_day_after_night_shift_phase import FreeDaysAfterNightShiftPhase +from scheduling.solver.cp_sat.variables import create_assignment_variables + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +NIGHT_SHIFT = Shift( + shift_id=1, + code="N", + type=ShiftType.NIGHT, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=1320, + end_minute=420, + net_work_minutes=460, +) + +EARLY_SHIFT = Shift( + shift_id=2, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _dataset() -> SchedulingDataset: + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(NIGHT_SHIFT, EARLY_SHIFT), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + ) + + +@pytest.mark.integration +def test_penalty_when_working_on_day_after_next() -> None: + # Pattern: night on day 1, free on day 2, working on day 3 → penalty + dataset = _dataset() + ctx = create_context(dataset=dataset) + create_assignment_variables(ctx) + + for (_employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): + if d == date(2024, 11, 1) and shift_id == NIGHT_SHIFT.shift_id: + ctx.model.add(var == 1) # night shift on day 1 + elif d == date(2024, 11, 2): + ctx.model.add(var == 0) # free on day 2 + elif d == date(2024, 11, 3) and shift_id == EARLY_SHIFT.shift_id: + ctx.model.add(var == 1) # working on day 3 + else: + ctx.model.add(var == 0) + + penalties = FreeDaysAfterNightShiftPhase().add_to_model(ctx, params={}) + assert penalties + + ctx.model.minimize(penalties[0].expression) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + # night → free → work pattern should produce a penalty + assert solver.objective_value > 0 + + +@pytest.mark.integration +def test_no_penalty_when_two_free_days_after_night() -> None: + # Pattern: night on day 1, free on day 2, free on day 3 → no penalty + dataset = _dataset() + ctx = create_context(dataset=dataset) + create_assignment_variables(ctx) + + for (_employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): + if d == date(2024, 11, 1) and shift_id == NIGHT_SHIFT.shift_id: + ctx.model.add(var == 1) # night shift on day 1 + else: + ctx.model.add(var == 0) # free on all other days + + penalties = FreeDaysAfterNightShiftPhase().add_to_model(ctx, params={}) + assert penalties + + ctx.model.minimize(penalties[0].expression) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + # two free days after night → no penalty + assert solver.objective_value == 0 \ No newline at end of file diff --git a/tests/cp/objectives/test_free_days_near_weekend.py b/tests/cp/objectives/test_free_days_near_weekend.py new file mode 100644 index 00000000..cbee3d52 --- /dev/null +++ b/tests/cp/objectives/test_free_days_near_weekend.py @@ -0,0 +1,126 @@ +from datetime import date + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.free_days_near_weekend import FreeDaysNearWeekend +from scheduling.solver.cp_sat.variables import create_assignment_variables + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _dataset() -> SchedulingDataset: + # November 2024: Mon 4, Fri 1, Sat 2 are useful test dates + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT,), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + ) + + +@pytest.mark.integration +def test_higher_reward_when_friday_and_saturday_both_free() -> None: + # Friday (Nov 1) and Saturday (Nov 2) both free → bigger reward than only Friday free + dataset = _dataset() + ctx_both = create_context(dataset=dataset) + create_assignment_variables(ctx_both) + + for (_employee_id, _unit, d, _shift, _level), var in ctx_both.assignment_variables.items(): + ctx_both.model.add(var == 0) # all days free + + penalties_both = FreeDaysNearWeekend().add_to_model(ctx_both, params={}) + assert penalties_both + ctx_both.model.minimize(penalties_both[0].expression) # multiplier=-1 so minimizing = maximizing reward + solver_both = cp_model.CpSolver() + solver_both.solve(ctx_both.model) + reward_both = -solver_both.objective_value # negate because multiplier=-1 + + # Now force Friday worked → less reward + dataset2 = _dataset() + ctx_fri = create_context(dataset=dataset2) + create_assignment_variables(ctx_fri) + + for (_employee_id, _unit, d, _shift, _level), var in ctx_fri.assignment_variables.items(): + if d == date(2024, 11, 1): + ctx_fri.model.add(var == 1) # Friday worked + else: + ctx_fri.model.add(var == 0) + + penalties_fri = FreeDaysNearWeekend().add_to_model(ctx_fri, params={}) + assert penalties_fri + ctx_fri.model.minimize(penalties_fri[0].expression) + solver_fri = cp_model.CpSolver() + solver_fri.solve(ctx_fri.model) + reward_fri = -solver_fri.objective_value + + # All free should give more reward than Friday worked. + # Values are negative because multiplier=-1; less negative = higher reward. + assert reward_both < reward_fri + + +@pytest.mark.integration +def test_no_reward_when_all_days_worked() -> None: + # All days worked → no free near-weekend days → reward should be 0 + dataset = _dataset() + ctx = create_context(dataset=dataset) + create_assignment_variables(ctx) + + for (_employee_id, _unit, d, _shift, _level), var in ctx.assignment_variables.items(): + ctx.model.add(var == 1) # all days worked + + penalties = FreeDaysNearWeekend().add_to_model(ctx, params={}) + assert penalties + + ctx.model.minimize(penalties[0].expression) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + # No free near-weekend days → reward = 0, so penalty expression = 0 + assert solver.objective_value == 0 \ No newline at end of file diff --git a/tests/cp/objectives/test_minimize_consecutive_night_shifts.py b/tests/cp/objectives/test_minimize_consecutive_night_shifts.py new file mode 100644 index 00000000..09c335d9 --- /dev/null +++ b/tests/cp/objectives/test_minimize_consecutive_night_shifts.py @@ -0,0 +1,151 @@ +from datetime import date + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.minimize_consecutive_night_shifts import MinimizeConsecutiveNightShifts +from scheduling.solver.cp_sat.variables import create_assignment_variables + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +NIGHT_SHIFT = Shift( + shift_id=1, + code="N", + type=ShiftType.NIGHT, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=1320, + end_minute=420, + net_work_minutes=460, +) + +EARLY_SHIFT = Shift( + shift_id=2, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _dataset() -> SchedulingDataset: + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(NIGHT_SHIFT, EARLY_SHIFT), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + ) + + +@pytest.mark.integration +def test_penalty_for_three_consecutive_night_shifts() -> None: + # 3 consecutive nights → penalty expected + dataset = _dataset() + ctx = create_context(dataset=dataset) + create_assignment_variables(ctx) + + night_dates = {date(2024, 11, 1), date(2024, 11, 2), date(2024, 11, 3)} + for (_employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): + if d in night_dates and shift_id == NIGHT_SHIFT.shift_id: + ctx.model.add(var == 1) + else: + ctx.model.add(var == 0) + + penalties = MinimizeConsecutiveNightShifts().add_to_model(ctx, params={}) + assert penalties + + total = sum(p.multiplier * p.expression for p in penalties) + ctx.model.minimize(total) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + assert solver.objective_value > 0 + + +@pytest.mark.integration +def test_no_penalty_for_single_night_shift() -> None: + # Only 1 night shift → no consecutive phase → no penalty + dataset = _dataset() + ctx = create_context(dataset=dataset) + create_assignment_variables(ctx) + + for (_employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): + if d == date(2024, 11, 1) and shift_id == NIGHT_SHIFT.shift_id: + ctx.model.add(var == 1) + else: + ctx.model.add(var == 0) + + penalties = MinimizeConsecutiveNightShifts().add_to_model(ctx, params={}) + assert penalties + + total = sum(p.multiplier * p.expression for p in penalties) + ctx.model.minimize(total) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + assert solver.objective_value == 0 + + +@pytest.mark.integration +def test_longer_phase_has_higher_penalty() -> None: + # 4 consecutive nights should produce a higher penalty than 2 consecutive nights + def _penalty_for_nights(night_dates: set[date]) -> float: + dataset = _dataset() + ctx = create_context(dataset=dataset) + create_assignment_variables(ctx) + + for (_employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): + if d in night_dates and shift_id == NIGHT_SHIFT.shift_id: + ctx.model.add(var == 1) + else: + ctx.model.add(var == 0) + + penalties = MinimizeConsecutiveNightShifts().add_to_model(ctx, params={}) + total = sum(p.multiplier * p.expression for p in penalties) + ctx.model.minimize(total) + solver = cp_model.CpSolver() + solver.solve(ctx.model) + return solver.objective_value + + penalty_2 = _penalty_for_nights({date(2024, 11, 1), date(2024, 11, 2)}) + penalty_4 = _penalty_for_nights({date(2024, 11, 1), date(2024, 11, 2), date(2024, 11, 3), date(2024, 11, 4)}) + + assert penalty_4 > penalty_2 \ No newline at end of file From 3c26e535dfd58abedb133cdd42703934932a8a91 Mon Sep 17 00:00:00 2001 From: xXxEmilioxXx Date: Fri, 10 Jul 2026 09:40:21 +0200 Subject: [PATCH 06/17] tried some bugfixes --- src/scheduling/solver/service.py | 6 +++++- src/scheduling/timeoffice/reading/wishes.py | 12 +++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/scheduling/solver/service.py b/src/scheduling/solver/service.py index 228b6c51..8d5bce93 100644 --- a/src/scheduling/solver/service.py +++ b/src/scheduling/solver/service.py @@ -212,10 +212,14 @@ def _audit_solution( dataset: SchedulingDataset, assignments: tuple[Assignment, ...], ) -> AuditReport: + + #add the already existing assignments to the ones generated by the solver + all_assignments = assignments + tuple(dataset.assignments) + audit_ctx = AuditContext( dataset=dataset, index=build_result.ctx.index, - assignments=assignments, + assignments=all_assignments ) findings: list[AuditFinding] = [] diff --git a/src/scheduling/timeoffice/reading/wishes.py b/src/scheduling/timeoffice/reading/wishes.py index 5ff31745..4cd26819 100644 --- a/src/scheduling/timeoffice/reading/wishes.py +++ b/src/scheduling/timeoffice/reading/wishes.py @@ -35,11 +35,13 @@ def validate_row_type(self) -> Self: has_work_shift = self.work_shift_id is not None has_absence = self.global_absence_shift_id is not None or self.absence_shift_id is not None - if has_work_shift and has_absence: - raise ValueError( - "Ambiguous TimeOffice wish row: both work shift and absence are set " - f"for employee_id={self.employee_id}, wish_date={self.wish_date}." - ) + + #Rauskommentiert, weil TimeOffice wieder Quatschdaten gibt, wo das hier verletzt wird + # if has_work_shift and has_absence: + # raise ValueError( + # "Ambiguous TimeOffice wish row: both work shift and absence are set " + # f"for employee_id={self.employee_id}, wish_date={self.wish_date}." + # ) if not has_work_shift and not has_absence: raise ValueError( From 5ef06530fbb4c4eba8ae61fd916750364d58496a Mon Sep 17 00:00:00 2001 From: Julius Ketz Date: Fri, 10 Jul 2026 09:48:18 +0200 Subject: [PATCH 07/17] Julius constraints (#301) * Constraints auf neue architektur gezogen * bugfixes zu constraints --- src/scheduling/solver/config.py | 12 ++ src/scheduling/solver/cp_sat/builder.py | 16 +- .../constraints/availabilities_constraint.py | 154 +++++++++++++++ .../free_day_after_night_shift_phase.py | 127 +++++++++++++ .../hierarchy_of_intermediate_shifts.py | 176 ++++++++++++++++++ .../cp_sat/constraints/min_rest_time.py | 123 ++++++++++++ .../cp_sat/constraints/minimum_staffing.py | 1 + .../constraints/one_assignment_per_day.py | 90 +++++++++ .../constraints/rounds_in_early_shift.py | 134 +++++++++++++ .../cp_sat/constraints/target_working_time.py | 127 +++++++++++++ src/scheduling/solver/index.py | 17 ++ 11 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 src/scheduling/solver/cp_sat/constraints/availabilities_constraint.py create mode 100644 src/scheduling/solver/cp_sat/constraints/free_day_after_night_shift_phase.py create mode 100644 src/scheduling/solver/cp_sat/constraints/hierarchy_of_intermediate_shifts.py create mode 100644 src/scheduling/solver/cp_sat/constraints/min_rest_time.py create mode 100644 src/scheduling/solver/cp_sat/constraints/one_assignment_per_day.py create mode 100644 src/scheduling/solver/cp_sat/constraints/rounds_in_early_shift.py create mode 100644 src/scheduling/solver/cp_sat/constraints/target_working_time.py diff --git a/src/scheduling/solver/config.py b/src/scheduling/solver/config.py index ea1a330f..a4eb6692 100644 --- a/src/scheduling/solver/config.py +++ b/src/scheduling/solver/config.py @@ -3,7 +3,13 @@ from pydantic import Field from scheduling.domain import SchedulingBaseModel +from scheduling.solver.cp_sat.constraints.availabilities_constraint import AvailabilitiesConstraint +from scheduling.solver.cp_sat.constraints.free_day_after_night_shift_phase import FreeDayAfterNightShiftPhase +from scheduling.solver.cp_sat.constraints.hierarchy_of_intermediate_shifts import HierarchyOfIntermediateShifts from scheduling.solver.cp_sat.constraints.minimum_staffing import MinimumStaffing +from scheduling.solver.cp_sat.constraints.one_assignment_per_day import OneAssignmentPerDay +from scheduling.solver.cp_sat.constraints.rounds_in_early_shift import RoundsInEarlyShift +from scheduling.solver.cp_sat.constraints.target_working_time import TargetWorkingTime from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( TemporaryBalanceGeneratedAssignments, ) @@ -43,6 +49,12 @@ def create_base_solver_config() -> SolverConfig: return SolverConfig( constraints={ MinimumStaffing.id: ConstraintConfig(enabled=True), + FreeDayAfterNightShiftPhase.id: ConstraintConfig(enabled=True), + RoundsInEarlyShift.id: ConstraintConfig(enabled=True), + AvailabilitiesConstraint.id: ConstraintConfig(enabled=True), + HierarchyOfIntermediateShifts.id: ConstraintConfig(enabled=True), + OneAssignmentPerDay.id: ConstraintConfig(enabled=True), + TargetWorkingTime.id: ConstraintConfig(enabled=True), }, objectives={ TemporaryBalanceGeneratedAssignments.id: ObjectiveConfig( diff --git a/src/scheduling/solver/cp_sat/builder.py b/src/scheduling/solver/cp_sat/builder.py index e9a6a5be..2df670e9 100644 --- a/src/scheduling/solver/cp_sat/builder.py +++ b/src/scheduling/solver/cp_sat/builder.py @@ -5,7 +5,13 @@ from scheduling.domain import SchedulingDataset from scheduling.solver.config import SolverConfig, create_base_solver_config from scheduling.solver.cp_sat.constraint import Constraint +from scheduling.solver.cp_sat.constraints.availabilities_constraint import AvailabilitiesConstraint +from scheduling.solver.cp_sat.constraints.free_day_after_night_shift_phase import FreeDayAfterNightShiftPhase +from scheduling.solver.cp_sat.constraints.hierarchy_of_intermediate_shifts import HierarchyOfIntermediateShifts from scheduling.solver.cp_sat.constraints.minimum_staffing import MinimumStaffing +from scheduling.solver.cp_sat.constraints.one_assignment_per_day import OneAssignmentPerDay +from scheduling.solver.cp_sat.constraints.rounds_in_early_shift import RoundsInEarlyShift +from scheduling.solver.cp_sat.constraints.target_working_time import TargetWorkingTime from scheduling.solver.cp_sat.context import SolverContext, create_context from scheduling.solver.cp_sat.objective import Objective, WeightedPenalty, minimize_weighted_penalties from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( @@ -24,7 +30,15 @@ from scheduling.solver.cp_sat.variables import create_assignment_variables -CP_SAT_CONSTRAINTS: tuple[Constraint, ...] = (MinimumStaffing(),) +CP_SAT_CONSTRAINTS: tuple[Constraint, ...] = ( + MinimumStaffing(), + FreeDayAfterNightShiftPhase(), + RoundsInEarlyShift(), + AvailabilitiesConstraint(), + HierarchyOfIntermediateShifts(), + OneAssignmentPerDay(), + TargetWorkingTime(), +) CP_SAT_OBJECTIVES: tuple[Objective, ...] = (TemporaryBalanceGeneratedAssignments(), MinimizeOvertime(), diff --git a/src/scheduling/solver/cp_sat/constraints/availabilities_constraint.py b/src/scheduling/solver/cp_sat/constraints/availabilities_constraint.py new file mode 100644 index 00000000..08954c99 --- /dev/null +++ b/src/scheduling/solver/cp_sat/constraints/availabilities_constraint.py @@ -0,0 +1,154 @@ +import datetime +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from scheduling.solver.audit import AuditFinding, AuditSeverity +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.diagnostics import SolverDiagnostic +from scheduling.solver.index import is_night_shift + +# Angenommen, Ihre Enum/Models sind importierbar, andernfalls als Typ-Hinweise nutzen +# from scheduling.domain.availability import Availability, AvailabilityType + + +class AvailabilitiesConstraint: + """Ensure employees do not work on dates or shifts they are unavailable for. + + Also prevents night shifts from spilling over into full-day absences (e.g., vacations). + """ + + id: ClassVar[str] = "employee_availabilities" + required: ClassVar[bool] = True + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[SolverDiagnostic, ...]: + del params + + blocked_days, allowed_shifts_for_day = _parse_availabilities(ctx) + + for key, variable in ctx.assignment_variables.items(): + employee_id, _, date, shift_id, _ = key + + # Regel 1: Voller Abwesenheitstag (Urlaub, Training, etc.) + if (employee_id, date) in blocked_days: + ctx.model.add(variable == 0).with_name( + f"avail_blocked_full__emp_{employee_id}_date_{date:%Y%m%d}_shift_{shift_id}" + ) + continue + + # Regel 2: Partielle Verfügbarkeit (AVAILABLE_ONLY) + if (employee_id, date) in allowed_shifts_for_day: + if shift_id not in allowed_shifts_for_day[(employee_id, date)]: + ctx.model.add(variable == 0).with_name( + f"avail_blocked_partial__emp_{employee_id}_date_{date:%Y%m%d}_shift_{shift_id}" + ) + continue + + # Regel 3: Spillover-Prävention (Nachtschicht vor einem vollen Abwesenheitstag) + if is_night_shift(ctx.index.shifts_by_id[shift_id]): + tomorrow = date + datetime.timedelta(days=1) + if (employee_id, tomorrow) in blocked_days: + ctx.model.add(variable == 0).with_name( + f"avail_blocked_spillover__emp_{employee_id}_date_{date:%Y%m%d}_shift_{shift_id}" + ) + + return () + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + del params + + findings: list[AuditFinding] = [] + blocked_days, allowed_shifts_for_day = _parse_availabilities(ctx) + + for assignment in ctx.assignments: + # Type-Guards (Nur relevante Zuweisungen prüfen) + if assignment.employee_id is None or assignment.shift_id is None or assignment.date is None: # type: ignore + continue + + emp_id = assignment.employee_id + shift_id = assignment.shift_id + date = assignment.date + + # Audit 1: Zuweisung an einem voll blockierten Tag + if (emp_id, date) in blocked_days: + findings.append( + AuditFinding( + code="employee_availabilities.violation_full_day", + severity=AuditSeverity.ERROR, + source_id=self.id, + message=( + f"Employee is assigned to a shift on a fully blocked day. " + f"employee_id={emp_id} date={date.isoformat()} shift_id={shift_id}." + ), + date=date, + ) + ) + + # Audit 2: Zuweisung einer unzulässigen Schicht (AVAILABLE_ONLY) + elif (emp_id, date) in allowed_shifts_for_day and shift_id not in allowed_shifts_for_day[(emp_id, date)]: + findings.append( + AuditFinding( + code="employee_availabilities.violation_partial_day", + severity=AuditSeverity.ERROR, + source_id=self.id, + message=( + f"Employee is assigned to a restricted shift type. " + f"employee_id={emp_id} date={date.isoformat()} shift_id={shift_id}." + ), + date=date, + ) + ) + + # Audit 3: Unzulässiger Spillover (Nachtschicht am Vortag) + elif is_night_shift(ctx.index.shifts_by_id[shift_id]): + tomorrow = date + datetime.timedelta(days=1) + if (emp_id, tomorrow) in blocked_days: + findings.append( + AuditFinding( + code="employee_availabilities.violation_spillover", + severity=AuditSeverity.ERROR, + source_id=self.id, + message=( + f"Employee is assigned to a night shift spilling over into a blocked day. " + f"employee_id={emp_id} date={date.isoformat()} shift_id={shift_id}." + ), + date=tomorrow, # Das Datum des Regelbruchs ist der blockierte Tag + ) + ) + + return tuple(findings) + + +# --- Helper Functions --- + + +def _parse_availabilities( + ctx: SolverContext | AuditContext, +) -> tuple[set[tuple[int, datetime.date]], dict[tuple[int, datetime.date], set[int]]]: + """ + Parses the new Availability Pydantic models into O(1) lookup structures. + """ + blocked_days: set[tuple[int, datetime.date]] = set() + allowed_shifts: defaultdict[tuple[int, datetime.date], set[int]] = defaultdict(set) + + # Annahme: Availabilities sind Teil des Contexts oder Index + # (Passen Sie den Attributnamen ctx.availabilities entsprechend Ihrer Struktur an) + availabilities = ctx.dataset.availability + + for avail in availabilities: + # Hier nutzen wir das Enum als String, um Abhängigkeiten gering zu halten + if str(avail.availability_type) == "available_only": + if avail.shift_ids: + allowed_shifts[(avail.employee_id, avail.date)].update(avail.shift_ids) + else: + blocked_days.add((avail.employee_id, avail.date)) + + return blocked_days, dict(allowed_shifts) diff --git a/src/scheduling/solver/cp_sat/constraints/free_day_after_night_shift_phase.py b/src/scheduling/solver/cp_sat/constraints/free_day_after_night_shift_phase.py new file mode 100644 index 00000000..e5acfa6a --- /dev/null +++ b/src/scheduling/solver/cp_sat/constraints/free_day_after_night_shift_phase.py @@ -0,0 +1,127 @@ +import datetime +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding, AuditSeverity +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.diagnostics import SolverDiagnostic +from scheduling.solver.index import is_night_shift + + +class FreeDayAfterNightShiftPhase: + """Ensure an employee has a free day after a night shift phase ends.""" + + id: ClassVar[str] = "free_day_after_night_shift_phase" + required: ClassVar[bool] = True + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[SolverDiagnostic, ...]: + del params + + all_vars_by_emp_date, night_vars_by_emp_date = _group_vars(ctx) + + for (employee_id, date), night_vars_today in night_vars_by_emp_date.items(): + if not night_vars_today: + continue + + tomorrow = date + datetime.timedelta(days=1) + all_vars_tomorrow = all_vars_by_emp_date.get((employee_id, tomorrow), []) + night_vars_tomorrow = night_vars_by_emp_date.get((employee_id, tomorrow), []) + + if not all_vars_tomorrow: + continue + + works_night_today = ctx.model.new_bool_var(f"works_night_emp_{employee_id}_date_{date:%Y%m%d}") + ctx.model.add_max_equality(works_night_today, night_vars_today) + + works_night_tomorrow = ctx.model.new_bool_var(f"works_night_emp_{employee_id}_date_{tomorrow:%Y%m%d}") + if night_vars_tomorrow: + ctx.model.add_max_equality(works_night_tomorrow, night_vars_tomorrow) + else: + ctx.model.add(works_night_tomorrow == 0) + + constraint = ctx.model.add(sum(all_vars_tomorrow) == 0) + constraint.only_enforce_if([works_night_today, works_night_tomorrow.Not()]) # type: ignore + constraint.with_name(_constraint_name(employee_id, tomorrow)) + + return () + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + del params + + findings: list[AuditFinding] = [] + actual_shifts = _group_actual_shifts(ctx) + + for (employee_id, date), shifts_today in actual_shifts.items(): + if not any(is_night_shift(ctx.index.shifts_by_id[shift_id]) for shift_id in shifts_today): + continue + + tomorrow = date + datetime.timedelta(days=1) + shifts_tomorrow = actual_shifts.get((employee_id, tomorrow), []) + + if not shifts_tomorrow: + continue + + if not any(is_night_shift(ctx.index.shifts_by_id[shift_id]) for shift_id in shifts_tomorrow): + findings.append( + AuditFinding( + code="free_day_after_night_shift_phase.violation", + severity=AuditSeverity.ERROR, + source_id=self.id, + message=( + f"Employee did not get a free day after a night shift phase ended. " + f"employee_id={employee_id} next_day={tomorrow.isoformat()}." + ), + date=tomorrow, + ) + ) + + return tuple(findings) + + +# --- Helper Functions --- + + +def _constraint_name(employee_id: int, date_of_free_day: datetime.date) -> str: + return f"free_day_after_night_shift__emp_{employee_id}__date_{date_of_free_day:%Y%m%d}" + + +def _group_vars( + ctx: SolverContext, +) -> tuple[ + dict[tuple[int, datetime.date], list[cp_model.IntVar]], dict[tuple[int, datetime.date], list[cp_model.IntVar]] +]: + all_vars: defaultdict[tuple[int, datetime.date], list[cp_model.IntVar]] = defaultdict(list) + night_vars: defaultdict[tuple[int, datetime.date], list[cp_model.IntVar]] = defaultdict(list) + + for key, variable in ctx.assignment_variables.items(): + employee_id, _, assignment_date, shift_id, _ = key + + all_vars[(employee_id, assignment_date)].append(variable) + + if is_night_shift(ctx.index.shifts_by_id[shift_id]): + night_vars[(employee_id, assignment_date)].append(variable) + + return dict(all_vars), dict(night_vars) + + +# FIX: list[str] zu list[int] geändert, da shift_id ein int ist +def _group_actual_shifts( + ctx: AuditContext, +) -> dict[tuple[int, datetime.date], list[int]]: + actual_shifts: defaultdict[tuple[int, datetime.date], list[int]] = defaultdict(list) + + for assignment in ctx.assignments: + actual_shifts[(assignment.employee_id, assignment.date)].append(assignment.shift_id) + + return dict(actual_shifts) diff --git a/src/scheduling/solver/cp_sat/constraints/hierarchy_of_intermediate_shifts.py b/src/scheduling/solver/cp_sat/constraints/hierarchy_of_intermediate_shifts.py new file mode 100644 index 00000000..8467121d --- /dev/null +++ b/src/scheduling/solver/cp_sat/constraints/hierarchy_of_intermediate_shifts.py @@ -0,0 +1,176 @@ +import datetime +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding, AuditSeverity +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.diagnostics import SolverDiagnostic +from scheduling.solver.index import is_intermediate_shift + + +class HierarchyOfIntermediateShifts: + """Enforce a strict hierarchy and even distribution for intermediate shifts. + + Per planning_unit and week: Mon-Fri must be filled before weekends are assigned. + """ + + id: ClassVar[str] = "hierarchy_of_intermediate_shifts" + required: ClassVar[bool] = True + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[SolverDiagnostic, ...]: + del params + + vars_by_unit_week_day, active_unit_weeks = _group_vars(ctx) + + for planning_unit_id, (iso_year, iso_week) in active_unit_weeks: + days_in_week = vars_by_unit_week_day[planning_unit_id][(iso_year, iso_week)] + + weekdays_exprs: list[cp_model.LinearExpr] = [] + weekends_exprs: list[cp_model.LinearExpr] = [] + + for date, variables in days_in_week.items(): + day_sum = cp_model.LinearExpr.Sum(variables) # type: ignore + + if date.isoweekday() in {6, 7}: + weekends_exprs.append(day_sum) + else: + weekdays_exprs.append(day_sum) + + if weekdays_exprs and weekends_exprs: + max_capacity = len(ctx.assignment_variables) + + max_wd = ctx.model.new_int_var( + 0, max_capacity, f"max_wd_u:{planning_unit_id}_y:{iso_year}_w:{iso_week}" + ) + min_wd = ctx.model.new_int_var( + 0, max_capacity, f"min_wd_u:{planning_unit_id}_y:{iso_year}_w:{iso_week}" + ) + max_we = ctx.model.new_int_var( + 0, max_capacity, f"max_we_u:{planning_unit_id}_y:{iso_year}_w:{iso_week}" + ) + min_we = ctx.model.new_int_var( + 0, max_capacity, f"min_we_u:{planning_unit_id}_y:{iso_year}_w:{iso_week}" + ) + + ctx.model.add_max_equality(max_wd, weekdays_exprs) + ctx.model.add_min_equality(min_wd, weekdays_exprs) + ctx.model.add_max_equality(max_we, weekends_exprs) + ctx.model.add_min_equality(min_we, weekends_exprs) + + constr_dist_wd = ctx.model.add(max_wd - min_wd <= 1) + constr_dist_wd.with_name(f"hier_inter_dist_wd__unit_{planning_unit_id}__y_{iso_year}_w_{iso_week}") + + constr_dist_we = ctx.model.add(max_we - min_we <= 1) + constr_dist_we.with_name(f"hier_inter_dist_we__unit_{planning_unit_id}__y_{iso_year}_w_{iso_week}") + + constr_step_a = ctx.model.add(max_wd <= min_we + 1) + constr_step_a.with_name(f"hier_inter_step_a__unit_{planning_unit_id}__y_{iso_year}_w_{iso_week}") + + constr_step_b = ctx.model.add(min_wd >= max_we) + constr_step_b.with_name(f"hier_inter_step_b__unit_{planning_unit_id}__y_{iso_year}_w_{iso_week}") + + return () + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + del params + + findings: list[AuditFinding] = [] + counts_by_day, active_dates_by_unit = _group_actual_shifts(ctx) + + for planning_unit_id, active_dates in active_dates_by_unit.items(): + weeks: defaultdict[tuple[int, int], list[datetime.date]] = defaultdict(list) + for date in active_dates: + iso_year, iso_week = date.isocalendar()[:2] + weeks[(iso_year, iso_week)].append(date) + + for (iso_year, iso_week), dates_in_week in weeks.items(): + wd_counts: list[int] = [] + we_counts: list[int] = [] + + for date in dates_in_week: + count = counts_by_day.get((planning_unit_id, date), 0) + + if date.isoweekday() in {6, 7}: + we_counts.append(count) + else: + wd_counts.append(count) + + if wd_counts and we_counts: + max_wd, min_wd = max(wd_counts), min(wd_counts) + max_we, min_we = max(we_counts), min(we_counts) + + is_dist_invalid: bool = (max_wd - min_wd > 1) or (max_we - min_we > 1) + is_hier_invalid: bool = (max_wd > min_we + 1) or (min_wd < max_we) + + if is_dist_invalid or is_hier_invalid: + findings.append( + AuditFinding( + code="hierarchy_of_intermediate_shifts.violation", + severity=AuditSeverity.ERROR, + source_id=self.id, + message=( + f"Intermediate shifts hierarchy violated. " + f"planning_unit_id={planning_unit_id} year={iso_year} week={iso_week} " + f"max_wd={max_wd} min_wd={min_wd} max_we={max_we} min_we={min_we}." + ), + planning_unit_id=planning_unit_id, + date=dates_in_week[0], + ) + ) + + return tuple(findings) + + +# --- Helper Functions --- +def _group_vars( + ctx: SolverContext, +) -> tuple[ + dict[int, dict[tuple[int, int], dict[datetime.date, list[cp_model.IntVar]]]], set[tuple[int, tuple[int, int]]] +]: + grouped: defaultdict[int, defaultdict[tuple[int, int], defaultdict[datetime.date, list[cp_model.IntVar]]]] = ( + defaultdict(lambda: defaultdict(lambda: defaultdict(list))) + ) + active_weeks: set[tuple[int, tuple[int, int]]] = set() + + for key, variable in ctx.assignment_variables.items(): + _, planning_unit_id, assignment_date, shift_id, _ = key + + if not is_intermediate_shift(ctx.index.shifts_by_id[shift_id]): + continue + + iso_year, iso_week = assignment_date.isocalendar()[:2] + grouped[planning_unit_id][(iso_year, iso_week)][assignment_date].append(variable) + active_weeks.add((planning_unit_id, (iso_year, iso_week))) + + clean_grouped = {p_id: {week: dict(days) for week, days in weeks.items()} for p_id, weeks in grouped.items()} + + return clean_grouped, active_weeks + + +def _group_actual_shifts( + ctx: AuditContext, +) -> tuple[dict[tuple[int, datetime.date], int], dict[int, set[datetime.date]]]: + counts_by_day: defaultdict[tuple[int, datetime.date], int] = defaultdict(int) + active_dates_by_unit: defaultdict[int, set[datetime.date]] = defaultdict(set) + + for assignment in ctx.assignments: + if assignment.planning_unit_id is None: + continue + + active_dates_by_unit[assignment.planning_unit_id].add(assignment.date) + + if is_intermediate_shift(ctx.index.shifts_by_id[assignment.shift_id]): + counts_by_day[(assignment.planning_unit_id, assignment.date)] += 1 + + return dict(counts_by_day), dict(active_dates_by_unit) diff --git a/src/scheduling/solver/cp_sat/constraints/min_rest_time.py b/src/scheduling/solver/cp_sat/constraints/min_rest_time.py new file mode 100644 index 00000000..17cd2876 --- /dev/null +++ b/src/scheduling/solver/cp_sat/constraints/min_rest_time.py @@ -0,0 +1,123 @@ +import datetime +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding, AuditSeverity +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.diagnostics import SolverDiagnostic +from scheduling.solver.index import is_early_shift, is_late_shift + + +class MinimumRestTime: + """Ensure an employee has a minimum rest time between shifts. + + Specifically prevents a Late shift from being immediately followed by an Early shift on the next day. + """ + + id: ClassVar[str] = "minimum_rest_time" + required: ClassVar[bool] = True + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[SolverDiagnostic, ...]: + del params + + late_vars_by_emp_date, early_vars_by_emp_date = _group_vars(ctx) + + for (employee_id, date), late_vars_today in late_vars_by_emp_date.items(): + if not late_vars_today: + continue + + tomorrow = date + datetime.timedelta(days=1) + early_vars_tomorrow = early_vars_by_emp_date.get((employee_id, tomorrow), []) + + if not early_vars_tomorrow: + continue + + # # OR-Tools native Summenbildung (mit Ignorieren der unvollständigen OR-Tools-Stubs) + sum_late = cp_model.LinearExpr.Sum(late_vars_today) # type: ignore + sum_early = cp_model.LinearExpr.Sum(early_vars_tomorrow) # type: ignore + + # Entkoppeltes Method-Chaining zur Vermeidung von Linter-Warnungen + constraint = ctx.model.add(sum_late + sum_early <= 1) + constraint.with_name(_constraint_name(employee_id, date)) + + return () + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + del params + + findings: list[AuditFinding] = [] + late_shifts_actual, early_shifts_actual = _group_actual_shifts(ctx) + + for employee_id, date in late_shifts_actual: + tomorrow = date + datetime.timedelta(days=1) + + # Ein simpler Lookup im Set ($O(1)$) reicht aus + if (employee_id, tomorrow) in early_shifts_actual: + findings.append( + AuditFinding( + code="minimum_rest_time.violation", + severity=AuditSeverity.ERROR, + source_id=self.id, + message=( + f"Minimum rest time violated. Late shift followed by an Early shift. " + f"employee_id={employee_id} late_date={date.isoformat()} early_date={tomorrow.isoformat()}." + ), + date=tomorrow, # Das Datum der Regelverletzung ist der Tag der unzulässigen Frühschicht + ) + ) + + return tuple(findings) + + +# --- Helper Functions --- + + +def _constraint_name(employee_id: int, late_shift_date: datetime.date) -> str: + return f"min_rest_time__emp_{employee_id}__date_{late_shift_date:%Y%m%d}" + + +def _group_vars( + ctx: SolverContext, +) -> tuple[ + dict[tuple[int, datetime.date], list[cp_model.IntVar]], dict[tuple[int, datetime.date], list[cp_model.IntVar]] +]: + late_vars: defaultdict[tuple[int, datetime.date], list[cp_model.IntVar]] = defaultdict(list) + early_vars: defaultdict[tuple[int, datetime.date], list[cp_model.IntVar]] = defaultdict(list) + + for key, variable in ctx.assignment_variables.items(): + employee_id, _, assignment_date, shift_id, _ = key + + if is_late_shift(ctx.index.shifts_by_id[shift_id]): + late_vars[(employee_id, assignment_date)].append(variable) + elif is_early_shift(ctx.index.shifts_by_id[shift_id]): + early_vars[(employee_id, assignment_date)].append(variable) + + return dict(late_vars), dict(early_vars) + + +def _group_actual_shifts( + ctx: AuditContext, +) -> tuple[set[tuple[int, datetime.date]], set[tuple[int, datetime.date]]]: + # Sets sind performanter als Dicts, da wir nicht iterieren oder zählen müssen, + # sondern nur prüfen: "Gab es an diesem Tag diesen Schichttyp?" + late_shifts: set[tuple[int, datetime.date]] = set() + early_shifts: set[tuple[int, datetime.date]] = set() + + for assignment in ctx.assignments: + if is_late_shift(ctx.index.shifts_by_id[assignment.shift_id]): + late_shifts.add((assignment.employee_id, assignment.date)) + elif is_early_shift(ctx.index.shifts_by_id[assignment.shift_id]): + early_shifts.add((assignment.employee_id, assignment.date)) + + return late_shifts, early_shifts diff --git a/src/scheduling/solver/cp_sat/constraints/minimum_staffing.py b/src/scheduling/solver/cp_sat/constraints/minimum_staffing.py index c75adb1b..2eec1f06 100644 --- a/src/scheduling/solver/cp_sat/constraints/minimum_staffing.py +++ b/src/scheduling/solver/cp_sat/constraints/minimum_staffing.py @@ -24,6 +24,7 @@ def add_to_model( ctx: SolverContext, params: Mapping[str, Any], ) -> tuple[SolverDiagnostic, ...]: + del params diagnostics: list[SolverDiagnostic] = [] vars_by_demand = _group_vars_by_demand(ctx) diff --git a/src/scheduling/solver/cp_sat/constraints/one_assignment_per_day.py b/src/scheduling/solver/cp_sat/constraints/one_assignment_per_day.py new file mode 100644 index 00000000..bb2264ab --- /dev/null +++ b/src/scheduling/solver/cp_sat/constraints/one_assignment_per_day.py @@ -0,0 +1,90 @@ +import datetime +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding, AuditSeverity +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.diagnostics import SolverDiagnostic + + +class OneAssignmentPerDay: + """Prevent more than one generated assignment per employee and day.""" + + id: ClassVar[str] = "one_assignment_per_day" + required: ClassVar[bool] = True + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[SolverDiagnostic, ...]: + del params + + vars_by_employee_date = _group_vars_by_employee_date(ctx) + + for (employee_id, assignment_date), variables in vars_by_employee_date.items(): + ctx.model.add(sum(variables) <= 1).with_name(_constraint_name(employee_id, assignment_date)) + + return () + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + del params + + findings: list[AuditFinding] = [] + actual_by_employee_date = _count_actual_assignments_by_employee_date(ctx) + + for (employee_id, assignment_date), count in actual_by_employee_date.items(): + if count <= 1: + continue + + findings.append( + AuditFinding( + code="one_assignment_per_day.violation", + severity=AuditSeverity.ERROR, + source_id=self.id, + message=( + f"Employee is assigned to multiple shifts on a single day. " + f"employee_id={employee_id} date={assignment_date.isoformat()} count={count}." + ), + date=assignment_date, + ) + ) + + return tuple(findings) + + +# --- Helper Functions --- + + +def _constraint_name(employee_id: int, date: datetime.date) -> str: + return f"one_assignment_per_day__emp_{employee_id}__date_{date:%Y%m%d}" + + +def _group_vars_by_employee_date( + ctx: SolverContext, +) -> dict[tuple[int, datetime.date], list[cp_model.IntVar]]: + grouped: defaultdict[tuple[int, datetime.date], list[cp_model.IntVar]] = defaultdict(list) + + for key, variable in ctx.assignment_variables.items(): + employee_id, _, assignment_date, _, _ = key + grouped[(employee_id, assignment_date)].append(variable) + + return dict(grouped) + + +def _count_actual_assignments_by_employee_date( + ctx: AuditContext, +) -> dict[tuple[int, datetime.date], int]: + actual: defaultdict[tuple[int, datetime.date], int] = defaultdict(int) + + for assignment in ctx.assignments: + actual[(assignment.employee_id, assignment.date)] += 1 + + return dict(actual) diff --git a/src/scheduling/solver/cp_sat/constraints/rounds_in_early_shift.py b/src/scheduling/solver/cp_sat/constraints/rounds_in_early_shift.py new file mode 100644 index 00000000..f7d7349d --- /dev/null +++ b/src/scheduling/solver/cp_sat/constraints/rounds_in_early_shift.py @@ -0,0 +1,134 @@ +import datetime +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.domain.employee import Capability +from scheduling.solver.audit import AuditFinding, AuditSeverity +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.diagnostics import DiagnosticSeverity, SolverDiagnostic +from scheduling.solver.index import is_early_shift + + +class RoundsInEarlyShift: + """Ensure at least one employee qualified for 'rounds' is assigned to an early shift on weekdays.""" + + id: ClassVar[str] = "rounds_in_early_shift" + required: ClassVar[bool] = True + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[SolverDiagnostic, ...]: + del params + + diagnostics: list[SolverDiagnostic] = [] + early_round_vars_by_date, active_weekdays = _group_vars(ctx) + + for date in active_weekdays: + vars_for_date = early_round_vars_by_date.get(date, []) + + # Diagnostik: Wenn niemand Qualifiziertes an diesem Wochentag zur Verfügung steht, + # warnen wir das System, anstatt eine Infeasible-Exception zu provozieren. + if not vars_for_date: + diagnostics.append( + SolverDiagnostic( + code="rounds_in_early_shift.no_candidates", + severity=DiagnosticSeverity.ERROR, + message=f"""No employees qualified for 'rounds' are + available for an early shift on {date.isoformat()}.""", + ) + ) + continue + + sum_expr = cp_model.LinearExpr.Sum(vars_for_date) # type: ignore + + # Entkoppeltes Method-Chaining für Linter-Sicherheit + constraint = ctx.model.add(sum_expr >= 1) + constraint.with_name(_constraint_name(date)) + + return tuple(diagnostics) + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + del params + + findings: list[AuditFinding] = [] + counts_by_date, active_weekdays = _group_actual_shifts(ctx) + + for date in active_weekdays: + if counts_by_date.get(date, 0) == 0: + findings.append( + AuditFinding( + code="rounds_in_early_shift.violation", + severity=AuditSeverity.ERROR, + source_id=self.id, + message=( + f"No employee qualified for 'rounds' is assigned to an early shift. " + f"date={date.isoformat()}." + ), + date=date, + ) + ) + + return tuple(findings) + + +# --- Helper Functions --- + + +def _constraint_name(date: datetime.date) -> str: + return f"rounds_in_early_shift__date_{date:%Y%m%d}" + + +def _is_qualified_for_rounds(ctx: SolverContext | AuditContext, employee_id: int) -> bool: + # Direkter, performanter Zugriff über den Index + employee = ctx.index.employees_by_id.get(employee_id) + + if employee: + return Capability.ROUNDS in employee.capabilities + + return False + + +def _group_vars( + ctx: SolverContext, +) -> tuple[dict[datetime.date, list[cp_model.IntVar]], set[datetime.date]]: + grouped: defaultdict[datetime.date, list[cp_model.IntVar]] = defaultdict(list) + active_weekdays: set[datetime.date] = set() + + for key, variable in ctx.assignment_variables.items(): + employee_id, _, assignment_date, shift_id, _ = key + + # .isoweekday() gibt 1 (Montag) bis 7 (Sonntag) zurück + if assignment_date.isoweekday() <= 5: + active_weekdays.add(assignment_date) + + if is_early_shift(ctx.index.shifts_by_id[shift_id]) and _is_qualified_for_rounds(ctx, employee_id): + grouped[assignment_date].append(variable) + + return dict(grouped), active_weekdays + + +def _group_actual_shifts( + ctx: AuditContext, +) -> tuple[dict[datetime.date, int], set[datetime.date]]: + counts: defaultdict[datetime.date, int] = defaultdict(int) + active_weekdays: set[datetime.date] = set() + + for assignment in ctx.assignments: + if assignment.date.isoweekday() <= 5: + active_weekdays.add(assignment.date) + + if is_early_shift(ctx.index.shifts_by_id[assignment.shift_id]) and _is_qualified_for_rounds( + ctx, assignment.employee_id + ): + counts[assignment.date] += 1 + + return dict(counts), active_weekdays diff --git a/src/scheduling/solver/cp_sat/constraints/target_working_time.py b/src/scheduling/solver/cp_sat/constraints/target_working_time.py new file mode 100644 index 00000000..78fa0d1d --- /dev/null +++ b/src/scheduling/solver/cp_sat/constraints/target_working_time.py @@ -0,0 +1,127 @@ +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.solver.audit import AuditFinding, AuditSeverity +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.diagnostics import SolverDiagnostic + + +class TargetWorkingTime: + """Ensure each employee works their target monthly amount of time.""" + + id: ClassVar[str] = "target_working_time" + required: ClassVar[bool] = True + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[SolverDiagnostic, ...]: + # Parameter aus params mit Fallbacks + tolerance_less = int(params.get("tolerance_less", 0)) + tolerance_more = int(params.get("tolerance_more", 0)) + + # Mapping von employee_id auf MonthlyWorkAccount + accounts = {acc.employee_id: acc for acc in ctx.dataset.monthly_work_accounts} + + # Gruppen der gewichteten Variablen (Schichtdauer) pro Mitarbeiter + exprs_by_employee = _group_weighted_vars(ctx) + + for employee_id, expressions in exprs_by_employee.items(): + account = accounts.get(employee_id) + if not account: + continue + + # Zielvorgabe: Target minus bereits geleistete Zeit + actual_worked = account.actual_minutes or 0 + target_net = max(account.target_minutes - actual_worked, 0) + + # Summe der zu planenden Schichten + total_work_expr = cp_model.LinearExpr.Sum(expressions) # type: ignore + + # Constraints hinzufügen + upper_limit = target_net + tolerance_more + lower_limit = target_net - tolerance_less + + ctx.model.add(total_work_expr <= upper_limit).with_name(f"target_work_upper__emp_{employee_id}") + ctx.model.add(total_work_expr >= lower_limit).with_name(f"target_work_lower__emp_{employee_id}") + + return () + + def audit( + self, + ctx: AuditContext, + params: Mapping[str, Any], + ) -> tuple[AuditFinding, ...]: + tolerance_less = int(params.get("tolerance_less", 0)) + tolerance_more = int(params.get("tolerance_more", 0)) + + findings: list[AuditFinding] = [] + accounts = {acc.employee_id: acc for acc in ctx.dataset.monthly_work_accounts} + + # Historische Zuweisungen aus dem Audit-Kontext + actual_durations = _group_actual_durations(ctx) + + for employee_id, total_worked in actual_durations.items(): + account = accounts.get(employee_id) + if not account: + continue + + target_net = max(account.target_minutes - (account.actual_minutes or 0), 0) + + upper_limit = target_net + tolerance_more + lower_limit = target_net - tolerance_less + + if total_worked > upper_limit: + findings.append(_create_finding(self.id, employee_id, total_worked, target_net, upper_limit, "upper")) + elif total_worked < lower_limit: + findings.append(_create_finding(self.id, employee_id, total_worked, target_net, lower_limit, "lower")) + + return tuple(findings) + + +# --- Helper Functions --- + + +def _create_finding(source_id: str, employee_id: int, actual: int, target: int, limit: int, bound: str) -> AuditFinding: + return AuditFinding( + code=f"target_working_time.violation_{bound}", + severity=AuditSeverity.ERROR, + source_id=source_id, + message=( + f"Employee working time violates {bound} limit. " + f"employee_id={employee_id} actual_minutes={actual} target_minutes={target} limit={limit}." + ), + date=None, + ) + + +def _group_weighted_vars(ctx: SolverContext) -> dict[int, list[cp_model.LinearExpr]]: + exprs: defaultdict[int, list[cp_model.LinearExpr]] = defaultdict(list) + + # Mapping für schnellen Zugriff auf Schichtdauern + shift_durations = {s.shift_id: (s.end_minute - s.start_minute) for s in ctx.dataset.shifts} + + for key, variable in ctx.assignment_variables.items(): + employee_id, _, _, shift_id, _ = key + + # Falls Schicht nicht in Dataset (sollte nicht passieren), Dauer 0 + duration = shift_durations.get(shift_id, 0) + exprs[employee_id].append(variable * duration) + + return dict(exprs) + + +def _group_actual_durations(ctx: AuditContext) -> dict[int, int]: + durations: defaultdict[int, int] = defaultdict(int) + shift_durations = {s.shift_id: (s.end_minute - s.start_minute) for s in ctx.dataset.shifts} + + for assignment in ctx.assignments: + # Pydantic Modelle garantieren die Existenz + if assignment.employee_id is not None and assignment.shift_id is not None: # type: ignore + durations[assignment.employee_id] += shift_durations.get(assignment.shift_id, 0) + + return dict(durations) diff --git a/src/scheduling/solver/index.py b/src/scheduling/solver/index.py index 2f727a04..9390e430 100644 --- a/src/scheduling/solver/index.py +++ b/src/scheduling/solver/index.py @@ -12,6 +12,7 @@ SchedulingDataset, Shift, ShiftId, + ShiftType, ) from scheduling.solver.cp_sat.keys import DemandKey, EmployeeDateKey @@ -90,3 +91,19 @@ def _count_required_demand_by_key( required[key] += demand.required_count return dict(required) + + +def is_night_shift(shift: Shift) -> bool: + return shift.type == ShiftType.NIGHT + + +def is_early_shift(shift: Shift) -> bool: + return shift.type == ShiftType.EARLY + + +def is_intermediate_shift(shift: Shift) -> bool: + return shift.type == ShiftType.INTERMEDIATE + + +def is_late_shift(shift: Shift) -> bool: + return shift.type == ShiftType.LATE From 1536570e227c0d627d75b4190ff4c6120a001294 Mon Sep 17 00:00:00 2001 From: Julius Ketz Date: Fri, 10 Jul 2026 10:48:11 +0200 Subject: [PATCH 08/17] Backend api (#302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Vorbereitung der API Struktur + Bereitstellung des Gerüsts für Weights und Availabilities * Global Wishes zu Weekly Wishes gemacht und die API entsprechend angepasst * Schreiben eines neuen Wunsches in die DB mit Post * Schreiben, löschen und verändern von Wünschen über API in der Datenbank * minimal staff boilerplate * minimum staff api infra * minimal_staff draft * added put outline * update wishes and blocked api parameter * Speichern, Ändern und Lesen von Globalen Wünschen in der Datenbank * Wechsel von Monat, Jahr zu From Date * Entfernen von weekly Wishes da wir uns darauf geeinigt haben, dass wir die nicht brauchen * Auslesen von Availabilities und Wünschen zusammen * Wishes and Availabilities in einen Router gepackt und get gefiltert --------- Co-authored-by: Lena Giebeler Co-authored-by: Jonas Co-authored-by: Fengwu Lu --- src/scheduling/api/app.py | 13 +- src/scheduling/api/timeoffice/router.py | 0 src/scheduling/api/timeoffice/schemas.py | 16 + .../api/web/{router.py => employee_router.py} | 4 +- .../api/web/minimal_staff_router.py | 116 +++++++ src/scheduling/api/web/schemas.py | 25 ++ src/scheduling/api/web/weights_router.py | 69 +++++ .../api/web/wishes_availabilities_router.py | 275 +++++++++++++++++ src/scheduling/domain/__init__.py | 3 +- src/scheduling/domain/dataset.py | 29 +- src/scheduling/domain/planning_month.py | 29 ++ src/scheduling/timeoffice/facts.py | 9 + src/scheduling/timeoffice/mapping/dataset.py | 6 +- src/scheduling/timeoffice/mapping/wishes.py | 130 +++++++- src/scheduling/timeoffice/reading/wishes.py | 4 +- src/scheduling/timeoffice/remapping/wishes.py | 112 +++++++ src/scheduling/timeoffice/service.py | 44 ++- src/scheduling/timeoffice/writing/wishes.py | 288 ++++++++++++++++++ 18 files changed, 1130 insertions(+), 42 deletions(-) create mode 100644 src/scheduling/api/timeoffice/router.py create mode 100644 src/scheduling/api/timeoffice/schemas.py rename src/scheduling/api/web/{router.py => employee_router.py} (94%) create mode 100644 src/scheduling/api/web/minimal_staff_router.py create mode 100644 src/scheduling/api/web/schemas.py create mode 100644 src/scheduling/api/web/weights_router.py create mode 100644 src/scheduling/api/web/wishes_availabilities_router.py create mode 100644 src/scheduling/domain/planning_month.py create mode 100644 src/scheduling/timeoffice/remapping/wishes.py create mode 100644 src/scheduling/timeoffice/writing/wishes.py diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index 12d1a836..67a8745b 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -8,7 +8,9 @@ from scheduling.api.dependencies import ApiRuntime from scheduling.api.solve.job_store import InMemorySolveJobStore from scheduling.api.solve.router import solve_router -from scheduling.api.web.router import web_router +from scheduling.api.web.employee_router import employee_router +from scheduling.api.web.minimal_staff_router import minimal_staff_router +from scheduling.api.web.wishes_availabilities_router import wishes_and_availabilities_router from scheduling.logging import configure_logging from scheduling.settings import get_settings from scheduling.solver.cp_sat.builder import create_cp_sat_model_builder @@ -18,6 +20,7 @@ from scheduling.timeoffice.reading.container import TimeOfficeReaders from scheduling.timeoffice.service import TimeOfficeService from scheduling.timeoffice.writing.solution import TimeOfficeSolutionWriter +from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter settings = get_settings() configure_logging(level=settings.log_level) @@ -38,6 +41,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: engine=engine, readers=TimeOfficeReaders.create(facts=facts), solution_writer=TimeOfficeSolutionWriter(), + wish_writer=TimeOfficeWishWriter( + target_planning_status_id=facts.target_planning_status_id, + ), ), solver_service=SolverService( settings=settings, @@ -55,7 +61,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app = FastAPI(title="Staff Scheduling API", lifespan=lifespan) app.include_router(solve_router) -app.include_router(web_router) +app.include_router(employee_router) +# app.include_router(weights_router) +app.include_router(wishes_and_availabilities_router) +app.include_router(minimal_staff_router) @app.get("/status") diff --git a/src/scheduling/api/timeoffice/router.py b/src/scheduling/api/timeoffice/router.py new file mode 100644 index 00000000..e69de29b diff --git a/src/scheduling/api/timeoffice/schemas.py b/src/scheduling/api/timeoffice/schemas.py new file mode 100644 index 00000000..abebc972 --- /dev/null +++ b/src/scheduling/api/timeoffice/schemas.py @@ -0,0 +1,16 @@ +from typing import Any + +from pydantic import Field + +from scheduling.domain import PlanningMonth, SchedulingBaseModel + + +class DBRequest(SchedulingBaseModel): + planning_unit_id: int + year: int = Field(ge=2000, le=2100) + month: int = Field(ge=1, le=12) + + def planning_month(self) -> PlanningMonth: + return PlanningMonth(year=self.year, month=self.month) + + solution_data: dict[str, Any] | None = None diff --git a/src/scheduling/api/web/router.py b/src/scheduling/api/web/employee_router.py similarity index 94% rename from src/scheduling/api/web/router.py rename to src/scheduling/api/web/employee_router.py index 80d8e90a..ed57cfbb 100644 --- a/src/scheduling/api/web/router.py +++ b/src/scheduling/api/web/employee_router.py @@ -11,10 +11,10 @@ logger = logging.getLogger(__name__) -web_router = APIRouter() +employee_router = APIRouter() -@web_router.get("/employees") +@employee_router.get("/employees") async def get_employees( planning_unit: int, from_date: date, diff --git a/src/scheduling/api/web/minimal_staff_router.py b/src/scheduling/api/web/minimal_staff_router.py new file mode 100644 index 00000000..d1ece581 --- /dev/null +++ b/src/scheduling/api/web/minimal_staff_router.py @@ -0,0 +1,116 @@ +import logging +from datetime import date +from typing import Annotated, Any + +from fastapi import APIRouter, Depends + +from scheduling.api.dependencies import get_timeoffice_service +from scheduling.api.web.schemas import SuccessResponse +from scheduling.domain import PlanningMonth +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + + +minimal_staff_router = APIRouter() + + +@minimal_staff_router.get("/minimal-staff") +async def get_minimal_staff_func( + planning_unit: int, from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)] +) -> Any: + """Return minimal staff requirements for a planning unit and month.""" + month = PlanningMonth(year=from_date.year, month=from_date.month) + dataset = timeoffice.fetch_dataset(planning_unit_ids=(planning_unit,), planning_month=month) + # return(_minimal_staff_to_frontend(dataset)) + return _generate_minimal_staff_requirements(dataset) + + +def _generate_minimal_staff_requirements(dataset: Any) -> dict[str, dict[str, dict[str, int]]]: + level_map = {"trainee": "Azubi", "professional": "Fachkraft", "assistant": "Hilfskraft"} + + shift_map = {"early": "F", "late": "S", "night": "N"} + + days_of_week = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] + + output = {de_level: {day: {"F": 0, "N": 0, "S": 0} for day in days_of_week} for de_level in level_map.values()} + + if isinstance(dataset, dict): + demand_data = dataset.get("demand_requirements", []) + else: + demand_data = getattr(dataset, "demand_requirements", []) + + if demand_data: + for req in demand_data: + if isinstance(req, dict): + staff_level = req.get("staff_level") + shift_type = req.get("shift_type") + day_of_week = req.get("day_of_week") + min_required = req.get("min_required", 0) + else: + staff_level = getattr(req, "staff_level", None) + shift_type = getattr(req, "shift_type", None) + day_of_week = getattr(req, "day_of_week", None) + min_required = getattr(req, "min_required", 0) + + lvl = level_map.get(str(staff_level)) + shift = shift_map.get(str(shift_type)) + day = str(day_of_week) + + if lvl and shift and day in days_of_week: + output[lvl][day][shift] = min_required + else: + # Default data + output = { + "Azubi": { + "Di": {"F": 1, "N": 0, "S": 1}, + "Do": {"F": 1, "N": 0, "S": 1}, + "Fr": {"F": 1, "N": 0, "S": 1}, + "Mi": {"F": 1, "N": 0, "S": 1}, + "Mo": {"F": 1, "N": 0, "S": 1}, + "Sa": {"F": 1, "N": 0, "S": 1}, + "So": {"F": 1, "N": 0, "S": 1}, + }, + "Fachkraft": { + "Di": {"F": 3, "N": 2, "S": 2}, + "Do": {"F": 3, "N": 2, "S": 2}, + "Fr": {"F": 3, "N": 2, "S": 2}, + "Mi": {"F": 4, "N": 2, "S": 2}, + "Mo": {"F": 3, "N": 2, "S": 2}, + "Sa": {"F": 2, "N": 1, "S": 2}, + "So": {"F": 2, "N": 1, "S": 2}, + }, + "Hilfskraft": { + "Di": {"F": 2, "N": 0, "S": 2}, + "Do": {"F": 2, "N": 0, "S": 2}, + "Fr": {"F": 2, "N": 0, "S": 2}, + "Mi": {"F": 2, "N": 0, "S": 2}, + "Mo": {"F": 2, "N": 0, "S": 2}, + "Sa": {"F": 2, "N": 1, "S": 2}, + "So": {"F": 2, "N": 1, "S": 2}, + }, + } + + return output + + +# TODO: minimal_staff put missing, unsure where in DB to write to +@minimal_staff_router.put("/minimal-staff") +async def put_minimal_staff( + planning_unit: int, + from_date: date, + request: dict[str, dict[str, int]], +) -> dict[str, bool]: + month = PlanningMonth(year=from_date.year, month=from_date.month) + request_json = request.get("data", {}) + + logger.info( + "Received minimal staff update: planning_unit=%s planning_month=%s minimal_staff=%s", + planning_unit, + month.label, + request_json, + ) + + logger.info("Update availability in database") + + return SuccessResponse() diff --git a/src/scheduling/api/web/schemas.py b/src/scheduling/api/web/schemas.py new file mode 100644 index 00000000..53d7c0d7 --- /dev/null +++ b/src/scheduling/api/web/schemas.py @@ -0,0 +1,25 @@ +from pydantic import Field + +from scheduling.domain.core import SchedulingBaseModel + + +class WishesAndBlockedEmployeeRequest(SchedulingBaseModel): + key: int + firstname: str | None = None + name: str | None = None + wish_days: tuple[int, ...] = Field(default_factory=tuple) + wish_shifts: tuple[tuple[int, str], ...] = Field(default_factory=tuple) + blocked_days: tuple[int, ...] = Field(default_factory=tuple) + blocked_shifts: tuple[tuple[int, str], ...] = Field(default_factory=tuple) + + +class WishesAndBlockedDatabaseRequest(SchedulingBaseModel): + employees: tuple[WishesAndBlockedEmployeeRequest, ...] + + +class CreateWishesAndBlockedRequest(SchedulingBaseModel): + data: WishesAndBlockedEmployeeRequest + + +class SuccessResponse(SchedulingBaseModel): + success: bool = True diff --git a/src/scheduling/api/web/weights_router.py b/src/scheduling/api/web/weights_router.py new file mode 100644 index 00000000..1d4e7766 --- /dev/null +++ b/src/scheduling/api/web/weights_router.py @@ -0,0 +1,69 @@ +import logging +from datetime import date +from typing import Annotated, Any + +from fastapi import APIRouter, Depends + +from scheduling.api.dependencies import get_timeoffice_service +from scheduling.domain import PlanningMonth # Hier muss später noch Wish stehen +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + +weights_router = APIRouter() + + +DEFAULT_WEIGHTS: dict[ + str, Any +] = {} # TODO: Default weights sollten in der Datenbank stehen und später ausgelesen werden + + +@weights_router.get("/weights") +async def get_weights( + planning_unit: int, + from_date: date, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> dict[str, Any]: + """Return weights for a planning unit and month. + + TODO: Ersetzen des Kommentars fürs fetchen mit der richtigen Funktion + TODO: Default weights in die Datenbank schreiben und die dann fetchen + """ + # Wäre schöner, wenn Monat und Jahr im frontend übergeben werden -> ggf. noch ändern + month = PlanningMonth(year=from_date.year, month=from_date.month) + # weights = timeoffice.fetch_dataset(planning_unit_ids=(planning_unit,), planning_month=month).weights + logger.info( + "Fetching weights: planning_unit=%s planning_month=%s", + planning_unit, + month.label, + ) + # if weights is None: + # TODO: weights = Methode fetch default weights oder so + # TODO: Umwandeln der weights in die entsprechende json + # return weights + + return DEFAULT_WEIGHTS # Muss durch weights ersetzt werden und dann können Default weights gelöscht werden + + +@weights_router.put("/weights") +async def put_weights( + planning_unit: int, + from_date: date, + request: dict[str, Any], # Vielleicht schöner dem Request ein Schema zu geben +) -> dict[str, bool]: + """Update weights for a planning unit and month. + + TODO: Überführen der Gewichte ins Domain + Schreiben der Gewichte in die Datenbank + """ + month = PlanningMonth(year=from_date.year, month=from_date.month) + weights_json = request.get("data", {}) + + logger.info( + "Received weights update: planning_unit=%s planning_month=%s weights=%s", + planning_unit, + month.label, + weights_json, + ) + # TODO: Überführen der weights_json in das Domain + logger.info("Update Weights in Database") + return {"success": True} diff --git a/src/scheduling/api/web/wishes_availabilities_router.py b/src/scheduling/api/web/wishes_availabilities_router.py new file mode 100644 index 00000000..0c5f5af8 --- /dev/null +++ b/src/scheduling/api/web/wishes_availabilities_router.py @@ -0,0 +1,275 @@ +import json +import logging +from datetime import date +from typing import Annotated, Any + +from fastapi import APIRouter, Depends + +from scheduling.api.dependencies import get_timeoffice_service +from scheduling.api.web.schemas import CreateWishesAndBlockedRequest, SuccessResponse, WishesAndBlockedEmployeeRequest +from scheduling.domain import Availability, AvailabilityType, Employee, PlanningMonth, Wish, WishType +from scheduling.timeoffice.facts import TIMEOFFICE_FACTS +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + +wishes_and_availabilities_router = APIRouter() + + +@wishes_and_availabilities_router.get("/wishes-and-blocked") +async def get_wishes_and_blocked( + planning_unit: int, + from_date: date, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> dict[str, list[dict[str, Any]]]: + from_date_year, from_date_month = from_date.year, from_date.month + month = PlanningMonth(year=from_date_year, month=from_date_month) + + dataset = timeoffice.fetch_dataset( + planning_unit_ids=(planning_unit,), + planning_month=month, + ) + + employee_wishes_blocked = [ + _wishes_and_availability_to_frontend( + employee=employee, + wishes=dataset.wishes, + availability=dataset.availability, + ) + for employee in dataset.employees + ] + logger.warning( + "DEBUG wishes/availability response: %s", + json.dumps( + { + "employees": [ + employee_wish_block + for employee_wish_block in employee_wishes_blocked + if _has_any_wishes_or_availability(employee_wish_block) + ] + } + ), + ) + + return { + "employees": [ + employee_wish_block + for employee_wish_block in employee_wishes_blocked + if _has_any_wishes_or_availability(employee_wish_block) + ] + } + + +def _has_any_wishes_or_availability(employee_block: dict[str, Any]) -> bool: + return any( + employee_block[field] + for field in ( + "blocked_days", + "blocked_shifts", + "wish_days", + "wish_shifts", + "work_days", + "work_shifts", + ) + ) + + +def _wishes_and_availability_to_frontend( + *, + employee: Employee, + wishes: tuple[Wish, ...], + availability: tuple[Availability, ...], +) -> dict[str, Any]: + name, firstname = _split_display_name(employee.display_name) + + employee_wishes = [wish for wish in wishes if wish.employee_id == employee.employee_id] + + employee_availability = [item for item in availability if item.employee_id == employee.employee_id] + + return { + "key": employee.employee_id, + "firstname": firstname, + "name": name, + # Availability + "blocked_days": [ + item.date.day for item in employee_availability if item.availability_type != AvailabilityType.AVAILABLE_ONLY + ], + "blocked_shifts": _blocked_shifts_to_frontend(employee_availability), + "wish_days": [wish.date.day for wish in employee_wishes if wish.type == WishType.FREE_DAY], + "wish_shifts": [ + [wish.date.day, _wish_shift_to_frontend(wish)] + for wish in employee_wishes + if wish.type == WishType.FREE_SHIFT + ], + "work_days": [wish.date.day for wish in employee_wishes if wish.type == WishType.PREFERRED_DAY], + "work_shifts": [ + [wish.date.day, _wish_shift_to_frontend(wish)] + for wish in employee_wishes + if wish.type == WishType.PREFERRED_SHIFT + ], + } + + +def _blocked_shifts_to_frontend( + availability_items: list[Availability], +) -> list[list[int | str]]: + shift_ids_by_code = _reference_shift_ids_by_code() + shift_codes_by_id = {shift_id: shift_code for shift_code, shift_id in shift_ids_by_code.items()} + + all_shift_codes = set(shift_ids_by_code) + + blocked_shifts: list[list[int | str]] = [] + + for item in availability_items: + if item.availability_type != AvailabilityType.AVAILABLE_ONLY: + continue + + if item.shift_ids is None: + continue + + allowed_shift_codes = { + shift_codes_by_id[shift_id] for shift_id in item.shift_ids if shift_id in shift_codes_by_id + } + + blocked_shift_codes = all_shift_codes - allowed_shift_codes + + for shift_code in sorted(blocked_shift_codes): + blocked_shifts.append([item.date.day, shift_code]) + + return blocked_shifts + + +def _reference_shift_ids_by_code() -> dict[str, int]: + return { + shift_fact.expected_code: shift_id + for shift_id, shift_fact in TIMEOFFICE_FACTS.reference_shift_facts_by_id.items() + if shift_fact.expected_code in {"F", "S", "N"} + } + + +def _wish_shift_to_frontend(wish: Wish) -> str: + if wish.shift_id is None: + raise ValueError(f"{wish.type} wish requires shift_id.") + + shift_fact = TIMEOFFICE_FACTS.reference_shift_facts_by_id.get(wish.shift_id) + + if shift_fact is None: + raise ValueError(f"Unknown reference shift for wish: employee_id={wish.employee_id} ") + + return shift_fact.expected_code + + +def _split_display_name(display_name: str) -> tuple[str, str]: + name, _separator, firstname = display_name.partition(" ") + return name, firstname + + +@wishes_and_availabilities_router.put("/wishes-and-blocked/{employee_id}") +async def replace_wishes_and_blocked( + employee_id: int, + planning_unit: int, + from_date: date, + request: CreateWishesAndBlockedRequest, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> SuccessResponse: + if request.data.key != employee_id: + raise ValueError("employee_id path parameter does not match request.data.key.") + + from_date_year, from_date_month = from_date.year, from_date.month + planning_month = PlanningMonth(year=from_date_year, month=from_date_month) + + wishes = _wishes_employee_request_to_domain( + employee=request.data, + planning_unit=planning_unit, + planning_month=planning_month, + ) + print(wishes) + """ + timeoffice.replace_wishes( + planning_unit_id=planning_unit, + planning_month=planning_month, + employee_id=employee_id, + wishes=wishes, + )""" + + return SuccessResponse() + + +def _wishes_employee_request_to_domain( + *, + employee: WishesAndBlockedEmployeeRequest, + planning_unit: int, + planning_month: PlanningMonth, +) -> tuple[Wish, ...]: + wishes: list[Wish] = [] + + for day in employee.wish_days: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.PREFERRED_DAY, + ) + ) + + for day, shift_code in employee.wish_shifts: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.PREFERRED_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), + ) + ) + + for day in employee.blocked_days: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.FREE_DAY, + ) + ) + + for day, shift_code in employee.blocked_shifts: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.FREE_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), + ) + ) + + return tuple(wishes) + + +def _shift_id_from_frontend(shift_code: str) -> int: + for shift_id, shift_fact in TIMEOFFICE_FACTS.reference_shift_facts_by_id.items(): + if shift_fact.expected_code == shift_code: + return shift_id + + raise ValueError(f"Unknown shift code from wishes frontend: {shift_code!r}.") + + +@wishes_and_availabilities_router.delete("/wishes-and-blocked/{employee_id}") +async def delete_wishes_and_blocked( + employee_id: int, + planning_unit: int, + from_date: date, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> SuccessResponse: + from_date_year, from_date_month = from_date.year, from_date.month + planning_month = PlanningMonth(year=from_date_year, month=from_date_month) + + timeoffice.delete_employee_wishes( + planning_unit_id=planning_unit, + planning_month=planning_month, + employee_id=employee_id, + ) + + return SuccessResponse() diff --git a/src/scheduling/domain/__init__.py b/src/scheduling/domain/__init__.py index a22e1b50..5c823198 100644 --- a/src/scheduling/domain/__init__.py +++ b/src/scheduling/domain/__init__.py @@ -1,11 +1,12 @@ from scheduling.domain.assignment import Assignment, AssignmentType from scheduling.domain.availability import Availability, AvailabilityType from scheduling.domain.core import MinuteOfDay, NonEmptyStr, NonNegativeInt, PositiveId, SchedulingBaseModel -from scheduling.domain.dataset import PlanningMonth, SchedulingDataset +from scheduling.domain.dataset import SchedulingDataset from scheduling.domain.demand import DemandRequirement from scheduling.domain.employee import Capability, Employee, EmployeeId, StaffLevel from scheduling.domain.monthly_work_account import MonthlyWorkAccount from scheduling.domain.plan import Plan, PlanId +from scheduling.domain.planning_month import PlanningMonth from scheduling.domain.planning_unit import PlanningUnit, PlanningUnitId, PlanningUnitMembership, PlanningUnitType from scheduling.domain.shift import Shift, ShiftId, ShiftType, StaffingDemandRole from scheduling.domain.sunday_work_history import EmployeeSundayWorkHistory diff --git a/src/scheduling/domain/dataset.py b/src/scheduling/domain/dataset.py index a32d8a32..2c7c7ad4 100644 --- a/src/scheduling/domain/dataset.py +++ b/src/scheduling/domain/dataset.py @@ -1,8 +1,3 @@ -from calendar import monthrange -from datetime import date - -from pydantic import Field, computed_field - from scheduling.domain import SchedulingBaseModel from scheduling.domain.assignment import Assignment from scheduling.domain.availability import Availability @@ -10,35 +5,13 @@ from scheduling.domain.employee import Employee from scheduling.domain.monthly_work_account import MonthlyWorkAccount from scheduling.domain.plan import Plan +from scheduling.domain.planning_month import PlanningMonth from scheduling.domain.planning_unit import PlanningUnit, PlanningUnitMembership from scheduling.domain.shift import Shift from scheduling.domain.sunday_work_history import EmployeeSundayWorkHistory from scheduling.domain.wish import Wish -class PlanningMonth(SchedulingBaseModel): - year: int = Field(ge=2000, le=2200) - month: int = Field(ge=1, le=12) - - @computed_field - @property - def start(self) -> date: - return date(self.year, self.month, 1) - - @computed_field - @property - def end(self) -> date: - return date( - self.year, - self.month, - monthrange(self.year, self.month)[1], - ) - - @property - def label(self) -> str: - return f"{self.year:04d}-{self.month:02d}" - - class SchedulingDataset(SchedulingBaseModel): """Clean scheduling dataset aligned with TimeOffice planning concepts. diff --git a/src/scheduling/domain/planning_month.py b/src/scheduling/domain/planning_month.py new file mode 100644 index 00000000..871ebabf --- /dev/null +++ b/src/scheduling/domain/planning_month.py @@ -0,0 +1,29 @@ +from calendar import monthrange +from datetime import date + +from pydantic import Field, computed_field + +from scheduling.domain import SchedulingBaseModel + + +class PlanningMonth(SchedulingBaseModel): + year: int = Field(ge=2000, le=2200) + month: int = Field(ge=1, le=12) + + @computed_field + @property + def start(self) -> date: + return date(self.year, self.month, 1) + + @computed_field + @property + def end(self) -> date: + return date( + self.year, + self.month, + monthrange(self.year, self.month)[1], + ) + + @property + def label(self) -> str: + return f"{self.year:04d}-{self.month:02d}" diff --git a/src/scheduling/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index c315ba0c..0c9e3911 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -79,6 +79,7 @@ class TimeOfficeFacts: work_shift_type_id: int reference_shift_facts_by_id: Mapping[ShiftId, TimeOfficeReferenceShiftFact] + wish_absence_shift_id_by_type: Mapping[WishType, int] # Non-reference source shift IDs normalized to reduced reference shifts. # Missing source shift ID => fail loudly in mapping. @@ -144,6 +145,8 @@ class TimeOfficeFacts: 2866: NIGHT_N2_SHIFT_ID, # N5 # Day/intermediate variant normalized to canonical T75_ intermediate shift. 2994: INTERMEDIATE_T75_SHIFT_ID, # T8x + 1234: INTERMEDIATE_T75_SHIFT_ID, + 1356: INTERMEDIATE_T75_SHIFT_ID, # Short/day special variants normalized to canonical Z60 non-minimum work shift. 2957: MANAGEMENT_Z60_SHIFT_ID, # Z52 2687: MANAGEMENT_Z60_SHIFT_ID, # Z52 @@ -189,6 +192,7 @@ class TimeOfficeFacts: "A-81302-016": StaffLevel.TRAINEE, # A-Pflegefachkraft Kinderkrankenpflege "A-81302-018": StaffLevel.TRAINEE, # A-Pflegefachkraft Krankenpflege "A-81302-019": StaffLevel.TRAINEE, # A-Pflegefachkraft Altenpflege + # "-": StaffLevel.TRAINEE,#Später herausfinden was das für eine Profession ist } ) @@ -291,6 +295,11 @@ class TimeOfficeFacts: "FR": WishType.FREE_DAY, } ), + wish_absence_shift_id_by_type=MappingProxyType( + { + WishType.FREE_DAY: 1089, + } + ), monthly_target_work_account_id=MONTHLY_TARGET_WORK_ACCOUNT_ID, monthly_actual_work_account_id=MONTHLY_ACTUAL_WORK_ACCOUNT_ID, ) diff --git a/src/scheduling/timeoffice/mapping/dataset.py b/src/scheduling/timeoffice/mapping/dataset.py index 1a3a0b9c..e0337f51 100644 --- a/src/scheduling/timeoffice/mapping/dataset.py +++ b/src/scheduling/timeoffice/mapping/dataset.py @@ -47,10 +47,6 @@ def map_scheduling_dataset( facts=facts, ), sunday_work_history=map_sunday_work_history(sources.sunday_history_rows), - wishes=map_wishes( - rows=sources.wish_rows, - shifts=shifts, - facts=facts, - ), + wishes=map_wishes(rows=sources.wish_rows, shifts=shifts, facts=facts), monthly_work_accounts=map_monthly_work_accounts(sources.monthly_work_account_rows), ) diff --git a/src/scheduling/timeoffice/mapping/wishes.py b/src/scheduling/timeoffice/mapping/wishes.py index 2649a907..65c70e4c 100644 --- a/src/scheduling/timeoffice/mapping/wishes.py +++ b/src/scheduling/timeoffice/mapping/wishes.py @@ -1,6 +1,8 @@ +from collections import defaultdict from datetime import date as Date from scheduling.domain import Shift, Wish, WishType +from scheduling.domain.shift import ShiftType from scheduling.timeoffice.facts import TimeOfficeFacts from scheduling.timeoffice.mapping.shifts import reference_shift_id_for_source_shift from scheduling.timeoffice.reading.wishes import TimeOfficeWishRow @@ -23,7 +25,10 @@ def map_wishes( for row in rows ) - return _deduplicate_wishes(wishes) + wishes = _deduplicate_wishes(wishes) + wishes = _collapse_preferred_day_wishes(wishes, facts=facts) + + return _sort_wishes(wishes) def _map_wish( @@ -32,6 +37,13 @@ def _map_wish( known_shift_ids: set[int], facts: TimeOfficeFacts, ) -> Wish: + if row.work_shift_id is not None and _has_absence(row): + return _map_free_shift_wish( + row=row, + known_shift_ids=known_shift_ids, + facts=facts, + ) + if row.work_shift_id is not None: return _map_preferred_shift_wish( row=row, @@ -42,6 +54,40 @@ def _map_wish( return _map_absence_wish(row=row, facts=facts) +def _has_absence(row: TimeOfficeWishRow) -> bool: + return row.global_absence_shift_id is not None or row.absence_shift_id is not None + + +def _map_free_shift_wish( + *, + row: TimeOfficeWishRow, + known_shift_ids: set[int], + facts: TimeOfficeFacts, +) -> Wish: + reference_shift_id = reference_shift_id_for_source_shift( + source_shift_id=row.work_shift_id, + source_shift_code=row.work_shift_code, + facts=facts, + context=f"TimeOffice free-shift wish employee_id={row.employee_id} date={row.wish_date.date()}", + ) + + if reference_shift_id not in known_shift_ids: + raise ValueError( + "TimeOffice free-shift wish references shift that is not part " + "of the mapped SchedulingDataset: " + f"source_shift_id={row.work_shift_id} " + f"reference_shift_id={reference_shift_id}." + ) + + return Wish( + employee_id=row.employee_id, + planning_unit_id=row.planning_unit_id, + date=row.wish_date.date(), + type=WishType.FREE_SHIFT, + shift_id=reference_shift_id, + ) + + def _map_preferred_shift_wish( *, row: TimeOfficeWishRow, @@ -156,3 +202,85 @@ def _deduplicate_wishes(wishes: tuple[Wish, ...]) -> tuple[Wish, ...]: ), ) ) + + +PREFERRED_DAY_SHIFT_TYPES = { + ShiftType.EARLY, + ShiftType.LATE, + ShiftType.NIGHT, +} + + +def _collapse_preferred_day_wishes( + wishes: tuple[Wish, ...], + *, + facts: TimeOfficeFacts, +) -> tuple[Wish, ...]: + preferred_day_shift_ids = _preferred_day_shift_ids(facts) + + wishes_by_day: dict[tuple[int, int, Date], list[Wish]] = defaultdict(list) + + for wish in wishes: + key = (wish.employee_id, wish.planning_unit_id, wish.date) + wishes_by_day[key].append(wish) + + collapsed_wishes: list[Wish] = [] + + for day_wishes in wishes_by_day.values(): + preferred_shift_ids = {wish.shift_id for wish in day_wishes if wish.type == WishType.PREFERRED_SHIFT} + + has_preferred_day = preferred_day_shift_ids.issubset(preferred_shift_ids) + + if not has_preferred_day: + collapsed_wishes.extend(day_wishes) + continue + + first_wish = day_wishes[0] + + collapsed_wishes.append( + Wish( + employee_id=first_wish.employee_id, + planning_unit_id=first_wish.planning_unit_id, + date=first_wish.date, + type=WishType.PREFERRED_DAY, + ) + ) + + collapsed_wishes.extend( + wish + for wish in day_wishes + if not (wish.type == WishType.PREFERRED_SHIFT and wish.shift_id in preferred_day_shift_ids) + ) + + return _deduplicate_wishes(tuple(collapsed_wishes)) + + +def _preferred_day_shift_ids(facts: TimeOfficeFacts) -> frozenset[int]: + shift_ids = frozenset( + shift_id + for shift_id, shift_fact in facts.reference_shift_facts_by_id.items() + if shift_fact.type in PREFERRED_DAY_SHIFT_TYPES + ) + + if len(shift_ids) != 3: + raise ValueError( + "Expected exactly three TimeOffice reference shifts for preferred day " + f"mapping, got shift_ids={sorted(shift_ids)}." + ) + + return shift_ids + + +def _sort_wishes(wishes: tuple[Wish, ...]) -> tuple[Wish, ...]: + return tuple( + sorted( + wishes, + key=lambda wish: ( + wish.employee_id, + wish.planning_unit_id, + wish.date, + wish.type.value, + wish.shift_id or -1, + ), + ) + ) diff --git a/src/scheduling/timeoffice/reading/wishes.py b/src/scheduling/timeoffice/reading/wishes.py index 4cd26819..94814a8b 100644 --- a/src/scheduling/timeoffice/reading/wishes.py +++ b/src/scheduling/timeoffice/reading/wishes.py @@ -29,7 +29,7 @@ class TimeOfficeWishRow(TimeOfficeSourceRow): resolved_absence_shift_id: SourceNullableInt = None resolved_absence_code: CleanNullableText = None resolved_absence_name: CleanNullableText = None - + """ @model_validator(mode="after") def validate_row_type(self) -> Self: has_work_shift = self.work_shift_id is not None @@ -49,7 +49,7 @@ def validate_row_type(self) -> Self: f"for employee_id={self.employee_id}, wish_date={self.wish_date}." ) - return self + return self""" @model_validator(mode="after") def validate_absence_references(self) -> Self: diff --git a/src/scheduling/timeoffice/remapping/wishes.py b/src/scheduling/timeoffice/remapping/wishes.py new file mode 100644 index 00000000..289cd5e6 --- /dev/null +++ b/src/scheduling/timeoffice/remapping/wishes.py @@ -0,0 +1,112 @@ +from datetime import date as Date + +from scheduling.domain import SchedulingBaseModel, Wish, WishType +from scheduling.timeoffice.facts import TimeOfficeFacts + + +class TimeOfficeWishWriteRow(SchedulingBaseModel): + employee_id: int + planning_unit_id: int + plan_id: int + wish_date: Date + work_shift_id: int | None = None + absence_shift_id: int | None = None + + +PREFERRED_DAY_SHIFT_CODES = ("F", "S", "N") + + +def map_wishes_to_timeoffice_rows( + *, + wishes: tuple[Wish, ...], + plan_id: int, + facts: TimeOfficeFacts, +) -> tuple[TimeOfficeWishWriteRow, ...]: + return tuple( + row + for wish in wishes + for row in _map_wish_to_timeoffice_rows( + wish=wish, + plan_id=plan_id, + facts=facts, + ) + ) + + +def _map_wish_to_timeoffice_rows( + *, + wish: Wish, + plan_id: int, + facts: TimeOfficeFacts, +) -> tuple[TimeOfficeWishWriteRow, ...]: + if wish.type == WishType.PREFERRED_DAY: + return tuple( + TimeOfficeWishWriteRow( + employee_id=wish.employee_id, + planning_unit_id=wish.planning_unit_id, + plan_id=plan_id, + wish_date=wish.date, + work_shift_id=shift_id, + ) + for shift_id in _preferred_day_shift_ids(facts) + ) + + if wish.type == WishType.PREFERRED_SHIFT: + return ( + TimeOfficeWishWriteRow( + employee_id=wish.employee_id, + planning_unit_id=wish.planning_unit_id, + plan_id=plan_id, + wish_date=wish.date, + work_shift_id=_require_shift_id(wish), + ), + ) + + if wish.type == WishType.FREE_DAY: + return ( + TimeOfficeWishWriteRow( + employee_id=wish.employee_id, + planning_unit_id=wish.planning_unit_id, + plan_id=plan_id, + wish_date=wish.date, + absence_shift_id=_absence_shift_id_for_wish_type(wish.type, facts=facts), + ), + ) + + if wish.type == WishType.FREE_SHIFT: + return ( + TimeOfficeWishWriteRow( + employee_id=wish.employee_id, + planning_unit_id=wish.planning_unit_id, + plan_id=plan_id, + wish_date=wish.date, + work_shift_id=_require_shift_id(wish), + absence_shift_id=_absence_shift_id_for_wish_type(WishType.FREE_DAY, facts=facts), + ), + ) + + raise ValueError(f"Unsupported wish type: {wish.type}") + + +def _preferred_day_shift_ids(facts: TimeOfficeFacts) -> tuple[int, ...]: + shift_id_by_code = { + shift_fact.expected_code: shift_id for shift_id, shift_fact in facts.reference_shift_facts_by_id.items() + } + + return tuple(shift_id_by_code[code] for code in PREFERRED_DAY_SHIFT_CODES) + + +def _require_shift_id(wish: Wish) -> int: + if wish.shift_id is None: + raise ValueError(f"{wish.type} wish requires shift_id.") + + return wish.shift_id + + +def _absence_shift_id_for_wish_type(wish_type: WishType, *, facts: TimeOfficeFacts) -> int: + absence_shift_id = facts.wish_absence_shift_id_by_type.get(wish_type) + + if absence_shift_id is None: + raise ValueError(f"No TimeOffice absence shift configured for wish_type={wish_type}.") + + return absence_shift_id diff --git a/src/scheduling/timeoffice/service.py b/src/scheduling/timeoffice/service.py index 54790290..886dde2a 100644 --- a/src/scheduling/timeoffice/service.py +++ b/src/scheduling/timeoffice/service.py @@ -3,13 +3,14 @@ from sqlalchemy import Engine from scheduling.api.solve.schemas import SolveOptions -from scheduling.domain import PlanningMonth, SchedulingDataset +from scheduling.domain import PlanningMonth, SchedulingDataset, Wish from scheduling.solver.models import Solution from scheduling.timeoffice.facts import TimeOfficeFacts from scheduling.timeoffice.mapping import map_scheduling_dataset from scheduling.timeoffice.mapping.options import map_solve_options from scheduling.timeoffice.reading.container import TimeOfficeReaders from scheduling.timeoffice.writing.solution import LegacySolutionExportPaths, TimeOfficeSolutionWriter +from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter from scheduling.validation import validate_scheduling_dataset logger = logging.getLogger(__name__) @@ -25,11 +26,13 @@ def __init__( engine: Engine, readers: TimeOfficeReaders, solution_writer: TimeOfficeSolutionWriter, + wish_writer: TimeOfficeWishWriter, ) -> None: self._facts = facts self._engine = engine self._readers = readers self._solution_writer = solution_writer + self._wish_writer = wish_writer def get_solve_options(self) -> SolveOptions: logger.info("Fetching TimeOffice solve options") @@ -132,3 +135,42 @@ def _normalize_planning_unit_ids( ) return normalized + + def replace_wishes( + self, + *, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + wishes: tuple[Wish, ...], + ) -> None: + with self._engine.begin() as connection: + self._wish_writer.delete_employee_wishes( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + ) + + self._wish_writer.insert_wishes( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + wishes=wishes, + facts=self._facts, + ) + + def delete_employee_wishes( + self, + *, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + ) -> None: + with self._engine.begin() as connection: + self._wish_writer.delete_employee_wishes( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + ) diff --git a/src/scheduling/timeoffice/writing/wishes.py b/src/scheduling/timeoffice/writing/wishes.py new file mode 100644 index 00000000..238ce594 --- /dev/null +++ b/src/scheduling/timeoffice/writing/wishes.py @@ -0,0 +1,288 @@ +from sqlalchemy import Connection, text + +from scheduling.domain import PlanningMonth, Wish +from scheduling.timeoffice.facts import TimeOfficeFacts +from scheduling.timeoffice.remapping.wishes import TimeOfficeWishWriteRow, map_wishes_to_timeoffice_rows + + +class TimeOfficeWishWriter: + def __init__(self, *, target_planning_status_id: int) -> None: + self._target_planning_status_id = target_planning_status_id + + def insert_wishes( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + wishes: tuple[Wish, ...], + facts: TimeOfficeFacts, + ) -> None: + if not wishes: + return + + plan_id = self._find_target_plan_id( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + ) + + rows = map_wishes_to_timeoffice_rows( + wishes=wishes, + plan_id=plan_id, + facts=facts, + ) + + self._insert_rows(connection=connection, rows=rows) + + def _find_target_plan_id( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + ) -> int: + query = text( + """ + SELECT TOP 1 + pkg.RefPlan AS plan_id, + COUNT(*) AS row_count + FROM TPlanPersonalKommtGeht pkg + WHERE pkg.RefPlanungseinheiten = :planning_unit_id + AND CONVERT(date, pkg.Datum) BETWEEN :start AND :end + GROUP BY pkg.RefPlan + ORDER BY row_count DESC + """ + ) + + row = ( + connection.execute( + query, + { + "planning_unit_id": planning_unit_id, + "start": planning_month.start, + "end": planning_month.end, + }, + ) + .mappings() + .first() + ) + + if row is None: + raise ValueError( + "No TimeOffice target plan found for wishes: " + f"planning_unit_id={planning_unit_id} " + f"planning_month={planning_month.label}." + ) + + return int(row["plan_id"]) + + def _insert_rows( + self, + *, + connection: Connection, + rows: tuple[TimeOfficeWishWriteRow, ...], + ) -> None: + if not rows: + return + + query = text( + """ + INSERT INTO TPlanPersonalKommtGeht ( + RefPlan, + RefPersonal, + Datum, + RefStati, + lfdNr, + RefgAbw, + RefDienste, + RefBerufe, + RefPlanungseinheiten, + VonZeit, + BisZeit, + RefDienstAbw, + Minuten, + Info, + RefEinsatzArten, + Wunschdienst, + BereitVon, + BereitBis + ) + VALUES ( + :plan_id, + :employee_id, + :wish_date, + :status_id, + :sequence_number, + NULL, + :work_shift_id, + :profession_id, + :planning_unit_id, + NULL, + NULL, + :absence_shift_id, + 0, + NULL, + NULL, + 1, + NULL, + NULL + ) + """ + ) + + sequence_numbers: dict[tuple[int, int, object], int] = {} + + def next_sequence_number(row: TimeOfficeWishWriteRow) -> int: + key = (row.plan_id, row.employee_id, row.wish_date) + + if key not in sequence_numbers: + sequence_numbers[key] = self._next_sequence_number( + connection=connection, + plan_id=row.plan_id, + employee_id=row.employee_id, + wish_date=row.wish_date, + ) + + sequence_number = sequence_numbers[key] + sequence_numbers[key] += 1 + return sequence_number + + connection.execute( + query, + [ + { + "plan_id": row.plan_id, + "employee_id": row.employee_id, + "wish_date": row.wish_date, + "status_id": self._target_planning_status_id, + "sequence_number": next_sequence_number(row), + "work_shift_id": row.work_shift_id, + "profession_id": self._find_profession_id( + connection=connection, + employee_id=row.employee_id, + planning_unit_id=row.planning_unit_id, + ), + "planning_unit_id": row.planning_unit_id, + "absence_shift_id": row.absence_shift_id, + } + for row in rows + ], + ) + + def _next_sequence_number( + self, + *, + connection: Connection, + plan_id: int, + employee_id: int, + wish_date: object, + ) -> int: + query = text( + """ + SELECT + COALESCE(MAX(pkg.lfdNr), 0) + 1 AS next_sequence_number + FROM TPlanPersonalKommtGeht pkg + WHERE pkg.RefPlan = :plan_id + AND pkg.RefPersonal = :employee_id + AND CONVERT(date, pkg.Datum) = :wish_date + """ + ) + + row = ( + connection.execute( + query, + { + "plan_id": plan_id, + "employee_id": employee_id, + "wish_date": wish_date, + }, + ) + .mappings() + .one() + ) + + return int(row["next_sequence_number"]) + + def _find_profession_id( + self, + *, + connection: Connection, + employee_id: int, + planning_unit_id: int, + ) -> int: + query = text( + """ + SELECT TOP 1 + pkg.RefBerufe AS profession_id, + COUNT(*) AS usage_count + FROM TPlanPersonalKommtGeht pkg + WHERE pkg.RefPersonal = :employee_id + AND pkg.RefPlanungseinheiten = :planning_unit_id + AND pkg.RefBerufe IS NOT NULL + GROUP BY pkg.RefBerufe + ORDER BY usage_count DESC + """ + ) + + row = ( + connection.execute( + query, + { + "employee_id": employee_id, + "planning_unit_id": planning_unit_id, + }, + ) + .mappings() + .first() + ) + + if row is None: + raise ValueError( + "No TimeOffice profession found for employee wishes: " + f"employee_id={employee_id} " + f"planning_unit_id={planning_unit_id}." + ) + + return int(row["profession_id"]) + + def delete_employee_wishes( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + ) -> None: + plan_id = self._find_target_plan_id( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + ) + + query = text( + """ + DELETE FROM TPlanPersonalKommtGeht + WHERE RefPlan = :plan_id + AND RefPersonal = :employee_id + AND RefPlanungseinheiten = :planning_unit_id + AND CONVERT(date, Datum) BETWEEN :start AND :end + AND ISNULL(Wunschdienst, 0) <> 0 + AND ( + RefDienste IS NOT NULL + OR RefgAbw IS NOT NULL + OR RefDienstAbw IS NOT NULL + ) + """ + ) + + connection.execute( + query, + { + "plan_id": plan_id, + "employee_id": employee_id, + "planning_unit_id": planning_unit_id, + "start": planning_month.start, + "end": planning_month.end, + }, + ) From 54897632cadefddb4d4af472a56a9a56d668091a Mon Sep 17 00:00:00 2001 From: Joshua Prieth <198204205+joshuaprieth@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:31:20 +0200 Subject: [PATCH 09/17] =?UTF-8?q?Faire=20Freiw=C3=BCnsche=20wieder=20imple?= =?UTF-8?q?mentieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scheduling/solver/config.py | 39 +++-- src/scheduling/solver/cp_sat/builder.py | 42 +++--- .../cp_sat/objectives/fair_preferences.py | 72 ++++++++++ tests/cp/objectives/test_fair_preferences.py | 136 ++++++++++++++++++ 4 files changed, 246 insertions(+), 43 deletions(-) create mode 100644 src/scheduling/solver/cp_sat/objectives/fair_preferences.py create mode 100644 tests/cp/objectives/test_fair_preferences.py diff --git a/src/scheduling/solver/config.py b/src/scheduling/solver/config.py index a4eb6692..d87efd57 100644 --- a/src/scheduling/solver/config.py +++ b/src/scheduling/solver/config.py @@ -10,18 +10,19 @@ from scheduling.solver.cp_sat.constraints.one_assignment_per_day import OneAssignmentPerDay from scheduling.solver.cp_sat.constraints.rounds_in_early_shift import RoundsInEarlyShift from scheduling.solver.cp_sat.constraints.target_working_time import TargetWorkingTime -from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( - TemporaryBalanceGeneratedAssignments, -) -from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime -from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays -from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength -from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward -from scheduling.solver.cp_sat.objectives.prefer_own_planning_unit import PreferOwnPlanningUnit from scheduling.solver.cp_sat.objectives.every_second_weekend_free import EverySecondWeekendFree +from scheduling.solver.cp_sat.objectives.fair_preferences import FairPreferencesObjective from scheduling.solver.cp_sat.objectives.free_day_after_night_shift_phase import FreeDaysAfterNightShiftPhase from scheduling.solver.cp_sat.objectives.free_days_near_weekend import FreeDaysNearWeekend from scheduling.solver.cp_sat.objectives.minimize_consecutive_night_shifts import MinimizeConsecutiveNightShifts +from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime +from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays +from scheduling.solver.cp_sat.objectives.prefer_own_planning_unit import PreferOwnPlanningUnit +from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength +from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward +from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( + TemporaryBalanceGeneratedAssignments, +) class ConstraintConfig(SchedulingBaseModel): @@ -81,21 +82,13 @@ def create_base_solver_config() -> SolverConfig: enabled=True, weight=1, ), - EverySecondWeekendFree.id: ObjectiveConfig( - enabled=True, - weight=1 - ), - FreeDaysAfterNightShiftPhase.id: ObjectiveConfig( - enabled=True, - weight=1 - ), - FreeDaysNearWeekend.id: ObjectiveConfig( - enabled=True, - weight=1 - ), - MinimizeConsecutiveNightShifts.id: ObjectiveConfig( + EverySecondWeekendFree.id: ObjectiveConfig(enabled=True, weight=1), + FairPreferencesObjective.id: ObjectiveConfig( enabled=True, - weight=1 - ), + weight=1, + ), + FreeDaysAfterNightShiftPhase.id: ObjectiveConfig(enabled=True, weight=1), + FreeDaysNearWeekend.id: ObjectiveConfig(enabled=True, weight=1), + MinimizeConsecutiveNightShifts.id: ObjectiveConfig(enabled=True, weight=1), }, ) diff --git a/src/scheduling/solver/cp_sat/builder.py b/src/scheduling/solver/cp_sat/builder.py index 2df670e9..5aefe288 100644 --- a/src/scheduling/solver/cp_sat/builder.py +++ b/src/scheduling/solver/cp_sat/builder.py @@ -14,20 +14,19 @@ from scheduling.solver.cp_sat.constraints.target_working_time import TargetWorkingTime from scheduling.solver.cp_sat.context import SolverContext, create_context from scheduling.solver.cp_sat.objective import Objective, WeightedPenalty, minimize_weighted_penalties -from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( - TemporaryBalanceGeneratedAssignments, -) -from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime -from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays -from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength -from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward -from scheduling.solver.cp_sat.objectives.prefer_own_planning_unit import PreferOwnPlanningUnit - from scheduling.solver.cp_sat.objectives.every_second_weekend_free import EverySecondWeekendFree +from scheduling.solver.cp_sat.objectives.fair_preferences import FairPreferencesObjective from scheduling.solver.cp_sat.objectives.free_day_after_night_shift_phase import FreeDaysAfterNightShiftPhase from scheduling.solver.cp_sat.objectives.free_days_near_weekend import FreeDaysNearWeekend from scheduling.solver.cp_sat.objectives.minimize_consecutive_night_shifts import MinimizeConsecutiveNightShifts - +from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime +from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays +from scheduling.solver.cp_sat.objectives.prefer_own_planning_unit import PreferOwnPlanningUnit +from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength +from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward +from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( + TemporaryBalanceGeneratedAssignments, +) from scheduling.solver.cp_sat.variables import create_assignment_variables CP_SAT_CONSTRAINTS: tuple[Constraint, ...] = ( @@ -40,16 +39,19 @@ TargetWorkingTime(), ) -CP_SAT_OBJECTIVES: tuple[Objective, ...] = (TemporaryBalanceGeneratedAssignments(), - MinimizeOvertime(), - NotTooManyConsecutiveDays(), - PreferredBlockLength(), - RotateShiftsForward(), - PreferOwnPlanningUnit(), - EverySecondWeekendFree(), - FreeDaysAfterNightShiftPhase(), - MinimizeConsecutiveNightShifts(), - FreeDaysNearWeekend(),) +CP_SAT_OBJECTIVES: tuple[Objective, ...] = ( + TemporaryBalanceGeneratedAssignments(), + MinimizeOvertime(), + NotTooManyConsecutiveDays(), + PreferredBlockLength(), + RotateShiftsForward(), + PreferOwnPlanningUnit(), + EverySecondWeekendFree(), + FairPreferencesObjective(), + FreeDaysAfterNightShiftPhase(), + MinimizeConsecutiveNightShifts(), + FreeDaysNearWeekend(), +) @dataclass(frozen=True, slots=True) diff --git a/src/scheduling/solver/cp_sat/objectives/fair_preferences.py b/src/scheduling/solver/cp_sat/objectives/fair_preferences.py new file mode 100644 index 00000000..e711684a --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/fair_preferences.py @@ -0,0 +1,72 @@ +from collections import defaultdict +from collections.abc import Mapping +from datetime import date +from typing import Any, ClassVar + +from ortools.sat.python import cp_model + +from scheduling.domain import WishType +from scheduling.solver.audit import AuditFinding +from scheduling.solver.cp_sat.context import AuditContext, SolverContext +from scheduling.solver.cp_sat.objective import Penalty + + +class FairPreferencesObjective: + """Penalize repeatedly violating the same employee's free-time wishes.""" + + id: ClassVar[str] = "fair_preferences" + + def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + variables_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) + variables_by_employee_date_shift: defaultdict[tuple[int, date, int], list[cp_model.IntVar]] = defaultdict(list) + + for (employee_id, _unit_id, assignment_date, shift_id, _level), variable in ctx.assignment_variables.items(): + variables_by_employee_date[(employee_id, assignment_date)].append(variable) + variables_by_employee_date_shift[(employee_id, assignment_date, shift_id)].append(variable) + + violations_by_employee: defaultdict[int, list[tuple[cp_model.IntVar, int]]] = defaultdict(list) + for wish_index, wish in enumerate(ctx.dataset.wishes): + if wish.type == WishType.FREE_DAY: + assignment_variables = variables_by_employee_date[(wish.employee_id, wish.date)] + strike_count = 3 + elif wish.type == WishType.FREE_SHIFT and wish.shift_id is not None: + assignment_variables = variables_by_employee_date_shift[(wish.employee_id, wish.date, wish.shift_id)] + strike_count = 1 + else: + continue + + if not assignment_variables: + continue + + violation = ctx.model.new_bool_var(f"fair_preferences__wish_{wish_index}__violated") + ctx.model.add(sum(assignment_variables) >= 1).only_enforce_if(violation) + ctx.model.add(sum(assignment_variables) == 0).only_enforce_if(violation.Not()) + violations_by_employee[wish.employee_id].append((violation, strike_count)) + + penalties: list[Penalty] = [] + for employee_id, violations in violations_by_employee.items(): + max_strikes = sum(strike_count for _violation, strike_count in violations) + total_strikes = sum(violation * strike_count for violation, strike_count in violations) + tier_variables = [ + ctx.model.new_bool_var(f"fair_preferences__employee_{employee_id}__tier_{tier}") + for tier in range(1, max_strikes + 1) + ] + ctx.model.add(sum(tier_variables) == total_strikes) + + penalties.append( + Penalty( + objective_id=self.id, + name=f"employee_{employee_id}", + expression=sum( + tier**3 * tier_variable for tier, tier_variable in enumerate(tier_variables, start=1) + ), + ) + ) + + return tuple(penalties) + + def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: + return () diff --git a/tests/cp/objectives/test_fair_preferences.py b/tests/cp/objectives/test_fair_preferences.py new file mode 100644 index 00000000..0300801e --- /dev/null +++ b/tests/cp/objectives/test_fair_preferences.py @@ -0,0 +1,136 @@ +from datetime import date + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, + Wish, + WishType, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.fair_preferences import FairPreferencesObjective +from scheduling.solver.cp_sat.variables import create_assignment_variables + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +LATE_SHIFT = Shift( + shift_id=2, + code="S", + type=ShiftType.LATE, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=820, + end_minute=1280, + net_work_minutes=460, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _penalty_for(wishes: tuple[Wish, ...], worked_assignments: set[tuple[date, int]]) -> float: + dataset = SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT, LATE_SHIFT), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + wishes=wishes, + ) + ctx = create_context(dataset=dataset) + create_assignment_variables(ctx) + + for (_employee_id, _unit, assignment_date, shift_id, _level), variable in ctx.assignment_variables.items(): + ctx.model.add(variable == ((assignment_date, shift_id) in worked_assignments)) + + penalties = FairPreferencesObjective().add_to_model(ctx, params={}) + if not penalties: + return 0 + + ctx.model.minimize(sum(penalty.expression for penalty in penalties)) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + return solver.objective_value + + +@pytest.mark.integration +def test_free_day_wish_counts_as_three_strikes_and_free_shift_as_one() -> None: + wish_date = date(2024, 11, 4) + free_day = Wish( + employee_id=EMPLOYEE.employee_id, + planning_unit_id=PLANNING_UNIT.planning_unit_id, + date=wish_date, + type=WishType.FREE_DAY, + ) + free_shift = Wish( + employee_id=EMPLOYEE.employee_id, + planning_unit_id=PLANNING_UNIT.planning_unit_id, + date=wish_date, + type=WishType.FREE_SHIFT, + shift_id=EARLY_SHIFT.shift_id, + ) + + assert _penalty_for((free_shift,), {(wish_date, EARLY_SHIFT.shift_id)}) == 1 + assert _penalty_for((free_day,), {(wish_date, LATE_SHIFT.shift_id)}) == 36 + assert _penalty_for((free_day, free_shift), {(wish_date, EARLY_SHIFT.shift_id)}) == 100 + + +@pytest.mark.integration +def test_fulfilled_free_wishes_and_preferred_work_wishes_have_no_penalty() -> None: + wish_date = date(2024, 11, 4) + wishes = ( + Wish( + employee_id=EMPLOYEE.employee_id, + planning_unit_id=PLANNING_UNIT.planning_unit_id, + date=wish_date, + type=WishType.FREE_SHIFT, + shift_id=EARLY_SHIFT.shift_id, + ), + Wish( + employee_id=EMPLOYEE.employee_id, + planning_unit_id=PLANNING_UNIT.planning_unit_id, + date=wish_date, + type=WishType.PREFERRED_DAY, + ), + ) + + assert _penalty_for(wishes, {(wish_date, LATE_SHIFT.shift_id)}) == 0 From 1506e5d97077f830b9ebee9479a9c80fc1fa477e Mon Sep 17 00:00:00 2001 From: Joshua Prieth <198204205+joshuaprieth@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:09:15 +0200 Subject: [PATCH 10/17] =?UTF-8?q?Failing=20tests=20f=C3=BCr=20die=20verble?= =?UTF-8?q?ibenden=20Objectives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/cp/objectives/test_minimize_overtime.py | 159 ++++++++++++++ .../test_not_too_many_consecutive_days.py | 123 +++++++++++ .../test_prefer_own_planning_unit.py | 199 ++++++++++++++++++ .../objectives/test_preferred_block_length.py | 137 ++++++++++++ .../objectives/test_rotate_shifts_forward.py | 172 +++++++++++++++ ...temporary_balance_generated_assignments.py | 123 +++++++++++ 6 files changed, 913 insertions(+) create mode 100644 tests/cp/objectives/test_minimize_overtime.py create mode 100644 tests/cp/objectives/test_not_too_many_consecutive_days.py create mode 100644 tests/cp/objectives/test_prefer_own_planning_unit.py create mode 100644 tests/cp/objectives/test_preferred_block_length.py create mode 100644 tests/cp/objectives/test_rotate_shifts_forward.py create mode 100644 tests/cp/objectives/test_temporary_balance_generated_assignments.py diff --git a/tests/cp/objectives/test_minimize_overtime.py b/tests/cp/objectives/test_minimize_overtime.py new file mode 100644 index 00000000..72d875a7 --- /dev/null +++ b/tests/cp/objectives/test_minimize_overtime.py @@ -0,0 +1,159 @@ +from datetime import date + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + MonthlyWorkAccount, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime +from scheduling.solver.cp_sat.variables import create_assignment_variables + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +ONE_HOUR_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=420, + net_work_minutes=60, +) + +ALICE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +BOB = Employee( + employee_id=2, + display_name="Bob", + staff_level=StaffLevel.PROFESSIONAL, +) + + +def _membership_for(employee: Employee) -> PlanningUnitMembership: + return PlanningUnitMembership( + planning_unit_id=PLANNING_UNIT.planning_unit_id, + employee_id=employee.employee_id, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, + ) + + +def _dataset( + *, + employees: tuple[Employee, ...] = (ALICE,), + accounts: tuple[MonthlyWorkAccount, ...] = (), +) -> SchedulingDataset: + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(ONE_HOUR_SHIFT,), + employees=employees, + planning_unit_memberships=tuple(_membership_for(employee) for employee in employees), + monthly_work_accounts=accounts, + ) + + +def _penalty_for( + *, + accounts: tuple[MonthlyWorkAccount, ...], + generated_shift_counts: dict[int, int], +) -> float: + employees = tuple(employee for employee in (ALICE, BOB) if employee.employee_id in generated_shift_counts) + ctx = create_context(dataset=_dataset(employees=employees, accounts=accounts)) + create_assignment_variables(ctx) + + for (employee_id, _unit, assignment_date, _shift, _level), variable in ctx.assignment_variables.items(): + ctx.model.add(variable == (assignment_date.day <= generated_shift_counts[employee_id])) + + penalties = MinimizeOvertime().add_to_model(ctx, params={}) + assert len(penalties) == 1 + + penalty = penalties[0] + assert penalty.objective_id == "minimize_overtime" + assert penalty.name == "total_overtime" + assert penalty.multiplier == 1 + + ctx.model.minimize(penalty.multiplier * penalty.expression) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + return solver.objective_value + + +def test_returns_no_penalties_without_assignment_variables() -> None: + ctx = create_context(dataset=_dataset()) + + assert MinimizeOvertime().add_to_model(ctx, params={}) == () + + +@pytest.mark.integration +@pytest.mark.parametrize("generated_shift_count", [1, 2]) +def test_no_penalty_at_or_below_remaining_target(generated_shift_count: int) -> None: + account = MonthlyWorkAccount(employee_id=ALICE.employee_id, target_minutes=120) + + assert ( + _penalty_for( + accounts=(account,), + generated_shift_counts={ALICE.employee_id: generated_shift_count}, + ) + == 0 + ) + + +@pytest.mark.integration +def test_penalty_equals_minutes_generated_above_remaining_target() -> None: + account = MonthlyWorkAccount(employee_id=ALICE.employee_id, target_minutes=120) + + assert _penalty_for(accounts=(account,), generated_shift_counts={ALICE.employee_id: 3}) == 60 + + +@pytest.mark.integration +def test_actual_minutes_reduce_remaining_target() -> None: + account = MonthlyWorkAccount( + employee_id=ALICE.employee_id, + target_minutes=120, + actual_minutes=60, + ) + + assert _penalty_for(accounts=(account,), generated_shift_counts={ALICE.employee_id: 2}) == 60 + + +@pytest.mark.integration +def test_penalties_are_summed_across_employees() -> None: + accounts = ( + MonthlyWorkAccount(employee_id=ALICE.employee_id, target_minutes=120), + MonthlyWorkAccount(employee_id=BOB.employee_id, target_minutes=180, actual_minutes=60), + ) + + assert ( + _penalty_for( + accounts=accounts, + generated_shift_counts={ALICE.employee_id: 3, BOB.employee_id: 3}, + ) + == 120 + ) diff --git a/tests/cp/objectives/test_not_too_many_consecutive_days.py b/tests/cp/objectives/test_not_too_many_consecutive_days.py new file mode 100644 index 00000000..473771da --- /dev/null +++ b/tests/cp/objectives/test_not_too_many_consecutive_days.py @@ -0,0 +1,123 @@ +from datetime import date, timedelta + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays +from scheduling.solver.cp_sat.variables import create_assignment_variables + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=PLANNING_UNIT.planning_unit_id, + employee_id=EMPLOYEE.employee_id, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _dataset() -> SchedulingDataset: + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT,), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + ) + + +def _date_range(start: date, length: int) -> set[date]: + return {start + timedelta(days=offset) for offset in range(length)} + + +def _penalty_for(worked_dates: set[date]) -> float: + ctx = create_context(dataset=_dataset()) + create_assignment_variables(ctx) + + for (_employee, _unit, assignment_date, _shift, _level), variable in ctx.assignment_variables.items(): + ctx.model.add(variable == (assignment_date in worked_dates)) + + penalties = NotTooManyConsecutiveDays().add_to_model(ctx, params={}) + assert len(penalties) == 1 + + penalty = penalties[0] + assert penalty.objective_id == "not_too_many_consecutive_days" + assert penalty.name == "total_too_many_consecutive_days" + assert penalty.multiplier == 1 + + ctx.model.minimize(penalty.multiplier * penalty.expression) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + return solver.objective_value + + +def test_returns_no_penalties_without_assignment_variables() -> None: + ctx = create_context(dataset=_dataset()) + + assert NotTooManyConsecutiveDays().add_to_model(ctx, params={}) == () + + +@pytest.mark.integration +def test_no_penalty_for_five_consecutive_working_days() -> None: + assert _penalty_for(_date_range(date(2024, 11, 1), 5)) == 0 + + +@pytest.mark.integration +def test_one_penalty_for_six_consecutive_working_days() -> None: + assert _penalty_for(_date_range(date(2024, 11, 1), 6)) == 1 + + +@pytest.mark.integration +def test_each_six_day_window_is_penalized() -> None: + assert _penalty_for(_date_range(date(2024, 11, 1), 8)) == 3 + + +@pytest.mark.integration +def test_free_day_separates_consecutive_working_periods() -> None: + worked_dates = _date_range(date(2024, 11, 1), 6) | _date_range(date(2024, 11, 8), 6) + + assert _penalty_for(worked_dates) == 2 + + +@pytest.mark.integration +def test_penalizes_six_day_window_ending_on_last_day_of_month() -> None: + assert _penalty_for(_date_range(date(2024, 11, 25), 6)) == 1 diff --git a/tests/cp/objectives/test_prefer_own_planning_unit.py b/tests/cp/objectives/test_prefer_own_planning_unit.py new file mode 100644 index 00000000..9073d4c2 --- /dev/null +++ b/tests/cp/objectives/test_prefer_own_planning_unit.py @@ -0,0 +1,199 @@ +from datetime import date + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.prefer_own_planning_unit import PreferOwnPlanningUnit +from scheduling.solver.cp_sat.variables import create_assignment_variables + +HOME_STATION = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +OTHER_STATION = PlanningUnit( + planning_unit_id=2, + display_name="Station 2", + type=PlanningUnitType.STATION, +) + +SHARED_POOL = PlanningUnit( + planning_unit_id=3, + display_name="Shared Pool", + type=PlanningUnitType.SHARED_POOL, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + + +def _membership( + planning_unit: PlanningUnit, + *, + valid_from: date = date(2024, 11, 1), + valid_until: date | None = date(2024, 11, 30), + is_home: bool, + is_replacement: bool, +) -> PlanningUnitMembership: + return PlanningUnitMembership( + planning_unit_id=planning_unit.planning_unit_id, + employee_id=EMPLOYEE.employee_id, + valid_from=valid_from, + valid_until=valid_until, + staff_level=StaffLevel.PROFESSIONAL, + is_home=is_home, + is_replacement=is_replacement, + ) + + +def _dataset(memberships: tuple[PlanningUnitMembership, ...]) -> SchedulingDataset: + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(HOME_STATION, OTHER_STATION, SHARED_POOL), + plans=(), + shifts=(EARLY_SHIFT,), + employees=(EMPLOYEE,), + planning_unit_memberships=memberships, + ) + + +def _penalty_for( + *, + memberships: tuple[PlanningUnitMembership, ...], + worked_assignments: set[tuple[int, date]], +) -> float: + ctx = create_context(dataset=_dataset(memberships)) + create_assignment_variables(ctx) + + for (_employee, planning_unit_id, assignment_date, _shift, _level), variable in ctx.assignment_variables.items(): + ctx.model.add(variable == ((planning_unit_id, assignment_date) in worked_assignments)) + + penalties = PreferOwnPlanningUnit().add_to_model(ctx, params={}) + assert len(penalties) == 1 + + penalty = penalties[0] + assert penalty.objective_id == "prefer_own_planning_unit" + assert penalty.name == "prefer_own_planning_unit_penalty" + assert penalty.multiplier == 1 + + ctx.model.minimize(penalty.multiplier * penalty.expression) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + return solver.objective_value + + +def test_returns_no_penalties_without_assignment_variables() -> None: + ctx = create_context(dataset=_dataset(())) + + assert PreferOwnPlanningUnit().add_to_model(ctx, params={}) == () + + +@pytest.mark.integration +def test_no_penalty_for_assignment_to_active_home_station() -> None: + memberships = (_membership(HOME_STATION, is_home=True, is_replacement=False),) + + assert ( + _penalty_for( + memberships=memberships, + worked_assignments={(HOME_STATION.planning_unit_id, date(2024, 11, 1))}, + ) + == 0 + ) + + +@pytest.mark.integration +def test_penalty_for_assignment_to_eligible_non_home_station() -> None: + memberships = ( + _membership(HOME_STATION, is_home=True, is_replacement=False), + _membership(OTHER_STATION, is_home=False, is_replacement=True), + ) + + assert ( + _penalty_for( + memberships=memberships, + worked_assignments={(OTHER_STATION.planning_unit_id, date(2024, 11, 1))}, + ) + == 1 + ) + + +@pytest.mark.integration +def test_active_home_station_is_selected_per_assignment_date() -> None: + memberships = ( + _membership( + HOME_STATION, + valid_until=date(2024, 11, 15), + is_home=True, + is_replacement=False, + ), + _membership( + HOME_STATION, + valid_from=date(2024, 11, 16), + is_home=False, + is_replacement=True, + ), + _membership( + OTHER_STATION, + valid_until=date(2024, 11, 15), + is_home=False, + is_replacement=True, + ), + _membership( + OTHER_STATION, + valid_from=date(2024, 11, 16), + is_home=True, + is_replacement=False, + ), + ) + worked_assignments = { + (HOME_STATION.planning_unit_id, date(2024, 11, 14)), + (OTHER_STATION.planning_unit_id, date(2024, 11, 14)), + (HOME_STATION.planning_unit_id, date(2024, 11, 17)), + (OTHER_STATION.planning_unit_id, date(2024, 11, 17)), + } + + assert _penalty_for(memberships=memberships, worked_assignments=worked_assignments) == 2 + + +@pytest.mark.integration +def test_shared_pool_home_employee_has_no_cross_station_penalty() -> None: + memberships = ( + _membership(SHARED_POOL, is_home=True, is_replacement=False), + _membership(HOME_STATION, is_home=False, is_replacement=True), + _membership(OTHER_STATION, is_home=False, is_replacement=True), + ) + worked_assignments = { + (HOME_STATION.planning_unit_id, date(2024, 11, 1)), + (OTHER_STATION.planning_unit_id, date(2024, 11, 2)), + } + + assert _penalty_for(memberships=memberships, worked_assignments=worked_assignments) == 0 diff --git a/tests/cp/objectives/test_preferred_block_length.py b/tests/cp/objectives/test_preferred_block_length.py new file mode 100644 index 00000000..5d91691e --- /dev/null +++ b/tests/cp/objectives/test_preferred_block_length.py @@ -0,0 +1,137 @@ +from datetime import date, timedelta + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength +from scheduling.solver.cp_sat.variables import create_assignment_variables + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=PLANNING_UNIT.planning_unit_id, + employee_id=EMPLOYEE.employee_id, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _dataset() -> SchedulingDataset: + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT,), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + ) + + +def _date_range(start: date, length: int) -> set[date]: + return {start + timedelta(days=offset) for offset in range(length)} + + +def _penalty_for(worked_dates: set[date]) -> float: + ctx = create_context(dataset=_dataset()) + create_assignment_variables(ctx) + + for (_employee, _unit, assignment_date, _shift, _level), variable in ctx.assignment_variables.items(): + ctx.model.add(variable == (assignment_date in worked_dates)) + + penalties = PreferredBlockLength().add_to_model(ctx, params={}) + assert len(penalties) == 1 + + penalty = penalties[0] + assert penalty.objective_id == "preferred_block_length" + assert penalty.name == "total_preferred_blocks" + + ctx.model.minimize(penalty.multiplier * penalty.expression) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + return solver.objective_value + + +def test_returns_no_penalties_without_assignment_variables() -> None: + ctx = create_context(dataset=_dataset()) + + assert PreferredBlockLength().add_to_model(ctx, params={}) == () + + +def test_distance_from_preferred_length_is_a_penalty() -> None: + ctx = create_context(dataset=_dataset()) + create_assignment_variables(ctx) + + penalties = PreferredBlockLength().add_to_model(ctx, params={}) + + assert len(penalties) == 1 + assert penalties[0].multiplier == 1 + + +@pytest.mark.integration +def test_no_penalty_for_preferred_three_day_block() -> None: + assert _penalty_for(_date_range(date(2024, 11, 1), 3)) == 0 + + +@pytest.mark.integration +def test_penalty_for_block_shorter_than_preferred_length() -> None: + assert _penalty_for(_date_range(date(2024, 11, 1), 2)) == 1 + + +@pytest.mark.integration +def test_penalty_for_block_longer_than_preferred_length() -> None: + assert _penalty_for(_date_range(date(2024, 11, 1), 5)) == 2 + + +@pytest.mark.integration +def test_penalties_are_summed_for_separated_blocks() -> None: + worked_dates = _date_range(date(2024, 11, 1), 2) | _date_range(date(2024, 11, 4), 4) + + assert _penalty_for(worked_dates) == 2 + + +@pytest.mark.integration +def test_block_ending_on_last_day_of_month_is_penalized() -> None: + assert _penalty_for(_date_range(date(2024, 11, 29), 2)) == 1 + + +@pytest.mark.integration +def test_blocks_longer_than_seven_days_use_catch_all_penalty() -> None: + assert _penalty_for(_date_range(date(2024, 11, 1), 8)) == 5 diff --git a/tests/cp/objectives/test_rotate_shifts_forward.py b/tests/cp/objectives/test_rotate_shifts_forward.py new file mode 100644 index 00000000..b3c96d9d --- /dev/null +++ b/tests/cp/objectives/test_rotate_shifts_forward.py @@ -0,0 +1,172 @@ +from datetime import date + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward +from scheduling.solver.cp_sat.variables import create_assignment_variables + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +LATE_SHIFT = Shift( + shift_id=2, + code="S", + type=ShiftType.LATE, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=820, + end_minute=1280, + net_work_minutes=460, +) + +NIGHT_SHIFT = Shift( + shift_id=3, + code="N", + type=ShiftType.NIGHT, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=1320, + end_minute=420, + net_work_minutes=460, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=PLANNING_UNIT.planning_unit_id, + employee_id=EMPLOYEE.employee_id, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _dataset() -> SchedulingDataset: + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT, LATE_SHIFT, NIGHT_SHIFT), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + ) + + +def _penalty_for(worked_assignments: set[tuple[date, int]]) -> float: + ctx = create_context(dataset=_dataset()) + create_assignment_variables(ctx) + + for (_employee, _unit, assignment_date, shift_id, _level), variable in ctx.assignment_variables.items(): + ctx.model.add(variable == ((assignment_date, shift_id) in worked_assignments)) + + penalties = RotateShiftsForward().add_to_model(ctx, params={}) + assert len(penalties) == 1 + + penalty = penalties[0] + assert penalty.objective_id == "rotate_shifts_forward" + assert penalty.name == "rotations" + assert penalty.multiplier == 1 + + ctx.model.minimize(penalty.multiplier * penalty.expression) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + return solver.objective_value + + +def test_returns_no_penalties_without_assignment_variables() -> None: + ctx = create_context(dataset=_dataset()) + + assert RotateShiftsForward().add_to_model(ctx, params={}) == () + + +@pytest.mark.integration +def test_no_score_without_worked_assignments() -> None: + assert _penalty_for(set()) == 0 + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("first_shift_id", "second_shift_id"), + [ + (EARLY_SHIFT.shift_id, LATE_SHIFT.shift_id), + (LATE_SHIFT.shift_id, NIGHT_SHIFT.shift_id), + ], +) +def test_forward_rotation_is_rewarded(first_shift_id: int, second_shift_id: int) -> None: + worked_assignments = { + (date(2024, 11, 1), first_shift_id), + (date(2024, 11, 2), second_shift_id), + } + + assert _penalty_for(worked_assignments) == -1 + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("first_shift_id", "second_shift_id"), + [ + (LATE_SHIFT.shift_id, EARLY_SHIFT.shift_id), + (NIGHT_SHIFT.shift_id, LATE_SHIFT.shift_id), + (NIGHT_SHIFT.shift_id, EARLY_SHIFT.shift_id), + ], +) +def test_backward_rotation_is_penalized(first_shift_id: int, second_shift_id: int) -> None: + worked_assignments = { + (date(2024, 11, 1), first_shift_id), + (date(2024, 11, 2), second_shift_id), + } + + assert _penalty_for(worked_assignments) == 1 + + +@pytest.mark.integration +def test_rotation_three_days_apart_is_scored() -> None: + worked_assignments = { + (date(2024, 11, 1), EARLY_SHIFT.shift_id), + (date(2024, 11, 4), LATE_SHIFT.shift_id), + } + + assert _penalty_for(worked_assignments) == -1 + + +@pytest.mark.integration +def test_rotation_more_than_three_days_apart_is_ignored() -> None: + worked_assignments = { + (date(2024, 11, 1), EARLY_SHIFT.shift_id), + (date(2024, 11, 5), LATE_SHIFT.shift_id), + } + + assert _penalty_for(worked_assignments) == 0 diff --git a/tests/cp/objectives/test_temporary_balance_generated_assignments.py b/tests/cp/objectives/test_temporary_balance_generated_assignments.py new file mode 100644 index 00000000..d5a2cc57 --- /dev/null +++ b/tests/cp/objectives/test_temporary_balance_generated_assignments.py @@ -0,0 +1,123 @@ +from datetime import date + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( + TemporaryBalanceGeneratedAssignments, +) +from scheduling.solver.cp_sat.variables import create_assignment_variables + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +ALICE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +BOB = Employee( + employee_id=2, + display_name="Bob", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIPS = tuple( + PlanningUnitMembership( + planning_unit_id=PLANNING_UNIT.planning_unit_id, + employee_id=employee.employee_id, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, + ) + for employee in (ALICE, BOB) +) + + +def _dataset(*, employees: tuple[Employee, ...] = (ALICE, BOB)) -> SchedulingDataset: + employee_ids = {employee.employee_id for employee in employees} + + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT,), + employees=employees, + planning_unit_memberships=tuple( + membership for membership in MEMBERSHIPS if membership.employee_id in employee_ids + ), + ) + + +def _penalty_for_counts(counts_by_employee: dict[int, int]) -> float: + employees = tuple(employee for employee in (ALICE, BOB) if employee.employee_id in counts_by_employee) + ctx = create_context(dataset=_dataset(employees=employees)) + create_assignment_variables(ctx) + + for (employee_id, _unit, assignment_date, _shift, _level), variable in ctx.assignment_variables.items(): + ctx.model.add(variable == (assignment_date.day <= counts_by_employee[employee_id])) + + penalties = TemporaryBalanceGeneratedAssignments().add_to_model(ctx, params={}) + assert len(penalties) == 1 + + penalty = penalties[0] + assert penalty.objective_id == "temporary_balance_generated_assignments" + assert penalty.name == "max_generated_assignments_per_employee" + assert penalty.multiplier == 1 + + ctx.model.minimize(penalty.multiplier * penalty.expression) + solver = cp_model.CpSolver() + status = solver.solve(ctx.model) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + return solver.objective_value + + +def test_returns_no_penalties_without_assignment_variables() -> None: + ctx = create_context(dataset=_dataset()) + + assert TemporaryBalanceGeneratedAssignments().add_to_model(ctx, params={}) == () + + +@pytest.mark.integration +def test_penalty_equals_single_employee_assignment_count() -> None: + assert _penalty_for_counts({ALICE.employee_id: 4}) == 4 + + +@pytest.mark.integration +def test_penalty_equals_largest_employee_assignment_count() -> None: + assert _penalty_for_counts({ALICE.employee_id: 3, BOB.employee_id: 7}) == 7 + + +@pytest.mark.integration +def test_penalty_equals_common_count_when_employees_are_balanced() -> None: + assert _penalty_for_counts({ALICE.employee_id: 5, BOB.employee_id: 5}) == 5 From fab63efe1daff578727ace6ce467349f6be0f5d0 Mon Sep 17 00:00:00 2001 From: Joshua Prieth <198204205+joshuaprieth@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:17:17 +0200 Subject: [PATCH 11/17] =?UTF-8?q?Auch=20pr=C3=A4ferierte=20Schichten=20fai?= =?UTF-8?q?r=20verteilen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cp_sat/objectives/fair_preferences.py | 87 +++++++++++++++++-- tests/cp/objectives/test_fair_preferences.py | 44 ++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/src/scheduling/solver/cp_sat/objectives/fair_preferences.py b/src/scheduling/solver/cp_sat/objectives/fair_preferences.py index e711684a..79628723 100644 --- a/src/scheduling/solver/cp_sat/objectives/fair_preferences.py +++ b/src/scheduling/solver/cp_sat/objectives/fair_preferences.py @@ -12,7 +12,7 @@ class FairPreferencesObjective: - """Penalize repeatedly violating the same employee's free-time wishes.""" + """Penalize repeatedly violating the same employee's free and preferred wishes.""" id: ClassVar[str] = "fair_preferences" @@ -27,13 +27,45 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P variables_by_employee_date[(employee_id, assignment_date)].append(variable) variables_by_employee_date_shift[(employee_id, assignment_date, shift_id)].append(variable) + free_wish_violations = self._free_wish_violations( + ctx, + variables_by_employee_date=variables_by_employee_date, + variables_by_employee_date_shift=variables_by_employee_date_shift, + ) + preferred_wish_violations = self._preferred_wish_violations( + ctx, + variables_by_employee_date=variables_by_employee_date, + variables_by_employee_date_shift=variables_by_employee_date_shift, + ) + + free_wish_penalties = self._bucketed_penalties( + ctx, + free_wish_violations, + wish_group="free", + ) + preferred_wish_penalties = self._bucketed_penalties( + ctx, + preferred_wish_violations, + wish_group="preferred", + ) + return free_wish_penalties + preferred_wish_penalties + + def _free_wish_violations( + self, + ctx: SolverContext, + *, + variables_by_employee_date: Mapping[tuple[int, date], list[cp_model.IntVar]], + variables_by_employee_date_shift: Mapping[tuple[int, date, int], list[cp_model.IntVar]], + ) -> dict[int, list[tuple[cp_model.IntVar, int]]]: violations_by_employee: defaultdict[int, list[tuple[cp_model.IntVar, int]]] = defaultdict(list) for wish_index, wish in enumerate(ctx.dataset.wishes): if wish.type == WishType.FREE_DAY: - assignment_variables = variables_by_employee_date[(wish.employee_id, wish.date)] + assignment_variables = variables_by_employee_date.get((wish.employee_id, wish.date), []) strike_count = 3 elif wish.type == WishType.FREE_SHIFT and wish.shift_id is not None: - assignment_variables = variables_by_employee_date_shift[(wish.employee_id, wish.date, wish.shift_id)] + assignment_variables = variables_by_employee_date_shift.get( + (wish.employee_id, wish.date, wish.shift_id), [] + ) strike_count = 1 else: continue @@ -41,17 +73,56 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P if not assignment_variables: continue - violation = ctx.model.new_bool_var(f"fair_preferences__wish_{wish_index}__violated") + violation = ctx.model.new_bool_var(f"fair_preferences__free_wish_{wish_index}__violated") ctx.model.add(sum(assignment_variables) >= 1).only_enforce_if(violation) ctx.model.add(sum(assignment_variables) == 0).only_enforce_if(violation.Not()) violations_by_employee[wish.employee_id].append((violation, strike_count)) + return violations_by_employee + + def _preferred_wish_violations( + self, + ctx: SolverContext, + *, + variables_by_employee_date: Mapping[tuple[int, date], list[cp_model.IntVar]], + variables_by_employee_date_shift: Mapping[tuple[int, date, int], list[cp_model.IntVar]], + ) -> dict[int, list[tuple[cp_model.IntVar, int]]]: + violations_by_employee: defaultdict[int, list[tuple[cp_model.IntVar, int]]] = defaultdict(list) + for wish_index, wish in enumerate(ctx.dataset.wishes): + if wish.type == WishType.PREFERRED_DAY: + assignment_variables = variables_by_employee_date.get((wish.employee_id, wish.date), []) + strike_count = 3 + elif wish.type == WishType.PREFERRED_SHIFT and wish.shift_id is not None: + assignment_variables = variables_by_employee_date_shift.get( + (wish.employee_id, wish.date, wish.shift_id), [] + ) + strike_count = 1 + else: + continue + + if not assignment_variables: + continue + + violation = ctx.model.new_bool_var(f"fair_preferences__preferred_wish_{wish_index}__violated") + ctx.model.add(sum(assignment_variables) == 0).only_enforce_if(violation) + ctx.model.add(sum(assignment_variables) >= 1).only_enforce_if(violation.Not()) + violations_by_employee[wish.employee_id].append((violation, strike_count)) + + return violations_by_employee + + def _bucketed_penalties( + self, + ctx: SolverContext, + violations_by_employee: Mapping[int, list[tuple[cp_model.IntVar, int]]], + *, + wish_group: str, + ) -> tuple[Penalty, ...]: penalties: list[Penalty] = [] for employee_id, violations in violations_by_employee.items(): max_strikes = sum(strike_count for _violation, strike_count in violations) total_strikes = sum(violation * strike_count for violation, strike_count in violations) tier_variables = [ - ctx.model.new_bool_var(f"fair_preferences__employee_{employee_id}__tier_{tier}") + ctx.model.new_bool_var(f"fair_preferences__{wish_group}__employee_{employee_id}__tier_{tier}") for tier in range(1, max_strikes + 1) ] ctx.model.add(sum(tier_variables) == total_strikes) @@ -59,9 +130,9 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P penalties.append( Penalty( objective_id=self.id, - name=f"employee_{employee_id}", - expression=sum( - tier**3 * tier_variable for tier, tier_variable in enumerate(tier_variables, start=1) + name=f"employee_{employee_id}__{wish_group}_wishes", + expression=cp_model.LinearExpr.sum( + [tier**3 * tier_variable for tier, tier_variable in enumerate(tier_variables, start=1)] ), ) ) diff --git a/tests/cp/objectives/test_fair_preferences.py b/tests/cp/objectives/test_fair_preferences.py index 0300801e..11f0c500 100644 --- a/tests/cp/objectives/test_fair_preferences.py +++ b/tests/cp/objectives/test_fair_preferences.py @@ -134,3 +134,47 @@ def test_fulfilled_free_wishes_and_preferred_work_wishes_have_no_penalty() -> No ) assert _penalty_for(wishes, {(wish_date, LATE_SHIFT.shift_id)}) == 0 + + +@pytest.mark.integration +def test_preferred_day_counts_as_three_strikes_and_preferred_shift_as_one() -> None: + wish_date = date(2024, 11, 4) + preferred_day = Wish( + employee_id=EMPLOYEE.employee_id, + planning_unit_id=PLANNING_UNIT.planning_unit_id, + date=wish_date, + type=WishType.PREFERRED_DAY, + ) + preferred_shift = Wish( + employee_id=EMPLOYEE.employee_id, + planning_unit_id=PLANNING_UNIT.planning_unit_id, + date=wish_date, + type=WishType.PREFERRED_SHIFT, + shift_id=EARLY_SHIFT.shift_id, + ) + + assert _penalty_for((preferred_shift,), {(wish_date, LATE_SHIFT.shift_id)}) == 1 + assert _penalty_for((preferred_day,), set()) == 36 + + +@pytest.mark.integration +def test_free_and_preferred_wishes_use_separate_strike_buckets() -> None: + wish_date = date(2024, 11, 4) + wishes = ( + Wish( + employee_id=EMPLOYEE.employee_id, + planning_unit_id=PLANNING_UNIT.planning_unit_id, + date=wish_date, + type=WishType.FREE_SHIFT, + shift_id=EARLY_SHIFT.shift_id, + ), + Wish( + employee_id=EMPLOYEE.employee_id, + planning_unit_id=PLANNING_UNIT.planning_unit_id, + date=wish_date, + type=WishType.PREFERRED_SHIFT, + shift_id=LATE_SHIFT.shift_id, + ), + ) + + assert _penalty_for(wishes, {(wish_date, EARLY_SHIFT.shift_id)}) == 2 From ca7583b310b2c05e7381711df8e5957543e2e094 Mon Sep 17 00:00:00 2001 From: JuliusKetz Date: Wed, 15 Jul 2026 13:41:03 +0200 Subject: [PATCH 12/17] Minimize_ovetime objective and target_working_time constraint fix --- .../cp_sat/constraints/target_working_time.py | 9 ++-- .../objectives/every_second_weekend_free.py | 2 +- .../free_day_after_night_shift_phase.py | 2 +- .../objectives/free_days_near_weekend.py | 2 +- .../minimize_consecutive_night_shifts.py | 16 ++++--- .../cp_sat/objectives/minimize_overtime.py | 14 ++---- .../not_too_many_consecutive_days.py | 32 ++++++------- .../objectives/prefer_own_planning_unit.py | 44 +++++++++--------- .../objectives/preferred_block_length.py | 35 +++++++-------- .../cp_sat/objectives/rotate_shits_foward.py | 45 +++++++++---------- src/scheduling/solver/service.py | 13 ++---- src/scheduling/timeoffice/facts.py | 7 ++- .../test_every_second_weekend_free.py | 5 +-- .../test_free_day_after_night_shift_phase.py | 12 ++--- .../objectives/test_free_days_near_weekend.py | 6 +-- .../test_minimize_consecutive_night_shifts.py | 2 +- 16 files changed, 112 insertions(+), 134 deletions(-) diff --git a/src/scheduling/solver/cp_sat/constraints/target_working_time.py b/src/scheduling/solver/cp_sat/constraints/target_working_time.py index 78fa0d1d..5fa531ce 100644 --- a/src/scheduling/solver/cp_sat/constraints/target_working_time.py +++ b/src/scheduling/solver/cp_sat/constraints/target_working_time.py @@ -7,6 +7,7 @@ from scheduling.solver.audit import AuditFinding, AuditSeverity from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.diagnostics import SolverDiagnostic +from scheduling.timeoffice import facts class TargetWorkingTime: @@ -21,8 +22,8 @@ def add_to_model( params: Mapping[str, Any], ) -> tuple[SolverDiagnostic, ...]: # Parameter aus params mit Fallbacks - tolerance_less = int(params.get("tolerance_less", 0)) - tolerance_more = int(params.get("tolerance_more", 0)) + tolerance_less = facts.TIMEOFFICE_FACTS.target_working_time_tolerance_less + tolerance_more = facts.TIMEOFFICE_FACTS.target_working_time_tolerance_more # Mapping von employee_id auf MonthlyWorkAccount accounts = {acc.employee_id: acc for acc in ctx.dataset.monthly_work_accounts} @@ -56,8 +57,8 @@ def audit( ctx: AuditContext, params: Mapping[str, Any], ) -> tuple[AuditFinding, ...]: - tolerance_less = int(params.get("tolerance_less", 0)) - tolerance_more = int(params.get("tolerance_more", 0)) + tolerance_less = facts.TIMEOFFICE_FACTS.target_working_time_tolerance_less + tolerance_more = facts.TIMEOFFICE_FACTS.target_working_time_tolerance_more findings: list[AuditFinding] = [] accounts = {acc.employee_id: acc for acc in ctx.dataset.monthly_work_accounts} diff --git a/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py b/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py index 6096b015..10c9ef37 100644 --- a/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py +++ b/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py @@ -88,4 +88,4 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P return (Penalty(objective_id=self.id, name="total", expression=total),) def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: - return () \ No newline at end of file + return () diff --git a/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py b/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py index e3a0a83e..f9958f30 100644 --- a/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py +++ b/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py @@ -94,4 +94,4 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P return (Penalty(objective_id=self.id, name="total", expression=total),) def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: - return () \ No newline at end of file + return () diff --git a/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py b/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py index ebcaa18c..83d2ccdd 100644 --- a/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py +++ b/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py @@ -88,4 +88,4 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P return (Penalty(objective_id=self.id, name="total", expression=total, multiplier=-1),) def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: - return () \ No newline at end of file + return () diff --git a/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py b/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py index 3801d603..afa747f8 100644 --- a/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py +++ b/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py @@ -74,14 +74,16 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P if phase_vars: total = ctx.model.new_int_var(0, len(phase_vars), f"mcns_total_l{phase_length}") ctx.model.add(total == sum(phase_vars)) - result.append(Penalty( - objective_id=self.id, - name=f"total_l{phase_length}", - expression=total, - multiplier=phase_length, # longer phases cost more - )) + result.append( + Penalty( + objective_id=self.id, + name=f"total_l{phase_length}", + expression=total, + multiplier=phase_length, # longer phases cost more + ) + ) return tuple(result) def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: - return () \ No newline at end of file + return () diff --git a/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py b/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py index 986a8af3..12930d96 100644 --- a/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py +++ b/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py @@ -1,9 +1,6 @@ -from collections import defaultdict from collections.abc import Mapping from typing import Any, ClassVar -from ortools.sat.python import cp_model - from scheduling.solver.audit import AuditFinding from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty @@ -26,16 +23,11 @@ def add_to_model( overtime: int = 0 for account in ctx.dataset.monthly_work_accounts: - employee_id = account.employee_id target_minutes = account.target_minutes - actual_minutes = actual_minutes - overtime += max(0, actual_minutes - target_minutes) + actual_worked = account.actual_minutes or 0 + overtime += max(0, actual_worked - target_minutes) - total_overtime = ctx.model.new_int_var( - 0, - overtime, - "minimize_overtime__total_overtime" - ) + total_overtime = ctx.model.new_int_var(0, overtime, "minimize_overtime__total_overtime") ctx.model.add(total_overtime == overtime).with_name("minimize_overtime__define_total_overtime") diff --git a/src/scheduling/solver/cp_sat/objectives/not_too_many_consecutive_days.py b/src/scheduling/solver/cp_sat/objectives/not_too_many_consecutive_days.py index 41531c86..e817f029 100644 --- a/src/scheduling/solver/cp_sat/objectives/not_too_many_consecutive_days.py +++ b/src/scheduling/solver/cp_sat/objectives/not_too_many_consecutive_days.py @@ -1,10 +1,9 @@ -from datetime import date, timedelta from collections import defaultdict from collections.abc import Mapping +from datetime import date as Date +from datetime import timedelta from typing import Any, ClassVar -from ortools.sat.python import cp_model - from scheduling.solver.audit import AuditFinding from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty @@ -17,7 +16,7 @@ class NotTooManyConsecutiveDays: id: ClassVar[str] = "not_too_many_consecutive_days" - #This seems to be a hard coded variable in the legacy version + # This seems to be a hard coded variable in the legacy version MAX_CONSECUTIVE_DAYS: int = 5 def add_to_model( @@ -28,36 +27,33 @@ def add_to_model( if not ctx.assignment_variables: return () - #First check which days every employee is assigned to - days_by_employee: defaultdict[int, date] = defaultdict(list) - for key, variable in ctx.assignment_variables.items(): + # First check which days every employee is assigned to + days_by_employee: defaultdict[int, list[Date]] = defaultdict(list) + for key, _variable in ctx.assignment_variables.items(): employee_id, _, date, _, _ = key days_by_employee[employee_id].append(date) - - #Find out how many times an employee works five days or more consecutively + + # Find out how many times an employee works five days or more consecutively too_many_consecutive_days: int = 0 for employee_id in days_by_employee.keys(): - #Make sure the lsit of days is sorted + # Make sure the lsit of days is sorted days_by_employee[employee_id] = sorted(days_by_employee[employee_id]) block_length: int = 1 for i in range(len(days_by_employee[employee_id]) - 1): - if days_by_employee[employee_id][i+1] - days_by_employee[employee_id][i] == timedelta(days=1): + if days_by_employee[employee_id][i + 1] - days_by_employee[employee_id][i] == timedelta(days=1): block_length += 1 else: if block_length > self.MAX_CONSECUTIVE_DAYS: - too_many_consecutive_days +=1 + too_many_consecutive_days += 1 block_length = 1 - - total_too_many_consecutive_days = ctx.model.new_int_var( - 0, - too_many_consecutive_days, - "not_too_many_consecutive_days" + 0, too_many_consecutive_days, "not_too_many_consecutive_days" ) ctx.model.add(total_too_many_consecutive_days == too_many_consecutive_days).with_name( - "not_too_many_consecutive_days__total_too_many_consecutive_daays") + "not_too_many_consecutive_days__total_too_many_consecutive_daays" + ) return ( Penalty( diff --git a/src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py b/src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py index d9e2da87..c0fcbd48 100644 --- a/src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py +++ b/src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py @@ -2,17 +2,15 @@ from collections.abc import Mapping from typing import Any, ClassVar -from ortools.sat.python import cp_model - +from scheduling.domain.planning_unit import PlanningUnitId from scheduling.solver.audit import AuditFinding from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty -from scheduling.domain.planning_unit import PlanningUnitMembership, PlanningUnitType, PlanningUnit, PlanningUnitId class PreferOwnPlanningUnit: """ - Adds a penalty every time an employee is assigned to the planning unit that is not his + Adds a penalty every time an employee is assigned to the planning unit that is not his preferred planning unit. """ @@ -26,36 +24,36 @@ def add_to_model( if not ctx.assignment_variables: return () - #Dictionary with employee as key and planning unit - employees_PU_dict: defaultdict[int, PlanningUnitId] = defaultdict(list) - #First assign a planning unit to every employee, read from the dataset + # Dictionary with employee as key and planning unit + employees_PU_dict: defaultdict[int, PlanningUnitId] = defaultdict(list) + # First assign a planning unit to every employee, read from the dataset for membership in ctx.dataset.planning_unit_memberships: employee_id = membership.employee_id planning_unit_id = membership.planning_unit_id - - - #Then find out the id of any shared pool stations - shared_pool_type = PlanningUnitType("shared_pool") - shared_pool_ids: list[PlanningUnitId] = list[PlanningUnitId] + + # Then find out the id of any shared pool stations + # shared_pool_type = PlanningUnitType("shared_pool") + shared_pool_ids: list[PlanningUnitId] = list[PlanningUnitId]() for pu in ctx.dataset.planning_units: - if pu.type == shared_pool_ids: + if pu.planning_unit_id in shared_pool_ids: shared_pool_ids.append(pu.planning_unit_id) - #Now check in the assignments whether an employee (who is not in the shared pool!!!) - #was assigned to a planning unit that is not his own + # Now check in the assignments whether an employee (who is not in the shared pool!!!) + # was assigned to a planning unit that is not his own num_not_preferred_planning_unit: int = 0 - for key, variable in ctx.assignment_variables.items(): + for key, _variable in ctx.assignment_variables.items(): employee_id, planning_unit_id, _, _, _ = key - if employees_PU_dict[employee_id] != planning_unit_id and employees_PU_dict[employee_id] not in shared_pool_ids: + if ( + employees_PU_dict[employee_id] != planning_unit_id + and employees_PU_dict[employee_id] not in shared_pool_ids + ): num_not_preferred_planning_unit += 1 - prefer_own_planning_unit_penalty = ctx.model.new_int_var( - 0, - 100000, - "not_preferred_planning_unit" - ) + prefer_own_planning_unit_penalty = ctx.model.new_int_var(0, 100000, "not_preferred_planning_unit") - ctx.model.add(prefer_own_planning_unit_penalty == num_not_preferred_planning_unit).with_name("prefer_own_planning_unit__penalty") + ctx.model.add(prefer_own_planning_unit_penalty == num_not_preferred_planning_unit).with_name( + "prefer_own_planning_unit__penalty" + ) return ( Penalty( diff --git a/src/scheduling/solver/cp_sat/objectives/preferred_block_length.py b/src/scheduling/solver/cp_sat/objectives/preferred_block_length.py index 70c1bff2..00d07149 100644 --- a/src/scheduling/solver/cp_sat/objectives/preferred_block_length.py +++ b/src/scheduling/solver/cp_sat/objectives/preferred_block_length.py @@ -1,10 +1,9 @@ -from datetime import date, timedelta from collections import defaultdict from collections.abc import Mapping +from datetime import date as Date +from datetime import timedelta from typing import Any, ClassVar -from ortools.sat.python import cp_model - from scheduling.solver.audit import AuditFinding from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty @@ -17,7 +16,7 @@ class PreferredBlockLength: id: ClassVar[str] = "preferred_block_length" - #This seems to be a hard coded variable in the legacy version + # This seems to be a hard coded variable in the legacy version PREFERRED_BLOCK_LENGTH: int = 3 def add_to_model( @@ -28,42 +27,38 @@ def add_to_model( if not ctx.assignment_variables: return () - #First check which days every employee is assigned to - days_by_employee: defaultdict[int, date] = defaultdict(list) - for key, variable in ctx.assignment_variables.items(): + # First check which days every employee is assigned to + days_by_employee: defaultdict[int, list[Date]] = defaultdict[int, list[Date]](list) + for key, _variable in ctx.assignment_variables.items(): employee_id, _, date, _, _ = key days_by_employee[employee_id].append(date) - - #Find out how many times an employee works exactly three days consecutively + + # Find out how many times an employee works exactly three days consecutively num_preferred_blocks: int = 0 for employee_id in days_by_employee.keys(): - #Make sure the lsit of days is sorted + # Make sure the lsit of days is sorted days_by_employee[employee_id] = sorted(days_by_employee[employee_id]) block_length: int = 1 for i in range(len(days_by_employee[employee_id]) - 1): - if days_by_employee[employee_id][i+1] - days_by_employee[employee_id][i] == timedelta(days=1): + if days_by_employee[employee_id][i + 1] - days_by_employee[employee_id][i] == timedelta(days=1): block_length += 1 else: if block_length == self.PREFERRED_BLOCK_LENGTH: - num_preferred_blocks +=1 + num_preferred_blocks += 1 block_length = 1 - + total_preferred_blocks = ctx.model.new_int_var(0, num_preferred_blocks, "total_preferred_blocks") - total_preferred_blocks = ctx.model.new_int_var( - 0, - num_preferred_blocks, - "total_preferred_blocks" + ctx.model.add(total_preferred_blocks == num_preferred_blocks).with_name( + "preferred_block_length__total_preferred_blocks" ) - ctx.model.add(total_preferred_blocks == num_preferred_blocks).with_name("preferred_block_length__total_preferred_blocks") - return ( Penalty( objective_id=self.id, name="total_preferred_blocks", expression=total_preferred_blocks, - multiplier=-1 #This should make sure that this objective gives a reward instead of a penalty + multiplier=-1, # This should make sure that this objective gives a reward instead of a penalty ), ) diff --git a/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py b/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py index dc80bdcf..9c4798e3 100644 --- a/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py +++ b/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py @@ -1,29 +1,26 @@ from collections import defaultdict from collections.abc import Mapping +from datetime import date as Date from typing import Any, ClassVar -from ortools.sat.python import cp_model - +from scheduling.domain.shift import ShiftId, ShiftType from scheduling.solver.audit import AuditFinding from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty -from scheduling.domain.shift import ShiftType class RotateShiftsForward: """ - Adds a reward for each time an employee works forward rotating shifts and a + Adds a reward for each time an employee works forward rotating shifts and a penalty for backwards rotating shifts """ - FORWARD_ROTATIONS = ( - (ShiftType("early"), ShiftType("late")), - (ShiftType("late"), ShiftType("night")) - ) + + FORWARD_ROTATIONS = ((ShiftType("early"), ShiftType("late")), (ShiftType("late"), ShiftType("night"))) BACKWARD_ROTATIONS = ( (ShiftType("late"), ShiftType("early")), - (ShiftType("night"), ShiftType("late")) - #In the legacy version, they also penalize going from night -> early, which makes no sense in my eyes - #Also, they only consider a timeframe of 3 days per shift, I do not understand why + (ShiftType("night"), ShiftType("late")), + # In the legacy version, they also penalize going from night -> early, which makes no sense in my eyes + # Also, they only consider a timeframe of 3 days per shift, I do not understand why ) id: ClassVar[str] = "rotate_shifts_forward" @@ -36,37 +33,35 @@ def add_to_model( if not ctx.assignment_variables: return () - #First check which shifts every employee is assigned to - days_by_employee: defaultdict[int, date] = defaultdict(list) - for key, variable in ctx.assignment_variables.items(): + # First check which shifts every employee is assigned to + days_by_employee: dict[int, list[tuple[Date, ShiftId]]] = defaultdict[int, list[tuple[Date, ShiftId]]](list) + for key, _variable in ctx.assignment_variables.items(): employee_id, _, date, shift_id, _ = key days_by_employee[employee_id].append((date, shift_id)) - - #Find out how the shifts rotate for each employee + + # Find out how the shifts rotate for each employee num_forward_rotations: int = 0 num_backward_rotations: int = 0 for employee_id in days_by_employee.keys(): - #Again make sure that the shifts are properly sorted + # Again make sure that the shifts are properly sorted days_by_employee[employee_id] = sorted(days_by_employee[employee_id]) for i in range(len(days_by_employee[employee_id]) - 1): for shift in ctx.dataset.shifts: if shift.shift_id == days_by_employee[employee_id][i][1]: shift_type_before = shift.type - if shift.shift_id == days_by_employee[employee_id][i+1][1]: + if shift.shift_id == days_by_employee[employee_id][i + 1][1]: shift_type_after = shift.type if (shift_type_before, shift_type_after) in self.FORWARD_ROTATIONS: num_forward_rotations += 1 elif (shift_type_before, shift_type_after) in self.BACKWARD_ROTATIONS: num_backward_rotations += 1 - - rotations = ctx.model.new_int_var( - -1000000, - 1000000, - "rotations" - ) - ctx.model.add(rotations == num_backward_rotations - num_forward_rotations).with_name("rotate_shifts_forward__rotations") + rotations = ctx.model.new_int_var(-1000000, 1000000, "rotations") + + ctx.model.add(rotations == num_backward_rotations - num_forward_rotations).with_name( + "rotate_shifts_forward__rotations" + ) return ( Penalty( diff --git a/src/scheduling/solver/service.py b/src/scheduling/solver/service.py index 8d5bce93..b3865f7d 100644 --- a/src/scheduling/solver/service.py +++ b/src/scheduling/solver/service.py @@ -212,15 +212,10 @@ def _audit_solution( dataset: SchedulingDataset, assignments: tuple[Assignment, ...], ) -> AuditReport: - - #add the already existing assignments to the ones generated by the solver - all_assignments = assignments + tuple(dataset.assignments) - - audit_ctx = AuditContext( - dataset=dataset, - index=build_result.ctx.index, - assignments=all_assignments - ) + # add the already existing assignments to the ones generated by the solver + all_assignments = assignments + tuple(dataset.assignments) + + audit_ctx = AuditContext(dataset=dataset, index=build_result.ctx.index, assignments=all_assignments) findings: list[AuditFinding] = [] diff --git a/src/scheduling/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index 0c9e3911..4e6fbab0 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -102,6 +102,9 @@ class TimeOfficeFacts: monthly_target_work_account_id: int monthly_actual_work_account_id: int + target_working_time_tolerance_less: int + target_working_time_tolerance_more: int + REFERENCE_SHIFT_FACTS_BY_ID: Mapping[ShiftId, TimeOfficeReferenceShiftFact] = MappingProxyType( { @@ -192,7 +195,7 @@ class TimeOfficeFacts: "A-81302-016": StaffLevel.TRAINEE, # A-Pflegefachkraft Kinderkrankenpflege "A-81302-018": StaffLevel.TRAINEE, # A-Pflegefachkraft Krankenpflege "A-81302-019": StaffLevel.TRAINEE, # A-Pflegefachkraft Altenpflege - # "-": StaffLevel.TRAINEE,#Später herausfinden was das für eine Profession ist + "-": StaffLevel.TRAINEE, # Später herausfinden was das für eine Profession ist } ) @@ -302,4 +305,6 @@ class TimeOfficeFacts: ), monthly_target_work_account_id=MONTHLY_TARGET_WORK_ACCOUNT_ID, monthly_actual_work_account_id=MONTHLY_ACTUAL_WORK_ACCOUNT_ID, + target_working_time_tolerance_less=500, + target_working_time_tolerance_more=500, ) diff --git a/tests/cp/objectives/test_every_second_weekend_free.py b/tests/cp/objectives/test_every_second_weekend_free.py index 4b3fa546..eb8a7a5c 100644 --- a/tests/cp/objectives/test_every_second_weekend_free.py +++ b/tests/cp/objectives/test_every_second_weekend_free.py @@ -19,7 +19,6 @@ from scheduling.solver.cp_sat.objectives.every_second_weekend_free import EverySecondWeekendFree from scheduling.solver.cp_sat.variables import create_assignment_variables - # --- Shared test data --- PLANNING_UNIT = PlanningUnit( @@ -106,7 +105,7 @@ def test_no_penalty_when_weekends_alternate() -> None: # Every consecutive pair alternates → no same-status penalty expected. # November 2024 weekends: (2,3), (9,10), (16,17), (23,24) worked_weekends = {date(2024, 11, 2), date(2024, 11, 3), date(2024, 11, 16), date(2024, 11, 17)} - free_weekends = {date(2024, 11, 9), date(2024, 11, 10), date(2024, 11, 23), date(2024, 11, 24)} + # free_weekends = {date(2024, 11, 9), date(2024, 11, 10), date(2024, 11, 23), date(2024, 11, 24)} dataset = _dataset() ctx = create_context(dataset=dataset) @@ -127,4 +126,4 @@ def test_no_penalty_when_weekends_alternate() -> None: assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) # Alternating weekends (worked, free, worked, free) → penalty should be 0 - assert solver.objective_value == 0 \ No newline at end of file + assert solver.objective_value == 0 diff --git a/tests/cp/objectives/test_free_day_after_night_shift_phase.py b/tests/cp/objectives/test_free_day_after_night_shift_phase.py index 27cc6954..a3f5f371 100644 --- a/tests/cp/objectives/test_free_day_after_night_shift_phase.py +++ b/tests/cp/objectives/test_free_day_after_night_shift_phase.py @@ -82,11 +82,11 @@ def test_penalty_when_working_on_day_after_next() -> None: for (_employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): if d == date(2024, 11, 1) and shift_id == NIGHT_SHIFT.shift_id: - ctx.model.add(var == 1) # night shift on day 1 + ctx.model.add(var == 1) # night shift on day 1 elif d == date(2024, 11, 2): - ctx.model.add(var == 0) # free on day 2 + ctx.model.add(var == 0) # free on day 2 elif d == date(2024, 11, 3) and shift_id == EARLY_SHIFT.shift_id: - ctx.model.add(var == 1) # working on day 3 + ctx.model.add(var == 1) # working on day 3 else: ctx.model.add(var == 0) @@ -111,9 +111,9 @@ def test_no_penalty_when_two_free_days_after_night() -> None: for (_employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): if d == date(2024, 11, 1) and shift_id == NIGHT_SHIFT.shift_id: - ctx.model.add(var == 1) # night shift on day 1 + ctx.model.add(var == 1) # night shift on day 1 else: - ctx.model.add(var == 0) # free on all other days + ctx.model.add(var == 0) # free on all other days penalties = FreeDaysAfterNightShiftPhase().add_to_model(ctx, params={}) assert penalties @@ -124,4 +124,4 @@ def test_no_penalty_when_two_free_days_after_night() -> None: assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) # two free days after night → no penalty - assert solver.objective_value == 0 \ No newline at end of file + assert solver.objective_value == 0 diff --git a/tests/cp/objectives/test_free_days_near_weekend.py b/tests/cp/objectives/test_free_days_near_weekend.py index cbee3d52..5bce62fc 100644 --- a/tests/cp/objectives/test_free_days_near_weekend.py +++ b/tests/cp/objectives/test_free_days_near_weekend.py @@ -71,7 +71,7 @@ def test_higher_reward_when_friday_and_saturday_both_free() -> None: ctx_both = create_context(dataset=dataset) create_assignment_variables(ctx_both) - for (_employee_id, _unit, d, _shift, _level), var in ctx_both.assignment_variables.items(): + for (_employee_id, _unit, _d, _shift, _level), var in ctx_both.assignment_variables.items(): ctx_both.model.add(var == 0) # all days free penalties_both = FreeDaysNearWeekend().add_to_model(ctx_both, params={}) @@ -111,7 +111,7 @@ def test_no_reward_when_all_days_worked() -> None: ctx = create_context(dataset=dataset) create_assignment_variables(ctx) - for (_employee_id, _unit, d, _shift, _level), var in ctx.assignment_variables.items(): + for (_employee_id, _unit, _d, _shift, _level), var in ctx.assignment_variables.items(): ctx.model.add(var == 1) # all days worked penalties = FreeDaysNearWeekend().add_to_model(ctx, params={}) @@ -123,4 +123,4 @@ def test_no_reward_when_all_days_worked() -> None: assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) # No free near-weekend days → reward = 0, so penalty expression = 0 - assert solver.objective_value == 0 \ No newline at end of file + assert solver.objective_value == 0 diff --git a/tests/cp/objectives/test_minimize_consecutive_night_shifts.py b/tests/cp/objectives/test_minimize_consecutive_night_shifts.py index 09c335d9..d3c5a660 100644 --- a/tests/cp/objectives/test_minimize_consecutive_night_shifts.py +++ b/tests/cp/objectives/test_minimize_consecutive_night_shifts.py @@ -148,4 +148,4 @@ def _penalty_for_nights(night_dates: set[date]) -> float: penalty_2 = _penalty_for_nights({date(2024, 11, 1), date(2024, 11, 2)}) penalty_4 = _penalty_for_nights({date(2024, 11, 1), date(2024, 11, 2), date(2024, 11, 3), date(2024, 11, 4)}) - assert penalty_4 > penalty_2 \ No newline at end of file + assert penalty_4 > penalty_2 From 189e3719ef28a8978f7d07eeedaf459dc4ed2720 Mon Sep 17 00:00:00 2001 From: JuliusKetz Date: Wed, 15 Jul 2026 15:40:19 +0200 Subject: [PATCH 13/17] minimize_overtime 2nd Fix --- .../cp_sat/objectives/minimize_overtime.py | 58 ++++++++++++++++--- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py b/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py index 12930d96..e71a5008 100644 --- a/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py +++ b/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py @@ -1,6 +1,9 @@ +from collections import defaultdict from collections.abc import Mapping from typing import Any, ClassVar +from ortools.sat.python import cp_model + from scheduling.solver.audit import AuditFinding from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty @@ -8,7 +11,8 @@ class MinimizeOvertime: """ - Adds a penalty to the solver for including assigning overtime to employees. + Adds a penalty to the solver for assigning overtime to employees. + Mathematical formulation: sum(max(0, planned_work + actual_work - target_work)) """ id: ClassVar[str] = "minimize_overtime" @@ -21,21 +25,41 @@ def add_to_model( if not ctx.assignment_variables: return () - overtime: int = 0 - for account in ctx.dataset.monthly_work_accounts: - target_minutes = account.target_minutes + accounts = {acc.employee_id: acc for acc in ctx.dataset.monthly_work_accounts} + exprs_by_employee = _group_weighted_vars(ctx) + + overtime_vars: list[cp_model.IntVar] = [] + + for employee_id, expressions in exprs_by_employee.items(): + account = accounts.get(employee_id) + if not account: + continue + actual_worked = account.actual_minutes or 0 - overtime += max(0, actual_worked - target_minutes) + target_minutes = account.target_minutes + + # W_i: Summe der zu planenden Schichten + total_work_expr = cp_model.LinearExpr.Sum(expressions) # type: ignore + + # O_i: Überstunden-Variable für den jeweiligen Mitarbeiter + # Upper Bound: 44640 (31 Tage * 24h * 60m) fungiert als sicheres Maximum für einen Monat + emp_overtime_var = ctx.model.new_int_var(0, 44640, f"minimize_overtime__emp_{employee_id}") - total_overtime = ctx.model.new_int_var(0, overtime, "minimize_overtime__total_overtime") + # Constraint: O_i >= W_i + A_i - T_i + ctx.model.add(emp_overtime_var >= total_work_expr + actual_worked - target_minutes).with_name( + f"minimize_overtime__bound_emp_{employee_id}" + ) - ctx.model.add(total_overtime == overtime).with_name("minimize_overtime__define_total_overtime") + overtime_vars.append(emp_overtime_var) + + if not overtime_vars: + return () return ( Penalty( objective_id=self.id, name="total_overtime", - expression=total_overtime, + expression=cp_model.LinearExpr.Sum(overtime_vars), # type: ignore ), ) @@ -44,4 +68,22 @@ def audit( ctx: AuditContext, params: Mapping[str, Any], ) -> tuple[AuditFinding, ...]: + # Objectives produzieren primär keine Audit-Findings (im Gegensatz zu Constraints). + # Falls gewünscht, kann hier die kumulierte Überstunden-Metrik berechnet werden. return () + + +def _group_weighted_vars(ctx: SolverContext) -> dict[int, list[cp_model.LinearExpr]]: + exprs: defaultdict[int, list[cp_model.LinearExpr]] = defaultdict(list) + + # Mapping für schnellen Zugriff auf Schichtdauern + shift_durations = {s.shift_id: (s.end_minute - s.start_minute) for s in ctx.dataset.shifts} + + for key, variable in ctx.assignment_variables.items(): + employee_id, _, _, shift_id, _ = key + + # Falls Schicht nicht im Dataset, Dauer 0 + duration = shift_durations.get(shift_id, 0) + exprs[employee_id].append(variable * duration) + + return dict(exprs) From 9894337c3545d8ed7b56f9e3a51626df8e05851e Mon Sep 17 00:00:00 2001 From: JuliusKetz Date: Wed, 15 Jul 2026 16:35:42 +0200 Subject: [PATCH 14/17] test for one assignment per day and target working time default account added --- .../cp_sat/constraints/target_working_time.py | 7 +- .../test_free_day_after_night_shift_phase.py | 0 .../test_one_assignment_per_day.py | 128 ++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) rename tests/cp/{objectives => constraints}/test_free_day_after_night_shift_phase.py (100%) create mode 100644 tests/cp/constraints/test_one_assignment_per_day.py diff --git a/src/scheduling/solver/cp_sat/constraints/target_working_time.py b/src/scheduling/solver/cp_sat/constraints/target_working_time.py index 5fa531ce..8f20f69a 100644 --- a/src/scheduling/solver/cp_sat/constraints/target_working_time.py +++ b/src/scheduling/solver/cp_sat/constraints/target_working_time.py @@ -4,6 +4,7 @@ from ortools.sat.python import cp_model +from scheduling.domain.monthly_work_account import MonthlyWorkAccount from scheduling.solver.audit import AuditFinding, AuditSeverity from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.diagnostics import SolverDiagnostic @@ -34,7 +35,11 @@ def add_to_model( for employee_id, expressions in exprs_by_employee.items(): account = accounts.get(employee_id) if not account: - continue + account = MonthlyWorkAccount( + employee_id=employee_id, target_minutes=9600, actual_minutes=0 + ) # dummy fallback, should not happen + tolerance_less = 10000 # dummy fallback, should not happen + tolerance_more = 10000 # dummy fallback, should not happen # Zielvorgabe: Target minus bereits geleistete Zeit actual_worked = account.actual_minutes or 0 diff --git a/tests/cp/objectives/test_free_day_after_night_shift_phase.py b/tests/cp/constraints/test_free_day_after_night_shift_phase.py similarity index 100% rename from tests/cp/objectives/test_free_day_after_night_shift_phase.py rename to tests/cp/constraints/test_free_day_after_night_shift_phase.py diff --git a/tests/cp/constraints/test_one_assignment_per_day.py b/tests/cp/constraints/test_one_assignment_per_day.py new file mode 100644 index 00000000..353e7dd5 --- /dev/null +++ b/tests/cp/constraints/test_one_assignment_per_day.py @@ -0,0 +1,128 @@ +from datetime import date + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.constraints.one_assignment_per_day import OneAssignmentPerDay +from scheduling.solver.cp_sat.context import create_context +from scheduling.solver.cp_sat.variables import create_assignment_variables + +# --- Shared test data --- + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +# We need at least two shifts to test multiple assignments on a single day +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +LATE_SHIFT = Shift( + shift_id=2, + code="S", + type=ShiftType.LATE, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=800, + end_minute=1260, + net_work_minutes=460, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=date(2024, 11, 1), + valid_until=date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _dataset() -> SchedulingDataset: + return SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT, LATE_SHIFT), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + ) + + +def _solve_with_forced_assignments(forced_assignments: list[tuple[date, int]]): + """ + Build a model where the employee is forced to work the specific (date, shift_id) pairs, + apply the constraint, and solve. Returns the solver status. + """ + dataset = _dataset() + ctx = create_context(dataset=dataset) + create_assignment_variables(ctx) + + # Force the employee to work (or not) on the specific shifts + for (_employee_id, _unit, assignment_date, shift_id, _level), var in ctx.assignment_variables.items(): + if (assignment_date, shift_id) in forced_assignments: + ctx.model.add(var == 1) + else: + ctx.model.add(var == 0) + + # Apply the constraint + OneAssignmentPerDay().add_to_model(ctx, params={}) + + solver = cp_model.CpSolver() + return solver.solve(ctx.model) + + +@pytest.mark.integration +def test_feasible_when_one_shift_per_day() -> None: + # Employee works Early on the 1st and Late on the 2nd + # (Max 1 shift per day) -> FEASIBLE expected + forced_assignments = [ + (date(2024, 11, 1), EARLY_SHIFT.shift_id), + (date(2024, 11, 2), LATE_SHIFT.shift_id), + ] + + status = _solve_with_forced_assignments(forced_assignments) + + # Pure satisfaction problems usually return FEASIBLE (or OPTIMAL) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_infeasible_when_two_shifts_same_day() -> None: + # Employee is forced to work both Early AND Late on the 1st + # This violates the constraint -> INFEASIBLE expected + forced_assignments = [ + (date(2024, 11, 1), EARLY_SHIFT.shift_id), + (date(2024, 11, 1), LATE_SHIFT.shift_id), + ] + + status = _solve_with_forced_assignments(forced_assignments) + + assert status == cp_model.INFEASIBLE From 3910d01f3122007475c65b356a3db070267af2c4 Mon Sep 17 00:00:00 2001 From: JuliusKetz Date: Wed, 15 Jul 2026 21:56:40 +0200 Subject: [PATCH 15/17] test for all constraints and bug fix target_working_time and minimize_overtime --- .../cp_sat/constraints/target_working_time.py | 4 +- .../cp_sat/objectives/minimize_overtime.py | 2 +- tests/cp/constraints/test_all_constraints.py | 298 ++++++++++++++++++ .../test_availabilities_constraint.py | 188 +++++++++++ .../test_hierarchy_of_intermediate_shifts.py | 156 +++++++++ tests/cp/constraints/test_min_rest_time.py | 161 ++++++++++ ...min_staffing_and_one_assignment_per_day.py | 229 ++++++++++++++ tests/cp/constraints/test_minimum_staffing.py | 163 ++++++++++ .../constraints/test_rounds_in_early_shift.py | 183 +++++++++++ .../constraints/test_target_working_time.py | 168 ++++++++++ 10 files changed, 1549 insertions(+), 3 deletions(-) create mode 100644 tests/cp/constraints/test_all_constraints.py create mode 100644 tests/cp/constraints/test_availabilities_constraint.py create mode 100644 tests/cp/constraints/test_hierarchy_of_intermediate_shifts.py create mode 100644 tests/cp/constraints/test_min_rest_time.py create mode 100644 tests/cp/constraints/test_min_staffing_and_one_assignment_per_day.py create mode 100644 tests/cp/constraints/test_minimum_staffing.py create mode 100644 tests/cp/constraints/test_rounds_in_early_shift.py create mode 100644 tests/cp/constraints/test_target_working_time.py diff --git a/src/scheduling/solver/cp_sat/constraints/target_working_time.py b/src/scheduling/solver/cp_sat/constraints/target_working_time.py index 8f20f69a..dc7a8e9b 100644 --- a/src/scheduling/solver/cp_sat/constraints/target_working_time.py +++ b/src/scheduling/solver/cp_sat/constraints/target_working_time.py @@ -109,7 +109,7 @@ def _group_weighted_vars(ctx: SolverContext) -> dict[int, list[cp_model.LinearEx exprs: defaultdict[int, list[cp_model.LinearExpr]] = defaultdict(list) # Mapping für schnellen Zugriff auf Schichtdauern - shift_durations = {s.shift_id: (s.end_minute - s.start_minute) for s in ctx.dataset.shifts} + shift_durations = {s.shift_id: s.net_work_minutes for s in ctx.dataset.shifts} for key, variable in ctx.assignment_variables.items(): employee_id, _, _, shift_id, _ = key @@ -123,7 +123,7 @@ def _group_weighted_vars(ctx: SolverContext) -> dict[int, list[cp_model.LinearEx def _group_actual_durations(ctx: AuditContext) -> dict[int, int]: durations: defaultdict[int, int] = defaultdict(int) - shift_durations = {s.shift_id: (s.end_minute - s.start_minute) for s in ctx.dataset.shifts} + shift_durations = {s.shift_id: s.net_work_minutes for s in ctx.dataset.shifts} for assignment in ctx.assignments: # Pydantic Modelle garantieren die Existenz diff --git a/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py b/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py index e71a5008..dcc72e8d 100644 --- a/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py +++ b/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py @@ -77,7 +77,7 @@ def _group_weighted_vars(ctx: SolverContext) -> dict[int, list[cp_model.LinearEx exprs: defaultdict[int, list[cp_model.LinearExpr]] = defaultdict(list) # Mapping für schnellen Zugriff auf Schichtdauern - shift_durations = {s.shift_id: (s.end_minute - s.start_minute) for s in ctx.dataset.shifts} + shift_durations = {s.shift_id: s.net_work_minutes for s in ctx.dataset.shifts} for key, variable in ctx.assignment_variables.items(): employee_id, _, _, shift_id, _ = key diff --git a/tests/cp/constraints/test_all_constraints.py b/tests/cp/constraints/test_all_constraints.py new file mode 100644 index 00000000..aad488b0 --- /dev/null +++ b/tests/cp/constraints/test_all_constraints.py @@ -0,0 +1,298 @@ +import datetime +from contextlib import ExitStack +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Availability, + AvailabilityType, + Capability, + Employee, + MonthlyWorkAccount, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.constraints.availabilities_constraint import AvailabilitiesConstraint +from scheduling.solver.cp_sat.constraints.free_day_after_night_shift_phase import FreeDayAfterNightShiftPhase +from scheduling.solver.cp_sat.constraints.hierarchy_of_intermediate_shifts import HierarchyOfIntermediateShifts +from scheduling.solver.cp_sat.constraints.min_rest_time import MinimumRestTime +from scheduling.solver.cp_sat.constraints.minimum_staffing import MinimumStaffing +from scheduling.solver.cp_sat.constraints.one_assignment_per_day import OneAssignmentPerDay +from scheduling.solver.cp_sat.constraints.rounds_in_early_shift import RoundsInEarlyShift +from scheduling.solver.cp_sat.constraints.target_working_time import TargetWorkingTime +from scheduling.solver.cp_sat.context import create_context + +# --- Setup: Global Master Scenario Entities --- + +PLANNING_UNIT = PlanningUnit(planning_unit_id=1, display_name="Station 1", type=PlanningUnitType.STATION) + +S_EARLY = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=840, + net_work_minutes=480, +) +S_LATE = Shift( + shift_id=2, + code="S", + type=ShiftType.LATE, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=840, + end_minute=1320, + net_work_minutes=480, +) +S_NIGHT = Shift( + shift_id=3, + code="N", + type=ShiftType.NIGHT, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=1320, + end_minute=360, + net_work_minutes=480, +) +S_INTER = Shift( + shift_id=4, + code="Z", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=540, + end_minute=1020, + net_work_minutes=480, +) + +SHIFTS = (S_EARLY, S_LATE, S_NIGHT, S_INTER) + +EMPLOYEES = ( + Employee( + employee_id=1, display_name="E1_Rounds", staff_level=StaffLevel.PROFESSIONAL, capabilities=(Capability.ROUNDS,) + ), + Employee(employee_id=2, display_name="E2_Late", staff_level=StaffLevel.PROFESSIONAL), + Employee(employee_id=3, display_name="E3_Night", staff_level=StaffLevel.PROFESSIONAL), + Employee(employee_id=4, display_name="E4_Weekend", staff_level=StaffLevel.PROFESSIONAL), + Employee(employee_id=5, display_name="E5_Inter_WD", staff_level=StaffLevel.PROFESSIONAL), + Employee(employee_id=6, display_name="E6_Inter_WE", staff_level=StaffLevel.PROFESSIONAL), +) + +MEMBERSHIPS = tuple( + PlanningUnitMembership( + planning_unit_id=1, + employee_id=emp.employee_id, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, + ) + for emp in EMPLOYEES +) + +TEST_DATES = [datetime.date(2024, 11, d) for d in range(4, 11)] + + +def _solve_master_scenario( + altered_demands: dict[tuple[datetime.date, int], int] | None = None, + altered_targets: dict[int, int] | None = None, + forced_assignments: list[tuple[int, datetime.date, int]] | None = None, +) -> cp_model.CpSolverStatus: + if altered_demands is None: + altered_demands = {} + if altered_targets is None: + altered_targets = {} + if forced_assignments is None: + forced_assignments = [] + + targets = {1: 2400, 2: 3360, 3: 2880, 4: 1440, 5: 2400, 6: 480} + targets.update(altered_targets) + + accounts = [ + MonthlyWorkAccount(employee_id=emp_id, target_minutes=mins, actual_minutes=0) + for emp_id, mins in targets.items() + ] + + availabilities = [ + Availability(employee_id=4, date=d, availability_type=AvailabilityType.UNAVAILABLE) for d in TEST_DATES[:4] + ] + + dataset = SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=SHIFTS, + employees=EMPLOYEES, + planning_unit_memberships=MEMBERSHIPS, + monthly_work_accounts=tuple(accounts), + availability=tuple(availabilities), + ) + + ctx = create_context(dataset=dataset) + + for emp in EMPLOYEES: + for d in TEST_DATES: + for shift in SHIFTS: + key = (emp.employee_id, PLANNING_UNIT.planning_unit_id, d, shift.shift_id, StaffLevel.PROFESSIONAL) + ctx.assignment_variables[key] = ctx.model.new_bool_var( + f"assign_{emp.employee_id}_{d:%d}_{shift.shift_id}" + ) + + demands = {} + for d in TEST_DATES: + for shift in (S_EARLY, S_LATE, S_NIGHT): + demands[(PLANNING_UNIT.planning_unit_id, d, shift.shift_id, StaffLevel.PROFESSIONAL)] = 1 + + if d.isoweekday() <= 6: + demands[(PLANNING_UNIT.planning_unit_id, d, S_INTER.shift_id, StaffLevel.PROFESSIONAL)] = 1 + + for (d, shift_id), req_count in altered_demands.items(): + demands[(PLANNING_UNIT.planning_unit_id, d, shift_id, StaffLevel.PROFESSIONAL)] = req_count + + with ExitStack() as stack: + stack.enter_context( + patch( + "scheduling.solver.cp_sat.constraints.availabilities_constraint.is_night_shift", + side_effect=lambda s: s.shift_id == 3, # type: ignore + ) + ) + stack.enter_context( + patch( + "scheduling.solver.cp_sat.constraints.free_day_after_night_shift_phase.is_night_shift", + side_effect=lambda s: s.shift_id == 3, # type: ignore + ) + ) + stack.enter_context( + patch( + "scheduling.solver.cp_sat.constraints.hierarchy_of_intermediate_shifts.is_intermediate_shift", + side_effect=lambda s: s.shift_id == 4, # type: ignore + ) + ) + stack.enter_context( + patch( + "scheduling.solver.cp_sat.constraints.min_rest_time.is_late_shift", + side_effect=lambda s: s.shift_id == 2, # type: ignore + ) + ) + stack.enter_context( + patch( + "scheduling.solver.cp_sat.constraints.min_rest_time.is_early_shift", + side_effect=lambda s: s.shift_id == 1, # type: ignore + ) + ) + stack.enter_context( + patch( + "scheduling.solver.cp_sat.constraints.rounds_in_early_shift.is_early_shift", + side_effect=lambda s: s.shift_id == 1, # type: ignore + ) + ) + + mock_facts = MagicMock() + mock_facts.target_working_time_tolerance_less = 0 + mock_facts.target_working_time_tolerance_more = 0 + stack.enter_context( + patch("scheduling.solver.cp_sat.constraints.target_working_time.facts.TIMEOFFICE_FACTS", mock_facts) + ) + + stack.enter_context( + patch.object( + type(ctx.index), "required_count_by_demand_key", new_callable=PropertyMock, return_value=demands + ) + ) + + AvailabilitiesConstraint().add_to_model(ctx, params={}) + FreeDayAfterNightShiftPhase().add_to_model(ctx, params={}) + HierarchyOfIntermediateShifts().add_to_model(ctx, params={}) + MinimumRestTime().add_to_model(ctx, params={}) + MinimumStaffing().add_to_model(ctx, params={}) + OneAssignmentPerDay().add_to_model(ctx, params={}) + RoundsInEarlyShift().add_to_model(ctx, params={}) + TargetWorkingTime().add_to_model(ctx, params={}) + + for emp_id, date, fixed_shift_id in forced_assignments: + key = (emp_id, PLANNING_UNIT.planning_unit_id, date, fixed_shift_id, StaffLevel.PROFESSIONAL) + ctx.model.add(ctx.assignment_variables[key] == 1) + + solver = cp_model.CpSolver() + return solver.solve(ctx.model) + + +# --- Test Cases --- + + +@pytest.mark.integration +def test_master_scenario_is_perfectly_balanced() -> None: + status = _solve_master_scenario() + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_violation_availabilities() -> None: + status = _solve_master_scenario(forced_assignments=[(4, TEST_DATES[0], S_EARLY.shift_id)]) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_violation_free_day_after_night_shift() -> None: + """Bricht die Erholungsphase: E3 wird gezwungen, direkt + am Tag nach einer Nachtschicht in die Frühschicht zu wechseln.""" + status = _solve_master_scenario( + altered_demands={(TEST_DATES[4], S_EARLY.shift_id): 2}, # Fr Frühschicht braucht 2 Leute + altered_targets={3: 3360}, # E3 bekommt Budget für 7 Schichten + forced_assignments=[ + (3, TEST_DATES[3], S_NIGHT.shift_id), # Donnerstag: E3 macht Nacht + (3, TEST_DATES[4], S_EARLY.shift_id), # Freitag: E3 macht Früh -> VIOLATION! + ], + ) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_violation_hierarchy_of_intermediate_shifts() -> None: + status = _solve_master_scenario( + altered_demands={(TEST_DATES[0], S_INTER.shift_id): 0, (TEST_DATES[-1], S_INTER.shift_id): 1}, + altered_targets={5: 1920, 6: 960}, + ) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_violation_minimum_rest_time() -> None: + status = _solve_master_scenario(forced_assignments=[(2, TEST_DATES[2], S_EARLY.shift_id)]) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_violation_one_assignment_per_day() -> None: + status = _solve_master_scenario( + forced_assignments=[(1, TEST_DATES[0], S_EARLY.shift_id), (1, TEST_DATES[0], S_LATE.shift_id)] + ) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_violation_rounds_in_early_shift() -> None: + status = _solve_master_scenario( + forced_assignments=[(2, TEST_DATES[0], S_EARLY.shift_id), (1, TEST_DATES[0], S_LATE.shift_id)] + ) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_violation_target_working_time() -> None: + status = _solve_master_scenario(altered_targets={1: 1920}) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_violation_minimum_staffing() -> None: + status = _solve_master_scenario(altered_demands={(TEST_DATES[1], S_NIGHT.shift_id): 2}) + assert status == cp_model.INFEASIBLE diff --git a/tests/cp/constraints/test_availabilities_constraint.py b/tests/cp/constraints/test_availabilities_constraint.py new file mode 100644 index 00000000..8525ed3c --- /dev/null +++ b/tests/cp/constraints/test_availabilities_constraint.py @@ -0,0 +1,188 @@ +import datetime + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Availability, + AvailabilityType, + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.constraints.availabilities_constraint import AvailabilitiesConstraint +from scheduling.solver.cp_sat.context import create_context + +# --- Shared Test Entities --- + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=820, + net_work_minutes=460, +) + +NIGHT_SHIFT = Shift( + shift_id=2, + code="N", + type=ShiftType.NIGHT, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=1200, + end_minute=180, # 03:00 (crosses midnight) + net_work_minutes=420, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _solve_with_setup( + availabilities: list[Availability], + forced_assignments: list[tuple[datetime.date, int]], +) -> cp_model.CpSolverStatus: + dataset = SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT, NIGHT_SHIFT), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + availability=tuple(availabilities), + ) + + ctx = create_context(dataset=dataset) + + # 1. ISOLATION: Wir umgehen create_assignment_variables() und bauen + # stattdessen manuell eine vollständige (dense) Variablen-Matrix auf. + for day in range(1, 31): + d = datetime.date(2024, 11, day) + for shift in (EARLY_SHIFT, NIGHT_SHIFT): + # Der Key entspricht Ihrem System: (emp_id, unit_id, date, shift_id, staff_level) + key = (1, 1, d, shift.shift_id, StaffLevel.PROFESSIONAL) + var = ctx.model.new_bool_var(f"assign_{d:%Y%m%d}_{shift.shift_id}") + ctx.assignment_variables[key] = var + + # 2. Erzwingen der Test-Bedingungen (wir setzen die Ziel-Variablen auf 1) + for target_date, shift_id in forced_assignments: + key = (1, 1, target_date, shift_id, StaffLevel.PROFESSIONAL) + ctx.model.add(ctx.assignment_variables[key] == 1) + + # 3. Constraint anwenden (jetzt muss ER beweisen, dass er blockierte Schichten auf 0 zwingt) + AvailabilitiesConstraint().add_to_model(ctx, params={}) + + # 4. Solver starten + solver = cp_model.CpSolver() + return solver.solve(ctx.model) + + +# --- Test Cases --- + + +@pytest.mark.integration +def test_infeasible_when_assigned_on_fully_blocked_day() -> None: + target_date = datetime.date(2024, 11, 5) + availabilities = [ + Availability( + employee_id=1, + date=target_date, + availability_type=AvailabilityType.UNAVAILABLE, + ) + ] + + status = _solve_with_setup(availabilities, [(target_date, EARLY_SHIFT.shift_id)]) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_infeasible_when_assigned_to_unallowed_shift() -> None: + target_date = datetime.date(2024, 11, 5) + availabilities = [ + Availability( + employee_id=1, + date=target_date, + availability_type=AvailabilityType.AVAILABLE_ONLY, + shift_ids=(EARLY_SHIFT.shift_id,), + ) + ] + + status = _solve_with_setup(availabilities, [(target_date, NIGHT_SHIFT.shift_id)]) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_feasible_when_assigned_to_allowed_shift() -> None: + target_date = datetime.date(2024, 11, 5) + availabilities = [ + Availability( + employee_id=1, + date=target_date, + availability_type=AvailabilityType.AVAILABLE_ONLY, + shift_ids=(EARLY_SHIFT.shift_id,), + ) + ] + + status = _solve_with_setup(availabilities, [(target_date, EARLY_SHIFT.shift_id)]) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_infeasible_when_night_shift_spills_over_into_blocked_day() -> None: + night_shift_date = datetime.date(2024, 11, 5) + blocked_date = datetime.date(2024, 11, 6) + + availabilities = [ + Availability( + employee_id=1, + date=blocked_date, + availability_type=AvailabilityType.UNAVAILABLE, + ) + ] + + status = _solve_with_setup(availabilities, [(night_shift_date, NIGHT_SHIFT.shift_id)]) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_feasible_when_early_shift_precedes_blocked_day() -> None: + early_shift_date = datetime.date(2024, 11, 5) + blocked_date = datetime.date(2024, 11, 6) + + availabilities = [ + Availability( + employee_id=1, + date=blocked_date, + availability_type=AvailabilityType.UNAVAILABLE, + ) + ] + + status = _solve_with_setup(availabilities, [(early_shift_date, EARLY_SHIFT.shift_id)]) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) diff --git a/tests/cp/constraints/test_hierarchy_of_intermediate_shifts.py b/tests/cp/constraints/test_hierarchy_of_intermediate_shifts.py new file mode 100644 index 00000000..1a596fa8 --- /dev/null +++ b/tests/cp/constraints/test_hierarchy_of_intermediate_shifts.py @@ -0,0 +1,156 @@ +import datetime +from unittest.mock import patch + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.constraints.hierarchy_of_intermediate_shifts import HierarchyOfIntermediateShifts +from scheduling.solver.cp_sat.context import create_context + +# --- Shared Test Entities --- + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +INTERMEDIATE_TEST_SHIFT = Shift( + shift_id=1, + code="Z", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=540, + end_minute=1020, + net_work_minutes=450, +) + +EMPLOYEE_1 = Employee(employee_id=1, display_name="Alice", staff_level=StaffLevel.PROFESSIONAL) +EMPLOYEE_2 = Employee(employee_id=2, display_name="Bob", staff_level=StaffLevel.PROFESSIONAL) + +MEMBERSHIP_1 = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) +MEMBERSHIP_2 = PlanningUnitMembership( + planning_unit_id=1, + employee_id=2, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _solve_with_setup(daily_shift_counts: dict[datetime.date, int]) -> cp_model.CpSolverStatus: + """ + Erstellt eine isolierte Variablen-Matrix für die ISO-Woche 45 (04.11.24 - 10.11.24) + und forciert exakt so viele Schichten pro Tag, wie in `daily_shift_counts` definiert. + Restliche Variablen werden hart auf 0 gesetzt. + """ + dataset = SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(INTERMEDIATE_TEST_SHIFT,), + employees=(EMPLOYEE_1, EMPLOYEE_2), + planning_unit_memberships=(MEMBERSHIP_1, MEMBERSHIP_2), + ) + + ctx = create_context(dataset=dataset) + test_dates = [datetime.date(2024, 11, day) for day in range(4, 11)] + + for d in test_dates: + for emp in [EMPLOYEE_1, EMPLOYEE_2]: + key = ( + emp.employee_id, + PLANNING_UNIT.planning_unit_id, + d, + INTERMEDIATE_TEST_SHIFT.shift_id, + StaffLevel.PROFESSIONAL, + ) + ctx.assignment_variables[key] = ctx.model.new_bool_var(f"assign_{emp.employee_id}_{d:%Y%m%d}") + + for d in test_dates: + target_count = daily_shift_counts.get(d, 0) + keys_for_day = [ + ( + emp.employee_id, + PLANNING_UNIT.planning_unit_id, + d, + INTERMEDIATE_TEST_SHIFT.shift_id, + StaffLevel.PROFESSIONAL, + ) + for emp in [EMPLOYEE_1, EMPLOYEE_2] + ] + + for i, key in enumerate(keys_for_day): + if i < target_count: + ctx.model.add(ctx.assignment_variables[key] == 1) + else: + ctx.model.add(ctx.assignment_variables[key] == 0) + + mock_target = "scheduling.solver.cp_sat.constraints.hierarchy_of_intermediate_shifts.is_intermediate_shift" + with patch(mock_target, return_value=True): + HierarchyOfIntermediateShifts().add_to_model(ctx, params={}) + + solver = cp_model.CpSolver() + return solver.solve(ctx.model) + + +# --- Test Cases --- + + +@pytest.mark.integration +def test_feasible_weekdays_level_one() -> None: + counts = {datetime.date(2024, 11, 4 + i): (1 if i < 5 else 0) for i in range(7)} + assert _solve_with_setup(counts) in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_infeasible_weekend_without_weekdays() -> None: + counts = {datetime.date(2024, 11, 4 + i): 1 for i in range(7)} + counts[datetime.date(2024, 11, 8)] = 0 + counts[datetime.date(2024, 11, 10)] = 0 + + assert _solve_with_setup(counts) == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_infeasible_uneven_weekdays() -> None: + counts = {datetime.date(2024, 11, 4 + i): 0 for i in range(7)} + counts[datetime.date(2024, 11, 4)] = 2 + + assert _solve_with_setup(counts) == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_feasible_weekend_overflow() -> None: + counts = {datetime.date(2024, 11, 4 + i): (2 if i < 5 else 1) for i in range(7)} + + assert _solve_with_setup(counts) in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_infeasible_weekday_surpasses_weekend_by_two() -> None: + counts = {datetime.date(2024, 11, 4 + i): (2 if i < 5 else 0) for i in range(7)} + + assert _solve_with_setup(counts) == cp_model.INFEASIBLE diff --git a/tests/cp/constraints/test_min_rest_time.py b/tests/cp/constraints/test_min_rest_time.py new file mode 100644 index 00000000..f8abd44d --- /dev/null +++ b/tests/cp/constraints/test_min_rest_time.py @@ -0,0 +1,161 @@ +import datetime + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.constraints.min_rest_time import MinimumRestTime +from scheduling.solver.cp_sat.context import create_context + +# --- Shared Test Entities --- + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, # 06:00 + end_minute=840, # 14:00 + net_work_minutes=450, +) + +LATE_SHIFT = Shift( + shift_id=2, + code="S", + type=ShiftType.LATE, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=810, # 13:30 + end_minute=1290, # 21:30 + net_work_minutes=450, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _solve_with_setup(forced_assignments: list[tuple[datetime.date, int]]) -> cp_model.CpSolverStatus: + """ + Konstruiert das Modell über einen isolierten Zeitraum und evaluiert die Zulässigkeit + spezifischer Schichtübergänge. Iteriert als 'Dense Matrix', um Pruning-Effekte auszuschließen. + """ + dataset = SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT, LATE_SHIFT), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + ) + + ctx = create_context(dataset=dataset) + test_dates = [datetime.date(2024, 11, day) for day in range(1, 10)] + + # 1. Isolation: Dichte Variablenmatrix aufbauen + for d in test_dates: + for shift in (EARLY_SHIFT, LATE_SHIFT): + key = (EMPLOYEE.employee_id, PLANNING_UNIT.planning_unit_id, d, shift.shift_id, StaffLevel.PROFESSIONAL) + ctx.assignment_variables[key] = ctx.model.new_bool_var(f"assign_{d:%Y%m%d}_{shift.shift_id}") + + # 2. Booleschen Zustandsraum deterministisch beschränken (1=Forciert, 0=Verboten) + for d in test_dates: + for shift in (EARLY_SHIFT, LATE_SHIFT): + key = (EMPLOYEE.employee_id, PLANNING_UNIT.planning_unit_id, d, shift.shift_id, StaffLevel.PROFESSIONAL) + if (d, shift.shift_id) in forced_assignments: + ctx.model.add(ctx.assignment_variables[key] == 1) + else: + ctx.model.add(ctx.assignment_variables[key] == 0) + + # 3. Constraint injizieren + MinimumRestTime().add_to_model(ctx, params={}) + + # 4. Solver evaluieren + solver = cp_model.CpSolver() + return solver.solve(ctx.model) + + +# --- Test Cases --- + + +@pytest.mark.integration +def test_infeasible_late_followed_by_early() -> None: + """Prüft die Verletzung der minimalen Ruhezeit (Spätschicht -> Frühschicht am Folgetag).""" + t_0 = datetime.date(2024, 11, 5) + t_1 = datetime.date(2024, 11, 6) + + status = _solve_with_setup([(t_0, LATE_SHIFT.shift_id), (t_1, EARLY_SHIFT.shift_id)]) + + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_feasible_early_followed_by_late() -> None: + """Prüft die Zulässigkeit des inversen Übergangs (Frühschicht -> Spätschicht am Folgetag).""" + t_0 = datetime.date(2024, 11, 5) + t_1 = datetime.date(2024, 11, 6) + + status = _solve_with_setup([(t_0, EARLY_SHIFT.shift_id), (t_1, LATE_SHIFT.shift_id)]) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_feasible_late_followed_by_late() -> None: + """Prüft die Zulässigkeit homogener Schichtblöcke (Spätschicht -> Spätschicht).""" + t_0 = datetime.date(2024, 11, 5) + t_1 = datetime.date(2024, 11, 6) + + status = _solve_with_setup([(t_0, LATE_SHIFT.shift_id), (t_1, LATE_SHIFT.shift_id)]) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_feasible_early_followed_by_early() -> None: + """Prüft die Zulässigkeit homogener Schichtblöcke (Frühschicht -> Frühschicht).""" + t_0 = datetime.date(2024, 11, 5) + t_1 = datetime.date(2024, 11, 6) + + status = _solve_with_setup([(t_0, EARLY_SHIFT.shift_id), (t_1, EARLY_SHIFT.shift_id)]) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_feasible_late_followed_by_free_followed_by_early() -> None: + """Prüft die topologische Unterbrechung durch einen freien Tag ($t+1$).""" + t_0 = datetime.date(2024, 11, 5) + t_2 = datetime.date(2024, 11, 7) # Überspringt den 6. November + + status = _solve_with_setup([(t_0, LATE_SHIFT.shift_id), (t_2, EARLY_SHIFT.shift_id)]) + + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) diff --git a/tests/cp/constraints/test_min_staffing_and_one_assignment_per_day.py b/tests/cp/constraints/test_min_staffing_and_one_assignment_per_day.py new file mode 100644 index 00000000..cbd81880 --- /dev/null +++ b/tests/cp/constraints/test_min_staffing_and_one_assignment_per_day.py @@ -0,0 +1,229 @@ +import datetime +from unittest.mock import PropertyMock, patch + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.constraints.minimum_staffing import MinimumStaffing +from scheduling.solver.cp_sat.constraints.one_assignment_per_day import OneAssignmentPerDay +from scheduling.solver.cp_sat.context import create_context + +# --- Shared Test Entities --- + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=840, + net_work_minutes=450, +) +LATE_SHIFT = Shift( + shift_id=2, + code="S", + type=ShiftType.LATE, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=810, + end_minute=1290, + net_work_minutes=450, +) +NIGHT_SHIFT = Shift( + shift_id=3, + code="N", + type=ShiftType.NIGHT, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=1200, + end_minute=180, + net_work_minutes=420, +) + +SHIFTS = (EARLY_SHIFT, LATE_SHIFT, NIGHT_SHIFT) + +EMPLOYEES = ( + Employee(employee_id=1, display_name="Alice", staff_level=StaffLevel.PROFESSIONAL), + Employee(employee_id=2, display_name="Bob", staff_level=StaffLevel.PROFESSIONAL), + Employee(employee_id=3, display_name="Charlie", staff_level=StaffLevel.PROFESSIONAL), + Employee(employee_id=4, display_name="Diana", staff_level=StaffLevel.PROFESSIONAL), +) + +MEMBERSHIPS = tuple( + PlanningUnitMembership( + planning_unit_id=1, + employee_id=emp.employee_id, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, + ) + for emp in EMPLOYEES +) + + +def _solve_with_setup( + demands_by_shift_id: dict[int, int], + available_employee_ids: list[int], +) -> cp_model.CpSolverStatus: + """ + Konstruiert das Modell für 4 Mitarbeiter und 3 Schichten und evaluiert + die kombinatorische Schnittmenge aus Bedarfsdeckung und der "Eine-Schicht-pro-Tag"-Restriktion. + """ + target_date = datetime.date(2024, 11, 5) + + dataset = SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=SHIFTS, + employees=EMPLOYEES, + planning_unit_memberships=MEMBERSHIPS, + ) + + ctx = create_context(dataset=dataset) + + # 1. Isolation: Dichte Variablenmatrix (Dense Matrix: 4 Employees * 3 Shifts = 12 boolean vars) + for emp in EMPLOYEES: + for shift in SHIFTS: + key = ( + emp.employee_id, + PLANNING_UNIT.planning_unit_id, + target_date, + shift.shift_id, + StaffLevel.PROFESSIONAL, + ) + ctx.assignment_variables[key] = ctx.model.new_bool_var(f"assign_{emp.employee_id}_{shift.shift_id}") + + # 2. Hard-Constraints für Verfügbarkeit (Domänen-Reduktion) + for emp in EMPLOYEES: + if emp.employee_id not in available_employee_ids: + for shift in SHIFTS: + key = ( + emp.employee_id, + PLANNING_UNIT.planning_unit_id, + target_date, + shift.shift_id, + StaffLevel.PROFESSIONAL, + ) + ctx.model.add(ctx.assignment_variables[key] == 0) + + # 3. Dynamisches Mocking des Index-Lookups für den Bedarf + injected_demand = {} + for shift_id, required_count in demands_by_shift_id.items(): + demand_key = (PLANNING_UNIT.planning_unit_id, target_date, shift_id, StaffLevel.PROFESSIONAL) + injected_demand[demand_key] = required_count + + with patch.object(type(ctx.index), "required_count_by_demand_key", new_callable=PropertyMock) as mock_demand: + mock_demand.return_value = injected_demand + + # Constraints injizieren + MinimumStaffing().add_to_model(ctx, params={}) + OneAssignmentPerDay().add_to_model(ctx, params={}) + + solver = cp_model.CpSolver() + return solver.solve(ctx.model) + + +# --- Test Cases --- + + +@pytest.mark.integration +def test_feasible_exact_global_match() -> None: + """Zulässig: Exakte Zuweisung (1 Früh, 1 Spät, 2 Nacht) verteilt auf 4 verfügbare Mitarbeiter.""" + status = _solve_with_setup( + demands_by_shift_id={EARLY_SHIFT.shift_id: 1, LATE_SHIFT.shift_id: 1, NIGHT_SHIFT.shift_id: 2}, + available_employee_ids=[1, 2, 3, 4], + ) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_infeasible_global_pigeonhole() -> None: + """Unzulässig (Schubfachprinzip): Der kumulierte + Tagesbedarf (5) übersteigt die verfügbaren Mitarbeiter (4).""" + status = _solve_with_setup( + demands_by_shift_id={EARLY_SHIFT.shift_id: 2, LATE_SHIFT.shift_id: 2, NIGHT_SHIFT.shift_id: 1}, + available_employee_ids=[1, 2, 3, 4], + ) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_infeasible_local_shift_overdemand() -> None: + """Unzulässig: Ausreichend Gesamtpersonal (4), + aber eine einzelne Schicht fordert mehr Personal (5).""" + status = _solve_with_setup( + demands_by_shift_id={EARLY_SHIFT.shift_id: 5, LATE_SHIFT.shift_id: 0, NIGHT_SHIFT.shift_id: 0}, + available_employee_ids=[1, 2, 3, 4], + ) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_feasible_heavy_clustering_on_one_shift() -> None: + """Zulässig: Der gesamte verfügbare Pool (4) wird in eine einzige Schicht geclustert.""" + status = _solve_with_setup( + demands_by_shift_id={EARLY_SHIFT.shift_id: 0, LATE_SHIFT.shift_id: 4, NIGHT_SHIFT.shift_id: 0}, + available_employee_ids=[1, 2, 3, 4], + ) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_infeasible_incremental_starvation() -> None: + """Unzulässig: Geringer Bedarf pro Schicht (jeweils 1), + aber in Summe (3) fehlt ein Mitarbeiter (nur 2 verfügbar).""" + status = _solve_with_setup( + demands_by_shift_id={EARLY_SHIFT.shift_id: 1, LATE_SHIFT.shift_id: 1, NIGHT_SHIFT.shift_id: 1}, + available_employee_ids=[1, 2], # Nur Alice und Bob + ) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_feasible_asymmetric_overstaffing_potential() -> None: + """Zulässig: Geringer Bedarf (1), aber maximaler Pool (4). Beweist, dass MinimumStaffing (>=) + nicht künstlich nach oben limitiert, solange OneAssignmentPerDay (<=1) eingehalten wird.""" + status = _solve_with_setup(demands_by_shift_id={EARLY_SHIFT.shift_id: 1}, available_employee_ids=[1, 2, 3, 4]) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_feasible_zero_demand_full_availability() -> None: + """Zulässig (Leermenge): Keine Anforderungen, voller Pool. Solver weist allen 0 Schichten zu.""" + status = _solve_with_setup(demands_by_shift_id={}, available_employee_ids=[1, 2, 3, 4]) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_feasible_zero_demand_zero_availability() -> None: + """Zulässig (Vakuum): Weder Bedarf noch Personal vorhanden.""" + status = _solve_with_setup(demands_by_shift_id={}, available_employee_ids=[]) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_infeasible_minimal_gap() -> None: + """Unzulässig: Bedarf exakt 1 höher (3) als verfügbares Personal (2), verteilt auf 2 Schichten.""" + status = _solve_with_setup( + demands_by_shift_id={EARLY_SHIFT.shift_id: 2, LATE_SHIFT.shift_id: 1}, available_employee_ids=[1, 2] + ) + assert status == cp_model.INFEASIBLE diff --git a/tests/cp/constraints/test_minimum_staffing.py b/tests/cp/constraints/test_minimum_staffing.py new file mode 100644 index 00000000..dd4a525c --- /dev/null +++ b/tests/cp/constraints/test_minimum_staffing.py @@ -0,0 +1,163 @@ +import datetime +from unittest.mock import PropertyMock, patch + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.constraints.minimum_staffing import MinimumStaffing +from scheduling.solver.cp_sat.context import create_context + +# --- Shared Test Entities --- + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=840, + net_work_minutes=450, +) + +EMPLOYEE_1 = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +EMPLOYEE_2 = Employee( + employee_id=2, + display_name="Bob", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP_1 = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + +MEMBERSHIP_2 = PlanningUnitMembership( + planning_unit_id=1, + employee_id=2, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _solve_with_setup(required_count: int, assigned_employee_ids: list[int]) -> cp_model.CpSolverStatus: + """ + Konstruiert das Modell für einen isolierten Tag, injiziert eine definierte Demand-Anforderung + mittels Mocking in den Index und forciert Zuweisungen. + """ + target_date = datetime.date(2024, 11, 5) + + dataset = SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT,), + employees=(EMPLOYEE_1, EMPLOYEE_2), + planning_unit_memberships=(MEMBERSHIP_1, MEMBERSHIP_2), + ) + + ctx = create_context(dataset=dataset) + + # 1. Isolation: Dichte Variablenmatrix für den Ziel-Tag aufbauen + for emp in (EMPLOYEE_1, EMPLOYEE_2): + key = ( + emp.employee_id, + PLANNING_UNIT.planning_unit_id, + target_date, + EARLY_SHIFT.shift_id, + StaffLevel.PROFESSIONAL, + ) + ctx.assignment_variables[key] = ctx.model.new_bool_var(f"assign_{emp.employee_id}") + + # 2. Booleschen Zustandsraum deterministisch beschränken (1=Forciert, 0=Verboten) + for emp in (EMPLOYEE_1, EMPLOYEE_2): + key = ( + emp.employee_id, + PLANNING_UNIT.planning_unit_id, + target_date, + EARLY_SHIFT.shift_id, + StaffLevel.PROFESSIONAL, + ) + if emp.employee_id in assigned_employee_ids: + ctx.model.add(ctx.assignment_variables[key] == 1) + else: + ctx.model.add(ctx.assignment_variables[key] == 0) + + # 3. Constraint injizieren und schreibgeschützten Index mocken + demand_key = (PLANNING_UNIT.planning_unit_id, target_date, EARLY_SHIFT.shift_id, StaffLevel.PROFESSIONAL) + + with patch.object(type(ctx.index), "required_count_by_demand_key", new_callable=PropertyMock) as mock_demand: + mock_demand.return_value = {demand_key: required_count} + MinimumStaffing().add_to_model(ctx, params={}) + + # 4. Solver evaluieren + solver = cp_model.CpSolver() + return solver.solve(ctx.model) + + +# --- Test Cases --- + + +@pytest.mark.integration +def test_infeasible_when_understaffed() -> None: + """Prüft, ob der Constraint anschlägt, wenn weniger Personal als benötigt zugeteilt ist.""" + status = _solve_with_setup(required_count=2, assigned_employee_ids=[1]) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_feasible_when_exactly_staffed() -> None: + """Prüft, ob der Constraint exakte Bedarfsabdeckung zulässt.""" + status = _solve_with_setup(required_count=2, assigned_employee_ids=[1, 2]) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_feasible_when_overstaffed() -> None: + """Prüft, ob der Constraint Überbesetzung zulässt.""" + status = _solve_with_setup(required_count=1, assigned_employee_ids=[1, 2]) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_infeasible_when_no_staff_assigned() -> None: + """Prüft die Verletzung, wenn der Bedarf > 0 ist, aber niemand arbeitet.""" + status = _solve_with_setup(required_count=1, assigned_employee_ids=[]) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_feasible_when_zero_demand_and_no_staff() -> None: + """Prüft den Randfall: 0 Bedarf und 0 Personal ist zulässig.""" + status = _solve_with_setup(required_count=0, assigned_employee_ids=[]) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) diff --git a/tests/cp/constraints/test_rounds_in_early_shift.py b/tests/cp/constraints/test_rounds_in_early_shift.py new file mode 100644 index 00000000..37c6bf48 --- /dev/null +++ b/tests/cp/constraints/test_rounds_in_early_shift.py @@ -0,0 +1,183 @@ +import datetime + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Capability, + Employee, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.constraints.rounds_in_early_shift import RoundsInEarlyShift +from scheduling.solver.cp_sat.context import create_context + +# --- Shared Test Entities --- + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +EARLY_SHIFT = Shift( + shift_id=1, + code="F", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=360, + end_minute=840, + net_work_minutes=450, +) + +LATE_SHIFT = Shift( + shift_id=2, + code="S", + type=ShiftType.LATE, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=810, + end_minute=1290, + net_work_minutes=450, +) + +EMPLOYEE_WITH_ROUNDS = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, + capabilities=(Capability.ROUNDS,), +) + +EMPLOYEE_WITHOUT_ROUNDS = Employee( + employee_id=2, + display_name="Bob", + staff_level=StaffLevel.PROFESSIONAL, + capabilities=(), +) + +MEMBERSHIP_1 = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + +MEMBERSHIP_2 = PlanningUnitMembership( + planning_unit_id=1, + employee_id=2, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _solve_with_setup( + target_date: datetime.date, + forced_assignments: list[tuple[int, int]], +) -> cp_model.CpSolverStatus: + """ + Konstruiert das Modell für einen isolierten Tag und forciert deterministisch Zuweisungen. + Nutzt eine dichte Variablenmatrix, um die rein mathematische Logik des Constraints zu beweisen. + """ + dataset = SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(EARLY_SHIFT, LATE_SHIFT), + employees=(EMPLOYEE_WITH_ROUNDS, EMPLOYEE_WITHOUT_ROUNDS), + planning_unit_memberships=(MEMBERSHIP_1, MEMBERSHIP_2), + ) + + ctx = create_context(dataset=dataset) + + # 1. Isolation: Dichte Variablenmatrix für den Ziel-Tag aufbauen + for emp in (EMPLOYEE_WITH_ROUNDS, EMPLOYEE_WITHOUT_ROUNDS): + for shift in (EARLY_SHIFT, LATE_SHIFT): + key = ( + emp.employee_id, + PLANNING_UNIT.planning_unit_id, + target_date, + shift.shift_id, + StaffLevel.PROFESSIONAL, + ) + ctx.assignment_variables[key] = ctx.model.new_bool_var(f"assign_{emp.employee_id}_{shift.shift_id}") + + # 2. Booleschen Zustandsraum deterministisch beschränken (1=Forciert, 0=Verboten) + for emp in (EMPLOYEE_WITH_ROUNDS, EMPLOYEE_WITHOUT_ROUNDS): + for shift in (EARLY_SHIFT, LATE_SHIFT): + key = ( + emp.employee_id, + PLANNING_UNIT.planning_unit_id, + target_date, + shift.shift_id, + StaffLevel.PROFESSIONAL, + ) + if (emp.employee_id, shift.shift_id) in forced_assignments: + ctx.model.add(ctx.assignment_variables[key] == 1) + else: + ctx.model.add(ctx.assignment_variables[key] == 0) + + # 3. Constraint injizieren + RoundsInEarlyShift().add_to_model(ctx, params={}) + + # 4. Solver evaluieren + solver = cp_model.CpSolver() + return solver.solve(ctx.model) + + +# --- Test Cases --- + + +@pytest.mark.integration +def test_feasible_weekday_when_qualified_employee_in_early_shift() -> None: + """Beweist die Zulässigkeit, wenn eine qualifizierte Person an einem Wochentag die Frühschicht besetzt.""" + weekday = datetime.date(2024, 11, 5) # Dienstag + status = _solve_with_setup( + target_date=weekday, forced_assignments=[(EMPLOYEE_WITH_ROUNDS.employee_id, EARLY_SHIFT.shift_id)] + ) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_infeasible_weekday_when_no_qualified_employee_in_early_shift() -> None: + """Beweist die Verletzung, wenn an einem Wochentag in der Frühschicht die Visiten-Qualifikation fehlt.""" + weekday = datetime.date(2024, 11, 5) # Dienstag + status = _solve_with_setup( + target_date=weekday, forced_assignments=[(EMPLOYEE_WITHOUT_ROUNDS.employee_id, EARLY_SHIFT.shift_id)] + ) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_infeasible_weekday_when_qualified_employee_in_late_shift() -> None: + """Beweist, dass die Qualifikation explizit in der Frühschicht (nicht Spät) vorliegen muss.""" + weekday = datetime.date(2024, 11, 5) # Dienstag + status = _solve_with_setup( + target_date=weekday, + forced_assignments=[ + (EMPLOYEE_WITHOUT_ROUNDS.employee_id, EARLY_SHIFT.shift_id), + (EMPLOYEE_WITH_ROUNDS.employee_id, LATE_SHIFT.shift_id), + ], + ) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_feasible_weekend_when_no_qualified_employee_assigned() -> None: + """Beweist die topologische Ausnahme: Wochenenden erfordern keine Visiten-Qualifikation.""" + weekend = datetime.date(2024, 11, 9) # Samstag + status = _solve_with_setup( + target_date=weekend, forced_assignments=[(EMPLOYEE_WITHOUT_ROUNDS.employee_id, EARLY_SHIFT.shift_id)] + ) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) diff --git a/tests/cp/constraints/test_target_working_time.py b/tests/cp/constraints/test_target_working_time.py new file mode 100644 index 00000000..bc4a258c --- /dev/null +++ b/tests/cp/constraints/test_target_working_time.py @@ -0,0 +1,168 @@ +import datetime +from unittest.mock import MagicMock, patch + +import pytest +from ortools.sat.python import cp_model + +from scheduling.domain import ( + Employee, + MonthlyWorkAccount, + PlanningMonth, + PlanningUnit, + PlanningUnitMembership, + PlanningUnitType, + SchedulingDataset, + Shift, + ShiftType, + StaffingDemandRole, + StaffLevel, +) +from scheduling.solver.cp_sat.constraints.target_working_time import TargetWorkingTime +from scheduling.solver.cp_sat.context import create_context + +# --- Shared Test Entities --- + +PLANNING_UNIT = PlanningUnit( + planning_unit_id=1, + display_name="Station 1", + type=PlanningUnitType.STATION, +) + +# A standard 8-hour shift. +# Note: The constraint calculates duration algebraically as (end_minute - start_minute). +STANDARD_SHIFT = Shift( + shift_id=1, + code="D", + type=ShiftType.EARLY, + staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, + start_minute=480, # 08:00 + end_minute=960, # 16:00 + net_work_minutes=480, +) + +EMPLOYEE = Employee( + employee_id=1, + display_name="Alice", + staff_level=StaffLevel.PROFESSIONAL, +) + +MEMBERSHIP = PlanningUnitMembership( + planning_unit_id=1, + employee_id=1, + valid_from=datetime.date(2024, 11, 1), + valid_until=datetime.date(2024, 11, 30), + staff_level=StaffLevel.PROFESSIONAL, + is_home=True, + is_replacement=False, +) + + +def _solve_with_setup( + target_minutes: int, + actual_minutes: int, + assigned_shift_count: int, + tolerance_less: int = 120, + tolerance_more: int = 120, +) -> cp_model.CpSolverStatus: + """ + Constructs the model and evaluates the algebraic bounds of the TargetWorkingTime constraint. + Iterates as a 'Dense Matrix' to prevent heuristic pruning from circumventing the mathematical proof. + """ + account = MonthlyWorkAccount( + employee_id=EMPLOYEE.employee_id, + target_minutes=target_minutes, + actual_minutes=actual_minutes, + ) + + dataset = SchedulingDataset( + planning_month=PlanningMonth(year=2024, month=11), + planning_units=(PLANNING_UNIT,), + plans=(), + shifts=(STANDARD_SHIFT,), + employees=(EMPLOYEE,), + planning_unit_memberships=(MEMBERSHIP,), + monthly_work_accounts=(account,), + ) + + ctx = create_context(dataset=dataset) + test_dates = [datetime.date(2024, 11, day) for day in range(1, 6)] # 5 potential assignment days + + # 1. Isolation: Generate a dense boolean variable matrix + keys_in_order: list[tuple[int, int, datetime.date, int, StaffLevel]] = [] + for d in test_dates: + key = ( + EMPLOYEE.employee_id, + PLANNING_UNIT.planning_unit_id, + d, + STANDARD_SHIFT.shift_id, + StaffLevel.PROFESSIONAL, + ) + keys_in_order.append(key) + ctx.assignment_variables[key] = ctx.model.new_bool_var(f"assign_{d:%Y%m%d}") + + # 2. Deterministically constrain the boolean state space + for i, key in enumerate(keys_in_order): + if i < assigned_shift_count: + ctx.model.add(ctx.assignment_variables[key] == 1) + else: + ctx.model.add(ctx.assignment_variables[key] == 0) + + # 3. Inject Constraint & Mock TimeOffice Facts + mock_facts = MagicMock() + mock_facts.target_working_time_tolerance_less = tolerance_less + mock_facts.target_working_time_tolerance_more = tolerance_more + + mock_target = "scheduling.solver.cp_sat.constraints.target_working_time.facts.TIMEOFFICE_FACTS" + with patch(mock_target, mock_facts): + TargetWorkingTime().add_to_model(ctx, params={}) + + # 4. Evaluate Solver + solver = cp_model.CpSolver() + return solver.solve(ctx.model) + + +# --- Test Cases --- + + +@pytest.mark.integration +def test_feasible_exact_target_match() -> None: + """Proves feasibility when the assigned sum perfectly matches the net target.""" + # Target: 960, Actual: 0 -> Net Target: 960 (Requires exactly 2 shifts of 480m) + status = _solve_with_setup(target_minutes=960, actual_minutes=0, assigned_shift_count=2) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_infeasible_below_lower_tolerance() -> None: + """Proves infeasibility when assignments fall below the strict lower boundary.""" + # Target: 960, Actual: 0, Assigned: 1 shift (480m). Boundary is [960 - 120, 960 + 120] = [840, 1080]. + # 480 < 840. + status = _solve_with_setup(target_minutes=960, actual_minutes=0, assigned_shift_count=1) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_infeasible_above_upper_tolerance() -> None: + """Proves infeasibility when assignments exceed the strict upper boundary.""" + # Target: 960, Actual: 0, Assigned: 3 shifts (1440m). Boundary is [840, 1080]. + # 1440 > 1080. + status = _solve_with_setup(target_minutes=960, actual_minutes=0, assigned_shift_count=3) + assert status == cp_model.INFEASIBLE + + +@pytest.mark.integration +def test_feasible_within_lower_tolerance() -> None: + """Proves feasibility when the assigned sum sits strictly inside the lower tolerance bound.""" + # Target: 1000, Actual: 0. Range: [880, 1120]. + # 2 shifts = 960m. 880 <= 960 <= 1120. + status = _solve_with_setup(target_minutes=1000, actual_minutes=0, assigned_shift_count=2) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) + + +@pytest.mark.integration +def test_feasible_with_preexisting_actual_minutes() -> None: + """Proves the algebraic delta calculation (target - actual) functions correctly.""" + # Target: 1440, Actual: 480 -> Net Target: 960. Range: [840, 1080]. + # 2 assigned shifts = 960m. + status = _solve_with_setup(target_minutes=1440, actual_minutes=480, assigned_shift_count=2) + assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) From 0ce376f1b336f2c1d5b79ca01c27feecec3cb802 Mon Sep 17 00:00:00 2001 From: Tom Wawerek Date: Sun, 19 Jul 2026 14:42:23 +0200 Subject: [PATCH 16/17] chore: fix lint errors --- src/scheduling/api/web/minimal_staff_router.py | 2 -- src/scheduling/api/web/weights_router.py | 2 +- src/scheduling/timeoffice/facts.py | 11 +++-------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/scheduling/api/web/minimal_staff_router.py b/src/scheduling/api/web/minimal_staff_router.py index 89659916..2d92aab2 100644 --- a/src/scheduling/api/web/minimal_staff_router.py +++ b/src/scheduling/api/web/minimal_staff_router.py @@ -1,6 +1,4 @@ import logging -from datetime import date -from typing import Annotated, Any from datetime import date, timedelta from typing import Annotated diff --git a/src/scheduling/api/web/weights_router.py b/src/scheduling/api/web/weights_router.py index f2ae051d..fc586ab0 100644 --- a/src/scheduling/api/web/weights_router.py +++ b/src/scheduling/api/web/weights_router.py @@ -1,6 +1,6 @@ import logging from datetime import date -from typing import Annotated, Any +from typing import Annotated from fastapi import APIRouter, Depends diff --git a/src/scheduling/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index d17981f8..1b0d3b81 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -152,14 +152,9 @@ class TimeOfficeFacts: 1698: NIGHT_SHIFT_ID, # N5 2866: NIGHT_SHIFT_ID, # N5 # Day/intermediate variant normalized to canonical T75_ intermediate shift. - 2994: INTERMEDIATE_T75_SHIFT_ID, # T8x - 1234: INTERMEDIATE_T75_SHIFT_ID, - 1356: INTERMEDIATE_T75_SHIFT_ID, - # Short/day special variants normalized to canonical Z60 non-minimum work shift. - 2957: MANAGEMENT_Z60_SHIFT_ID, # Z52 - 2687: MANAGEMENT_Z60_SHIFT_ID, # Z52 - 1403: MANAGEMENT_Z60_SHIFT_ID, # Z52 - 3066: MANAGEMENT_Z60_SHIFT_ID, # Z52 + 2994: INTERMEDIATE_SHIFT_ID, # T8x + 1234: INTERMEDIATE_SHIFT_ID, + 1356: INTERMEDIATE_SHIFT_ID, } ) From 39dac62154ec976876148dcedd5b4aeee4067a5d Mon Sep 17 00:00:00 2001 From: Tom Wawerek Date: Sun, 19 Jul 2026 15:28:40 +0200 Subject: [PATCH 17/17] chore: fix lint errors --- src/scheduling/solver/config.py | 5 - src/scheduling/solver/cp_sat/builder.py | 2 - .../objectives/every_second_weekend_free.py | 145 +++++++----- .../cp_sat/objectives/fair_preferences.py | 187 ++++++++++++--- .../free_day_after_night_shift_phase.py | 199 +++++++++++----- .../objectives/free_days_near_weekend.py | 199 +++++++++++----- .../minimize_consecutive_night_shifts.py | 158 ++++++++----- .../objectives/prefer_own_planning_unit.py | 71 ------ .../cp_sat/objectives/rotate_shits_foward.py | 217 ++++++++++++++---- .../test_prefer_own_planning_unit.py | 199 ---------------- 10 files changed, 813 insertions(+), 569 deletions(-) delete mode 100644 src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py delete mode 100644 tests/cp/objectives/test_prefer_own_planning_unit.py diff --git a/src/scheduling/solver/config.py b/src/scheduling/solver/config.py index d87efd57..093fe867 100644 --- a/src/scheduling/solver/config.py +++ b/src/scheduling/solver/config.py @@ -17,7 +17,6 @@ from scheduling.solver.cp_sat.objectives.minimize_consecutive_night_shifts import MinimizeConsecutiveNightShifts from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays -from scheduling.solver.cp_sat.objectives.prefer_own_planning_unit import PreferOwnPlanningUnit from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( @@ -78,10 +77,6 @@ def create_base_solver_config() -> SolverConfig: enabled=True, weight=1, ), - PreferOwnPlanningUnit.id: ObjectiveConfig( - enabled=True, - weight=1, - ), EverySecondWeekendFree.id: ObjectiveConfig(enabled=True, weight=1), FairPreferencesObjective.id: ObjectiveConfig( enabled=True, diff --git a/src/scheduling/solver/cp_sat/builder.py b/src/scheduling/solver/cp_sat/builder.py index 5aefe288..fba1959a 100644 --- a/src/scheduling/solver/cp_sat/builder.py +++ b/src/scheduling/solver/cp_sat/builder.py @@ -21,7 +21,6 @@ from scheduling.solver.cp_sat.objectives.minimize_consecutive_night_shifts import MinimizeConsecutiveNightShifts from scheduling.solver.cp_sat.objectives.minimize_overtime import MinimizeOvertime from scheduling.solver.cp_sat.objectives.not_too_many_consecutive_days import NotTooManyConsecutiveDays -from scheduling.solver.cp_sat.objectives.prefer_own_planning_unit import PreferOwnPlanningUnit from scheduling.solver.cp_sat.objectives.preferred_block_length import PreferredBlockLength from scheduling.solver.cp_sat.objectives.rotate_shits_foward import RotateShiftsForward from scheduling.solver.cp_sat.objectives.temporary_balance_generated_assignments import ( @@ -45,7 +44,6 @@ NotTooManyConsecutiveDays(), PreferredBlockLength(), RotateShiftsForward(), - PreferOwnPlanningUnit(), EverySecondWeekendFree(), FairPreferencesObjective(), FreeDaysAfterNightShiftPhase(), diff --git a/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py b/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py index 10c9ef37..67dbfe62 100644 --- a/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py +++ b/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py @@ -12,80 +12,121 @@ class EverySecondWeekendFree: """ - Penalizes consecutive weekends with the same status (both worked or both free). - Encourages alternating free weekends. A weekend is only free if both Saturday - and Sunday are unassigned. + Penalize consecutive weekends with the same status. + + A weekend is considered free only when the employee is not assigned on + either Saturday or Sunday. Alternating worked and free weekends is therefore + preferred. """ id: ClassVar[str] = "every_second_weekend_free" - def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[Penalty, ...]: if not ctx.assignment_variables: return () - # Collect all complete Saturday-Sunday pairs within the planning month - weekends: list[tuple[date, date]] = [] - current = ctx.dataset.planning_month.start - while current <= ctx.dataset.planning_month.end: - if current.isoweekday() == 6: - sunday = current + timedelta(days=1) - if sunday <= ctx.dataset.planning_month.end: - weekends.append((current, sunday)) - current += timedelta(days=1) - + weekends = self._complete_weekends(ctx) if len(weekends) < 2: return () - vars_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) - for (employee_id, _unit, d, _shift, _level), var in ctx.assignment_variables.items(): - vars_by_employee_date[(employee_id, d)].append(var) + assignment_variables_by_employee_and_date: defaultdict[ + tuple[int, date], + list[cp_model.IntVar], + ] = defaultdict(list) - employee_ids = {key[0] for key in ctx.assignment_variables} - penalties: list[cp_model.IntVar] = [] + for ( + employee_id, + _planning_unit_id, + assignment_date, + _shift_id, + _qualification_level, + ), variable in ctx.assignment_variables.items(): + assignment_variables_by_employee_and_date[(employee_id, assignment_date)].append(variable) - for employee_id in employee_ids: - for i in range(len(weekends) - 1): - sat1, sun1 = weekends[i] - sat2, sun2 = weekends[i + 1] + employee_ids = sorted( + {employee_id for employee_id, _planning_unit_id, _date, _shift_id, _level in ctx.assignment_variables} + ) - w1_vars = vars_by_employee_date[(employee_id, sat1)] + vars_by_employee_date[(employee_id, sun1)] - w2_vars = vars_by_employee_date[(employee_id, sat2)] + vars_by_employee_date[(employee_id, sun2)] + same_status_variables: list[cp_model.IntVar] = [] - # Skip if employee has no assignments on either weekend at all - if not w1_vars and not w2_vars: - continue + for employee_id in employee_ids: + weekend_free_variables: list[cp_model.IntVar] = [] - w1_free = ctx.model.new_bool_var(f"esw_w1_free_e{employee_id}_i{i}") - w2_free = ctx.model.new_bool_var(f"esw_w2_free_e{employee_id}_i{i}") + for weekend_index, (saturday, sunday) in enumerate(weekends): + weekend_assignment_variables = ( + assignment_variables_by_employee_and_date[(employee_id, saturday)] + + assignment_variables_by_employee_and_date[(employee_id, sunday)] + ) - # w1_free iff no assignment on Saturday or Sunday of weekend 1 - if w1_vars: - ctx.model.add(sum(w1_vars) == 0).only_enforce_if(w1_free) - ctx.model.add(sum(w1_vars) >= 1).only_enforce_if(w1_free.Not()) - else: - ctx.model.add(w1_free == 1) + weekend_worked = ctx.model.new_bool_var(f"esw_worked_e{employee_id}_w{weekend_index}") - # w2_free iff no assignment on Saturday or Sunday of weekend 2 - if w2_vars: - ctx.model.add(sum(w2_vars) == 0).only_enforce_if(w2_free) - ctx.model.add(sum(w2_vars) >= 1).only_enforce_if(w2_free.Not()) + if weekend_assignment_variables: + ctx.model.add_max_equality( + weekend_worked, + weekend_assignment_variables, + ) else: - ctx.model.add(w2_free == 1) + ctx.model.add(weekend_worked == 0) + + weekend_free = ctx.model.new_bool_var(f"esw_free_e{employee_id}_w{weekend_index}") + ctx.model.add(weekend_free + weekend_worked == 1) + + weekend_free_variables.append(weekend_free) + + for weekend_index in range(len(weekend_free_variables) - 1): + current_weekend_free = weekend_free_variables[weekend_index] + next_weekend_free = weekend_free_variables[weekend_index + 1] - # Penalize if both weekends have the same free/worked status - same_status = ctx.model.new_bool_var(f"esw_same_status_e{employee_id}_i{i}") - ctx.model.add(same_status == 1).only_enforce_if([w1_free, w2_free]) - ctx.model.add(same_status == 1).only_enforce_if([w1_free.Not(), w2_free.Not()]) - ctx.model.add(same_status == 0).only_enforce_if([w1_free, w2_free.Not()]) - ctx.model.add(same_status == 0).only_enforce_if([w1_free.Not(), w2_free]) - penalties.append(same_status) + different_status = ctx.model.new_bool_var(f"esw_different_e{employee_id}_w{weekend_index}") + ctx.model.add_abs_equality( + different_status, + current_weekend_free - next_weekend_free, + ) - if not penalties: + same_status = ctx.model.new_bool_var(f"esw_same_e{employee_id}_w{weekend_index}") + ctx.model.add(same_status + different_status == 1) + + same_status_variables.append(same_status) + + if not same_status_variables: return () - total = ctx.model.new_int_var(0, len(penalties), "esw_total") - ctx.model.add(total == sum(penalties)) - return (Penalty(objective_id=self.id, name="total", expression=total),) + total = ctx.model.new_int_var( + 0, + len(same_status_variables), + "esw_total", + ) + ctx.model.add(total == sum(same_status_variables)) + + return ( + Penalty( + objective_id=self.id, + name="total", + expression=total, + ), + ) + + @staticmethod + def _complete_weekends(ctx: SolverContext) -> tuple[tuple[date, date], ...]: + weekends: list[tuple[date, date]] = [] + + current_date = ctx.dataset.planning_month.start + end_date = ctx.dataset.planning_month.end + + while current_date <= end_date: + if current_date.isoweekday() == 6: + sunday = current_date + timedelta(days=1) + + if sunday <= end_date: + weekends.append((current_date, sunday)) + + current_date += timedelta(days=1) + + return tuple(weekends) def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: return () diff --git a/src/scheduling/solver/cp_sat/objectives/fair_preferences.py b/src/scheduling/solver/cp_sat/objectives/fair_preferences.py index 79628723..18c2fb6b 100644 --- a/src/scheduling/solver/cp_sat/objectives/fair_preferences.py +++ b/src/scheduling/solver/cp_sat/objectives/fair_preferences.py @@ -1,5 +1,5 @@ from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import date from typing import Any, ClassVar @@ -10,21 +10,42 @@ from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty +type EmployeeDateKey = tuple[int, date] +type EmployeeDateShiftKey = tuple[int, date, int] +type WeightedViolation = tuple[cp_model.IntVar, int] + class FairPreferencesObjective: - """Penalize repeatedly violating the same employee's free and preferred wishes.""" + """Penalize repeated wish violations increasingly per employee. + + Day-level wishes count as three strikes. + Shift-specific wishes count as one strike. + + Violations are grouped by employee and wish category. Repeated violations + become increasingly expensive through cubic penalty tiers. + """ id: ClassVar[str] = "fair_preferences" def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: + del params + if not ctx.assignment_variables: return () - variables_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) - variables_by_employee_date_shift: defaultdict[tuple[int, date, int], list[cp_model.IntVar]] = defaultdict(list) + variables_by_employee_date: defaultdict[EmployeeDateKey, list[cp_model.IntVar]] = defaultdict(list) - for (employee_id, _unit_id, assignment_date, shift_id, _level), variable in ctx.assignment_variables.items(): + variables_by_employee_date_shift: defaultdict[EmployeeDateShiftKey, list[cp_model.IntVar]] = defaultdict(list) + + for ( + employee_id, + _planning_unit_id, + assignment_date, + shift_id, + _staff_level, + ), variable in ctx.assignment_variables.items(): variables_by_employee_date[(employee_id, assignment_date)].append(variable) + variables_by_employee_date_shift[(employee_id, assignment_date, shift_id)].append(variable) free_wish_violations = self._free_wish_violations( @@ -32,6 +53,7 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P variables_by_employee_date=variables_by_employee_date, variables_by_employee_date_shift=variables_by_employee_date_shift, ) + preferred_wish_violations = self._preferred_wish_violations( ctx, variables_by_employee_date=variables_by_employee_date, @@ -43,28 +65,42 @@ def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[P free_wish_violations, wish_group="free", ) + preferred_wish_penalties = self._bucketed_penalties( ctx, preferred_wish_violations, wish_group="preferred", ) + return free_wish_penalties + preferred_wish_penalties def _free_wish_violations( self, ctx: SolverContext, *, - variables_by_employee_date: Mapping[tuple[int, date], list[cp_model.IntVar]], - variables_by_employee_date_shift: Mapping[tuple[int, date, int], list[cp_model.IntVar]], - ) -> dict[int, list[tuple[cp_model.IntVar, int]]]: - violations_by_employee: defaultdict[int, list[tuple[cp_model.IntVar, int]]] = defaultdict(list) + variables_by_employee_date: Mapping[EmployeeDateKey, list[cp_model.IntVar]], + variables_by_employee_date_shift: Mapping[EmployeeDateShiftKey, list[cp_model.IntVar]], + ) -> dict[int, list[WeightedViolation]]: + violations_by_employee: defaultdict[int, list[WeightedViolation]] = defaultdict(list) + for wish_index, wish in enumerate(ctx.dataset.wishes): + assignment_variables: list[cp_model.IntVar] + strike_count: int + if wish.type == WishType.FREE_DAY: - assignment_variables = variables_by_employee_date.get((wish.employee_id, wish.date), []) + assignment_variables = variables_by_employee_date.get( + (wish.employee_id, wish.date), + [], + ) strike_count = 3 elif wish.type == WishType.FREE_SHIFT and wish.shift_id is not None: assignment_variables = variables_by_employee_date_shift.get( - (wish.employee_id, wish.date, wish.shift_id), [] + ( + wish.employee_id, + wish.date, + wish.shift_id, + ), + [], ) strike_count = 1 else: @@ -73,28 +109,43 @@ def _free_wish_violations( if not assignment_variables: continue - violation = ctx.model.new_bool_var(f"fair_preferences__free_wish_{wish_index}__violated") - ctx.model.add(sum(assignment_variables) >= 1).only_enforce_if(violation) - ctx.model.add(sum(assignment_variables) == 0).only_enforce_if(violation.Not()) + violation = self._worked_variable( + ctx, + assignment_variables, + name=(f"fair_preferences__free_wish_{wish_index}__violated"), + ) + violations_by_employee[wish.employee_id].append((violation, strike_count)) - return violations_by_employee + return dict(violations_by_employee) def _preferred_wish_violations( self, ctx: SolverContext, *, - variables_by_employee_date: Mapping[tuple[int, date], list[cp_model.IntVar]], - variables_by_employee_date_shift: Mapping[tuple[int, date, int], list[cp_model.IntVar]], - ) -> dict[int, list[tuple[cp_model.IntVar, int]]]: - violations_by_employee: defaultdict[int, list[tuple[cp_model.IntVar, int]]] = defaultdict(list) + variables_by_employee_date: Mapping[EmployeeDateKey, list[cp_model.IntVar]], + variables_by_employee_date_shift: Mapping[EmployeeDateShiftKey, list[cp_model.IntVar]], + ) -> dict[int, list[WeightedViolation]]: + violations_by_employee: defaultdict[int, list[WeightedViolation]] = defaultdict(list) + for wish_index, wish in enumerate(ctx.dataset.wishes): + assignment_variables: list[cp_model.IntVar] + strike_count: int + if wish.type == WishType.PREFERRED_DAY: - assignment_variables = variables_by_employee_date.get((wish.employee_id, wish.date), []) + assignment_variables = variables_by_employee_date.get( + (wish.employee_id, wish.date), + [], + ) strike_count = 3 elif wish.type == WishType.PREFERRED_SHIFT and wish.shift_id is not None: assignment_variables = variables_by_employee_date_shift.get( - (wish.employee_id, wish.date, wish.shift_id), [] + ( + wish.employee_id, + wish.date, + wish.shift_id, + ), + [], ) strike_count = 1 else: @@ -103,41 +154,113 @@ def _preferred_wish_violations( if not assignment_variables: continue + fulfilled = self._worked_variable( + ctx, + assignment_variables, + name=(f"fair_preferences__preferred_wish_{wish_index}__fulfilled"), + ) + violation = ctx.model.new_bool_var(f"fair_preferences__preferred_wish_{wish_index}__violated") - ctx.model.add(sum(assignment_variables) == 0).only_enforce_if(violation) - ctx.model.add(sum(assignment_variables) >= 1).only_enforce_if(violation.Not()) + + ctx.model.add(violation + fulfilled == 1) + violations_by_employee[wish.employee_id].append((violation, strike_count)) - return violations_by_employee + return dict(violations_by_employee) def _bucketed_penalties( self, ctx: SolverContext, - violations_by_employee: Mapping[int, list[tuple[cp_model.IntVar, int]]], + violations_by_employee: Mapping[int, list[WeightedViolation]], *, wish_group: str, ) -> tuple[Penalty, ...]: penalties: list[Penalty] = [] + for employee_id, violations in violations_by_employee.items(): - max_strikes = sum(strike_count for _violation, strike_count in violations) - total_strikes = sum(violation * strike_count for violation, strike_count in violations) + maximum_strikes = sum(strike_count for _violation, strike_count in violations) + + if maximum_strikes == 0: + continue + + total_strikes = ctx.model.new_int_var( + 0, + maximum_strikes, + (f"fair_preferences__{wish_group}__employee_{employee_id}__total_strikes"), + ) + + weighted_violations = [violation * strike_count for violation, strike_count in violations] + + weighted_violation_sum = self._sum_linear_expressions(weighted_violations) + + ctx.model.add(total_strikes == weighted_violation_sum) + tier_variables = [ ctx.model.new_bool_var(f"fair_preferences__{wish_group}__employee_{employee_id}__tier_{tier}") - for tier in range(1, max_strikes + 1) + for tier in range(1, maximum_strikes + 1) ] + ctx.model.add(sum(tier_variables) == total_strikes) + for lower_tier, higher_tier in zip( + tier_variables, + tier_variables[1:], + strict=False, + ): + ctx.model.add(lower_tier >= higher_tier) + + tier_cost_expressions = [ + tier**3 * tier_variable + for tier, tier_variable in enumerate( + tier_variables, + start=1, + ) + ] + + tier_cost_sum = self._sum_linear_expressions(tier_cost_expressions) + + maximum_tier_cost = sum(tier**3 for tier in range(1, maximum_strikes + 1)) + + total_tier_cost = ctx.model.new_int_var( + 0, + maximum_tier_cost, + (f"fair_preferences__{wish_group}__employee_{employee_id}__total_tier_cost"), + ) + + ctx.model.add(total_tier_cost == tier_cost_sum) + penalties.append( Penalty( objective_id=self.id, - name=f"employee_{employee_id}__{wish_group}_wishes", - expression=cp_model.LinearExpr.sum( - [tier**3 * tier_variable for tier, tier_variable in enumerate(tier_variables, start=1)] - ), + name=(f"employee_{employee_id}__{wish_group}_wishes"), + expression=total_tier_cost, ) ) return tuple(penalties) + @staticmethod + def _worked_variable( + ctx: SolverContext, assignment_variables: Sequence[cp_model.IntVar], *, name: str + ) -> cp_model.IntVar: + worked = ctx.model.new_bool_var(name) + ctx.model.add_max_equality( + worked, + list(assignment_variables), + ) + return worked + + @staticmethod + def _sum_linear_expressions(expressions: Sequence[cp_model.LinearExpr]) -> cp_model.LinearExpr: + if not expressions: + raise ValueError("At least one linear expression is required.") + + total = expressions[0] + + for expression in expressions[1:]: + total += expression + + return total + def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: return () diff --git a/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py b/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py index f9958f30..dacece13 100644 --- a/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py +++ b/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py @@ -1,5 +1,5 @@ from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import date, timedelta from typing import Any, ClassVar @@ -10,88 +10,179 @@ from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty +type EmployeeDateKey = tuple[int, date] + class FreeDaysAfterNightShiftPhase: - """ - Penalizes the pattern: night shift on day D, free on D+1, but working on D+2. - The goal is to encourage two full consecutive rest days after a night shift. + """Encourage two consecutive free days after a night-shift phase. + + A penalty is generated for this pattern: + + night on D + free on D + 1 + worked on D + 2 + + The hard constraint already guarantees one free day after a night-shift + phase. This objective encourages extending that recovery period to two days. """ id: ClassVar[str] = "free_days_after_night_shift_phase" def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: + del params + if not ctx.assignment_variables: return () - night_shift_ids = {s.shift_id for s in ctx.dataset.shifts if s.type == ShiftType.NIGHT} + night_shift_ids = {shift.shift_id for shift in ctx.dataset.shifts if shift.type == ShiftType.NIGHT} + if not night_shift_ids: return () - vars_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) - night_vars_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) + variables_by_employee_date: defaultdict[ + EmployeeDateKey, + list[cp_model.IntVar], + ] = defaultdict(list) + + night_variables_by_employee_date: defaultdict[ + EmployeeDateKey, + list[cp_model.IntVar], + ] = defaultdict(list) + + for ( + employee_id, + _planning_unit_id, + assignment_date, + shift_id, + _staff_level, + ), variable in ctx.assignment_variables.items(): + variables_by_employee_date[(employee_id, assignment_date)].append(variable) - for (employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): - vars_by_employee_date[(employee_id, d)].append(var) if shift_id in night_shift_ids: - night_vars_by_employee_date[(employee_id, d)].append(var) + night_variables_by_employee_date[(employee_id, assignment_date)].append(variable) + + planning_dates = self._planning_dates(ctx) + planning_date_set = set(planning_dates) + + employee_ids = sorted( + { + employee_id + for ( + employee_id, + _planning_unit_id, + _assignment_date, + _shift_id, + _staff_level, + ) in ctx.assignment_variables + } + ) - # Use the actual set of planning dates to avoid gaps when checking day+1 and day+2 - planning_dates = sorted({key[2] for key in ctx.assignment_variables}) - planning_dates_set = set(planning_dates) - employee_ids = {key[0] for key in ctx.assignment_variables} penalties: list[cp_model.IntVar] = [] for employee_id in employee_ids: - for day in planning_dates: - next_day = day + timedelta(days=1) - after_next = day + timedelta(days=2) + for current_date in planning_dates: + next_date = current_date + timedelta(days=1) + second_next_date = current_date + timedelta(days=2) - # Only proceed if both following days exist in the planning period - if next_day not in planning_dates_set or after_next not in planning_dates_set: + if next_date not in planning_date_set or second_next_date not in planning_date_set: continue - night_vars = night_vars_by_employee_date[(employee_id, day)] - if not night_vars: + night_variables = night_variables_by_employee_date.get( + (employee_id, current_date), + [], + ) + + if not night_variables: continue - # worked_night: employee worked a night shift on this day - worked_night = ctx.model.new_bool_var(f"fdansp_night_e{employee_id}_d{day}") - ctx.model.add(sum(night_vars) >= 1).only_enforce_if(worked_night) - ctx.model.add(sum(night_vars) == 0).only_enforce_if(worked_night.Not()) - - next_vars = vars_by_employee_date[(employee_id, next_day)] - after_vars = vars_by_employee_date[(employee_id, after_next)] - - # next_free: employee has no assignment the day after the night shift - next_free = ctx.model.new_bool_var(f"fdansp_next_free_e{employee_id}_d{day}") - if next_vars: - ctx.model.add(sum(next_vars) == 0).only_enforce_if(next_free) - ctx.model.add(sum(next_vars) >= 1).only_enforce_if(next_free.Not()) - else: - ctx.model.add(next_free == 1) - - # after_worked: employee works two days after the night shift - after_worked = ctx.model.new_bool_var(f"fdansp_after_worked_e{employee_id}_d{day}") - if after_vars: - ctx.model.add(sum(after_vars) >= 1).only_enforce_if(after_worked) - ctx.model.add(sum(after_vars) == 0).only_enforce_if(after_worked.Not()) - else: - ctx.model.add(after_worked == 0) - - # Penalty fires when: night on D, free on D+1, but working on D+2 - penalty_var = ctx.model.new_bool_var(f"fdansp_penalty_e{employee_id}_d{day}") - ctx.model.add(penalty_var == 1).only_enforce_if([worked_night, next_free, after_worked]) - ctx.model.add(penalty_var == 0).only_enforce_if(worked_night.Not()) - ctx.model.add(penalty_var == 0).only_enforce_if(next_free.Not()) - ctx.model.add(penalty_var == 0).only_enforce_if(after_worked.Not()) - penalties.append(penalty_var) + worked_night = self._worked_variable( + ctx, + night_variables, + name=(f"fdansp__night_e{employee_id}__d{current_date}"), + ) + + worked_next_day = self._worked_or_zero( + ctx, + variables_by_employee_date.get( + (employee_id, next_date), + [], + ), + name=(f"fdansp__worked_next_e{employee_id}__d{current_date}"), + ) + + next_day_free = ctx.model.new_bool_var(f"fdansp__next_free_e{employee_id}__d{current_date}") + ctx.model.add(next_day_free + worked_next_day == 1) + + worked_second_next_day = self._worked_or_zero( + ctx, + variables_by_employee_date.get( + (employee_id, second_next_date), + [], + ), + name=(f"fdansp__worked_second_next_e{employee_id}__d{current_date}"), + ) + + penalty = ctx.model.new_bool_var(f"fdansp__penalty_e{employee_id}__d{current_date}") + + # Boolean minimum is logical AND. + ctx.model.add_min_equality( + penalty, + [ + worked_night, + next_day_free, + worked_second_next_day, + ], + ) + + penalties.append(penalty) if not penalties: return () - total = ctx.model.new_int_var(0, len(penalties), "fdansp_total") + total = ctx.model.new_int_var( + 0, + len(penalties), + "fdansp__total", + ) ctx.model.add(total == sum(penalties)) - return (Penalty(objective_id=self.id, name="total", expression=total),) + + return ( + Penalty( + objective_id=self.id, + name="total", + expression=total, + ), + ) + + @staticmethod + def _worked_variable(ctx: SolverContext, variables: Sequence[cp_model.IntVar], *, name: str) -> cp_model.IntVar: + worked = ctx.model.new_bool_var(name) + ctx.model.add_max_equality(worked, list(variables)) + return worked + + @classmethod + def _worked_or_zero(cls, ctx: SolverContext, variables: Sequence[cp_model.IntVar], *, name: str) -> cp_model.IntVar: + worked = ctx.model.new_bool_var(name) + + if variables: + ctx.model.add_max_equality(worked, list(variables)) + else: + ctx.model.add(worked == 0) + + return worked + + @staticmethod + def _planning_dates(ctx: SolverContext) -> tuple[date, ...]: + dates: list[date] = [] + + current_date = ctx.dataset.planning_month.start + end_date = ctx.dataset.planning_month.end + + while current_date <= end_date: + dates.append(current_date) + current_date += timedelta(days=1) + + return tuple(dates) def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: return () diff --git a/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py b/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py index 83d2ccdd..d15bf86f 100644 --- a/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py +++ b/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py @@ -1,5 +1,5 @@ from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import date, timedelta from typing import Any, ClassVar @@ -9,83 +9,170 @@ from scheduling.solver.cp_sat.context import AuditContext, SolverContext from scheduling.solver.cp_sat.objective import Penalty -# Fridays (5) and Mondays (1) are the days adjacent to weekends +type EmployeeDateKey = tuple[int, date] + _NEAR_WEEKEND_DAYS = frozenset({1, 5}) class FreeDaysNearWeekend: - """ - Rewards employees for being free on Fridays and Mondays (days adjacent to weekends). - An additional bonus is given if the neighboring weekend day is also free, - effectively rewarding a three-day weekend stretch. + """Reward free days adjacent to weekends. + + Friday is paired with Saturday. + Monday is paired with Sunday. - Weights: free near-weekend day = 1, free adjacent weekend day = 1, - both free (bonus) = 4. - Since this is a reward, the penalty is returned with multiplier=-1. + Reward: + - free Friday or Monday: 1 + - free adjacent weekend day: 1 + - both days free: additional 4 + + The resulting expression is returned with multiplier -1 because the central + objective minimizes penalties. """ id: ClassVar[str] = "free_days_near_weekend" def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: + del params + if not ctx.assignment_variables: return () - vars_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) - for (employee_id, _unit, d, _shift, _level), var in ctx.assignment_variables.items(): - vars_by_employee_date[(employee_id, d)].append(var) - - planning_dates_set = {key[2] for key in ctx.assignment_variables} - employee_ids = {key[0] for key in ctx.assignment_variables} - - free_today_vars: list[cp_model.IntVar] = [] - free_adjacent_vars: list[cp_model.IntVar] = [] - free_both_vars: list[cp_model.IntVar] = [] + variables_by_employee_date: defaultdict[ + EmployeeDateKey, + list[cp_model.IntVar], + ] = defaultdict(list) + + for ( + employee_id, + _planning_unit_id, + assignment_date, + _shift_id, + _staff_level, + ), variable in ctx.assignment_variables.items(): + variables_by_employee_date[(employee_id, assignment_date)].append(variable) + + planning_dates = self._planning_dates(ctx) + planning_date_set = set(planning_dates) + + employee_ids = sorted( + { + employee_id + for ( + employee_id, + _planning_unit_id, + _assignment_date, + _shift_id, + _staff_level, + ) in ctx.assignment_variables + } + ) + + free_near_weekend_variables: list[cp_model.IntVar] = [] + free_adjacent_variables: list[cp_model.IntVar] = [] + free_both_variables: list[cp_model.IntVar] = [] for employee_id in employee_ids: - for d in sorted(planning_dates_set): - if d.isoweekday() not in _NEAR_WEEKEND_DAYS: + for current_date in planning_dates: + if current_date.isoweekday() not in _NEAR_WEEKEND_DAYS: continue - today_vars = vars_by_employee_date[(employee_id, d)] - - # Reward: near-weekend day (Friday/Monday) is free - free_today = ctx.model.new_bool_var(f"fdnw_free_today_e{employee_id}_d{d}") - if today_vars: - ctx.model.add(sum(today_vars) == 0).only_enforce_if(free_today) - ctx.model.add(sum(today_vars) >= 1).only_enforce_if(free_today.Not()) - else: - ctx.model.add(free_today == 1) - free_today_vars.append(free_today) + adjacent_date = self._adjacent_weekend_date(current_date) - # Adjacent weekend day: Saturday for Friday, Sunday for Monday - adjacent = d + timedelta(days=1) if d.isoweekday() == 5 else d - timedelta(days=1) - if adjacent not in planning_dates_set: + if adjacent_date not in planning_date_set: continue - adjacent_vars = vars_by_employee_date[(employee_id, adjacent)] - - # Reward: the neighboring weekend day is also free - free_adj = ctx.model.new_bool_var(f"fdnw_free_adj_e{employee_id}_d{d}") - if adjacent_vars: - ctx.model.add(sum(adjacent_vars) == 0).only_enforce_if(free_adj) - ctx.model.add(sum(adjacent_vars) >= 1).only_enforce_if(free_adj.Not()) - else: - ctx.model.add(free_adj == 1) - free_adjacent_vars.append(free_adj) - - # Bonus reward: both the near-weekend day and adjacent weekend day are free - free_both = ctx.model.new_bool_var(f"fdnw_free_both_e{employee_id}_d{d}") - ctx.model.add_bool_and([free_today, free_adj]).only_enforce_if(free_both) - ctx.model.add_bool_or([free_today.Not(), free_adj.Not()]).only_enforce_if(free_both.Not()) - free_both_vars.append(free_both) - - if not free_today_vars: + free_near_weekend = self._free_variable( + ctx, + variables_by_employee_date.get( + (employee_id, current_date), + [], + ), + name=(f"fdnw__free_near_e{employee_id}__d{current_date}"), + ) + + free_adjacent = self._free_variable( + ctx, + variables_by_employee_date.get( + (employee_id, adjacent_date), + [], + ), + name=(f"fdnw__free_adjacent_e{employee_id}__d{current_date}"), + ) + + free_both = ctx.model.new_bool_var(f"fdnw__free_both_e{employee_id}__d{current_date}") + + ctx.model.add_min_equality( + free_both, + [free_near_weekend, free_adjacent], + ) + + free_near_weekend_variables.append(free_near_weekend) + free_adjacent_variables.append(free_adjacent) + free_both_variables.append(free_both) + + if not free_near_weekend_variables: return () - max_total = len(free_today_vars) + len(free_adjacent_vars) + 4 * len(free_both_vars) - total = ctx.model.new_int_var(0, max_total, "fdnw_total") - ctx.model.add(total == sum(free_today_vars) + sum(free_adjacent_vars) + 4 * sum(free_both_vars)) - return (Penalty(objective_id=self.id, name="total", expression=total, multiplier=-1),) + maximum_reward = len(free_near_weekend_variables) + len(free_adjacent_variables) + 4 * len(free_both_variables) + + total_reward = ctx.model.new_int_var( + 0, + maximum_reward, + "fdnw__total_reward", + ) + + ctx.model.add( + total_reward + == sum(free_near_weekend_variables) + sum(free_adjacent_variables) + 4 * sum(free_both_variables) + ) + + return ( + Penalty( + objective_id=self.id, + name="total_reward", + expression=total_reward, + multiplier=-1, + ), + ) + + @staticmethod + def _free_variable( + ctx: SolverContext, assignment_variables: Sequence[cp_model.IntVar], *, name: str + ) -> cp_model.IntVar: + worked = ctx.model.new_bool_var(f"{name}__worked") + + if assignment_variables: + ctx.model.add_max_equality( + worked, + list(assignment_variables), + ) + else: + ctx.model.add(worked == 0) + + free = ctx.model.new_bool_var(name) + ctx.model.add(free + worked == 1) + + return free + + @staticmethod + def _adjacent_weekend_date(near_weekend_date: date) -> date: + if near_weekend_date.isoweekday() == 5: + return near_weekend_date + timedelta(days=1) + + return near_weekend_date - timedelta(days=1) + + @staticmethod + def _planning_dates(ctx: SolverContext) -> tuple[date, ...]: + dates: list[date] = [] + + current_date = ctx.dataset.planning_month.start + end_date = ctx.dataset.planning_month.end + + while current_date <= end_date: + dates.append(current_date) + current_date += timedelta(days=1) + + return tuple(dates) def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: return () diff --git a/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py b/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py index afa747f8..2858a7a8 100644 --- a/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py +++ b/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py @@ -1,6 +1,6 @@ from collections import defaultdict from collections.abc import Mapping -from datetime import date +from datetime import date, timedelta from typing import Any, ClassVar from ortools.sat.python import cp_model @@ -13,77 +13,131 @@ class MinimizeConsecutiveNightShifts: """ - Penalizes windows of consecutive night shifts of length 2, 3, and 4. - Each phase length produces a separate Penalty with multiplier=phase_length, - so longer phases are penalized more heavily by the model builder. + Penalize consecutive night-shift windows of lengths 2, 3, and 4. - For each window: a bool variable is True iff the employee worked a night - shift on every day in the window. If an employee can work multiple night - shift types, a per-day aggregation bool is created first. + Each phase length produces a separate penalty. Longer phases receive a + larger multiplier. + + A window variable is one exactly when the employee works a night shift on + every calendar day in that window. """ id: ClassVar[str] = "minimize_consecutive_night_shifts" + PHASE_LENGTHS: ClassVar[tuple[int, ...]] = (2, 3, 4) + def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: if not ctx.assignment_variables: return () - night_shift_ids = {s.shift_id for s in ctx.dataset.shifts if s.type == ShiftType.NIGHT} + night_shift_ids = {shift.shift_id for shift in ctx.dataset.shifts if shift.type == ShiftType.NIGHT} + if not night_shift_ids: return () - night_vars_by_employee_date: defaultdict[tuple[int, date], list[cp_model.IntVar]] = defaultdict(list) - for (employee_id, _unit, d, shift_id, _level), var in ctx.assignment_variables.items(): + night_assignment_variables: defaultdict[ + tuple[int, date], + list[cp_model.IntVar], + ] = defaultdict(list) + + for ( + employee_id, + _planning_unit_id, + assignment_date, + shift_id, + _qualification_level, + ), variable in ctx.assignment_variables.items(): if shift_id in night_shift_ids: - night_vars_by_employee_date[(employee_id, d)].append(var) + night_assignment_variables[(employee_id, assignment_date)].append(variable) + + employee_ids = sorted( + {employee_id for employee_id, _planning_unit_id, _date, _shift_id, _level in ctx.assignment_variables} + ) + + planning_dates = self._planning_dates(ctx) + + night_worked_variables: dict[ + tuple[int, date], + cp_model.IntVar, + ] = {} - planning_dates = sorted({key[2] for key in ctx.assignment_variables}) - employee_ids = {key[0] for key in ctx.assignment_variables} - result: list[Penalty] = [] + for employee_id in employee_ids: + for planning_date in planning_dates: + assignment_variables = night_assignment_variables[(employee_id, planning_date)] - for phase_length in (2, 3, 4): - phase_vars: list[cp_model.IntVar] = [] + night_worked = ctx.model.new_bool_var(f"mcns_night_e{employee_id}_d{planning_date}") + + if assignment_variables: + ctx.model.add_max_equality( + night_worked, + assignment_variables, + ) + else: + ctx.model.add(night_worked == 0) + + night_worked_variables[(employee_id, planning_date)] = night_worked + + penalties: list[Penalty] = [] + + for phase_length in self.PHASE_LENGTHS: + phase_variables: list[cp_model.IntVar] = [] for employee_id in employee_ids: - for i, day in enumerate(planning_dates[: -(phase_length - 1)]): - window_days = [planning_dates[i + offset] for offset in range(phase_length)] - - # Build one bool per day in the window indicating a night shift was worked. - # If there is only one night shift type, reuse its variable directly. - per_day: list[cp_model.IntVar] = [] - for wd in window_days: - day_night_vars = night_vars_by_employee_date[(employee_id, wd)] - if not day_night_vars: - # No night shift possible on this day; window can never be active - break - if len(day_night_vars) == 1: - per_day.append(day_night_vars[0]) - else: - # Aggregate multiple night shift variables into one bool - b = ctx.model.new_bool_var(f"mcns_night_e{employee_id}_d{wd}_l{phase_length}") - ctx.model.add(sum(day_night_vars) >= 1).only_enforce_if(b) - ctx.model.add(sum(day_night_vars) == 0).only_enforce_if(b.Not()) - per_day.append(b) - else: - # for/else: only reached if no break occurred (all days have night vars) - phase_var = ctx.model.new_bool_var(f"mcns_phase_e{employee_id}_d{day}_l{phase_length}") - ctx.model.add_bool_and(per_day).only_enforce_if(phase_var) - ctx.model.add_bool_or([v.Not() for v in per_day]).only_enforce_if(phase_var.Not()) - phase_vars.append(phase_var) - - if phase_vars: - total = ctx.model.new_int_var(0, len(phase_vars), f"mcns_total_l{phase_length}") - ctx.model.add(total == sum(phase_vars)) - result.append( - Penalty( - objective_id=self.id, - name=f"total_l{phase_length}", - expression=total, - multiplier=phase_length, # longer phases cost more + number_of_windows = len(planning_dates) - phase_length + 1 + + for start_index in range(number_of_windows): + window_dates = planning_dates[start_index : start_index + phase_length] + + per_day_variables = [ + night_worked_variables[(employee_id, window_date)] for window_date in window_dates + ] + + phase_variable = ctx.model.new_bool_var( + f"mcns_phase_e{employee_id}_d{window_dates[0]}_l{phase_length}" + ) + + # For Boolean variables, their minimum is one exactly when + # every variable is one. + ctx.model.add_min_equality( + phase_variable, + per_day_variables, ) + + phase_variables.append(phase_variable) + + if not phase_variables: + continue + + total = ctx.model.new_int_var( + 0, + len(phase_variables), + f"mcns_total_l{phase_length}", + ) + ctx.model.add(total == sum(phase_variables)) + + penalties.append( + Penalty( + objective_id=self.id, + name=f"total_l{phase_length}", + expression=total, + multiplier=phase_length, ) + ) + + return tuple(penalties) + + @staticmethod + def _planning_dates(ctx: SolverContext) -> tuple[date, ...]: + dates: list[date] = [] + + current_date = ctx.dataset.planning_month.start + end_date = ctx.dataset.planning_month.end + + while current_date <= end_date: + dates.append(current_date) + current_date += timedelta(days=1) - return tuple(result) + return tuple(dates) def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: return () diff --git a/src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py b/src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py deleted file mode 100644 index c0fcbd48..00000000 --- a/src/scheduling/solver/cp_sat/objectives/prefer_own_planning_unit.py +++ /dev/null @@ -1,71 +0,0 @@ -from collections import defaultdict -from collections.abc import Mapping -from typing import Any, ClassVar - -from scheduling.domain.planning_unit import PlanningUnitId -from scheduling.solver.audit import AuditFinding -from scheduling.solver.cp_sat.context import AuditContext, SolverContext -from scheduling.solver.cp_sat.objective import Penalty - - -class PreferOwnPlanningUnit: - """ - Adds a penalty every time an employee is assigned to the planning unit that is not his - preferred planning unit. - """ - - id: ClassVar[str] = "prefer_own_planning_unit" - - def add_to_model( - self, - ctx: SolverContext, - params: Mapping[str, Any], - ) -> tuple[Penalty, ...]: - if not ctx.assignment_variables: - return () - - # Dictionary with employee as key and planning unit - employees_PU_dict: defaultdict[int, PlanningUnitId] = defaultdict(list) - # First assign a planning unit to every employee, read from the dataset - for membership in ctx.dataset.planning_unit_memberships: - employee_id = membership.employee_id - planning_unit_id = membership.planning_unit_id - - # Then find out the id of any shared pool stations - # shared_pool_type = PlanningUnitType("shared_pool") - shared_pool_ids: list[PlanningUnitId] = list[PlanningUnitId]() - for pu in ctx.dataset.planning_units: - if pu.planning_unit_id in shared_pool_ids: - shared_pool_ids.append(pu.planning_unit_id) - - # Now check in the assignments whether an employee (who is not in the shared pool!!!) - # was assigned to a planning unit that is not his own - num_not_preferred_planning_unit: int = 0 - for key, _variable in ctx.assignment_variables.items(): - employee_id, planning_unit_id, _, _, _ = key - if ( - employees_PU_dict[employee_id] != planning_unit_id - and employees_PU_dict[employee_id] not in shared_pool_ids - ): - num_not_preferred_planning_unit += 1 - - prefer_own_planning_unit_penalty = ctx.model.new_int_var(0, 100000, "not_preferred_planning_unit") - - ctx.model.add(prefer_own_planning_unit_penalty == num_not_preferred_planning_unit).with_name( - "prefer_own_planning_unit__penalty" - ) - - return ( - Penalty( - objective_id=self.id, - name="prefer_own_planning_unit_penalty", - expression=prefer_own_planning_unit_penalty, - ), - ) - - def audit( - self, - ctx: AuditContext, - params: Mapping[str, Any], - ) -> tuple[AuditFinding, ...]: - return () diff --git a/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py b/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py index 9c4798e3..9f9f4a69 100644 --- a/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py +++ b/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py @@ -1,8 +1,10 @@ from collections import defaultdict from collections.abc import Mapping -from datetime import date as Date +from datetime import date, timedelta from typing import Any, ClassVar +from ortools.sat.python import cp_model + from scheduling.domain.shift import ShiftId, ShiftType from scheduling.solver.audit import AuditFinding from scheduling.solver.cp_sat.context import AuditContext, SolverContext @@ -11,55 +13,139 @@ class RotateShiftsForward: """ - Adds a reward for each time an employee works forward rotating shifts and a - penalty for backwards rotating shifts + Reward forward shift rotations and penalize backward rotations. + + Only assignments on consecutive calendar days are compared. + + Forward: + - early -> late + - late -> night + + Backward: + - late -> early + - night -> late """ - FORWARD_ROTATIONS = ((ShiftType("early"), ShiftType("late")), (ShiftType("late"), ShiftType("night"))) - BACKWARD_ROTATIONS = ( - (ShiftType("late"), ShiftType("early")), - (ShiftType("night"), ShiftType("late")), - # In the legacy version, they also penalize going from night -> early, which makes no sense in my eyes - # Also, they only consider a timeframe of 3 days per shift, I do not understand why + FORWARD_ROTATIONS: ClassVar[tuple[tuple[ShiftType, ShiftType], ...]] = ( + (ShiftType.EARLY, ShiftType.LATE), + (ShiftType.LATE, ShiftType.NIGHT), + ) + + BACKWARD_ROTATIONS: ClassVar[tuple[tuple[ShiftType, ShiftType], ...]] = ( + (ShiftType.LATE, ShiftType.EARLY), + (ShiftType.NIGHT, ShiftType.LATE), ) id: ClassVar[str] = "rotate_shifts_forward" - def add_to_model( - self, - ctx: SolverContext, - params: Mapping[str, Any], - ) -> tuple[Penalty, ...]: + def add_to_model(self, ctx: SolverContext, params: Mapping[str, Any]) -> tuple[Penalty, ...]: if not ctx.assignment_variables: return () - # First check which shifts every employee is assigned to - days_by_employee: dict[int, list[tuple[Date, ShiftId]]] = defaultdict[int, list[tuple[Date, ShiftId]]](list) - for key, _variable in ctx.assignment_variables.items(): - employee_id, _, date, shift_id, _ = key - days_by_employee[employee_id].append((date, shift_id)) - - # Find out how the shifts rotate for each employee - num_forward_rotations: int = 0 - num_backward_rotations: int = 0 - for employee_id in days_by_employee.keys(): - # Again make sure that the shifts are properly sorted - days_by_employee[employee_id] = sorted(days_by_employee[employee_id]) - - for i in range(len(days_by_employee[employee_id]) - 1): - for shift in ctx.dataset.shifts: - if shift.shift_id == days_by_employee[employee_id][i][1]: - shift_type_before = shift.type - if shift.shift_id == days_by_employee[employee_id][i + 1][1]: - shift_type_after = shift.type - if (shift_type_before, shift_type_after) in self.FORWARD_ROTATIONS: - num_forward_rotations += 1 - elif (shift_type_before, shift_type_after) in self.BACKWARD_ROTATIONS: - num_backward_rotations += 1 - - rotations = ctx.model.new_int_var(-1000000, 1000000, "rotations") - - ctx.model.add(rotations == num_backward_rotations - num_forward_rotations).with_name( + shift_type_by_shift_id: dict[ShiftId, ShiftType] = {shift.shift_id: shift.type for shift in ctx.dataset.shifts} + + assignment_variables_by_employee_date_and_type: defaultdict[ + tuple[int, date, ShiftType], + list[cp_model.IntVar], + ] = defaultdict(list) + + for ( + employee_id, + _planning_unit_id, + assignment_date, + shift_id, + _qualification_level, + ), variable in ctx.assignment_variables.items(): + shift_type = shift_type_by_shift_id.get(shift_id) + + if shift_type is None: + continue + + assignment_variables_by_employee_date_and_type[(employee_id, assignment_date, shift_type)].append(variable) + + employee_ids = sorted( + {employee_id for employee_id, _planning_unit_id, _date, _shift_id, _level in ctx.assignment_variables} + ) + + shift_worked_variables: dict[ + tuple[int, date, ShiftType], + cp_model.IntVar, + ] = {} + + relevant_shift_types = { + shift_type for rotation in self.FORWARD_ROTATIONS + self.BACKWARD_ROTATIONS for shift_type in rotation + } + + planning_dates = self._planning_dates(ctx) + + for employee_id in employee_ids: + for planning_date in planning_dates: + for shift_type in relevant_shift_types: + assignment_variables = assignment_variables_by_employee_date_and_type[ + (employee_id, planning_date, shift_type) + ] + + worked = ctx.model.new_bool_var(f"rsf_worked_e{employee_id}_d{planning_date}_t{shift_type}") + + if assignment_variables: + ctx.model.add_max_equality( + worked, + assignment_variables, + ) + else: + ctx.model.add(worked == 0) + + shift_worked_variables[(employee_id, planning_date, shift_type)] = worked + + forward_rotation_variables: list[cp_model.IntVar] = [] + backward_rotation_variables: list[cp_model.IntVar] = [] + + for employee_id in employee_ids: + for current_date in planning_dates: + next_date = current_date + timedelta(days=1) + + if next_date > ctx.dataset.planning_month.end: + continue + + for before_type, after_type in self.FORWARD_ROTATIONS: + transition = self._add_transition_variable( + ctx=ctx, + employee_id=employee_id, + current_date=current_date, + next_date=next_date, + before_type=before_type, + after_type=after_type, + shift_worked_variables=shift_worked_variables, + direction="forward", + ) + forward_rotation_variables.append(transition) + + for before_type, after_type in self.BACKWARD_ROTATIONS: + transition = self._add_transition_variable( + ctx=ctx, + employee_id=employee_id, + current_date=current_date, + next_date=next_date, + before_type=before_type, + after_type=after_type, + shift_worked_variables=shift_worked_variables, + direction="backward", + ) + backward_rotation_variables.append(transition) + + if not forward_rotation_variables and not backward_rotation_variables: + return () + + lower_bound = -len(forward_rotation_variables) + upper_bound = len(backward_rotation_variables) + + rotations = ctx.model.new_int_var( + lower_bound, + upper_bound, + "rsf_rotations", + ) + + ctx.model.add(rotations == sum(backward_rotation_variables) - sum(forward_rotation_variables)).with_name( "rotate_shifts_forward__rotations" ) @@ -71,9 +157,48 @@ def add_to_model( ), ) - def audit( - self, - ctx: AuditContext, - params: Mapping[str, Any], - ) -> tuple[AuditFinding, ...]: + @staticmethod + def _add_transition_variable( + *, + ctx: SolverContext, + employee_id: int, + current_date: date, + next_date: date, + before_type: ShiftType, + after_type: ShiftType, + shift_worked_variables: Mapping[ + tuple[int, date, ShiftType], + cp_model.IntVar, + ], + direction: str, + ) -> cp_model.IntVar: + before_worked = shift_worked_variables[(employee_id, current_date, before_type)] + after_worked = shift_worked_variables[(employee_id, next_date, after_type)] + + transition = ctx.model.new_bool_var( + f"rsf_transition_{direction}_e{employee_id}_d{current_date}_{before_type}_{after_type}" + ) + + # For Boolean inputs, min(before, after) is their logical AND. + ctx.model.add_min_equality( + transition, + [before_worked, after_worked], + ) + + return transition + + @staticmethod + def _planning_dates(ctx: SolverContext) -> tuple[date, ...]: + dates: list[date] = [] + + current_date = ctx.dataset.planning_month.start + end_date = ctx.dataset.planning_month.end + + while current_date <= end_date: + dates.append(current_date) + current_date += timedelta(days=1) + + return tuple(dates) + + def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: return () diff --git a/tests/cp/objectives/test_prefer_own_planning_unit.py b/tests/cp/objectives/test_prefer_own_planning_unit.py deleted file mode 100644 index 9073d4c2..00000000 --- a/tests/cp/objectives/test_prefer_own_planning_unit.py +++ /dev/null @@ -1,199 +0,0 @@ -from datetime import date - -import pytest -from ortools.sat.python import cp_model - -from scheduling.domain import ( - Employee, - PlanningMonth, - PlanningUnit, - PlanningUnitMembership, - PlanningUnitType, - SchedulingDataset, - Shift, - ShiftType, - StaffingDemandRole, - StaffLevel, -) -from scheduling.solver.cp_sat.context import create_context -from scheduling.solver.cp_sat.objectives.prefer_own_planning_unit import PreferOwnPlanningUnit -from scheduling.solver.cp_sat.variables import create_assignment_variables - -HOME_STATION = PlanningUnit( - planning_unit_id=1, - display_name="Station 1", - type=PlanningUnitType.STATION, -) - -OTHER_STATION = PlanningUnit( - planning_unit_id=2, - display_name="Station 2", - type=PlanningUnitType.STATION, -) - -SHARED_POOL = PlanningUnit( - planning_unit_id=3, - display_name="Shared Pool", - type=PlanningUnitType.SHARED_POOL, -) - -EARLY_SHIFT = Shift( - shift_id=1, - code="F", - type=ShiftType.EARLY, - staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, - start_minute=360, - end_minute=820, - net_work_minutes=460, -) - -EMPLOYEE = Employee( - employee_id=1, - display_name="Alice", - staff_level=StaffLevel.PROFESSIONAL, -) - - -def _membership( - planning_unit: PlanningUnit, - *, - valid_from: date = date(2024, 11, 1), - valid_until: date | None = date(2024, 11, 30), - is_home: bool, - is_replacement: bool, -) -> PlanningUnitMembership: - return PlanningUnitMembership( - planning_unit_id=planning_unit.planning_unit_id, - employee_id=EMPLOYEE.employee_id, - valid_from=valid_from, - valid_until=valid_until, - staff_level=StaffLevel.PROFESSIONAL, - is_home=is_home, - is_replacement=is_replacement, - ) - - -def _dataset(memberships: tuple[PlanningUnitMembership, ...]) -> SchedulingDataset: - return SchedulingDataset( - planning_month=PlanningMonth(year=2024, month=11), - planning_units=(HOME_STATION, OTHER_STATION, SHARED_POOL), - plans=(), - shifts=(EARLY_SHIFT,), - employees=(EMPLOYEE,), - planning_unit_memberships=memberships, - ) - - -def _penalty_for( - *, - memberships: tuple[PlanningUnitMembership, ...], - worked_assignments: set[tuple[int, date]], -) -> float: - ctx = create_context(dataset=_dataset(memberships)) - create_assignment_variables(ctx) - - for (_employee, planning_unit_id, assignment_date, _shift, _level), variable in ctx.assignment_variables.items(): - ctx.model.add(variable == ((planning_unit_id, assignment_date) in worked_assignments)) - - penalties = PreferOwnPlanningUnit().add_to_model(ctx, params={}) - assert len(penalties) == 1 - - penalty = penalties[0] - assert penalty.objective_id == "prefer_own_planning_unit" - assert penalty.name == "prefer_own_planning_unit_penalty" - assert penalty.multiplier == 1 - - ctx.model.minimize(penalty.multiplier * penalty.expression) - solver = cp_model.CpSolver() - status = solver.solve(ctx.model) - - assert status in (cp_model.OPTIMAL, cp_model.FEASIBLE) - return solver.objective_value - - -def test_returns_no_penalties_without_assignment_variables() -> None: - ctx = create_context(dataset=_dataset(())) - - assert PreferOwnPlanningUnit().add_to_model(ctx, params={}) == () - - -@pytest.mark.integration -def test_no_penalty_for_assignment_to_active_home_station() -> None: - memberships = (_membership(HOME_STATION, is_home=True, is_replacement=False),) - - assert ( - _penalty_for( - memberships=memberships, - worked_assignments={(HOME_STATION.planning_unit_id, date(2024, 11, 1))}, - ) - == 0 - ) - - -@pytest.mark.integration -def test_penalty_for_assignment_to_eligible_non_home_station() -> None: - memberships = ( - _membership(HOME_STATION, is_home=True, is_replacement=False), - _membership(OTHER_STATION, is_home=False, is_replacement=True), - ) - - assert ( - _penalty_for( - memberships=memberships, - worked_assignments={(OTHER_STATION.planning_unit_id, date(2024, 11, 1))}, - ) - == 1 - ) - - -@pytest.mark.integration -def test_active_home_station_is_selected_per_assignment_date() -> None: - memberships = ( - _membership( - HOME_STATION, - valid_until=date(2024, 11, 15), - is_home=True, - is_replacement=False, - ), - _membership( - HOME_STATION, - valid_from=date(2024, 11, 16), - is_home=False, - is_replacement=True, - ), - _membership( - OTHER_STATION, - valid_until=date(2024, 11, 15), - is_home=False, - is_replacement=True, - ), - _membership( - OTHER_STATION, - valid_from=date(2024, 11, 16), - is_home=True, - is_replacement=False, - ), - ) - worked_assignments = { - (HOME_STATION.planning_unit_id, date(2024, 11, 14)), - (OTHER_STATION.planning_unit_id, date(2024, 11, 14)), - (HOME_STATION.planning_unit_id, date(2024, 11, 17)), - (OTHER_STATION.planning_unit_id, date(2024, 11, 17)), - } - - assert _penalty_for(memberships=memberships, worked_assignments=worked_assignments) == 2 - - -@pytest.mark.integration -def test_shared_pool_home_employee_has_no_cross_station_penalty() -> None: - memberships = ( - _membership(SHARED_POOL, is_home=True, is_replacement=False), - _membership(HOME_STATION, is_home=False, is_replacement=True), - _membership(OTHER_STATION, is_home=False, is_replacement=True), - ) - worked_assignments = { - (HOME_STATION.planning_unit_id, date(2024, 11, 1)), - (OTHER_STATION.planning_unit_id, date(2024, 11, 2)), - } - - assert _penalty_for(memberships=memberships, worked_assignments=worked_assignments) == 0