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 8acb5c2a..093fe867 100644 --- a/src/scheduling/solver/config.py +++ b/src/scheduling/solver/config.py @@ -3,7 +3,22 @@ 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.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.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, ) @@ -34,11 +49,41 @@ 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( 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, + ), + RotateShiftsForward.id: ObjectiveConfig( + enabled=True, + weight=1, + ), + EverySecondWeekendFree.id: ObjectiveConfig(enabled=True, weight=1), + FairPreferencesObjective.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 eec7296d..fba1959a 100644 --- a/src/scheduling/solver/cp_sat/builder.py +++ b/src/scheduling/solver/cp_sat/builder.py @@ -5,17 +5,51 @@ 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.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.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, ...] = (MinimumStaffing(),) +CP_SAT_CONSTRAINTS: tuple[Constraint, ...] = ( + MinimumStaffing(), + FreeDayAfterNightShiftPhase(), + RoundsInEarlyShift(), + AvailabilitiesConstraint(), + HierarchyOfIntermediateShifts(), + OneAssignmentPerDay(), + TargetWorkingTime(), +) -CP_SAT_OBJECTIVES: tuple[Objective, ...] = (TemporaryBalanceGeneratedAssignments(),) +CP_SAT_OBJECTIVES: tuple[Objective, ...] = ( + TemporaryBalanceGeneratedAssignments(), + MinimizeOvertime(), + NotTooManyConsecutiveDays(), + PreferredBlockLength(), + RotateShiftsForward(), + EverySecondWeekendFree(), + FairPreferencesObjective(), + FreeDaysAfterNightShiftPhase(), + MinimizeConsecutiveNightShifts(), + FreeDaysNearWeekend(), +) @dataclass(frozen=True, slots=True) 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..dc7a8e9b --- /dev/null +++ b/src/scheduling/solver/cp_sat/constraints/target_working_time.py @@ -0,0 +1,133 @@ +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, ClassVar + +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 +from scheduling.timeoffice import facts + + +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 = 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} + + # 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: + 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 + 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 = 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} + + # 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.net_work_minutes 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.net_work_minutes 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/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..67dbfe62 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/every_second_weekend_free.py @@ -0,0 +1,132 @@ +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: + """ + 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, ...]: + if not ctx.assignment_variables: + return () + + weekends = self._complete_weekends(ctx) + if len(weekends) < 2: + return () + + assignment_variables_by_employee_and_date: 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(): + assignment_variables_by_employee_and_date[(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} + ) + + same_status_variables: list[cp_model.IntVar] = [] + + for employee_id in employee_ids: + weekend_free_variables: list[cp_model.IntVar] = [] + + 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)] + ) + + weekend_worked = ctx.model.new_bool_var(f"esw_worked_e{employee_id}_w{weekend_index}") + + if weekend_assignment_variables: + ctx.model.add_max_equality( + weekend_worked, + weekend_assignment_variables, + ) + else: + 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] + + 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, + ) + + 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(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 new file mode 100644 index 00000000..18c2fb6b --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/fair_preferences.py @@ -0,0 +1,266 @@ +from collections import defaultdict +from collections.abc import Mapping, Sequence +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 + +type EmployeeDateKey = tuple[int, date] +type EmployeeDateShiftKey = tuple[int, date, int] +type WeightedViolation = tuple[cp_model.IntVar, int] + + +class FairPreferencesObjective: + """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[EmployeeDateKey, list[cp_model.IntVar]] = defaultdict(list) + + 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( + 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[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), + [], + ) + 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, + ), + [], + ) + strike_count = 1 + else: + continue + + if not assignment_variables: + continue + + 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 dict(violations_by_employee) + + def _preferred_wish_violations( + self, + ctx: SolverContext, + *, + 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), + [], + ) + 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 + + 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(violation + fulfilled == 1) + + violations_by_employee[wish.employee_id].append((violation, strike_count)) + + return dict(violations_by_employee) + + def _bucketed_penalties( + self, + ctx: SolverContext, + violations_by_employee: Mapping[int, list[WeightedViolation]], + *, + wish_group: str, + ) -> tuple[Penalty, ...]: + penalties: list[Penalty] = [] + + for employee_id, violations in violations_by_employee.items(): + 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, 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=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 new file mode 100644 index 00000000..dacece13 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/free_day_after_night_shift_phase.py @@ -0,0 +1,188 @@ +from collections import defaultdict +from collections.abc import Mapping, Sequence +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 + +type EmployeeDateKey = tuple[int, date] + + +class FreeDaysAfterNightShiftPhase: + """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 = {shift.shift_id for shift in ctx.dataset.shifts if shift.type == ShiftType.NIGHT} + + if not night_shift_ids: + return () + + 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) + + if shift_id in night_shift_ids: + 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 + } + ) + + penalties: list[cp_model.IntVar] = [] + + for employee_id in employee_ids: + for current_date in planning_dates: + next_date = current_date + timedelta(days=1) + second_next_date = current_date + timedelta(days=2) + + if next_date not in planning_date_set or second_next_date not in planning_date_set: + continue + + night_variables = night_variables_by_employee_date.get( + (employee_id, current_date), + [], + ) + + if not night_variables: + continue + + 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", + ) + ctx.model.add(total == sum(penalties)) + + 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 new file mode 100644 index 00000000..d15bf86f --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/free_days_near_weekend.py @@ -0,0 +1,178 @@ +from collections import defaultdict +from collections.abc import Mapping, Sequence +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 + +type EmployeeDateKey = tuple[int, date] + +_NEAR_WEEKEND_DAYS = frozenset({1, 5}) + + +class FreeDaysNearWeekend: + """Reward free days adjacent to weekends. + + Friday is paired with Saturday. + Monday is paired with Sunday. + + 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 () + + 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 current_date in planning_dates: + if current_date.isoweekday() not in _NEAR_WEEKEND_DAYS: + continue + + adjacent_date = self._adjacent_weekend_date(current_date) + + if adjacent_date not in planning_date_set: + continue + + 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 () + + 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 new file mode 100644 index 00000000..2858a7a8 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/minimize_consecutive_night_shifts.py @@ -0,0 +1,143 @@ +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: + """ + Penalize consecutive night-shift windows of lengths 2, 3, and 4. + + 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 = {shift.shift_id for shift in ctx.dataset.shifts if shift.type == ShiftType.NIGHT} + + if not night_shift_ids: + return () + + 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_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, + ] = {} + + for employee_id in employee_ids: + for planning_date in planning_dates: + assignment_variables = night_assignment_variables[(employee_id, planning_date)] + + 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: + 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(dates) + + def audit(self, ctx: AuditContext, params: Mapping[str, Any]) -> tuple[AuditFinding, ...]: + return () 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..dcc72e8d --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/minimize_overtime.py @@ -0,0 +1,89 @@ +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 assigning overtime to employees. + Mathematical formulation: sum(max(0, planned_work + actual_work - target_work)) + """ + + id: ClassVar[str] = "minimize_overtime" + + def add_to_model( + self, + ctx: SolverContext, + params: Mapping[str, Any], + ) -> tuple[Penalty, ...]: + if not ctx.assignment_variables: + return () + + 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 + 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}") + + # 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}" + ) + + overtime_vars.append(emp_overtime_var) + + if not overtime_vars: + return () + + return ( + Penalty( + objective_id=self.id, + name="total_overtime", + expression=cp_model.LinearExpr.Sum(overtime_vars), # type: ignore + ), + ) + + def audit( + self, + 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.net_work_minutes 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) 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..e817f029 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/not_too_many_consecutive_days.py @@ -0,0 +1,71 @@ +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 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, 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 + 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..00d07149 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/preferred_block_length.py @@ -0,0 +1,70 @@ +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 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, 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 + 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..9f9f4a69 --- /dev/null +++ b/src/scheduling/solver/cp_sat/objectives/rotate_shits_foward.py @@ -0,0 +1,204 @@ +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 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 + + +class RotateShiftsForward: + """ + 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: 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, ...]: + if not ctx.assignment_variables: + return () + + 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" + ) + + return ( + Penalty( + objective_id=self.id, + name="rotations", + expression=rotations, + ), + ) + + @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/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 diff --git a/src/scheduling/solver/service.py b/src/scheduling/solver/service.py index 228b6c51..b3865f7d 100644 --- a/src/scheduling/solver/service.py +++ b/src/scheduling/solver/service.py @@ -212,11 +212,10 @@ def _audit_solution( dataset: SchedulingDataset, assignments: tuple[Assignment, ...], ) -> AuditReport: - audit_ctx = AuditContext( - dataset=dataset, - index=build_result.ctx.index, - assignments=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 2fcc0fa3..1b0d3b81 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -82,6 +82,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. @@ -107,6 +108,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( { @@ -149,7 +153,8 @@ class TimeOfficeFacts: 2866: NIGHT_SHIFT_ID, # N5 # Day/intermediate variant normalized to canonical T75_ intermediate shift. 2994: INTERMEDIATE_SHIFT_ID, # T8x - 3066: INTERMEDIATE_SHIFT_ID, # Z52 + 1234: INTERMEDIATE_SHIFT_ID, + 1356: INTERMEDIATE_SHIFT_ID, } ) @@ -190,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, # Schauen was für eine Profession das ist + "-": StaffLevel.TRAINEE, # Später herausfinden was das für eine Profession ist } ) @@ -305,4 +310,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/src/scheduling/timeoffice/reading/wishes.py b/src/scheduling/timeoffice/reading/wishes.py index 0b860161..94814a8b 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( 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_free_day_after_night_shift_phase.py b/tests/cp/constraints/test_free_day_after_night_shift_phase.py new file mode 100644 index 00000000..a3f5f371 --- /dev/null +++ b/tests/cp/constraints/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 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_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 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) 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..eb8a7a5c --- /dev/null +++ b/tests/cp/objectives/test_every_second_weekend_free.py @@ -0,0 +1,129 @@ +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 diff --git a/tests/cp/objectives/test_fair_preferences.py b/tests/cp/objectives/test_fair_preferences.py new file mode 100644 index 00000000..11f0c500 --- /dev/null +++ b/tests/cp/objectives/test_fair_preferences.py @@ -0,0 +1,180 @@ +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 + + +@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 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..5bce62fc --- /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 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..d3c5a660 --- /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 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_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