From c059172e83f79bbcfc901827a33bfe1292bf2704 Mon Sep 17 00:00:00 2001 From: CarryHof Date: Tue, 27 Jan 2026 17:17:18 +0100 Subject: [PATCH 1/9] added processed_solutions/* to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 56d3de2..4380ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ site .env tests/cp/constraints/output_test_all_constraints* tests/cp/constraints/mass* +processed_solutions/* From ee9d3811a448d1429bc8b58f6681f671dd269f78 Mon Sep 17 00:00:00 2001 From: CarryHof Date: Tue, 27 Jan 2026 17:28:11 +0100 Subject: [PATCH 2/9] fixed faulty date in test --- tests/cp/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cp/conftest.py b/tests/cp/conftest.py index b339cd8..96f7798 100644 --- a/tests/cp/conftest.py +++ b/tests/cp/conftest.py @@ -99,8 +99,8 @@ def setup_with_minstaffing( @pytest.fixture def setup_case_77() -> tuple[Model, dict[str, dict[str, dict[str, int]]]]: - start_date = datetime(2025, 11, 1).date() - end_date = datetime(2025, 11, 30).date() + start_date = datetime(2024, 11, 1).date() + end_date = datetime(2024, 11, 30).date() loader = FSLoader(case_id=77, start_date=start_date, end_date=end_date) days = loader.get_days(start_date, end_date) From b7f27781882149eba572b564e63baa88d9549adf Mon Sep 17 00:00:00 2001 From: CarryHof Date: Wed, 28 Jan 2026 10:06:17 +0100 Subject: [PATCH 3/9] fixed working time constraint check and added an objective to ecourage the solver the reduce the amount of active hidden employees (he does not respect it enough at the moment, maybe some optimization outside the model is required) --- src/cp/__init__.py | 3 ++ src/cp/constraints/target_working_time.py | 4 +- src/cp/objectives/__init__.py | 3 ++ .../minimize_hidden_employee_count.py | 54 +++++++++++++++++++ src/solve.py | 2 + .../test_hierarchy_of_intermediate_shifts.py | 2 +- .../constraints/test_target_working_time.py | 13 +++-- 7 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 src/cp/objectives/minimize_hidden_employee_count.py diff --git a/src/cp/__init__.py b/src/cp/__init__.py index bca0471..7b46935 100644 --- a/src/cp/__init__.py +++ b/src/cp/__init__.py @@ -44,6 +44,9 @@ from .objectives import ( MinimizeConsecutiveNightShiftsObjective as MinimizeConsecutiveNightShiftsObjective, ) +from .objectives import ( + MinimizeHiddenEmployeeCountObjective as MinimizeHiddenEmployeeCountObjective, +) from .objectives import ( MinimizeHiddenEmployeesObjective as MinimizeHiddenEmployeesObjective, ) diff --git a/src/cp/constraints/target_working_time.py b/src/cp/constraints/target_working_time.py index 73f0783..a504a17 100644 --- a/src/cp/constraints/target_working_time.py +++ b/src/cp/constraints/target_working_time.py @@ -62,8 +62,8 @@ def create( # target_working_time - TOLERANCE_LESS <= working_time_variable <= target_working_time + TOLERANCE_MORE target_working_time = round( max( - employee.target_working_time * len(available_days) / len(self._days), - -employee.actual_working_time, + employee.target_working_time * len(available_days) / len(self._days) + - employee.actual_working_time, 0, ) ) diff --git a/src/cp/objectives/__init__.py b/src/cp/objectives/__init__.py index b9c5d62..be8c5b0 100644 --- a/src/cp/objectives/__init__.py +++ b/src/cp/objectives/__init__.py @@ -13,6 +13,9 @@ from .minimize_consecutive_night_shifts import ( MinimizeConsecutiveNightShiftsObjective as MinimizeConsecutiveNightShiftsObjective, ) +from .minimize_hidden_employee_count import ( + MinimizeHiddenEmployeeCountObjective as MinimizeHiddenEmployeeCountObjective, +) from .minimize_hidden_employees import ( MinimizeHiddenEmployeesObjective as MinimizeHiddenEmployeesObjective, ) diff --git a/src/cp/objectives/minimize_hidden_employee_count.py b/src/cp/objectives/minimize_hidden_employee_count.py new file mode 100644 index 0000000..a893e7c --- /dev/null +++ b/src/cp/objectives/minimize_hidden_employee_count.py @@ -0,0 +1,54 @@ +from typing import cast + +from ortools.sat.python.cp_model import BoolVarT, CpModel, IntVar, LinearExpr + +from src.day import Day +from src.employee import Employee +from src.shift import Shift + +from ..variables import EmployeeWorksOnDayVariables, ShiftAssignmentVariables +from .objective import Objective + + +class MinimizeHiddenEmployeeCountObjective(Objective): + @property + def KEY(self) -> str: + return "minimize-hidden-employees" + + def __init__( + self, + weight: float, + employees: list[Employee], + days: list[Day], + shifts: list[Shift], + ): + """ + Initializes the objective to minimize overtime for employees. + Overtime is calculated as the difference between the total working time and the target working time. + """ + super().__init__(weight, employees, days, shifts) + + def create( + self, + model: CpModel, + shift_assignment_variables: ShiftAssignmentVariables, + employee_works_on_day_variables: EmployeeWorksOnDayVariables, + ) -> LinearExpr: + hidden_vars: list[IntVar] = [] + + for employee in self._employees: + if not employee.hidden: + continue + + vars: list[BoolVarT] = [] + for day in self._days: + for shift in self._shifts: + var = shift_assignment_variables[employee][day][shift] + vars += [var] + + true_if_not_working = model.new_bool_var(f"hidden_e_is_working:{employee.get_key()}") + model.AddBoolOr(vars + [true_if_not_working]) + + hidden_vars.append(true_if_not_working) + + return cast(LinearExpr, sum(hidden_vars)) * (-1) * self._weight diff --git a/src/solve.py b/src/solve.py index a5db2e2..5add362 100644 --- a/src/solve.py +++ b/src/solve.py @@ -48,6 +48,7 @@ def main( "free_weekend": 2, "consecutive_nights": 2, "hidden": 100, + "hidden_count": 100000, "overtime": 4, "consecutive_days": 1, "rotate": 1, @@ -87,6 +88,7 @@ def main( MaximizeEmployeeWishesObjective(weights["wishes"], employees, days, shifts), FreeDaysAfterNightShiftPhaseObjective(weights["after_night"], employees, days, shifts), EverySecondWeekendFreeObjective(weights["second_weekend"], employees, days), + # MinimizeHiddenEmployeeCountObjective(weights["hidden_count"], employees, days, shifts) ] model = Model(employees, days, shifts) diff --git a/tests/cp/constraints/test_hierarchy_of_intermediate_shifts.py b/tests/cp/constraints/test_hierarchy_of_intermediate_shifts.py index bcb32f1..99b2086 100644 --- a/tests/cp/constraints/test_hierarchy_of_intermediate_shifts.py +++ b/tests/cp/constraints/test_hierarchy_of_intermediate_shifts.py @@ -25,7 +25,7 @@ def find_hierarchy_of_intermediate_shifts_violations( weeks: list[tuple[list[Day], list[Day]]] = [ ( [day - timedelta(i) for i in range(1, 6) if day - timedelta(i) >= days[0]], - [day, day + timedelta(1) if day - timedelta(1) <= days[-1] else day], + [day, day + timedelta(1) if day + timedelta(1) <= days[-1] else day], ) for day in days if day.weekday() == 5 diff --git a/tests/cp/constraints/test_target_working_time.py b/tests/cp/constraints/test_target_working_time.py index 829d9c9..f0b37d0 100644 --- a/tests/cp/constraints/test_target_working_time.py +++ b/tests/cp/constraints/test_target_working_time.py @@ -26,19 +26,18 @@ def find_target_working_time_violations( var = shift_assignment_variables[employee][day][shift] var_keys.append(var) total_hours = total_hours + assignment[var] * shift.duration + + unvavailable_days = set(employee.vacation_days) | set(employee._forbidden_days) # pyright: ignore[reportPrivateUsage] + factor = 1 - len(unvavailable_days) / len(days) if ( - abs( - total_hours - + employee.hidden_actual_working_time - - round(employee.target_working_time * (1 - len(employee.vacation_days) / len(days))) - ) - > 460 + abs(total_hours + employee.hidden_actual_working_time - round(employee.target_working_time * factor)) > 460 + and employee.actual_working_time <= employee.target_working_time * factor ): violations.append( ( {cast(IntVar, var).name: assignment[var] for var in var_keys}, total_hours + employee.hidden_actual_working_time, - round(employee.target_working_time * (1 - len(employee.vacation_days) / len(days))), + round(employee.target_working_time * factor), ) ) return violations From ef27336409755eef5c457d33d0eaa8705a99b2a6 Mon Sep 17 00:00:00 2001 From: hoflinus <35694-hoflinus@user.noreply.git.rwth-aachen.de> Date: Wed, 28 Jan 2026 16:03:53 +0100 Subject: [PATCH 4/9] logic error, gareth fixed it, seems to work now. though "manual" minimization may still be the better option --- .../objectives/minimize_hidden_employee_count.py | 15 ++++++--------- src/solve.py | 5 +++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/cp/objectives/minimize_hidden_employee_count.py b/src/cp/objectives/minimize_hidden_employee_count.py index a893e7c..f5f39f8 100644 --- a/src/cp/objectives/minimize_hidden_employee_count.py +++ b/src/cp/objectives/minimize_hidden_employee_count.py @@ -34,21 +34,18 @@ def create( shift_assignment_variables: ShiftAssignmentVariables, employee_works_on_day_variables: EmployeeWorksOnDayVariables, ) -> LinearExpr: - hidden_vars: list[IntVar] = [] + hidden_employee_work_vars: list[IntVar] = [] for employee in self._employees: if not employee.hidden: continue - vars: list[BoolVarT] = [] + hidden_employee_is_used = model.new_bool_var(f"hidden_employee_is_used_{employee.get_key()}") for day in self._days: - for shift in self._shifts: - var = shift_assignment_variables[employee][day][shift] - vars += [var] + model.Add(employee_works_on_day_variables[employee][day] <= hidden_employee_is_used) - true_if_not_working = model.new_bool_var(f"hidden_e_is_working:{employee.get_key()}") - model.AddBoolOr(vars + [true_if_not_working]) + hidden_employee_work_vars.append(hidden_employee_is_used) + + return cast(LinearExpr, sum(hidden_employee_work_vars)) * self._weight - hidden_vars.append(true_if_not_working) - return cast(LinearExpr, sum(hidden_vars)) * (-1) * self._weight diff --git a/src/solve.py b/src/solve.py index 5add362..f09fd63 100644 --- a/src/solve.py +++ b/src/solve.py @@ -22,6 +22,7 @@ RoundsInEarlyShiftConstraint, TargetWorkingTimeConstraint, VacationDaysAndShiftsConstraint, + MinimizeHiddenEmployeeCountObjective ) from src.loader import FSLoader @@ -48,7 +49,7 @@ def main( "free_weekend": 2, "consecutive_nights": 2, "hidden": 100, - "hidden_count": 100000, + "hidden_count": 1000000, "overtime": 4, "consecutive_days": 1, "rotate": 1, @@ -88,7 +89,7 @@ def main( MaximizeEmployeeWishesObjective(weights["wishes"], employees, days, shifts), FreeDaysAfterNightShiftPhaseObjective(weights["after_night"], employees, days, shifts), EverySecondWeekendFreeObjective(weights["second_weekend"], employees, days), - # MinimizeHiddenEmployeeCountObjective(weights["hidden_count"], employees, days, shifts) + MinimizeHiddenEmployeeCountObjective(weights["hidden_count"], employees, days, shifts) ] model = Model(employees, days, shifts) From f47673c864818135910ec889201f1017abafe90d Mon Sep 17 00:00:00 2001 From: hoflinus <35694-hoflinus@user.noreply.git.rwth-aachen.de> Date: Wed, 28 Jan 2026 16:21:42 +0100 Subject: [PATCH 5/9] ruff --- src/cp/objectives/minimize_hidden_employee_count.py | 4 +--- src/solve.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/cp/objectives/minimize_hidden_employee_count.py b/src/cp/objectives/minimize_hidden_employee_count.py index f5f39f8..aacb584 100644 --- a/src/cp/objectives/minimize_hidden_employee_count.py +++ b/src/cp/objectives/minimize_hidden_employee_count.py @@ -1,6 +1,6 @@ from typing import cast -from ortools.sat.python.cp_model import BoolVarT, CpModel, IntVar, LinearExpr +from ortools.sat.python.cp_model import CpModel, IntVar, LinearExpr from src.day import Day from src.employee import Employee @@ -47,5 +47,3 @@ def create( hidden_employee_work_vars.append(hidden_employee_is_used) return cast(LinearExpr, sum(hidden_employee_work_vars)) * self._weight - - diff --git a/src/solve.py b/src/solve.py index f09fd63..926c43d 100644 --- a/src/solve.py +++ b/src/solve.py @@ -11,6 +11,7 @@ MaximizeEmployeeWishesObjective, MaxOneShiftPerDayConstraint, MinimizeConsecutiveNightShiftsObjective, + MinimizeHiddenEmployeeCountObjective, MinimizeHiddenEmployeesObjective, MinimizeOvertimeObjective, MinRestTimeConstraint, @@ -22,7 +23,6 @@ RoundsInEarlyShiftConstraint, TargetWorkingTimeConstraint, VacationDaysAndShiftsConstraint, - MinimizeHiddenEmployeeCountObjective ) from src.loader import FSLoader @@ -89,7 +89,7 @@ def main( MaximizeEmployeeWishesObjective(weights["wishes"], employees, days, shifts), FreeDaysAfterNightShiftPhaseObjective(weights["after_night"], employees, days, shifts), EverySecondWeekendFreeObjective(weights["second_weekend"], employees, days), - MinimizeHiddenEmployeeCountObjective(weights["hidden_count"], employees, days, shifts) + MinimizeHiddenEmployeeCountObjective(weights["hidden_count"], employees, days, shifts), ] model = Model(employees, days, shifts) From f155ca4bfb950062827c9d2f7cd4dd08398c4ee8 Mon Sep 17 00:00:00 2001 From: hoflinus <35694-hoflinus@user.noreply.git.rwth-aachen.de> Date: Sat, 14 Feb 2026 14:58:14 +0100 Subject: [PATCH 6/9] exchanged min hidden employee count objective with a small scheme similar to the one discussed in the corresponding issue --- src/cp/constraints/planned_shifts.py | 2 +- src/loader/filesystem_loader.py | 23 ++++++- src/main.py | 9 ++- src/solve.py | 89 +++++++++++++++++++++++++--- src/web/process_solution.py | 7 ++- 5 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src/cp/constraints/planned_shifts.py b/src/cp/constraints/planned_shifts.py index 093e6d5..d35d3ee 100644 --- a/src/cp/constraints/planned_shifts.py +++ b/src/cp/constraints/planned_shifts.py @@ -43,7 +43,7 @@ def create( # Map the shift code to ID shift_id = Shift.SHIFT_MAPPING.get(shift_code) if shift_id is None: - logging.warning(f"Unknown shift code: {shift_code} for {employee.name}") + # logging.warning(f"Unknown shift code: {shift_code} for {employee.name}") # Case 77 and case 3 both have shifts that trigger this case (e.g. see "Rodriques"). # I think this may originate in the fact that all shifts are hardcoded :( # print(f"\n\nUnknown shift code: {shift_code} for {employee.name}\n\n") diff --git a/src/loader/filesystem_loader.py b/src/loader/filesystem_loader.py index a24682c..27c529c 100644 --- a/src/loader/filesystem_loader.py +++ b/src/loader/filesystem_loader.py @@ -158,10 +158,31 @@ def get_employees(self, start: int = 0) -> list[Employee]: ) ) - employees += super().get_employees(len(employees)) + # employees += super().get_employees(len(employees)) + + # employees += self.get_hidden_employees({"Azubi":3,"Fachkraft":3,"Hilfskraft":3}) return employees + @staticmethod + def get_hidden_employees(num_hidden_employees_per_level: dict[str, int], start: int = 0): + hidden_employees: list[Employee] = [] + last_id = start + for level, num in num_hidden_employees_per_level.items(): + for new_id in range(last_id, last_id + num): + hidden_employees.append( + Employee( + key=new_id, + name="Hidden", + surname=f"{level}{new_id}", + level=level, + type="hidden", + ) + ) + last_id += num + + return hidden_employees + # shouldnt this be a static function? def get_shifts(self) -> list[Shift]: base_shifts = [ diff --git a/src/main.py b/src/main.py index 2980730..c08c4ac 100644 --- a/src/main.py +++ b/src/main.py @@ -34,7 +34,7 @@ def solve(unit: int, start: datetime, end: datetime, timeout: int): click.echo(f"Creating staff schedule for planning unit {unit} from {start.date()} to {end.date()}.") - solver( + employees, _, _ = solver( unit=unit, start_date=start.date(), end_date=end.date(), @@ -45,7 +45,12 @@ def solve(unit: int, start: datetime, end: datetime, timeout: int): solution_name = f"solution_{unit}_{start.date()}-{end.date()}_wdefault" - process_solution(loader=loader, output_filename=solution_name + "_processed.json", solution_file_name=solution_name) + process_solution( + loader=loader, + employees=employees, + output_filename=solution_name + "_processed.json", + solution_file_name=solution_name, + ) @cli.command("solve-multiple") diff --git a/src/solve.py b/src/solve.py index 926c43d..b3770ad 100644 --- a/src/solve.py +++ b/src/solve.py @@ -1,7 +1,10 @@ import logging +import time from collections.abc import Mapping from datetime import date +from ortools.sat.python.cp_model import CpSolver + from src.cp import ( EverySecondWeekendFreeObjective, FreeDayAfterNightShiftPhaseConstraint, @@ -11,7 +14,7 @@ MaximizeEmployeeWishesObjective, MaxOneShiftPerDayConstraint, MinimizeConsecutiveNightShiftsObjective, - MinimizeHiddenEmployeeCountObjective, + # MinimizeHiddenEmployeeCountObjective, MinimizeHiddenEmployeesObjective, MinimizeOvertimeObjective, MinRestTimeConstraint, @@ -24,13 +27,48 @@ TargetWorkingTimeConstraint, VacationDaysAndShiftsConstraint, ) +from src.day import Day +from src.employee import Employee from src.loader import FSLoader +from src.shift import Shift logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") MAX_CONSECUTIVE_DAYS = 5 +def solve_with_constraints_only( + employees: list[Employee], days: list[Day], shifts: list[Shift], min_staffing: dict[str, dict[str, dict[str, int]]] +) -> str: + constraints = [ + FreeDayAfterNightShiftPhaseConstraint(employees, days, shifts), + MinRestTimeConstraint(employees, days, shifts), + MinStaffingConstraint(min_staffing, employees, days, shifts), + RoundsInEarlyShiftConstraint(employees, days, shifts), + MaxOneShiftPerDayConstraint(employees, days, shifts), + TargetWorkingTimeConstraint(employees, days, shifts), + VacationDaysAndShiftsConstraint(employees, days, shifts), + HierarchyOfIntermediateShiftsConstraint(employees, days, shifts), + PlannedShiftsConstraint(employees, days, shifts), + ] + + model = Model(employees, days, shifts) + for constraint in constraints: + model.add_constraint(constraint) + + solver: CpSolver = CpSolver() + solver.parameters.num_workers = 8 + solver.parameters.max_time_in_seconds = 5 + solver.parameters.linearization_level = 0 + + start = time.time() + solver.solve(model.cpModel) + end = time.time() + logging.info(f"Wall time: {end - start}") + + return CpSolver.StatusName(solver) + + def main( unit: int, start_date: date, @@ -43,6 +81,43 @@ def main( employees = loader.get_employees() days = loader.get_days(start_date, end_date) shifts = loader.get_shifts() + min_staffing = loader.get_min_staffing() + + # minimize hidden employees "manually" + + # phase 1 - find an upper bound + # increase the hidden employee count raptitly for all classes simultaniously + logging.info("Minimizing Hidden Employee Count Phase 1") + status = "INFEASIBLE" + increase = 5 + num_hidden_employees_per_level = {"Azubi": -increase, "Fachkraft": -increase, "Hilfskraft": -increase} + while (status == "INFEASIBLE" or status == "UNKNOWN") and sum(num_hidden_employees_per_level.values()) <= 50: + num_hidden_employees_per_level = {x: y + increase for (x, y) in num_hidden_employees_per_level.items()} + logging.info(f"Trying to solve with {num_hidden_employees_per_level}") + status = solve_with_constraints_only( + employees + FSLoader.get_hidden_employees(num_hidden_employees_per_level), days, shifts, min_staffing + ) + logging.info(f'Solver returned status = "{status}"') + logging.info(f"Hidden Employee Upper Bound: {num_hidden_employees_per_level}\n") + + # phase 2 - find tight bounds + # for each employee level lower the count unti it is tight + logging.info("Minimizing Hidden Employee Count Phase 2") + for level, value in num_hidden_employees_per_level.items(): + tmp = num_hidden_employees_per_level + for i in range(value - 1, -1, -1): + tmp[level] = i + logging.info(f"Trying to solve with {tmp}") + status = solve_with_constraints_only( + employees + FSLoader.get_hidden_employees(tmp), days, shifts, min_staffing + ) + logging.info(f'Solver returned status = "{status}"') + if status == "INFEASIBLE" or status == "UNKNOWN": + num_hidden_employees_per_level[level] = i + 1 + break + logging.info(f"Hidden Employee Tight Bound: {num_hidden_employees_per_level}\n") + + employees += FSLoader.get_hidden_employees(num_hidden_employees_per_level) if weights is None: weights = { @@ -66,8 +141,6 @@ def main( logging.info(f" - number of days: {len(days)}") logging.info(f" - number of shifts: {len(shifts)}") - min_staffing = loader.get_min_staffing() - constraints = [ FreeDayAfterNightShiftPhaseConstraint(employees, days, shifts), MinRestTimeConstraint(employees, days, shifts), @@ -89,18 +162,20 @@ def main( MaximizeEmployeeWishesObjective(weights["wishes"], employees, days, shifts), FreeDaysAfterNightShiftPhaseObjective(weights["after_night"], employees, days, shifts), EverySecondWeekendFreeObjective(weights["second_weekend"], employees, days), - MinimizeHiddenEmployeeCountObjective(weights["hidden_count"], employees, days, shifts), + # MinimizeHiddenEmployeeCountObjective(weights["hidden_count"], employees, days, shifts), ] model = Model(employees, days, shifts) - for objective in objectives: - model.add_objective(objective) - for constraint in constraints: model.add_constraint(constraint) + for objective in objectives: + model.add_objective(objective) + solution = model.solve(timeout) wid = weight_id if weight_id is not None else "default" solution_name = f"solution_{unit}_{start_date}-{end_date}_w{wid}" loader.write_solution(solution, solution_name) + + return employees, days, shifts diff --git a/src/web/process_solution.py b/src/web/process_solution.py index 5eed6f3..85b1667 100644 --- a/src/web/process_solution.py +++ b/src/web/process_solution.py @@ -105,9 +105,12 @@ def collect_day_information(solution: Solution, employees: list[Employee], shift def process_solution( - loader: Loader, output_filename: str = "processed_solution.json", solution_file_name: str | None = None + loader: Loader, + employees: list[Employee], + output_filename: str = "processed_solution.json", + solution_file_name: str | None = None, ): - employees = loader.get_employees() + # employees = loader.get_employees() shifts = loader.get_shifts() solution_files = loader.load_solution_file_names() From 3f7b99246e57aed73e7420915255c2ab550a7e8b Mon Sep 17 00:00:00 2001 From: CarryHof Date: Sat, 14 Feb 2026 15:38:12 +0100 Subject: [PATCH 7/9] fixed small error in solve_multiple --- src/main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index c08c4ac..cc33d0d 100644 --- a/src/main.py +++ b/src/main.py @@ -111,7 +111,7 @@ def solve_multiple(unit: int, start: datetime, end: datetime, timeout: int): f"with weight set {weight_id}" ) - solver( + employees, _, _ = solver( unit=unit, start_date=start.date(), end_date=end.date(), @@ -123,7 +123,9 @@ def solve_multiple(unit: int, start: datetime, end: datetime, timeout: int): in_name = f"solution_{unit}_{start.date()}-{end.date()}_w{weight_id}" - process_solution(loader=loader, output_filename=in_name + "_processed.json", solution_file_name=in_name) + process_solution( + loader=loader, employees=employees, output_filename=in_name + "_processed.json", solution_file_name=in_name + ) @cli.command() From b68c01b85a810a1ffe1c33ed98353dbfd16590c3 Mon Sep 17 00:00:00 2001 From: CarryHof Date: Sat, 14 Feb 2026 16:19:57 +0100 Subject: [PATCH 8/9] made multiple solutions work more efficiently with the new minimization scheeme --- src/main.py | 2 ++ src/solve.py | 68 +++++++++++++++++++++++++++------------------------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/main.py b/src/main.py index cc33d0d..1a4e18d 100644 --- a/src/main.py +++ b/src/main.py @@ -104,6 +104,7 @@ def solve_multiple(unit: int, start: datetime, end: datetime, timeout: int): }, ] + employees = None for weight_id, weights in enumerate(weight_sets): click.echo( "Creating staff schedule for planning unit " @@ -118,6 +119,7 @@ def solve_multiple(unit: int, start: datetime, end: datetime, timeout: int): timeout=timeout, weights=weights, weight_id=weight_id, + employees=employees, ) loader = FSLoader(unit, start_date=start.date(), end_date=end.date()) diff --git a/src/solve.py b/src/solve.py index b3770ad..6b89931 100644 --- a/src/solve.py +++ b/src/solve.py @@ -76,48 +76,50 @@ def main( timeout: int, weights: Mapping[str, int | float] | None = None, weight_id: int | None = None, + employees: list[Employee] | None = None, ): loader = FSLoader(unit, start_date=start_date, end_date=end_date) - employees = loader.get_employees() days = loader.get_days(start_date, end_date) shifts = loader.get_shifts() min_staffing = loader.get_min_staffing() - # minimize hidden employees "manually" - - # phase 1 - find an upper bound - # increase the hidden employee count raptitly for all classes simultaniously - logging.info("Minimizing Hidden Employee Count Phase 1") - status = "INFEASIBLE" - increase = 5 - num_hidden_employees_per_level = {"Azubi": -increase, "Fachkraft": -increase, "Hilfskraft": -increase} - while (status == "INFEASIBLE" or status == "UNKNOWN") and sum(num_hidden_employees_per_level.values()) <= 50: - num_hidden_employees_per_level = {x: y + increase for (x, y) in num_hidden_employees_per_level.items()} - logging.info(f"Trying to solve with {num_hidden_employees_per_level}") - status = solve_with_constraints_only( - employees + FSLoader.get_hidden_employees(num_hidden_employees_per_level), days, shifts, min_staffing - ) - logging.info(f'Solver returned status = "{status}"') - logging.info(f"Hidden Employee Upper Bound: {num_hidden_employees_per_level}\n") - - # phase 2 - find tight bounds - # for each employee level lower the count unti it is tight - logging.info("Minimizing Hidden Employee Count Phase 2") - for level, value in num_hidden_employees_per_level.items(): - tmp = num_hidden_employees_per_level - for i in range(value - 1, -1, -1): - tmp[level] = i - logging.info(f"Trying to solve with {tmp}") + if employees is None: + employees = loader.get_employees() + # minimize hidden employees "manually" + + # phase 1 - find an upper bound + # increase the hidden employee count raptitly for all classes simultaniously + logging.info("Minimizing Hidden Employee Count Phase 1") + status = "INFEASIBLE" + increase = 5 + num_hidden_employees_per_level = {"Azubi": -increase, "Fachkraft": -increase, "Hilfskraft": -increase} + while (status == "INFEASIBLE" or status == "UNKNOWN") and sum(num_hidden_employees_per_level.values()) <= 50: + num_hidden_employees_per_level = {x: y + increase for (x, y) in num_hidden_employees_per_level.items()} + logging.info(f"Trying to solve with {num_hidden_employees_per_level}") status = solve_with_constraints_only( - employees + FSLoader.get_hidden_employees(tmp), days, shifts, min_staffing + employees + FSLoader.get_hidden_employees(num_hidden_employees_per_level), days, shifts, min_staffing ) logging.info(f'Solver returned status = "{status}"') - if status == "INFEASIBLE" or status == "UNKNOWN": - num_hidden_employees_per_level[level] = i + 1 - break - logging.info(f"Hidden Employee Tight Bound: {num_hidden_employees_per_level}\n") - - employees += FSLoader.get_hidden_employees(num_hidden_employees_per_level) + logging.info(f"Hidden Employee Upper Bound: {num_hidden_employees_per_level}\n") + + # phase 2 - find tight bounds + # for each employee level lower the count unti it is tight + logging.info("Minimizing Hidden Employee Count Phase 2") + for level, value in num_hidden_employees_per_level.items(): + tmp = num_hidden_employees_per_level + for i in range(value - 1, -1, -1): + tmp[level] = i + logging.info(f"Trying to solve with {tmp}") + status = solve_with_constraints_only( + employees + FSLoader.get_hidden_employees(tmp), days, shifts, min_staffing + ) + logging.info(f'Solver returned status = "{status}"') + if status == "INFEASIBLE" or status == "UNKNOWN": + num_hidden_employees_per_level[level] = i + 1 + break + logging.info(f"Hidden Employee Tight Bound: {num_hidden_employees_per_level}\n") + + employees += FSLoader.get_hidden_employees(num_hidden_employees_per_level) if weights is None: weights = { From 12b7af6893e919e49ffcda3e3d3272171ce077d3 Mon Sep 17 00:00:00 2001 From: CarryHof Date: Sat, 14 Feb 2026 16:50:45 +0100 Subject: [PATCH 9/9] adjusted tests accordingly --- tests/cp/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cp/conftest.py b/tests/cp/conftest.py index 96f7798..90f5f09 100644 --- a/tests/cp/conftest.py +++ b/tests/cp/conftest.py @@ -104,7 +104,7 @@ def setup_case_77() -> tuple[Model, dict[str, dict[str, dict[str, int]]]]: loader = FSLoader(case_id=77, start_date=start_date, end_date=end_date) days = loader.get_days(start_date, end_date) - employees = loader.get_employees() + employees = loader.get_employees() + FSLoader.get_hidden_employees({"Azubi": 3, "Fachkraft": 3, "Hilfskraft": 3}) shifts = loader.get_shifts() min_staffing = loader.get_min_staffing()