Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/cp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
from .constraints import (
Constraint as Constraint,
)
from .constraints import (
EverySecondWeekendFreeConstraint as EverySecondWeekendFreeConstraint,
)
from .constraints import (
FreeDayAfterNightShiftPhaseConstraint as FreeDayAfterNightShiftPhaseConstraint,
)
Expand Down Expand Up @@ -32,6 +29,9 @@
VacationDaysAndShiftsConstraint as VacationDaysAndShiftsConstraint,
)
from .model import Model as Model
from .objectives import (
EverySecondWeekendFreeObjective as EverySecondWeekendFreeObjective,
)
from .objectives import (
FreeDaysAfterNightShiftPhaseObjective as FreeDaysAfterNightShiftPhaseObjective,
)
Expand Down
3 changes: 0 additions & 3 deletions src/cp/constraints/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
from .constraint import Constraint as Constraint
from .every_second_weekend_free import (
EverySecondWeekendFreeConstraint as EverySecondWeekendFreeConstraint,
)
from .free_day_after_night_shift_phase import (
FreeDayAfterNightShiftPhaseConstraint as FreeDayAfterNightShiftPhaseConstraint,
)
Expand Down
3 changes: 3 additions & 0 deletions src/cp/objectives/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from .every_second_weekend_free import (
EverySecondWeekendFreeObjective as EverySecondWeekendFreeObjective,
)
from .free_days_after_night_shift_phase import (
FreeDaysAfterNightShiftPhaseObjective as FreeDaysAfterNightShiftPhaseObjective,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,41 @@
import logging
from datetime import timedelta
from typing import cast

from ortools.sat.python.cp_model import CpModel
from ortools.sat.python.cp_model import CpModel, IntVar, LinearExpr

from src.day import Day
from src.employee import Employee
from src.shift import Shift

from ..variables import EmployeeWorksOnDayVariables, ShiftAssignmentVariables
from .constraint import Constraint
from .objective import Objective


class EverySecondWeekendFreeConstraint(Constraint):
class EverySecondWeekendFreeObjective(Objective):
@property
def KEY(self) -> str:
return "every-second-weekend-free"

def __init__(
self,
weight: float,
employees: list[Employee],
days: list[Day],
shifts: list[Shift],
):
"""
Initializes the constraint that enforces alternating free weekends.
Initializes the objective that encourages alternating free weekends.
A weekend is defined as Saturday and Sunday, both days must be free.
"""
super().__init__(employees, days, shifts)
super().__init__(weight, employees, days, [])

def create(
self,
model: CpModel,
shift_assignment_variables: ShiftAssignmentVariables,
employee_works_on_day_variables: EmployeeWorksOnDayVariables,
) -> None:
) -> LinearExpr:
penalties: list[IntVar] = []

# Collect all complete weekends (Saturday-Sunday pairs) in the planning period
weekends: list[tuple[Day, Day]] = []

Expand All @@ -57,21 +59,17 @@ def create(
logging.info(f"Found {len(weekends)} complete weekends in the planning period")

for employee in self._employees:
if employee.hidden:
continue

# For each pair of consecutive weekends, enforce alternating pattern
# For each pair of consecutive weekends, penalize if both are free or both have work
for i in range(len(weekends) - 1):
# Get two consecutive weekends
weekend1_sat, weekend1_sun = weekends[i]
weekend2_sat, weekend2_sun = weekends[i + 1]

w1_sat_var = employee_works_on_day_variables[employee][weekend1_sat]
w1_sun_var = employee_works_on_day_variables[employee][weekend1_sun]
w2_sat_var = employee_works_on_day_variables[employee][weekend2_sat]
w2_sun_var = employee_works_on_day_variables[employee][weekend2_sun]

# Create boolean variables for weekend status
# Check if weekends are free (both days must be free)
w1_free = model.new_bool_var(f"w1_free_e:{employee.get_key()}_i:{i}")
w2_free = model.new_bool_var(f"w2_free_e:{employee.get_key()}_i:{i}")

Expand All @@ -81,7 +79,16 @@ def create(

model.add(w2_sat_var + w2_sun_var == 0).only_enforce_if(w2_free)
model.add(w2_sat_var + w2_sun_var >= 1).only_enforce_if(w2_free.Not())
same_status_penalty = model.new_bool_var(f"same_status_penalty_e:{employee.get_key()}_i:{i}")

# Penalty = 1 if (w1_free AND w2_free) OR (NOT w1_free AND NOT w2_free)
model.add(same_status_penalty == 1).only_enforce_if([w1_free, w2_free])
model.add(same_status_penalty == 1).only_enforce_if([w1_free.Not(), w2_free.Not()])

# these two penalties seem useless
model.add(same_status_penalty == 0).only_enforce_if([w1_free, w2_free.Not()])
model.add(same_status_penalty == 0).only_enforce_if([w1_free.Not(), w2_free])

penalties.append(same_status_penalty)

# Hard constraint: two consecutive weekends may not both be worked
# At least one of the two consecutive weekends must be free
model.add_bool_or([w1_free, w2_free])
return cast(LinearExpr, sum(penalties) * self.weight)
5 changes: 3 additions & 2 deletions src/solve.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import date

from src.cp import (
EverySecondWeekendFreeConstraint,
EverySecondWeekendFreeObjective,
FreeDayAfterNightShiftPhaseConstraint,
FreeDaysAfterNightShiftPhaseObjective,
FreeDaysNearWeekendObjective,
Expand Down Expand Up @@ -53,6 +53,7 @@ def main(
"rotate": 1,
"wishes": 3,
"after_night": 3,
"second_weekend": 1,
}

logging.info("General information:")
Expand All @@ -75,7 +76,6 @@ def main(
VacationDaysAndShiftsConstraint(employees, days, shifts),
HierarchyOfIntermediateShiftsConstraint(employees, days, shifts),
PlannedShiftsConstraint(employees, days, shifts),
EverySecondWeekendFreeConstraint(employees, days, shifts),
]
objectives = [
FreeDaysNearWeekendObjective(weights["free_weekend"], employees, days),
Expand All @@ -86,6 +86,7 @@ def main(
RotateShiftsForwardObjective(weights["rotate"], employees, days, shifts),
MaximizeEmployeeWishesObjective(weights["wishes"], employees, days, shifts),
FreeDaysAfterNightShiftPhaseObjective(weights["after_night"], employees, days, shifts),
EverySecondWeekendFreeObjective(weights["second_weekend"], employees, days),
]

model = Model(employees, days, shifts)
Expand Down
2 changes: 2 additions & 0 deletions tests/cp/constraints/test_all_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)
from src.cp.model import Model
from src.cp.objectives import (
EverySecondWeekendFreeObjective,
FreeDaysAfterNightShiftPhaseObjective,
FreeDaysNearWeekendObjective,
MaximizeEmployeeWishesObjective,
Expand Down Expand Up @@ -114,6 +115,7 @@ def test_all_constraints_mass_case(
model.add_objective(RotateShiftsForwardObjective(1.0, employees, days, shifts))
model.add_objective(MaximizeEmployeeWishesObjective(3.0, employees, days, shifts))
model.add_objective(FreeDaysAfterNightShiftPhaseObjective(3.0, employees, days, shifts))
model.add_objective(EverySecondWeekendFreeObjective(1.0, employees, days))

model.add_constraint(FreeDayAfterNightShiftPhaseConstraint(employees, days, shifts))
model.add_constraint(HierarchyOfIntermediateShiftsConstraint(employees, days, shifts))
Expand Down
Loading