From 12861c94697e4e37a88887e15e2b8ea0b950a77a Mon Sep 17 00:00:00 2001 From: poveda_g Date: Fri, 14 Aug 2026 22:05:39 +0200 Subject: [PATCH 01/10] new problem, rcpsp with alternative scheduling subproblems. Adapted rcpsp utils to work with this new problem --- examples/rcpsp_alternative/run_cpsat.py | 35 +++ src/discrete_optimization/rcpsp/problem.py | 26 +- .../rcpsp/solvers/cpsat.py | 6 +- src/discrete_optimization/rcpsp/utils.py | 8 +- .../rcpsp_alternative/__init__.py | 0 .../rcpsp_alternative/problem.py | 194 +++++++++++++ .../rcpsp_alternative/solvers/__init__.py | 0 .../rcpsp_alternative/solvers/cpsat.py | 255 ++++++++++++++++++ .../rcpsp_alternative/utils.py | 89 ++++++ 9 files changed, 594 insertions(+), 19 deletions(-) create mode 100644 examples/rcpsp_alternative/run_cpsat.py create mode 100644 src/discrete_optimization/rcpsp_alternative/__init__.py create mode 100644 src/discrete_optimization/rcpsp_alternative/problem.py create mode 100644 src/discrete_optimization/rcpsp_alternative/solvers/__init__.py create mode 100644 src/discrete_optimization/rcpsp_alternative/solvers/cpsat.py create mode 100644 src/discrete_optimization/rcpsp_alternative/utils.py diff --git a/examples/rcpsp_alternative/run_cpsat.py b/examples/rcpsp_alternative/run_cpsat.py new file mode 100644 index 000000000..2448143a3 --- /dev/null +++ b/examples/rcpsp_alternative/run_cpsat.py @@ -0,0 +1,35 @@ +# Copyright (c) 2026 AIRBUS and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +from discrete_optimization.generic_tools.cp_tools import ParametersCp +from discrete_optimization.rcpsp.parser import get_data_available, parse_file +from discrete_optimization.rcpsp.utils import plot_ressource_view, plot_task_gantt, plt +from discrete_optimization.rcpsp_alternative.problem import get_optional_tasks_done +from discrete_optimization.rcpsp_alternative.solvers.cpsat import ( + CpsatRcpspWithAlternativePathSolver, +) +from discrete_optimization.rcpsp_alternative.utils import create_problem_rcpsp + + +def run_cpsat(): + problem = parse_file([f for f in get_data_available() if "j601_1.sm" in f][0]) + problem = create_problem_rcpsp( + problem, + nb_alternative_paths=5, + range_nb_subpath=(1, 4), + range_len_subpath=(3, 5), + ) + solver = CpsatRcpspWithAlternativePathSolver(problem) + solver.init_model(strict_alternative_path=True) + res = solver.solve(parameters_cp=ParametersCp.default_cpsat(), time_limit=30) + sol = res[-1][0] + print(problem.evaluate(sol), problem.satisfy(sol)) + print(get_optional_tasks_done(sol, problem)) + plot_task_gantt(problem, sol) + plot_ressource_view(problem, sol) + plt.show() + + +if __name__ == "__main__": + run_cpsat() diff --git a/src/discrete_optimization/rcpsp/problem.py b/src/discrete_optimization/rcpsp/problem.py index 59326e9cd..6ec344e8f 100644 --- a/src/discrete_optimization/rcpsp/problem.py +++ b/src/discrete_optimization/rcpsp/problem.py @@ -560,8 +560,8 @@ def compute_graph(self, compute_predecessors: bool = False) -> Graph: ( n, { - str(mode): self.mode_details[n][mode]["duration"] - for mode in self.mode_details[n] + str(mode): self.get_task_mode_duration(n, mode) + for mode in self.get_task_modes(n) }, ) for n in self.tasks_list @@ -846,19 +846,19 @@ def create_np_data_and_jit_functions( ressource_renewable = np.ones((len(rcpsp_problem.resources_list)), dtype=bool) minimum_starting_time_array = np.zeros(rcpsp_problem.n_jobs, dtype=np.int_) - for i in range(len(rcpsp_problem.tasks_list)): + for i in range(rcpsp_problem.n_jobs): task = rcpsp_problem.tasks_list[i] index_mode = 0 - for mode in sorted( - rcpsp_problem.mode_details[rcpsp_problem.tasks_list[i]].keys() - ): + for mode in sorted(rcpsp_problem.get_task_modes(rcpsp_problem.tasks_list[i])): for k in range(len(rcpsp_problem.resources_list)): - consumption_array[i, index_mode, k] = rcpsp_problem.mode_details[task][ - mode - ].get(rcpsp_problem.resources_list[k], 0) - duration_array[i, index_mode] = rcpsp_problem.mode_details[task][mode][ - "duration" - ] + consumption_array[i, index_mode, k] = ( + rcpsp_problem.get_cumulative_resource_consumption( + rcpsp_problem.resources_list[k], task, mode + ) + ) + duration_array[i, index_mode] = rcpsp_problem.get_task_mode_duration( + task, mode + ) index_mode += 1 task_index = {rcpsp_problem.tasks_list[i]: i for i in range(rcpsp_problem.n_jobs)} @@ -876,7 +876,7 @@ def create_np_data_and_jit_functions( if rcpsp_problem.resources_list[k] in rcpsp_problem.non_renewable_resources: ressource_renewable[k] = False - for i in range(len(rcpsp_problem.tasks_list)): + for i in range(rcpsp_problem.n_jobs): task = rcpsp_problem.tasks_list[i] for s in rcpsp_problem.successors[task]: index_s = task_index[s] diff --git a/src/discrete_optimization/rcpsp/solvers/cpsat.py b/src/discrete_optimization/rcpsp/solvers/cpsat.py index c9d79ee6f..d0078c14d 100644 --- a/src/discrete_optimization/rcpsp/solvers/cpsat.py +++ b/src/discrete_optimization/rcpsp/solvers/cpsat.py @@ -118,15 +118,15 @@ def init_temporal_variable( starts_var[task] = model.NewIntVar(lb=lbs, ub=ubs, name=f"start_{task}") ends_var[task] = model.NewIntVar(lb=lbe, ub=ube, name=f"end_{task}") interval_per_tasks = {} - for task in self.problem.mode_details: + for task in self.problem.tasks_list: interval_per_tasks[task] = set() - for mode in self.problem.mode_details[task]: + for mode in self.problem.get_task_modes(task): is_present_var[(task, mode)] = model.NewBoolVar( f"is_present_{task, mode}" ) interval_var[(task, mode)] = model.NewOptionalIntervalVar( start=starts_var[task], - size=self.problem.mode_details[task][mode]["duration"], + size=self.problem.get_task_mode_duration(task, mode), end=ends_var[task], is_present=is_present_var[(task, mode)], name=f"interval_{task, mode}", diff --git a/src/discrete_optimization/rcpsp/utils.py b/src/discrete_optimization/rcpsp/utils.py index 24997f9f9..78970db48 100644 --- a/src/discrete_optimization/rcpsp/utils.py +++ b/src/discrete_optimization/rcpsp/utils.py @@ -51,8 +51,8 @@ def compute_resource_consumption( consumptions = np.zeros((len(list_resources), makespan + 1), dtype=np.int_) for act_id in rcpsp_sol.rcpsp_schedule: for ir in range(len(list_resources)): - use_ir = rcpsp_problem.mode_details[act_id][modes_dict[act_id]].get( - list_resources[ir], 0 + use_ir = rcpsp_problem.get_cumulative_resource_consumption( + list_resources[ir], act_id, modes_dict[act_id] ) if future_view: consumptions[ @@ -127,7 +127,9 @@ def plot_ressource_view( time_start = rcpsp_sol.rcpsp_schedule[j]["start_time"] time_end = rcpsp_sol.rcpsp_schedule[j]["end_time"] for i in range(len(list_resource)): - cons = rcpsp_problem.mode_details[j][modes_dict[j]].get(list_resource[i], 0) + cons = rcpsp_problem.get_cumulative_resource_consumption( + list_resource[i], j, modes_dict[j] + ) if cons == 0: continue bound: int = int(rcpsp_problem.get_max_resource_capacity(list_resource[i])) diff --git a/src/discrete_optimization/rcpsp_alternative/__init__.py b/src/discrete_optimization/rcpsp_alternative/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/discrete_optimization/rcpsp_alternative/problem.py b/src/discrete_optimization/rcpsp_alternative/problem.py new file mode 100644 index 000000000..c918aca29 --- /dev/null +++ b/src/discrete_optimization/rcpsp_alternative/problem.py @@ -0,0 +1,194 @@ +# Copyright (c) 2026 AIRBUS and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +# Implementation of RCPSP with optional alternative subproblems +# Between 2 mandatory task, different subpath of task should be accomplished (with or without precedence constraints). +# This problem can represent different alternative physical path of task to accomplish on a shop floor. +import logging +from dataclasses import dataclass +from typing import Any, Hashable, Optional, Union + +import networkx as nx + +from discrete_optimization.rcpsp.problem import RcpspProblem, RcpspSolution +from discrete_optimization.rcpsp.solution import RcpspSolution, Resource, Task +from discrete_optimization.rcpsp.special_constraints import ( + SpecialConstraintsDescription, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class AlternativeSchedulingSubProblem: + source_task: Hashable # (mandatory task from which originates the alternative) + sink_task: Hashable # (mandatory task from which finished the alternative) + graph: Optional[nx.DiGraph] = None + is_graph_successors: bool = True + successors: dict[Hashable, list[Hashable]] = None + list_paths: Optional[list[list[Hashable]]] = None + is_path_successors: bool = True + nb_path_to_do: int = 1 # by default 1 subpath to follow. + + def __post_init__(self): + if self.graph is None: + if self.list_paths: + graph = nx.DiGraph() + graph.add_node(self.source_task) + graph.add_node(self.sink_task) + for p in self.list_paths: + for e0, e1 in zip(p[:-1], p[1:]): + graph.add_edge(e0, e1) + if p[0] != self.source_task: + graph.add_edge(self.source_task, p[0]) + if p[-1] != self.sink_task: + graph.add_edge(self.source_task, p[0]) + self.graph = graph + if self.source_task not in self.graph.nodes: + predecessors = { + n: list(self.graph.predecessors(n)) for n in self.graph.nodes + } + self.graph.add_node(self.source_task) + for n in predecessors: + if len(predecessors[n]) == 0: + self.graph.add_edge(self.source_task, n) + if self.sink_task not in self.graph.nodes: + successors = {n: list(self.graph.successors(n)) for n in self.graph.nodes} + self.graph.add_node(self.sink_task) + for n in successors: + if len(successors[n]) == 0: + self.graph.add_edge(n, self.sink_task) + if self.list_paths is None: + self.list_paths = list( + nx.all_simple_paths(self.graph, self.source_task, self.sink_task) + ) + + +class RcpspWithAlternativePath(RcpspProblem): + def __init__( + self, + resources: dict[str, Union[int, list[int]]], + non_renewable_resources: list[str], + mode_details: dict[Hashable, dict[int, dict[str, int]]], + successors: dict[Hashable, list[Hashable]], + horizon: int, + tasks_list: Optional[list[Hashable]] = None, + source_task: Optional[Hashable] = None, + sink_task: Optional[Hashable] = None, + name_task: Optional[dict[Hashable, str]] = None, + calendar_details: Optional[dict[str, list[list[int]]]] = None, + special_constraints: Optional[SpecialConstraintsDescription] = None, + fixed_permutation: Optional[list[int]] = None, + fixed_modes: Optional[list[int]] = None, + alternative_tasks: Optional[list[Hashable]] = None, + alternative_tasks_data: dict[Hashable, dict[int, dict[str, int]]] = None, + alternative_successors: dict[Hashable, list[Hashable]] = None, + list_alternative_subproblem: list[AlternativeSchedulingSubProblem] = None, + **kwargs: Any, + ): + """ + Extension of RCPSPProblem, including + :param alternative_tasks: tasks that are not mandatory + :param alternative_tasks_data: data of the tasks when they are done (duration, resource usage), + given per mode (like mode_details attribute) + :param alternative_successors: successors of optional task (when active). + The successors can be either optional or mandatory task. + :param list_alternative_subproblem: list of alternative scheduling subproblem, describing the alternative paths. + """ + self.alternative_tasks = alternative_tasks + self.alternative_tasks_data = alternative_tasks_data + self.alternative_successors = alternative_successors + self.list_alternative_subproblem = list_alternative_subproblem + super().__init__( + resources=resources, + non_renewable_resources=non_renewable_resources, + mode_details=mode_details, + successors=successors, + horizon=horizon, + tasks_list=tasks_list, + source_task=source_task, + sink_task=sink_task, + name_task=name_task, + calendar_details=calendar_details, + special_constraints=special_constraints, + fixed_permutation=fixed_permutation, + fixed_modes=fixed_modes, + **kwargs, + ) + self.n_jobs = len(self.tasks_list) + self.n_jobs_non_dummy = self.n_jobs - 2 + self.index_task = {self.tasks_list[i]: i for i in range(self.n_jobs)} + self.tasks_list_non_dummy = [ + t for t in self.tasks_list if t not in {self.source_task, self.sink_task} + ] + self.index_task_non_dummy = { + self.tasks_list_non_dummy[i]: i for i in range(self.n_jobs_non_dummy) + } + + @property + def tasks_list(self) -> list[Task]: + return self._tasks_list + self.alternative_tasks + + def get_cumulative_resource_consumption( + self, resource: Resource, task: Task, mode: int + ) -> int: + if task in self.mode_details: + return self.mode_details[task][mode].get(resource, 0) + if task in self.alternative_tasks_data: + if mode is None: + return 0 + return self.alternative_tasks_data[task][mode].get(resource, 0) + return 0 + + def get_task_mode_duration(self, task: Task, mode: int) -> int: + if task in self._tasks_list: + return self.mode_details[task][mode]["duration"] + if task in self.alternative_tasks: + return self.alternative_tasks_data[task][mode]["duration"] + return 0 + + def get_task_modes(self, task: Task) -> set[int]: + if task in self._tasks_list: + return set(self.mode_details[task]) + if task in self.alternative_tasks: + return set(self.alternative_tasks_data[task]) + return None + + def satisfy(self, variable: RcpspSolution) -> bool: + sat = super().satisfy(variable) + if not sat: + return False + for alt_problem in self.list_alternative_subproblem: + paths = alt_problem.list_paths + nb_path_done = 0 + paths_done = [] + for p in paths: + if all( + task in variable.rcpsp_schedule + and variable.get_mode(task) is not None + for task in p + ): + nb_path_done += 1 + paths_done.append(p) + if nb_path_done > alt_problem.nb_path_to_do: + logger.info("Too much alternative path") + return False + if nb_path_done < alt_problem.nb_path_to_do: + logger.info("Not enough alternative path") + return False + if alt_problem.is_path_successors: + for p in paths_done: + for t0, t1 in zip(p[:-1], p[1:]): + if not ( + variable.get_end_time(t0) <= variable.get_start_time(t1) + ): + logger.info( + "Precedence constraints not respected in the final schedule" + ) + logger.info(f"between {t1} and {t0}") + return False + return True + + +def get_optional_tasks_done(sol: RcpspSolution, problem: RcpspWithAlternativePath): + return [t for t in problem.alternative_tasks if sol.get_mode(t) is not None] diff --git a/src/discrete_optimization/rcpsp_alternative/solvers/__init__.py b/src/discrete_optimization/rcpsp_alternative/solvers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/discrete_optimization/rcpsp_alternative/solvers/cpsat.py b/src/discrete_optimization/rcpsp_alternative/solvers/cpsat.py new file mode 100644 index 000000000..14dd7d818 --- /dev/null +++ b/src/discrete_optimization/rcpsp_alternative/solvers/cpsat.py @@ -0,0 +1,255 @@ +# Copyright (c) 2026 AIRBUS and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +from functools import reduce + +from ortools.sat.python.cp_model import CpSolverSolutionCallback + +from discrete_optimization.generic_tasks_tools.solvers.cpsat.scheduling import ( + SchedulingCpSatSolver, +) +from discrete_optimization.generic_tools.hyperparameters.hyperparameter import ( + CategoricalHyperparameter, +) +from discrete_optimization.rcpsp.solution import RcpspSolution +from discrete_optimization.rcpsp.solvers.cpsat import CpSatRcpspSolver +from discrete_optimization.rcpsp_alternative.problem import ( + AlternativeSchedulingSubProblem, + RcpspWithAlternativePath, +) + + +class CpsatRcpspWithAlternativePathSolver(CpSatRcpspSolver): + hyperparameters = [ + CategoricalHyperparameter( + name="strict_alternative_path", choices=[True, False], default=True + ) + ] + problem: RcpspWithAlternativePath + additional_variables: dict + + def create_is_done_variable(self): + is_done = {} + for t in self.problem.alternative_tasks: + is_done[t] = self.cp_model.NewBoolVar(name=f"is_done_{t}") + return is_done + + def link_is_done_and_modes(self, is_present_var, is_done, interval_per_tasks): + for t in interval_per_tasks: + self.cp_model.add_max_equality( + is_done[t], [is_present_var[key] for key in interval_per_tasks[t]] + ) + + def init_model(self, **kwargs): + kwargs = self.complete_with_default_hyperparameters(kwargs) + include_special_constraints = kwargs.get( + "include_special_constraints", self.problem.includes_special_constraint() + ) + strict_alternative_path = kwargs["strict_alternative_path"] + SchedulingCpSatSolver.init_model(self, **kwargs) + model = self.cp_model + ( + starts_var, + ends_var, + is_present_var, + interval_var, + interval_per_tasks, + ) = self.init_temporal_variable(model=model) + self.variables = { + "start": starts_var, + "end": ends_var, + "is_present": is_present_var, + "interval_var": interval_var, + } + self.add_one_mode_selected_per_task( + model=model, + is_present_var=is_present_var, + interval_per_tasks={ + t: interval_per_tasks[t] for t in self.problem._tasks_list + }, + ) + is_done = self.create_is_done_variable() + self.additional_variables = {"is_done": is_done} + self.link_is_done_and_modes( + is_present_var=is_present_var, + is_done=is_done, + interval_per_tasks={t: interval_per_tasks[t] for t in is_done}, + ) + self.create_precedence_constraints() + self.create_precedence_constraints_alternative() + resources = self.problem.resources_list + for resource in resources: + self.create_cumulative_constraint( + resource=resource, + ) + for i in range(len(self.problem.list_alternative_subproblem)): + self.create_alternative_path_constraint( + alternative_problem=self.problem.list_alternative_subproblem[i], + tag_alternative_problem=str(i), + strict_alternative_path=strict_alternative_path, + ) + if include_special_constraints: + if self.problem.special_constraints.pair_mode_constraint is not None: + self.create_mode_pair_constraint( + model=model, + interval_per_tasks=interval_per_tasks, + is_present_var=is_present_var, + pair_mode_constraint=self.problem.special_constraints.pair_mode_constraint, + ) + self.add_special_temporal_constraints( + model=model, + starts_var=starts_var, + ends_var=ends_var, + ) + objective = self.get_global_makespan_variable() + self.minimize_variable(objective) + + def create_precedence_constraints_alternative(self): + for t in self.problem.alternative_successors: + for tt in self.problem.alternative_successors[t]: + if tt in self.problem._tasks_list: + ( + self.cp_model.add( + self.variables["end"][t] <= self.variables["start"][tt] + ).only_enforce_if(self.additional_variables["is_done"][t]) + ) + if tt in self.problem.alternative_tasks: + ( + self.cp_model.add( + self.variables["end"][t] <= self.variables["start"][tt] + ).only_enforce_if( + self.additional_variables["is_done"][t], + self.additional_variables["is_done"][tt], + ) + ) + + def create_alternative_path_constraint( + self, + alternative_problem: AlternativeSchedulingSubProblem, + tag_alternative_problem: str, + strict_alternative_path: bool, + ): + ps = [ + [p for p in path if p in self.problem.alternative_tasks] + for path in alternative_problem.list_paths + ] + sum_len = sum([len(p) for p in ps]) + merged = reduce(lambda x, y: x.union(set(y)), ps, set()) + len_merged = len(merged) + if len_merged == sum_len: + # print("Disjoint paths") + # Disjoint paths, nominal case. + for p in ps: + for p0, p1 in zip(p[:-1], p[1:]): + self.cp_model.add_implication( + self.additional_variables["is_done"][p0], + self.additional_variables["is_done"][p1], + ) + nb_to_do = alternative_problem.nb_path_to_do + if len(ps) >= nb_to_do: + if nb_to_do == 1: + self.cp_model.add_exactly_one( + [self.additional_variables["is_done"][p[0]] for p in ps] + ) + else: + self.cp_model.add( + sum([self.additional_variables["is_done"][p[0]] for p in ps]) + == nb_to_do + ) + if alternative_problem.is_path_successors: + for p in ps: + for p0, p1 in zip(p[:-1], p[1:]): + # print(p0, p1) + self.cp_model.add( + self.variables["start"][p1] >= self.variables["end"][p0] + ) + for p in alternative_problem.list_paths: + for p0, p1 in zip(p[:-1], p[1:]): + # print(p0, p1) + self.cp_model.add( + self.variables["start"][p1] >= self.variables["end"][p0] + ) + if p[0] != alternative_problem.source_task: + self.cp_model.add( + self.variables["start"][p[0]] + >= self.variables["end"][alternative_problem.source_task] + ) + if p[-1] != alternative_problem.sink_task: + self.cp_model.add( + self.variables["start"][alternative_problem.sink_task] + >= self.variables["end"][p[-1]] + ) + else: + path_taken = [ + self.cp_model.new_bool_var(name=f"{tag_alternative_problem}_{i}") + for i in range(len(ps)) + ] + for i in range(len(path_taken)): + self.cp_model.add_min_equality( + path_taken[i], + [self.additional_variables["is_done"][p] for p in ps[i]], + ) + nb_to_do = alternative_problem.nb_path_to_do + if nb_to_do == 1: + self.cp_model.add_exactly_one(path_taken) + else: + self.cp_model.add(sum(path_taken) == nb_to_do) + if alternative_problem.is_path_successors: + for i in range(len(alternative_problem.list_paths)): + path = alternative_problem.list_paths[i] + for p0, p1 in zip(path[:-1], path[1:]): + ( + self.cp_model.add( + self.variables["start"][p1] >= self.variables["end"][p0] + ).only_enforce_if(path_taken[i]) + ) + if path[0] != alternative_problem.source_task: + ( + self.cp_model.add( + self.variables["start"][path[0]] + >= self.variables["end"][ + alternative_problem.source_task + ] + ).only_enforce_if(path_taken[i]) + ) + if path[-1] != alternative_problem.sink_task: + ( + self.cp_model.add( + self.variables["start"][alternative_problem.sink_task] + >= self.variables["end"][path[-1]] + ).only_enforce_if(path_taken[i]) + ) + if strict_alternative_path: + path_used = [ + self.cp_model.new_bool_var(name=f"{tag_alternative_problem}_{i}") + for i in range(len(ps)) + ] + for i in range(len(path_used)): + self.cp_model.add_max_equality( + path_used[i], + [self.additional_variables["is_done"][p] for p in ps[i]], + ) + self.cp_model.add(sum(path_used) <= alternative_problem.nb_path_to_do) + + def retrieve_solution(self, cpsolvercb: CpSolverSolutionCallback) -> RcpspSolution: + schedule = {} + modes_dict = {} + for task in self.variables["start"]: + schedule[task] = { + "start_time": cpsolvercb.Value(self.variables["start"][task]), + "end_time": cpsolvercb.Value(self.variables["end"][task]), + } + for task, mode in self.variables["is_present"]: + if cpsolvercb.Value(self.variables["is_present"][task, mode]): + modes_dict[task] = mode + for t in self.problem.alternative_tasks: + if not cpsolvercb.Value(self.additional_variables["is_done"][t]): + schedule[t]["start_time"] = 0 + schedule[t]["end_time"] = 0 + return RcpspSolution( + problem=self.problem, + rcpsp_schedule=schedule, + rcpsp_modes=[ + modes_dict.get(t, None) for t in self.problem.tasks_list_non_dummy + ], + ) diff --git a/src/discrete_optimization/rcpsp_alternative/utils.py b/src/discrete_optimization/rcpsp_alternative/utils.py new file mode 100644 index 000000000..832f10b1e --- /dev/null +++ b/src/discrete_optimization/rcpsp_alternative/utils.py @@ -0,0 +1,89 @@ +# Copyright (c) 2026 AIRBUS and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +import random + +from discrete_optimization.rcpsp.problem import RcpspProblem, Task +from discrete_optimization.rcpsp_alternative.problem import ( + AlternativeSchedulingSubProblem, + RcpspWithAlternativePath, +) + + +def create_problem_rcpsp( + problem: RcpspProblem, + nb_alternative_paths: int = 3, + range_nb_subpath: tuple = (1, 4), + range_len_subpath: tuple = (1, 5), +) -> RcpspWithAlternativePath: + graph = problem.graph + descendants = graph.descendants_map() + ancestors = graph.ancestors_map() + compatible_source_target: set[tuple[Task, Task]] = set() + for t0 in problem.tasks_list: + for t1 in problem.tasks_list: + if t0 == t1: + continue + if t1 not in ancestors[t0] and t0 not in descendants[t1]: + compatible_source_target.add((t0, t1)) + compatible_source_target = list(compatible_source_target) + alternative_tasks = [] + alternative_tasks_data: dict[Task, dict[int, dict[str, int]]] = {} + alternative_successors: dict[Task, list[Task]] = {} + list_alternative_subproblem: list[AlternativeSchedulingSubProblem] = [] + all_durations = [ + problem.get_task_mode_duration(task, mode) + for task in problem.tasks_list + for mode in problem.get_task_modes(task) + ] + min_duration = min(all_durations) + max_duration = max(all_durations) + for i in range(nb_alternative_paths): + source, sink = random.choice(compatible_source_target) + nb_subpath = random.randint(range_nb_subpath[0], range_nb_subpath[1]) + list_paths = [] + for j in range(nb_subpath): + path = [] + len_subpath = random.randint(range_len_subpath[0], range_len_subpath[1]) + for k in range(len_subpath): + task_key = ( + i, + j, + k, + ) # I-th alternative subproblem, j-th subpath, k-th task in the subpath. + alternative_tasks_data[task_key] = { + 1: {"duration": random.randint(min_duration, max_duration)} + } + for r in problem.resources_list: + alternative_tasks_data[task_key][1][r] = random.randint( + 0, problem.get_max_resource_capacity(r) // 2 + ) + alternative_tasks.append(task_key) + path.append(task_key) + list_paths.append(path) + list_alternative_subproblem.append( + AlternativeSchedulingSubProblem( + source_task=source, + sink_task=sink, + list_paths=list_paths, + is_path_successors=True, + nb_path_to_do=1, + ) + ) + return RcpspWithAlternativePath( + resources=problem.resources, + non_renewable_resources=problem.non_renewable_resources, + mode_details=problem.mode_details, + successors=problem.successors, + horizon=problem.horizon * 2, + tasks_list=problem.tasks_list, + source_task=problem.source_task, + sink_task=problem.sink_task, + name_task=problem.name_task, + calendar_details=problem.calendar_details, + special_constraints=problem.special_constraints, + alternative_tasks=alternative_tasks, + alternative_tasks_data=alternative_tasks_data, + alternative_successors=alternative_successors, + list_alternative_subproblem=list_alternative_subproblem, + ) From 952e05aa6f7af8c0a537827837f6bee511bba1f0 Mon Sep 17 00:00:00 2001 From: poveda_g Date: Mon, 17 Aug 2026 10:53:38 +0200 Subject: [PATCH 02/10] adding optional task capability and one problem implementation (rcpsp_alternative) using this feature --- examples/rcpsp_alternative/run_cpsat.py | 35 +++- src/discrete_optimization/binpack/problem.py | 4 + src/discrete_optimization/coloring/problem.py | 4 + src/discrete_optimization/facility/problem.py | 7 + .../flex_scheduling/problem.py | 8 +- .../generic_tasks_tools/__init__.py | 8 + .../alternative_subproblems.py | 105 +++++++++++ .../generic_tasks_tools/base.py | 27 +++ .../generic_tasks_tools/calendar_resource.py | 2 + .../generic_tasks_tools/generic_scheduling.py | 23 ++- .../generic_scheduling_impl.py | 42 ++++- .../generic_scheduling_utils.py | 9 +- .../generic_tasks_tools/multimode.py | 6 +- .../generic_tasks_tools/scheduling.py | 28 ++- .../solvers/cpsat/alternative_subproblems.py | 150 +++++++++++++++ .../generic_tasks_tools/solvers/cpsat/auto.py | 176 +++++++++++++----- .../solvers/cpsat/generic_scheduling.py | 4 + .../solvers/cpsat/scheduling.py | 8 + src/discrete_optimization/knapsack/problem.py | 7 + .../ovensched/problem.py | 8 +- src/discrete_optimization/rcpsp/problem.py | 31 ++- src/discrete_optimization/rcpsp/solution.py | 3 +- .../generic_scheduling_impl.py | 7 +- src/discrete_optimization/rcpsp/utils.py | 26 +-- .../rcpsp_alternative/problem.py | 143 +++----------- .../rcpsp_alternative/solvers/cpsat_auto.py | 49 +++++ .../rcpsp_alternative/utils.py | 21 ++- .../rcpsp_multiskill/problem.py | 3 + src/discrete_optimization/shop/base.py | 3 + .../singlebatch/problem.py | 6 + .../singlemachine/problem.py | 3 + src/discrete_optimization/vrptw/problem.py | 3 + .../workforce/allocation/problem.py | 4 + .../workforce/scheduling/problem.py | 3 + 34 files changed, 752 insertions(+), 214 deletions(-) create mode 100644 src/discrete_optimization/generic_tasks_tools/alternative_subproblems.py create mode 100644 src/discrete_optimization/generic_tasks_tools/solvers/cpsat/alternative_subproblems.py create mode 100644 src/discrete_optimization/rcpsp_alternative/solvers/cpsat_auto.py diff --git a/examples/rcpsp_alternative/run_cpsat.py b/examples/rcpsp_alternative/run_cpsat.py index 2448143a3..fa3b8da7a 100644 --- a/examples/rcpsp_alternative/run_cpsat.py +++ b/examples/rcpsp_alternative/run_cpsat.py @@ -2,6 +2,8 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. +import logging + from discrete_optimization.generic_tools.cp_tools import ParametersCp from discrete_optimization.rcpsp.parser import get_data_available, parse_file from discrete_optimization.rcpsp.utils import plot_ressource_view, plot_task_gantt, plt @@ -9,8 +11,14 @@ from discrete_optimization.rcpsp_alternative.solvers.cpsat import ( CpsatRcpspWithAlternativePathSolver, ) +from discrete_optimization.rcpsp_alternative.solvers.cpsat_auto import ( + CpsatAutoRcpspWithAlternativePathSolver, +) from discrete_optimization.rcpsp_alternative.utils import create_problem_rcpsp +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + def run_cpsat(): problem = parse_file([f for f in get_data_available() if "j601_1.sm" in f][0]) @@ -31,5 +39,30 @@ def run_cpsat(): plt.show() +def run_cpsat_auto(): + problem = parse_file([f for f in get_data_available() if "j601_1.sm" in f][0]) + # problem = parse_file([f for f in get_data_available() if "j1010_1.mm" in f][0]) + + problem = create_problem_rcpsp( + problem, + nb_alternative_paths=5, + range_nb_subpath=(1, 4), + range_len_subpath=(3, 5), + ) + solver = CpsatAutoRcpspWithAlternativePathSolver(problem) + solver.init_model(use_cpm_for_task_bounds=False, use_energy_constraints=False) + res = solver.solve( + parameters_cp=ParametersCp.default_cpsat(), + time_limit=30, + ortools_cpsat_solver_kwargs={"log_search_progress": True}, + ) + sol = res[-1][0] + print(problem.evaluate(sol), problem.satisfy(sol)) + print("Optional tasks done : ", get_optional_tasks_done(sol, problem)) + plot_task_gantt(problem, sol) + plot_ressource_view(problem, sol) + plt.show() + + if __name__ == "__main__": - run_cpsat() + run_cpsat_auto() diff --git a/src/discrete_optimization/binpack/problem.py b/src/discrete_optimization/binpack/problem.py index f3c63a78b..44c89149a 100644 --- a/src/discrete_optimization/binpack/problem.py +++ b/src/discrete_optimization/binpack/problem.py @@ -13,6 +13,7 @@ AllocationProblem, AllocationSolution, ) +from discrete_optimization.generic_tasks_tools.base import Task from discrete_optimization.generic_tasks_tools.scheduling import ( SchedulingProblem, SchedulingSolution, @@ -49,6 +50,9 @@ def copy(self) -> BinPackSolution: problem=self.problem, allocation=deepcopy(self.allocation) ) + def is_present(self, task: Task) -> bool: + return self.allocation[task] is not None + def get_end_time(self, task: Item) -> int: return self.allocation[task] + 1 diff --git a/src/discrete_optimization/coloring/problem.py b/src/discrete_optimization/coloring/problem.py index 46c7046c4..3cd2fc73e 100644 --- a/src/discrete_optimization/coloring/problem.py +++ b/src/discrete_optimization/coloring/problem.py @@ -19,6 +19,7 @@ AllocationProblem, AllocationSolution, ) +from discrete_optimization.generic_tasks_tools.base import Task from discrete_optimization.generic_tools.do_problem import ( ModeOptim, ObjectiveDoc, @@ -277,6 +278,9 @@ def __init__( self.constraints_coloring = constraints_coloring self.has_constraints_coloring = constraints_coloring is not None + def is_optional(self, task: Task) -> bool: + return False + @property def tasks_list(self) -> list[Node]: return self.nodes_name diff --git a/src/discrete_optimization/facility/problem.py b/src/discrete_optimization/facility/problem.py index f6c441594..5d1390795 100644 --- a/src/discrete_optimization/facility/problem.py +++ b/src/discrete_optimization/facility/problem.py @@ -22,6 +22,7 @@ AllocationProblem, AllocationSolution, ) +from discrete_optimization.generic_tasks_tools.base import Task from discrete_optimization.generic_tools.do_problem import ( ModeOptim, ObjectiveDoc, @@ -103,6 +104,9 @@ def change_problem(self, new_problem: Problem) -> None: def is_allocated(self, task: Customer, unary_resource: Facility) -> bool: return self.facility_for_customers[task.index] == unary_resource.index + def is_present(self, task: Task) -> bool: + return self.facility_for_customers[task] is not None + class FacilityProblem(AllocationProblem[Customer, Facility]): """Base class for the facility problem. @@ -128,6 +132,9 @@ def __init__( self.facilities = facilities self.customers = customers + def is_optional(self, task: Task) -> bool: + return False + @property def unary_resources_list(self) -> list[Facility]: return self.facilities diff --git a/src/discrete_optimization/flex_scheduling/problem.py b/src/discrete_optimization/flex_scheduling/problem.py index 54e26cb18..56c12b17a 100644 --- a/src/discrete_optimization/flex_scheduling/problem.py +++ b/src/discrete_optimization/flex_scheduling/problem.py @@ -2,7 +2,7 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. from copy import deepcopy -from dataclasses import dataclass, field +from dataclasses import field from functools import cache from typing import Dict, Hashable, List, Set, Tuple, Type @@ -308,6 +308,9 @@ def __init__( self.schedule = schedule self.modes = modes + def is_present(self, task: Task) -> bool: + return self.modes[self.problem.task_id_to_index[task]] is not None + def get_mode(self, task: Task) -> int: index = self.problem.task_id_to_index[task] return self.modes[index] @@ -338,6 +341,9 @@ class FlexProblem( ], WithoutAllocationProblem[Task], ): + def is_optional(self, task: Task) -> bool: + return False + @property def non_skill_cumulative_resources_list(self) -> list[Skill]: return [resource.id for resource in self.resources if resource.renewable] diff --git a/src/discrete_optimization/generic_tasks_tools/__init__.py b/src/discrete_optimization/generic_tasks_tools/__init__.py index e69de29bb..0289a548b 100644 --- a/src/discrete_optimization/generic_tasks_tools/__init__.py +++ b/src/discrete_optimization/generic_tasks_tools/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026 AIRBUS and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +from enum import Enum + + +class AbsentValue(Enum): + ABSENT = "absent" diff --git a/src/discrete_optimization/generic_tasks_tools/alternative_subproblems.py b/src/discrete_optimization/generic_tasks_tools/alternative_subproblems.py new file mode 100644 index 000000000..235a70324 --- /dev/null +++ b/src/discrete_optimization/generic_tasks_tools/alternative_subproblems.py @@ -0,0 +1,105 @@ +# Copyright (c) 2026 AIRBUS and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +import logging +from dataclasses import dataclass +from typing import Generic, Hashable, Optional + +import networkx as nx + +from discrete_optimization.generic_tasks_tools.base import Task +from discrete_optimization.generic_tasks_tools.multimode_scheduling import ( + MultimodeSchedulingProblem, + MultimodeSchedulingSolution, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class AlternativeSchedulingSubProblem: + source_task: Hashable # (mandatory task from which originates the alternative) + sink_task: Hashable # (mandatory task from which finished the alternative) + graph: Optional[nx.DiGraph] = None + is_graph_successors: bool = True + list_paths: Optional[list[list[Hashable]]] = None + is_path_successors: bool = True + nb_path_to_do: int = 1 # by default 1 subpath to follow. + + def __post_init__(self): + if self.graph is None: + if self.list_paths: + graph = nx.DiGraph() + graph.add_node(self.source_task) + graph.add_node(self.sink_task) + for p in self.list_paths: + for e0, e1 in zip(p[:-1], p[1:]): + graph.add_edge(e0, e1) + if p[0] != self.source_task: + graph.add_edge(self.source_task, p[0]) + if p[-1] != self.sink_task: + graph.add_edge(self.source_task, p[0]) + self.graph = graph + if self.source_task not in self.graph.nodes: + predecessors = { + n: list(self.graph.predecessors(n)) for n in self.graph.nodes + } + self.graph.add_node(self.source_task) + for n in predecessors: + if len(predecessors[n]) == 0: + self.graph.add_edge(self.source_task, n) + if self.sink_task not in self.graph.nodes: + successors = {n: list(self.graph.successors(n)) for n in self.graph.nodes} + self.graph.add_node(self.sink_task) + for n in successors: + if len(successors[n]) == 0: + self.graph.add_edge(n, self.sink_task) + if self.list_paths is None: + self.list_paths = list( + nx.all_simple_paths(self.graph, self.source_task, self.sink_task) + ) + + +class AlternativeSchedulingProblem(MultimodeSchedulingProblem[Task], Generic[Task]): + # @abstractmethod + def get_alternative_scheduling_subproblem( + self, + ) -> list[AlternativeSchedulingSubProblem]: + return [] + + +class NoAlternativeSchedulingProblem(AlternativeSchedulingProblem[Task]): + def get_alternative_scheduling_subproblem( + self, + ) -> list[AlternativeSchedulingSubProblem]: + return [] + + +class AlternativeSchedulingSolution(MultimodeSchedulingSolution[Task]): + problem: AlternativeSchedulingProblem[Task] + + def check_alternative_scheduling_subproblem(self) -> None: + for alt_problem in self.problem.get_alternative_scheduling_subproblem(): + paths = alt_problem.list_paths + nb_path_done = 0 + paths_done = [] + for p in paths: + if all(self.is_present(task) for task in p): + nb_path_done += 1 + paths_done.append(p) + if nb_path_done > alt_problem.nb_path_to_do: + logger.info("Too much alternative path") + return False + if nb_path_done < alt_problem.nb_path_to_do: + logger.info("Not enough alternative path") + return False + if alt_problem.is_path_successors: + for p in paths_done: + for t0, t1 in zip(p[:-1], p[1:]): + if not (self.get_end_time(t0) <= self.get_start_time(t1)): + logger.info( + "Precedence constraints not respected in the final schedule" + ) + logger.info(f"between {t1} and {t0}") + return False + return True diff --git a/src/discrete_optimization/generic_tasks_tools/base.py b/src/discrete_optimization/generic_tasks_tools/base.py index a3a4ddfce..a3dba5812 100644 --- a/src/discrete_optimization/generic_tasks_tools/base.py +++ b/src/discrete_optimization/generic_tasks_tools/base.py @@ -1,3 +1,6 @@ +# Copyright (c) 2026 AIRBUS and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. from __future__ import annotations from abc import abstractmethod @@ -21,6 +24,12 @@ def tasks_list(self) -> list[Task]: """List of all tasks to schedule or allocate to.""" ... + @abstractmethod + def is_optional(self, task: Task) -> bool: ... + @property + def optional_tasks_list(self) -> list[Task]: + return [t for t in self.tasks_list if self.is_optional(t)] + def get_index_from_task(self, task: Task) -> int: if self._map_task_to_index is None: self._map_task_to_index = { @@ -36,11 +45,29 @@ def update_tasks_list(self) -> None: self._map_task_to_index = None +class NoOptionalTasksProblem(TasksProblem[Task]): + def is_optional(self, task: Task) -> bool: + return False + + class TasksSolution(Solution, Generic[Task]): """Base class for scheduling/allocation solutions.""" problem: TasksProblem[Task] + @abstractmethod + def is_present(self, task: Task) -> bool: ... + + def check_present_tasks(self) -> bool: + for t in self.problem.tasks_list: + if not self.problem.is_optional(t): + if not self.is_present(t): + return False + return True + + def get_present_tasks(self): + return [t for t in self.problem.tasks_list if self.is_present(t)] + class TasksCpSolver(CpSolver, Generic[Task]): """Base class for cp solver handling tasks problems.""" diff --git a/src/discrete_optimization/generic_tasks_tools/calendar_resource.py b/src/discrete_optimization/generic_tasks_tools/calendar_resource.py index ba296bba8..77b3fb432 100644 --- a/src/discrete_optimization/generic_tasks_tools/calendar_resource.py +++ b/src/discrete_optimization/generic_tasks_tools/calendar_resource.py @@ -218,6 +218,8 @@ def _compute_calendar_resource_consumption_np( resource: np.zeros(makespan, dtype=int) for resource in resources } for task in self.problem.tasks_list: + if not self.is_present(task): + continue start = self.get_start_time(task) end = self.get_end_time(task) for resource in resources: diff --git a/src/discrete_optimization/generic_tasks_tools/generic_scheduling.py b/src/discrete_optimization/generic_tasks_tools/generic_scheduling.py index 32a710c9e..dec429c19 100644 --- a/src/discrete_optimization/generic_tasks_tools/generic_scheduling.py +++ b/src/discrete_optimization/generic_tasks_tools/generic_scheduling.py @@ -11,6 +11,10 @@ from discrete_optimization.generic_tasks_tools.allocation import ( UnaryResource, ) +from discrete_optimization.generic_tasks_tools.alternative_subproblems import ( + AlternativeSchedulingProblem, + AlternativeSchedulingSolution, +) from discrete_optimization.generic_tasks_tools.base import Task from discrete_optimization.generic_tasks_tools.enums import MinOrMax, StartOrEnd from discrete_optimization.generic_tasks_tools.generic_scheduling_utils import ( @@ -59,6 +63,7 @@ class GenericSchedulingProblem( TimelagProblem[Task], TimewindowProblem[Task], NoOverlapProblem[Task], + AlternativeSchedulingProblem[Task], Generic[ Task, UnaryResource, Skill, NonSkillCumulativeResource, NonRenewableResource ], @@ -629,6 +634,8 @@ def satisfy_partial( time_windows: bool = True, no_overlap: bool = True, forbidden_intervals: bool = True, + presence_tasks: bool = True, + alternative_subproblems: bool = True, ) -> bool: """Partial checks on solution. @@ -646,13 +653,21 @@ def satisfy_partial( time_windows: no_overlap: forbidden_intervals: - + presence_tasks: + alternative_subproblems: Returns: """ return ( + # alternative subproblems + ( + not alternative_subproblems + or variable.check_alternative_scheduling_subproblem() + ) + # presence of tasks + and (not presence_tasks or variable.check_present_tasks()) # duration consistency - (not duration or variable.check_duration_constraints()) + and (not duration or variable.check_duration_constraints()) # calendar resources capacity violations (unary resources + skills + cumulative resources) and ( not calendar @@ -696,6 +711,7 @@ class GenericSchedulingSolution( TimelagSolution[Task], TimewindowSolution[Task], NoOverlapSolution[Task], + AlternativeSchedulingSolution[Task], Generic[ Task, UnaryResource, Skill, NonSkillCumulativeResource, NonRenewableResource ], @@ -732,3 +748,6 @@ def compute_cost(self) -> int: ) for task in self.problem.tasks_list ) + + def is_present(self, task: Task) -> bool: + return self.is_scheduled(task) and self.has_a_mode(task) diff --git a/src/discrete_optimization/generic_tasks_tools/generic_scheduling_impl.py b/src/discrete_optimization/generic_tasks_tools/generic_scheduling_impl.py index 0ca5af70a..07f74075b 100644 --- a/src/discrete_optimization/generic_tasks_tools/generic_scheduling_impl.py +++ b/src/discrete_optimization/generic_tasks_tools/generic_scheduling_impl.py @@ -10,6 +10,10 @@ import numpy as np import wrapt +from discrete_optimization.generic_tasks_tools import AbsentValue +from discrete_optimization.generic_tasks_tools.alternative_subproblems import ( + AlternativeSchedulingSubProblem, +) from discrete_optimization.generic_tasks_tools.calendar_resource import ( convert_availability_intervals_to_calendar, convert_calendar_to_availability_intervals, @@ -99,6 +103,8 @@ def __init__( unary_resource_costs: Optional[ dict[Task, dict[int, dict[UnaryResource, int]]] ] = None, + optional_tasks: Optional[set[Task]] = None, + alternative_subproblems: Optional[list[AlternativeSchedulingSubProblem]] = None, compute_time_penalty: bool = True, ): """ @@ -252,6 +258,14 @@ def __init__( else: self.unary_resource_costs = unary_resource_costs self.compute_time_penalty = compute_time_penalty + if optional_tasks is None: + self._optional_tasks = set() + else: + self._optional_tasks = optional_tasks + if alternative_subproblems is None: + self.alternative_subproblems = [] + else: + self.alternative_subproblems = alternative_subproblems self.update_problem() def update_problem(self): @@ -263,7 +277,6 @@ def update_problem(self): ) self._non_renewable_resources_list = list(self.non_renewable_resources) self._unary_resources_list = list(self.unary_resources) - self.check_resources_lists() self.update_tasks_list() self.update_skills() @@ -439,6 +452,14 @@ def unary_resources_list(self) -> list[UnaryResource]: def tasks_list(self) -> list[Task]: return self._tasks_list + def is_optional(self, task: Task) -> bool: + return task in self._optional_tasks + + def get_alternative_scheduling_subproblem( + self, + ) -> list[AlternativeSchedulingSubProblem]: + return self.alternative_subproblems + def get_solution_type(self) -> type[Solution]: return GenericSchedulingImplSolution @@ -780,19 +801,34 @@ def is_skill_used( except KeyError: return False - def get_end_time(self, task: Task) -> int: + def get_end_time(self, task: Task) -> int | AbsentValue: + if task not in self.raw_sol.task_variables: + return AbsentValue.ABSENT return self.raw_sol.task_variables[task].end - def get_start_time(self, task: Task) -> int: + def get_start_time(self, task: Task) -> int | AbsentValue: + if task not in self.raw_sol.task_variables: + return AbsentValue.ABSENT return self.raw_sol.task_variables[task].start def get_mode(self, task: Task) -> int: + if task not in self.raw_sol.task_variables: + return AbsentValue.ABSENT return self.raw_sol.task_variables[task].mode + def is_present(self, task: Task) -> bool: + if task not in self.raw_sol.task_variables: + return False + return super().is_present(task) + def is_allocated(self, task: Task, unary_resource: UnaryResource) -> bool: + if task not in self.raw_sol.task_variables: + return False return unary_resource in self.raw_sol.task_variables[task].allocated def get_task_allocation(self, task: Task) -> set[UnaryResource]: + if task not in self.raw_sol.task_variables: + return set() return set(self.raw_sol.task_variables[task].allocated) def copy(self) -> Solution: diff --git a/src/discrete_optimization/generic_tasks_tools/generic_scheduling_utils.py b/src/discrete_optimization/generic_tasks_tools/generic_scheduling_utils.py index eae449436..7894f95e2 100644 --- a/src/discrete_optimization/generic_tasks_tools/generic_scheduling_utils.py +++ b/src/discrete_optimization/generic_tasks_tools/generic_scheduling_utils.py @@ -9,6 +9,7 @@ from enum import Enum from typing import Any, Generic +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.allocation import UnaryResource from discrete_optimization.generic_tasks_tools.base import Task from discrete_optimization.generic_tasks_tools.enums import StartOrEnd @@ -19,9 +20,9 @@ class TaskVariable(Generic[UnaryResource, Skill]): """Task characteristics found in a generic scheduling solution.""" - start: int # start time of the task - end: int # end time of the task - mode: int # chosen mode for the task + start: int | AbsentValue # start time of the task + end: int | AbsentValue # end time of the task + mode: int | AbsentValue # chosen mode for the task allocated: dict[UnaryResource, set[Skill]] = field( default_factory=dict ) # resources allocated to the task @@ -29,7 +30,7 @@ class TaskVariable(Generic[UnaryResource, Skill]): default_factory=dict ) # additional information if needed - def get_start_or_end(self, start_or_end: StartOrEnd) -> int: + def get_start_or_end(self, start_or_end: StartOrEnd) -> int | AbsentValue: if start_or_end == StartOrEnd.START: return self.start else: diff --git a/src/discrete_optimization/generic_tasks_tools/multimode.py b/src/discrete_optimization/generic_tasks_tools/multimode.py index e9fdddd7a..5135534a5 100644 --- a/src/discrete_optimization/generic_tasks_tools/multimode.py +++ b/src/discrete_optimization/generic_tasks_tools/multimode.py @@ -3,6 +3,7 @@ from abc import abstractmethod from typing import Any +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.base import ( Task, TasksCpSolver, @@ -17,7 +18,7 @@ class MultimodeSolution(TasksSolution[Task]): problem: MultimodeProblem[Task] @abstractmethod - def get_mode(self, task: Task) -> int: + def get_mode(self, task: Task) -> int | AbsentValue: """Retrieve mode found for given task. Args: @@ -28,6 +29,9 @@ def get_mode(self, task: Task) -> int: """ ... + def has_a_mode(self, task: Task) -> bool: + return self.get_mode(task) != AbsentValue.ABSENT + class MultimodeProblem(TasksProblem[Task]): """Class inherited by a solution exposing tasks modes.""" diff --git a/src/discrete_optimization/generic_tasks_tools/scheduling.py b/src/discrete_optimization/generic_tasks_tools/scheduling.py index 14bf69621..70d4cab85 100644 --- a/src/discrete_optimization/generic_tasks_tools/scheduling.py +++ b/src/discrete_optimization/generic_tasks_tools/scheduling.py @@ -4,6 +4,7 @@ from collections.abc import Iterable from typing import Any +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.base import ( Task, TasksCpSolver, @@ -49,23 +50,34 @@ class SchedulingSolution(TasksSolution[Task]): problem: SchedulingProblem[Task] @abstractmethod - def get_end_time(self, task: Task) -> int: ... + def get_end_time(self, task: Task) -> int | AbsentValue: ... @abstractmethod - def get_start_time(self, task: Task) -> int: ... + def get_start_time(self, task: Task) -> int | AbsentValue: ... - def get_start_or_end_time(self, task: Task, start_or_end: StartOrEnd) -> int: + def is_scheduled(self, task: Task) -> bool: + return self.get_start_time(task) != AbsentValue.ABSENT + + def get_start_or_end_time( + self, task: Task, start_or_end: StartOrEnd + ) -> int | AbsentValue: """Get the start or end time for a given task.""" if start_or_end == StartOrEnd.START: return self.get_start_time(task) else: return self.get_end_time(task) - def get_duration(self, task: Task) -> int: - return self.get_end_time(task) - self.get_start_time(task) + def get_duration(self, task: Task) -> int | AbsentValue: + if self.is_scheduled(task): + return self.get_end_time(task) - self.get_start_time(task) + return AbsentValue.ABSENT def get_max_end_time(self) -> int: - return max(self.get_end_time(task) for task in self.problem.get_last_tasks()) + return max( + self.get_end_time(task) + for task in self.problem.get_last_tasks() + if self.is_scheduled(task) + ) def constraint_on_task_satisfied( self, task: Task, start_or_end: StartOrEnd, sign: SignEnum, time: int @@ -92,8 +104,8 @@ def get_running_tasks(self, time: int) -> list[Task]: return [ task for task in self.problem.tasks_list - if self.get_start_time(task=task) <= time - and self.get_end_time(task=task) > time + if self.is_scheduled(task) + and self.get_start_time(task=task) <= time < self.get_end_time(task=task) ] diff --git a/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/alternative_subproblems.py b/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/alternative_subproblems.py new file mode 100644 index 000000000..99c40d3a4 --- /dev/null +++ b/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/alternative_subproblems.py @@ -0,0 +1,150 @@ +# Copyright (c) 2026 AIRBUS and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +from functools import reduce +from typing import Generic + +from discrete_optimization.generic_tasks_tools.alternative_subproblems import ( + AlternativeSchedulingProblem, + AlternativeSchedulingSubProblem, +) +from discrete_optimization.generic_tasks_tools.base import Task +from discrete_optimization.generic_tasks_tools.enums import StartOrEnd +from discrete_optimization.generic_tasks_tools.solvers.cpsat.multimode_scheduling import ( + MultimodeSchedulingCpSatSolver, +) + + +class AlternativeSubproblemCpSatSolver( + MultimodeSchedulingCpSatSolver[Task], Generic[Task] +): + problem: AlternativeSchedulingProblem[Task] + + def create_alternative_subproblems_constraints(self): + subproblems = self.problem.get_alternative_scheduling_subproblem() + for i in range(len(subproblems)): + self.create_alternative_path_constraint(subproblems[i], str(i), True) + + def create_alternative_path_constraint( + self, + alternative_problem: AlternativeSchedulingSubProblem, + tag_alternative_problem: str, + strict_alternative_path: bool, + ): + ps = [ + [p for p in path if p in self.problem.optional_tasks_list] + for path in alternative_problem.list_paths + ] + sum_len = sum([len(p) for p in ps]) + merged = reduce(lambda x, y: x.union(set(y)), ps, set()) + len_merged = len(merged) + if len_merged == sum_len: + # Disjoint paths, nominal case. + for p in ps: + for p0, p1 in zip(p[:-1], p[1:]): + self.cp_model.add_implication( + self.get_task_scheduled_variable(p0), + self.get_task_scheduled_variable(p1), + ) + nb_to_do = alternative_problem.nb_path_to_do + if len(ps) >= nb_to_do: + if nb_to_do == 1: + self.cp_model.add_exactly_one( + [self.get_task_scheduled_variable(p[0]) for p in ps] + ) + else: + self.cp_model.add( + sum([self.get_task_scheduled_variable(p[0]) for p in ps]) + == nb_to_do + ) + if alternative_problem.is_path_successors: + for p in ps: + for p0, p1 in zip(p[:-1], p[1:]): + self.cp_model.add( + self.get_task_start_or_end_variable(p1, StartOrEnd.START) + >= self.get_task_start_or_end_variable(p0, StartOrEnd.END) + ) + for p in alternative_problem.list_paths: + for p0, p1 in zip(p[:-1], p[1:]): + self.cp_model.add( + self.get_task_start_or_end_variable(p1, StartOrEnd.START) + >= self.get_task_start_or_end_variable(p0, StartOrEnd.END) + ) + if p[0] != alternative_problem.source_task: + self.cp_model.add( + self.get_task_start_or_end_variable(p[0], StartOrEnd.START) + >= self.get_task_start_or_end_variable( + alternative_problem.source_task, StartOrEnd.END + ) + ) + if p[-1] != alternative_problem.sink_task: + self.cp_model.add( + self.get_task_start_or_end_variable( + alternative_problem.sink_task, StartOrEnd.START + ) + >= self.get_task_start_or_end_variable( + p[-1], StartOrEnd.END + ) + ) + else: + path_taken = [ + self.cp_model.new_bool_var(name=f"{tag_alternative_problem}_{i}") + for i in range(len(ps)) + ] + for i in range(len(path_taken)): + self.cp_model.add_min_equality( + path_taken[i], + [self.get_task_scheduled_variable(p) for p in ps[i]], + ) + nb_to_do = alternative_problem.nb_path_to_do + if nb_to_do == 1: + self.cp_model.add_exactly_one(path_taken) + else: + self.cp_model.add(sum(path_taken) == nb_to_do) + if alternative_problem.is_path_successors: + for i in range(len(alternative_problem.list_paths)): + path = alternative_problem.list_paths[i] + for p0, p1 in zip(path[:-1], path[1:]): + ( + self.cp_model.add( + self.get_task_start_or_end_variable( + p1, StartOrEnd.START + ) + >= self.get_task_start_or_end_variable( + p0, StartOrEnd.END + ) + ).only_enforce_if(path_taken[i]) + ) + if path[0] != alternative_problem.source_task: + ( + self.cp_model.add( + self.get_task_start_or_end_variable( + path[0], StartOrEnd.START + ) + >= self.get_task_start_or_end_variable( + alternative_problem.source_task, StartOrEnd.END + ) + ).only_enforce_if(path_taken[i]) + ) + if path[-1] != alternative_problem.sink_task: + ( + self.cp_model.add( + self.get_task_start_or_end_variable( + alternative_problem.sink_task, StartOrEnd.START + ) + >= self.get_task_start_or_end_variable( + path[-1], StartOrEnd.END + ) + ).only_enforce_if(path_taken[i]) + ) + if strict_alternative_path: + path_used = [ + self.cp_model.new_bool_var(name=f"{tag_alternative_problem}_{i}") + for i in range(len(ps)) + ] + for i in range(len(path_used)): + self.cp_model.add_max_equality( + path_used[i], + [self.get_task_scheduled_variable(p) for p in ps[i]], + ) + self.cp_model.add(sum(path_used) <= alternative_problem.nb_path_to_do) diff --git a/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/auto.py b/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/auto.py index 7099fbe8b..5312394fc 100644 --- a/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/auto.py +++ b/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/auto.py @@ -15,6 +15,7 @@ LinearExprT, ) +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.allocation import UnaryResource from discrete_optimization.generic_tasks_tools.enums import StartOrEnd from discrete_optimization.generic_tasks_tools.generic_scheduling import ( @@ -140,12 +141,17 @@ class GenericSchedulingAutoCpSatSolver( These constraints are redundant with the calendar constraints on unary_resources as the calendar for a skill is deduce from unary_resource calendars. + """ + create_present_task_variables_for_all_tasks = False + """ + Either creating present var for all task (not only for optional tasks) """ # cpsat variables start_or_end_variables: dict[tuple[Task, StartOrEnd], LinearExprT] duration_variables: dict[Task, LinearExprT] task_interval_variables = dict[Task, IntervalVar] + task_is_present: dict[Task, LinearExprT] modes_is_present: dict[Task, dict[int, LinearExprT]] modes_intervals: dict[Task, dict[int, IntervalVar]] modes_start_variables: dict[Task, dict[int, LinearExprT]] @@ -280,12 +286,17 @@ def init_model( use_energy_constraints: Optional[bool] = None, keep_only_most_nested_energy_constraints: Optional[bool] = None, add_redundant_skill_cumulative_constraints: Optional[bool] = None, + create_present_task_variables_for_all_tasks: Optional[bool] = None, **kwargs: Any, ) -> None: """Init cp model and reset stored variables if any.""" super().init_model(**kwargs) # update default settings + if create_present_task_variables_for_all_tasks is not None: + self.create_present_task_variables_for_all_tasks = ( + create_present_task_variables_for_all_tasks + ) if add_redundant_skill_cumulative_constraints is not None: self.add_redundant_skill_cumulative_constraints = ( add_redundant_skill_cumulative_constraints @@ -319,6 +330,7 @@ def _reset_variables(self): self.start_or_end_variables = {} self.duration_variables = {} self.task_interval_variables = {} + self.task_is_scheduled = {} self.modes_is_present = {} self.modes_intervals = {} self.modes_start_variables = {} @@ -336,6 +348,7 @@ def _reset_variables(self): self.resource_level_variables = {} def _create_variables(self): + self._create_present_variables() self._create_start_or_end_variables() self._create_mode_variables() if self.needs_duration_variables or self.needs_task_interval: @@ -386,12 +399,23 @@ def _create_task_duration_and_interval_variables(self): ) if self.needs_task_interval: # interval constraint - self.task_interval_variables[task] = self.cp_model.new_interval_var( - start=self.start_or_end_variables[task, StartOrEnd.START], - size=self.duration_variables[task], - end=self.start_or_end_variables[task, StartOrEnd.END], - name=f"interval_{task}", - ) + if task in self.task_is_scheduled: + self.task_interval_variables[task] = ( + self.cp_model.new_optional_interval_var( + start=self.start_or_end_variables[task, StartOrEnd.START], + size=self.duration_variables[task], + is_present=self.task_is_scheduled[task], + end=self.start_or_end_variables[task, StartOrEnd.END], + name=f"interval_{task}", + ) + ) + else: + self.task_interval_variables[task] = self.cp_model.new_interval_var( + start=self.start_or_end_variables[task, StartOrEnd.START], + size=self.duration_variables[task], + end=self.start_or_end_variables[task, StartOrEnd.END], + name=f"interval_{task}", + ) def _create_mode_variables(self): for task in self.problem.tasks_list: @@ -399,7 +423,7 @@ def _create_mode_variables(self): self.modes_intervals[task] = {} self.modes_start_variables[task] = {} modes = self.problem.get_task_modes(task=task) - if len(modes) == 1: + if len(modes) == 1 and not self.problem.is_optional(task): # single mode (at least for this very task) mode = next(iter(modes)) self.modes_is_present[task][mode] = 1 @@ -409,28 +433,54 @@ def _create_mode_variables(self): self.modes_is_present[task][mode] = self.cp_model.new_bool_var( name=f"is_present_mode_{task}_{mode}" ) - self.cp_model.add_exactly_one( - self.modes_is_present[task][mode] for mode in modes - ) + if not self.problem.is_optional(task): + self.cp_model.add_exactly_one( + self.modes_is_present[task][mode] for mode in modes + ) + else: + self.cp_model.add_at_most_one( + self.modes_is_present[task][mode] for mode in modes + ) if not self.avoid_interval_optional: for mode in modes: self._create_mode_interval_on_the_fly( task=task, mode=mode, modes=modes ) + def _create_present_variables(self): + for task in self.problem.tasks_list: + if ( + self.create_present_task_variables_for_all_tasks + or task in self.problem.optional_tasks_list + ): + self.task_is_scheduled[task] = self.cp_model.new_bool_var( + f"is_present_task_{task}" + ) + def _create_mode_interval_on_the_fly( self, task: Task, mode: int, modes: Optional[set[int]] = None ) -> None: if modes is None: modes = self.problem.get_task_modes(task=task) if len(modes) == 1: # single mode - # create the interval var with start and end => constraint on end - start - self.modes_intervals[task][mode] = self.cp_model.new_interval_var( - start=self.start_or_end_variables[task, StartOrEnd.START], - size=self.problem.get_task_mode_duration(task=task, mode=mode), - end=self.start_or_end_variables[task, StartOrEnd.END], - name=f"interval_mode_{task}_{mode}", - ) + if task in self.task_is_scheduled: + self.modes_intervals[task][mode] = ( + self.cp_model.new_optional_interval_var( + start=self.start_or_end_variables[task, StartOrEnd.START], + size=self.problem.get_task_mode_duration(task=task, mode=mode), + end=self.start_or_end_variables[task, StartOrEnd.END], + is_present=self.task_is_scheduled[task], + name=f"interval_mode_{task}_{mode}", + ) + ) + else: + # create the interval var with start and end => constraint on end - start + self.modes_intervals[task][mode] = self.cp_model.new_interval_var( + start=self.start_or_end_variables[task, StartOrEnd.START], + size=self.problem.get_task_mode_duration(task=task, mode=mode), + end=self.start_or_end_variables[task, StartOrEnd.END], + name=f"interval_mode_{task}_{mode}", + ) if self.duplicate_start_var_per_mode: self.modes_start_variables[task][mode] = self.start_or_end_variables[ task, StartOrEnd.START @@ -921,7 +971,16 @@ def get_cost_variable(self) -> LinearExprT: self._create_cost_variables() return self._get_total_cost_variable() + def create_link_mode_to_presence(self): + for t in self.task_is_scheduled: + self.cp_model.add_max_equality( + self.task_is_scheduled[t], + [self.modes_is_present[t][mode] for mode in self.modes_is_present[t]], + ) + def _add_constraints(self) -> None: + # mode selection -> presence + self.create_link_mode_to_presence() # time lag self.create_timelag_constraints() # non-renewable resources capacity @@ -947,6 +1006,8 @@ def _add_constraints(self) -> None: self.create_no_overlap_constraints() # forbidden intervals self.create_forbidden_intervals_constraints() + # alternative subproblems + self.create_alternative_subproblems_constraints() def _set_objective(self) -> None: if self.objective == Objective.CUSTOM: @@ -1083,40 +1144,57 @@ def retrieve_tasks_variables( """ task_variables = {} for task in self.problem.tasks_list: - start = cpsolvercb.Value( - self.start_or_end_variables[task, StartOrEnd.START] - ) - end = cpsolvercb.Value(self.start_or_end_variables[task, StartOrEnd.END]) - modes = self.problem.get_task_modes(task) - if len(modes) == 1: - mode = next(iter(modes)) + if task in self.task_is_scheduled and not cpsolvercb.Value( + self.task_is_scheduled[task] + ): + task_variables[task] = TaskVariable( + start=AbsentValue.ABSENT, + end=AbsentValue.ABSENT, + mode=AbsentValue.ABSENT, + allocated={}, + ) else: - for mode in modes: - if cpsolvercb.Value(self.modes_is_present[task][mode]): - break - - def get_skill_used(task: Task, unary_resource: UnaryResource) -> set[Skill]: - try: - skill_variables = self.skill_variables[task][unary_resource] - except KeyError: - return set() + start = cpsolvercb.Value( + self.start_or_end_variables[task, StartOrEnd.START] + ) + end = cpsolvercb.Value( + self.start_or_end_variables[task, StartOrEnd.END] + ) + modes = self.problem.get_task_modes(task) + mode = None + if len(modes) == 1: + mode = next(iter(modes)) else: - return { - skill - for skill, skill_var in skill_variables.items() - if cpsolvercb.Value(skill_var) - } - - allocated = { - unary_resource: get_skill_used(task=task, unary_resource=unary_resource) - for unary_resource, is_allocated_var in self.allocation_is_present[ - task - ].items() - if cpsolvercb.Value(is_allocated_var) - } - task_variables[task] = TaskVariable( - start=start, end=end, mode=mode, allocated=allocated - ) + for mode in modes: + if cpsolvercb.Value(self.modes_is_present[task][mode]): + break + + def get_skill_used( + task: Task, unary_resource: UnaryResource + ) -> set[Skill]: + try: + skill_variables = self.skill_variables[task][unary_resource] + except KeyError: + return set() + else: + return { + skill + for skill, skill_var in skill_variables.items() + if cpsolvercb.Value(skill_var) + } + + allocated = { + unary_resource: get_skill_used( + task=task, unary_resource=unary_resource + ) + for unary_resource, is_allocated_var in self.allocation_is_present[ + task + ].items() + if cpsolvercb.Value(is_allocated_var) + } + task_variables[task] = TaskVariable( + start=start, end=end, mode=mode, allocated=allocated + ) return RawSolution(task_variables=task_variables) def retrieve_solution(self, cpsolvercb: CpSolverSolutionCallback) -> Solution: diff --git a/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/generic_scheduling.py b/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/generic_scheduling.py index 965e491c9..735850dea 100644 --- a/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/generic_scheduling.py +++ b/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/generic_scheduling.py @@ -20,6 +20,9 @@ NonSkillCumulativeResource, Skill, ) +from discrete_optimization.generic_tasks_tools.solvers.cpsat.alternative_subproblems import ( + AlternativeSubproblemCpSatSolver, +) from discrete_optimization.generic_tasks_tools.solvers.cpsat.no_overlap import ( NoOverlapCpSatSolver, ) @@ -45,6 +48,7 @@ class GenericSchedulingCpSatSolver( PrecedenceSchedulingCpSatSolver[Task], TimelagCpSatSolver[Task], NoOverlapCpSatSolver[Task], + AlternativeSubproblemCpSatSolver[Task], Generic[ Task, UnaryResource, Skill, NonSkillCumulativeResource, NonRenewableResource ], diff --git a/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/scheduling.py b/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/scheduling.py index f5e151161..76958e640 100644 --- a/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/scheduling.py +++ b/src/discrete_optimization/generic_tasks_tools/solvers/cpsat/scheduling.py @@ -27,6 +27,9 @@ class SchedulingCpSatSolver(OrtoolsCpSatSolver, SchedulingCpSolver[Task]): _subtasks_makespan: Optional[IntVar] = None """Internal variable use to define the partial makespan.""" + task_is_scheduled: dict[Task, IntVar] + """For optional/alternative scheduling problems.""" + constraints_on_makespan: Optional[list[Any]] = None """Constraints on partial makespan so that it can be considered as the objective.""" @@ -37,6 +40,11 @@ def init_model(self, **kwargs: Any) -> None: self._subtasks_makespan = None self.constraints_on_makespan = None + def get_task_scheduled_variable(self, task: Task) -> LinearExprT: + if self.task_is_scheduled is None: + return None + return self.task_is_scheduled[task] + @abstractmethod def get_task_start_or_end_variable( self, task: Task, start_or_end: StartOrEnd diff --git a/src/discrete_optimization/knapsack/problem.py b/src/discrete_optimization/knapsack/problem.py index 3d99365e9..eef8267ac 100644 --- a/src/discrete_optimization/knapsack/problem.py +++ b/src/discrete_optimization/knapsack/problem.py @@ -15,6 +15,7 @@ AllocationProblem, AllocationSolution, ) +from discrete_optimization.generic_tasks_tools.base import Task from discrete_optimization.generic_tools.do_problem import ( MethodAggregating, ModeOptim, @@ -69,6 +70,9 @@ def __init__( self.weight = weight self.list_taken = list_taken + def is_present(self, task: Item) -> bool: + return True + def is_allocated(self, task: Item, unary_resource: Knapsack) -> bool: if unary_resource == KNAPSACK_RESOURCE: i_item = self.problem.item_to_index_list[task] @@ -142,6 +146,9 @@ def __init__( self.item_to_index_list = {item: i for i, item in enumerate(self.list_items)} self.force_recompute_values = force_recompute_values + def is_optional(self, task: Task) -> bool: + return True + @property def unary_resources_list(self) -> list[Knapsack]: return [KNAPSACK_RESOURCE] diff --git a/src/discrete_optimization/ovensched/problem.py b/src/discrete_optimization/ovensched/problem.py index b8f14c52c..ae620d640 100644 --- a/src/discrete_optimization/ovensched/problem.py +++ b/src/discrete_optimization/ovensched/problem.py @@ -11,6 +11,7 @@ AllocationProblem, AllocationSolution, ) +from discrete_optimization.generic_tasks_tools.base import NoOptionalTasksProblem from discrete_optimization.generic_tasks_tools.scheduling import ( SchedulingProblem, SchedulingSolution, @@ -54,6 +55,9 @@ class OvenSchedulingSolution( Represents a solution to the Oven Scheduling Problem. """ + def is_present(self, task: Task) -> bool: + return task in self.schedule_per_task + problem: OvenSchedulingProblem def __init__( @@ -191,7 +195,9 @@ class MachineData: class OvenSchedulingProblem( - SchedulingProblem[Task], AllocationProblem[Task, UnaryResource] + SchedulingProblem[Task], + AllocationProblem[Task, UnaryResource], + NoOptionalTasksProblem[Task], ): """Defines an instance of the Oven Scheduling Problem (OSP) and its evaluation logic.""" diff --git a/src/discrete_optimization/rcpsp/problem.py b/src/discrete_optimization/rcpsp/problem.py index 6ec344e8f..fc4c8acc3 100644 --- a/src/discrete_optimization/rcpsp/problem.py +++ b/src/discrete_optimization/rcpsp/problem.py @@ -272,6 +272,9 @@ def __init__( self.update_problem() + def is_optional(self, task: Task) -> bool: + return False + def update_problem(self) -> None: """Method to call when some attributes have been modified. @@ -851,11 +854,22 @@ def create_np_data_and_jit_functions( index_mode = 0 for mode in sorted(rcpsp_problem.get_task_modes(rcpsp_problem.tasks_list[i])): for k in range(len(rcpsp_problem.resources_list)): - consumption_array[i, index_mode, k] = ( - rcpsp_problem.get_cumulative_resource_consumption( - rcpsp_problem.resources_list[k], task, mode + if ( + rcpsp_problem.resources_list[k] + in rcpsp_problem.cumulative_resources_list + ): + consumption_array[i, index_mode, k] = ( + rcpsp_problem.get_cumulative_resource_consumption( + rcpsp_problem.resources_list[k], task, mode + ) ) - ) + else: + consumption_array[i, index_mode, k] = ( + rcpsp_problem.get_non_renewable_resource_consumption( + rcpsp_problem.resources_list[k], task, mode + ) + ) + duration_array[i, index_mode] = rcpsp_problem.get_task_mode_duration( task, mode ) @@ -878,10 +892,11 @@ def create_np_data_and_jit_functions( for i in range(rcpsp_problem.n_jobs): task = rcpsp_problem.tasks_list[i] - for s in rcpsp_problem.successors[task]: - index_s = task_index[s] - predecessors[index_s, i] = 1 - successors[i, index_s] = 1 + if task in rcpsp_problem.successors: + for s in rcpsp_problem.successors[task]: + index_s = task_index[s] + predecessors[index_s, i] = 1 + successors[i, index_s] = 1 if "special_constraints" in rcpsp_problem.__dict__.keys(): for t in rcpsp_problem.special_constraints.start_times_window: diff --git a/src/discrete_optimization/rcpsp/solution.py b/src/discrete_optimization/rcpsp/solution.py index 65e9b3931..5b68daad7 100644 --- a/src/discrete_optimization/rcpsp/solution.py +++ b/src/discrete_optimization/rcpsp/solution.py @@ -276,7 +276,8 @@ def generate_permutation_from_schedule(self) -> list[int]: sorted_task = [ self.problem.index_task_non_dummy[i] for i in sorted( - self.rcpsp_schedule, key=lambda x: self.rcpsp_schedule[x]["start_time"] + [t for t in self.problem.tasks_list if self.is_present(t)], + key=lambda x: self.rcpsp_schedule[x]["start_time"], ) if i in self.problem.index_task_non_dummy ] diff --git a/src/discrete_optimization/rcpsp/transformations/generic_scheduling_impl.py b/src/discrete_optimization/rcpsp/transformations/generic_scheduling_impl.py index 3297d14ce..7e8ed7edd 100644 --- a/src/discrete_optimization/rcpsp/transformations/generic_scheduling_impl.py +++ b/src/discrete_optimization/rcpsp/transformations/generic_scheduling_impl.py @@ -9,6 +9,7 @@ import itertools from collections.abc import Hashable +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.enums import StartOrEnd from discrete_optimization.generic_tasks_tools.generic_scheduling_impl import ( GenericSchedulingImplProblem, @@ -43,7 +44,7 @@ def transform_solution_from_raw_generic_to_rcpsp( """Convert generic solution to RCPSP solution. Args: - solution: + raw_sol: problem: Returns: @@ -60,7 +61,9 @@ def transform_solution_from_raw_generic_to_rcpsp( return RcpspSolution( problem=problem, rcpsp_schedule=schedule, - rcpsp_modes=[modes_dict[t] for t in problem.tasks_list_non_dummy], + rcpsp_modes=[ + modes_dict.get(t, AbsentValue.ABSENT) for t in problem.tasks_list_non_dummy + ], ) diff --git a/src/discrete_optimization/rcpsp/utils.py b/src/discrete_optimization/rcpsp/utils.py index 78970db48..cf6b35040 100644 --- a/src/discrete_optimization/rcpsp/utils.py +++ b/src/discrete_optimization/rcpsp/utils.py @@ -49,7 +49,7 @@ def compute_resource_consumption( if list_resources is None: list_resources = rcpsp_problem.resources_list consumptions = np.zeros((len(list_resources), makespan + 1), dtype=np.int_) - for act_id in rcpsp_sol.rcpsp_schedule: + for act_id in rcpsp_sol.get_present_tasks(): for ir in range(len(list_resources)): use_ir = rcpsp_problem.get_cumulative_resource_consumption( list_resources[ir], act_id, modes_dict[act_id] @@ -120,7 +120,7 @@ def plot_ressource_view( polygons_ax: dict[int, list[Polygon]] = {i: [] for i in range(len(list_resource))} labels_ax: dict[int, list[Hashable]] = {i: [] for i in range(len(list_resource))} sorted_activities = sorted( - rcpsp_sol.rcpsp_schedule, + rcpsp_sol.get_present_tasks(), key=lambda x: rcpsp_sol.rcpsp_schedule[x]["start_time"], ) for j in sorted_activities: @@ -208,29 +208,29 @@ def plot_task_gantt( ax.set_title("Gantt Task") else: ax.set_title(title) - tasks = rcpsp_problem.tasks_list - nb_task = len(tasks) + tasks_of_interest = rcpsp_sol.get_present_tasks() + nb_task = len(tasks_of_interest) sorted_task_by_start = sorted( - rcpsp_sol.rcpsp_schedule, + tasks_of_interest, key=lambda x: 100000 * rcpsp_sol.get_start_time(x) + rcpsp_problem.index_task[x], ) sorted_task_by_end = sorted( - rcpsp_sol.rcpsp_schedule, + tasks_of_interest, key=lambda x: 100000 * rcpsp_sol.get_end_time(x) + rcpsp_problem.index_task[x], ) max_time = rcpsp_sol.get_end_time(sorted_task_by_end[-1]) min_time = rcpsp_sol.get_start_time(sorted_task_by_start[0]) patches = [] for j in range(nb_task): - nb_colors = len(tasks) // 2 + nb_colors = len(tasks_of_interest) // 2 colors = get_cmap_with_nb_colors("hsv", nb_colors) box = [ - (j - 0.25, rcpsp_sol.rcpsp_schedule[tasks[j]]["start_time"]), - (j - 0.25, rcpsp_sol.rcpsp_schedule[tasks[j]]["end_time"]), - (j + 0.25, rcpsp_sol.rcpsp_schedule[tasks[j]]["end_time"]), - (j + 0.25, rcpsp_sol.rcpsp_schedule[tasks[j]]["start_time"]), - (j - 0.25, rcpsp_sol.rcpsp_schedule[tasks[j]]["start_time"]), + (j - 0.25, rcpsp_sol.get_start_time(tasks_of_interest[j])), + (j - 0.25, rcpsp_sol.get_end_time(tasks_of_interest[j])), + (j + 0.25, rcpsp_sol.get_end_time(tasks_of_interest[j])), + (j + 0.25, rcpsp_sol.get_start_time(tasks_of_interest[j])), + (j - 0.25, rcpsp_sol.get_start_time(tasks_of_interest[j])), ] polygon = Polygon([(b[1], b[0]) for b in box]) x, y = polygon.exterior.xy @@ -252,7 +252,7 @@ def plot_task_gantt( ax.set_ylim((-0.5, nb_task)) ax.set_yticks(range(nb_task)) ax.set_yticklabels( - tuple([str(tasks[j]) for j in range(nb_task)]), fontdict={"size": 5} + tuple([str(tasks_of_interest[j]) for j in range(nb_task)]), fontdict={"size": 5} ) ax.set_ylabel("Task number") ax.set_xlabel("Timestep") diff --git a/src/discrete_optimization/rcpsp_alternative/problem.py b/src/discrete_optimization/rcpsp_alternative/problem.py index c918aca29..c7d77b4b0 100644 --- a/src/discrete_optimization/rcpsp_alternative/problem.py +++ b/src/discrete_optimization/rcpsp_alternative/problem.py @@ -5,13 +5,19 @@ # Between 2 mandatory task, different subpath of task should be accomplished (with or without precedence constraints). # This problem can represent different alternative physical path of task to accomplish on a shop floor. import logging -from dataclasses import dataclass from typing import Any, Hashable, Optional, Union -import networkx as nx - -from discrete_optimization.rcpsp.problem import RcpspProblem, RcpspSolution -from discrete_optimization.rcpsp.solution import RcpspSolution, Resource, Task +from discrete_optimization.generic_tasks_tools import AbsentValue +from discrete_optimization.generic_tasks_tools.alternative_subproblems import ( + AlternativeSchedulingSubProblem, +) +from discrete_optimization.rcpsp.problem import RcpspProblem +from discrete_optimization.rcpsp.solution import ( + NonRenewableResource, + RcpspSolution, + Resource, + Task, +) from discrete_optimization.rcpsp.special_constraints import ( SpecialConstraintsDescription, ) @@ -19,51 +25,6 @@ logger = logging.getLogger(__name__) -@dataclass -class AlternativeSchedulingSubProblem: - source_task: Hashable # (mandatory task from which originates the alternative) - sink_task: Hashable # (mandatory task from which finished the alternative) - graph: Optional[nx.DiGraph] = None - is_graph_successors: bool = True - successors: dict[Hashable, list[Hashable]] = None - list_paths: Optional[list[list[Hashable]]] = None - is_path_successors: bool = True - nb_path_to_do: int = 1 # by default 1 subpath to follow. - - def __post_init__(self): - if self.graph is None: - if self.list_paths: - graph = nx.DiGraph() - graph.add_node(self.source_task) - graph.add_node(self.sink_task) - for p in self.list_paths: - for e0, e1 in zip(p[:-1], p[1:]): - graph.add_edge(e0, e1) - if p[0] != self.source_task: - graph.add_edge(self.source_task, p[0]) - if p[-1] != self.sink_task: - graph.add_edge(self.source_task, p[0]) - self.graph = graph - if self.source_task not in self.graph.nodes: - predecessors = { - n: list(self.graph.predecessors(n)) for n in self.graph.nodes - } - self.graph.add_node(self.source_task) - for n in predecessors: - if len(predecessors[n]) == 0: - self.graph.add_edge(self.source_task, n) - if self.sink_task not in self.graph.nodes: - successors = {n: list(self.graph.successors(n)) for n in self.graph.nodes} - self.graph.add_node(self.sink_task) - for n in successors: - if len(successors[n]) == 0: - self.graph.add_edge(n, self.sink_task) - if self.list_paths is None: - self.list_paths = list( - nx.all_simple_paths(self.graph, self.source_task, self.sink_task) - ) - - class RcpspWithAlternativePath(RcpspProblem): def __init__( self, @@ -81,8 +42,6 @@ def __init__( fixed_permutation: Optional[list[int]] = None, fixed_modes: Optional[list[int]] = None, alternative_tasks: Optional[list[Hashable]] = None, - alternative_tasks_data: dict[Hashable, dict[int, dict[str, int]]] = None, - alternative_successors: dict[Hashable, list[Hashable]] = None, list_alternative_subproblem: list[AlternativeSchedulingSubProblem] = None, **kwargs: Any, ): @@ -96,8 +55,6 @@ def __init__( :param list_alternative_subproblem: list of alternative scheduling subproblem, describing the alternative paths. """ self.alternative_tasks = alternative_tasks - self.alternative_tasks_data = alternative_tasks_data - self.alternative_successors = alternative_successors self.list_alternative_subproblem = list_alternative_subproblem super().__init__( resources=resources, @@ -115,79 +72,41 @@ def __init__( fixed_modes=fixed_modes, **kwargs, ) - self.n_jobs = len(self.tasks_list) - self.n_jobs_non_dummy = self.n_jobs - 2 - self.index_task = {self.tasks_list[i]: i for i in range(self.n_jobs)} - self.tasks_list_non_dummy = [ - t for t in self.tasks_list if t not in {self.source_task, self.sink_task} - ] - self.index_task_non_dummy = { - self.tasks_list_non_dummy[i]: i for i in range(self.n_jobs_non_dummy) - } - @property - def tasks_list(self) -> list[Task]: - return self._tasks_list + self.alternative_tasks + def is_optional(self, task: Task) -> bool: + return task in self.alternative_tasks + + def get_alternative_scheduling_subproblem( + self, + ) -> list[AlternativeSchedulingSubProblem]: + return self.list_alternative_subproblem def get_cumulative_resource_consumption( self, resource: Resource, task: Task, mode: int ) -> int: + if mode is None or mode == AbsentValue.ABSENT: + return 0 if task in self.mode_details: return self.mode_details[task][mode].get(resource, 0) - if task in self.alternative_tasks_data: - if mode is None: - return 0 - return self.alternative_tasks_data[task][mode].get(resource, 0) return 0 + def get_non_renewable_resource_consumption( + self, resource: NonRenewableResource, task: Task, mode: int + ) -> int: + if mode is None or mode == AbsentValue.ABSENT: + return 0 + mode_detail = self.mode_details[task][mode] + return mode_detail.get(resource, 0) + def get_task_mode_duration(self, task: Task, mode: int) -> int: + if mode is None or mode == AbsentValue.ABSENT: + return 0 if task in self._tasks_list: return self.mode_details[task][mode]["duration"] - if task in self.alternative_tasks: - return self.alternative_tasks_data[task][mode]["duration"] return 0 def get_task_modes(self, task: Task) -> set[int]: - if task in self._tasks_list: - return set(self.mode_details[task]) - if task in self.alternative_tasks: - return set(self.alternative_tasks_data[task]) - return None - - def satisfy(self, variable: RcpspSolution) -> bool: - sat = super().satisfy(variable) - if not sat: - return False - for alt_problem in self.list_alternative_subproblem: - paths = alt_problem.list_paths - nb_path_done = 0 - paths_done = [] - for p in paths: - if all( - task in variable.rcpsp_schedule - and variable.get_mode(task) is not None - for task in p - ): - nb_path_done += 1 - paths_done.append(p) - if nb_path_done > alt_problem.nb_path_to_do: - logger.info("Too much alternative path") - return False - if nb_path_done < alt_problem.nb_path_to_do: - logger.info("Not enough alternative path") - return False - if alt_problem.is_path_successors: - for p in paths_done: - for t0, t1 in zip(p[:-1], p[1:]): - if not ( - variable.get_end_time(t0) <= variable.get_start_time(t1) - ): - logger.info( - "Precedence constraints not respected in the final schedule" - ) - logger.info(f"between {t1} and {t0}") - return False - return True + return set(self.mode_details[task]) def get_optional_tasks_done(sol: RcpspSolution, problem: RcpspWithAlternativePath): diff --git a/src/discrete_optimization/rcpsp_alternative/solvers/cpsat_auto.py b/src/discrete_optimization/rcpsp_alternative/solvers/cpsat_auto.py new file mode 100644 index 000000000..bf42df051 --- /dev/null +++ b/src/discrete_optimization/rcpsp_alternative/solvers/cpsat_auto.py @@ -0,0 +1,49 @@ +# Copyright (c) 2026 AIRBUS and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +from discrete_optimization.generic_tasks_tools.allocation import ( + NoUnaryResource, + UnaryResource, +) +from discrete_optimization.generic_tasks_tools.base import Task +from discrete_optimization.generic_tasks_tools.generic_scheduling import ( + GenericSchedulingSolution, +) +from discrete_optimization.generic_tasks_tools.generic_scheduling_utils import ( + RawSolution, +) +from discrete_optimization.generic_tasks_tools.non_renewable_resource import ( + NonRenewableResource, +) +from discrete_optimization.generic_tasks_tools.skill import ( + NonSkillCumulativeResource, + NoSkill, + Skill, +) +from discrete_optimization.generic_tasks_tools.solvers.cpsat.auto import ( + GenericSchedulingAutoCpSatSolver, +) +from discrete_optimization.rcpsp.transformations.generic_scheduling_impl import ( + transform_solution_from_raw_generic_to_rcpsp, +) +from discrete_optimization.rcpsp_alternative.problem import ( + RcpspWithAlternativePath, +) + + +class CpsatAutoRcpspWithAlternativePathSolver( + GenericSchedulingAutoCpSatSolver[ + Task, NoUnaryResource, NoSkill, NonSkillCumulativeResource, NonRenewableResource + ] +): + problem: RcpspWithAlternativePath + additional_variables: dict + + def convert_task_variables_to_solution( + self, raw_sol: RawSolution[Task, UnaryResource, Skill] + ) -> GenericSchedulingSolution[ + Task, UnaryResource, Skill, NonSkillCumulativeResource, NonRenewableResource + ]: + return transform_solution_from_raw_generic_to_rcpsp( + raw_sol=raw_sol, problem=self.problem + ) diff --git a/src/discrete_optimization/rcpsp_alternative/utils.py b/src/discrete_optimization/rcpsp_alternative/utils.py index 832f10b1e..d6c21f515 100644 --- a/src/discrete_optimization/rcpsp_alternative/utils.py +++ b/src/discrete_optimization/rcpsp_alternative/utils.py @@ -2,6 +2,7 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. import random +from copy import deepcopy from discrete_optimization.rcpsp.problem import RcpspProblem, Task from discrete_optimization.rcpsp_alternative.problem import ( @@ -15,6 +16,7 @@ def create_problem_rcpsp( nb_alternative_paths: int = 3, range_nb_subpath: tuple = (1, 4), range_len_subpath: tuple = (1, 5), + factor_makespan: float = 3.0, ) -> RcpspWithAlternativePath: graph = problem.graph descendants = graph.descendants_map() @@ -55,9 +57,12 @@ def create_problem_rcpsp( 1: {"duration": random.randint(min_duration, max_duration)} } for r in problem.resources_list: - alternative_tasks_data[task_key][1][r] = random.randint( - 0, problem.get_max_resource_capacity(r) // 2 - ) + if r in problem.non_renewable_resources_list: + alternative_tasks_data[task_key][1][r] = 0 + else: + alternative_tasks_data[task_key][1][r] = random.randint( + 0, problem.get_max_resource_capacity(r) // 2 + ) alternative_tasks.append(task_key) path.append(task_key) list_paths.append(path) @@ -70,20 +75,20 @@ def create_problem_rcpsp( nb_path_to_do=1, ) ) + mode_details = deepcopy(problem.mode_details) + mode_details.update(alternative_tasks_data) return RcpspWithAlternativePath( resources=problem.resources, non_renewable_resources=problem.non_renewable_resources, - mode_details=problem.mode_details, + mode_details=mode_details, successors=problem.successors, - horizon=problem.horizon * 2, - tasks_list=problem.tasks_list, + horizon=int(problem.horizon * factor_makespan), + tasks_list=problem.tasks_list + alternative_tasks, source_task=problem.source_task, sink_task=problem.sink_task, name_task=problem.name_task, calendar_details=problem.calendar_details, special_constraints=problem.special_constraints, alternative_tasks=alternative_tasks, - alternative_tasks_data=alternative_tasks_data, - alternative_successors=alternative_successors, list_alternative_subproblem=list_alternative_subproblem, ) diff --git a/src/discrete_optimization/rcpsp_multiskill/problem.py b/src/discrete_optimization/rcpsp_multiskill/problem.py index 4484b9adb..32b90656d 100644 --- a/src/discrete_optimization/rcpsp_multiskill/problem.py +++ b/src/discrete_optimization/rcpsp_multiskill/problem.py @@ -2249,6 +2249,9 @@ def update_resource_availabilities(self) -> None: super().update_resource_availabilities() self.get_resource_availabilities.cache_clear() + def is_optional(self, task: Task) -> bool: + return False + def get_no_overlap(self) -> set[frozenset[Task]]: if self.do_special_constraints: return { diff --git a/src/discrete_optimization/shop/base.py b/src/discrete_optimization/shop/base.py index 9549d4451..02de8d12b 100644 --- a/src/discrete_optimization/shop/base.py +++ b/src/discrete_optimization/shop/base.py @@ -270,6 +270,9 @@ def satisfy(self, variable: AnyShopSolution) -> bool: return False return True + def is_optional(self, task: Task) -> bool: + return False + def get_makespan_upper_bound(self) -> int: return self.horizon diff --git a/src/discrete_optimization/singlebatch/problem.py b/src/discrete_optimization/singlebatch/problem.py index aa11a08a3..9bcdd15d9 100644 --- a/src/discrete_optimization/singlebatch/problem.py +++ b/src/discrete_optimization/singlebatch/problem.py @@ -39,6 +39,9 @@ def __repr__(self) -> str: class BatchProcessingSolution(SchedulingSolution[Task]): """A solution mapping jobs to distinct batches.""" + def is_present(self, task: Task) -> bool: + return self.job_to_batch[task] is not None + problem: "SingleBatchProcessingProblem" def __init__( @@ -79,6 +82,9 @@ def change_problem(self, new_problem: "SingleBatchProcessingProblem") -> None: class SingleBatchProcessingProblem(SchedulingProblem[Task]): """The Single Batch-Processing Machine Scheduling Problem.""" + def is_optional(self, task: Task) -> bool: + return False + def get_makespan_upper_bound(self) -> int: return sum([j.processing_time for j in self.jobs]) diff --git a/src/discrete_optimization/singlemachine/problem.py b/src/discrete_optimization/singlemachine/problem.py index 303bbb48d..4dc7fc346 100644 --- a/src/discrete_optimization/singlemachine/problem.py +++ b/src/discrete_optimization/singlemachine/problem.py @@ -42,6 +42,9 @@ def __init__( self.permutation = permutation self.compute_schedule_from_permutation() + def is_present(self, task: Task) -> bool: + return self.schedule[task][0] is not None + def compute_schedule_from_permutation(self): if self.schedule is None: assert self.permutation is not None diff --git a/src/discrete_optimization/vrptw/problem.py b/src/discrete_optimization/vrptw/problem.py index e395af069..f98b33766 100644 --- a/src/discrete_optimization/vrptw/problem.py +++ b/src/discrete_optimization/vrptw/problem.py @@ -51,6 +51,9 @@ class VRPTWSolution(SchedulingSolution[Task], AllocationSolution[Task, UnaryReso capacity_violation (float): Total violation of vehicle capacities. """ + def is_present(self, task: Task) -> bool: + return self.get_start_time(task) is not None + def is_allocated(self, task: Task, unary_resource: UnaryResource) -> bool: return task in self.routes[unary_resource] diff --git a/src/discrete_optimization/workforce/allocation/problem.py b/src/discrete_optimization/workforce/allocation/problem.py index 417bbd409..69a931629 100644 --- a/src/discrete_optimization/workforce/allocation/problem.py +++ b/src/discrete_optimization/workforce/allocation/problem.py @@ -217,6 +217,10 @@ def lazy_copy(self) -> Solution: problem=self.problem, allocation=self.allocation, **self.kpis ) + def is_present(self, task: Task) -> bool: + i_task = self.problem.index_activities_name[task] + return self.allocation[i_task] is not None + def is_allocated(self, task: Task, unary_resource: UnaryResource) -> bool: i_task = self.problem.index_activities_name[task] i_team = self.problem.index_teams_name[unary_resource] diff --git a/src/discrete_optimization/workforce/scheduling/problem.py b/src/discrete_optimization/workforce/scheduling/problem.py index 3df9befc6..38fdd32c9 100644 --- a/src/discrete_optimization/workforce/scheduling/problem.py +++ b/src/discrete_optimization/workforce/scheduling/problem.py @@ -186,6 +186,9 @@ def __init__( self.horizon_start_shift = horizon_start_shift self.update_problem() + def is_optional(self, task: Task) -> bool: + return False + @property def non_skill_cumulative_resources_list(self) -> list[NonSkillCumulativeResource]: return self.resources_list From 9b078364d56cfc4cb25dde4c077a54c11df59e40 Mon Sep 17 00:00:00 2001 From: poveda_g Date: Mon, 17 Aug 2026 11:12:25 +0200 Subject: [PATCH 03/10] add unit test for rcpsp alternative --- tests/rcpsp_alternative/test_cpsat.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/rcpsp_alternative/test_cpsat.py diff --git a/tests/rcpsp_alternative/test_cpsat.py b/tests/rcpsp_alternative/test_cpsat.py new file mode 100644 index 000000000..e69de29bb From d2d59dff59de7add3a3258f98d329ba9f3c38730 Mon Sep 17 00:00:00 2001 From: poveda_g Date: Mon, 17 Aug 2026 11:14:42 +0200 Subject: [PATCH 04/10] add missing new function (is_optional) to allocation problem --- .../generic_scheduling_impl.py | 2 + .../workforce/allocation/problem.py | 9 +++- tests/rcpsp_alternative/test_cpsat.py | 48 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/discrete_optimization/generic_tasks_tools/transformations/generic_scheduling_impl.py b/src/discrete_optimization/generic_tasks_tools/transformations/generic_scheduling_impl.py index 1fdf9329b..8e3fb75b2 100644 --- a/src/discrete_optimization/generic_tasks_tools/transformations/generic_scheduling_impl.py +++ b/src/discrete_optimization/generic_tasks_tools/transformations/generic_scheduling_impl.py @@ -300,6 +300,8 @@ def transform_problem( objective=objective, custom_evaluate_fn=custom_evaluate_fn, objective_resource_weights=objective_resource_weights, + optional_tasks=set(source_problem.optional_tasks_list), + alternative_subproblems=source_problem.get_alternative_scheduling_subproblem(), compute_time_penalty=compute_time_penalty, ) diff --git a/src/discrete_optimization/workforce/allocation/problem.py b/src/discrete_optimization/workforce/allocation/problem.py index 69a931629..498965ebd 100644 --- a/src/discrete_optimization/workforce/allocation/problem.py +++ b/src/discrete_optimization/workforce/allocation/problem.py @@ -16,6 +16,7 @@ from networkx import bipartite from discrete_optimization.coloring.problem import ColoringConstraints, ColoringProblem +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.allocation import ( AllocationProblem, AllocationSolution, @@ -219,7 +220,10 @@ def lazy_copy(self) -> Solution: def is_present(self, task: Task) -> bool: i_task = self.problem.index_activities_name[task] - return self.allocation[i_task] is not None + return ( + self.allocation[i_task] is not None + and self.allocation[i_task] != AbsentValue.ABSENT + ) def is_allocated(self, task: Task, unary_resource: UnaryResource) -> bool: i_task = self.problem.index_activities_name[task] @@ -399,6 +403,9 @@ def __init__( } self.compatibility_task_team = self.compute_compatibility_for_all_tasks() + def is_optional(self, task: Task) -> bool: + return False + @property def tasks_list(self) -> list[Task]: return self.activities_name diff --git a/tests/rcpsp_alternative/test_cpsat.py b/tests/rcpsp_alternative/test_cpsat.py index e69de29bb..0e490e65d 100644 --- a/tests/rcpsp_alternative/test_cpsat.py +++ b/tests/rcpsp_alternative/test_cpsat.py @@ -0,0 +1,48 @@ +# Copyright (c) 2026 AIRBUS and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +import logging + +from discrete_optimization.generic_tools.cp_tools import ParametersCp +from discrete_optimization.rcpsp.parser import get_data_available, parse_file +from discrete_optimization.rcpsp_alternative.solvers.cpsat import ( + CpsatRcpspWithAlternativePathSolver, +) +from discrete_optimization.rcpsp_alternative.solvers.cpsat_auto import ( + CpsatAutoRcpspWithAlternativePathSolver, +) +from discrete_optimization.rcpsp_alternative.utils import create_problem_rcpsp + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def test_cpsat(): + problem = parse_file([f for f in get_data_available() if "j301_1.sm" in f][0]) + problem = create_problem_rcpsp( + problem, + nb_alternative_paths=2, + range_nb_subpath=(1, 4), + range_len_subpath=(3, 5), + ) + solver = CpsatRcpspWithAlternativePathSolver(problem) + solver.init_model(strict_alternative_path=True) + res = solver.solve(parameters_cp=ParametersCp.default_cpsat(), time_limit=10) + sol = res[-1][0] + assert problem.satisfy(sol) + + +def test_cpsat_auto(): + problem = parse_file([f for f in get_data_available() if "j301_1.sm" in f][0]) + problem = create_problem_rcpsp( + problem, + nb_alternative_paths=2, + range_nb_subpath=(1, 4), + range_len_subpath=(3, 5), + ) + solver = CpsatAutoRcpspWithAlternativePathSolver(problem) + solver.init_model() + res = solver.solve(parameters_cp=ParametersCp.default_cpsat(), time_limit=10) + sol = res[-1][0] + assert problem.satisfy(sol) From 1d9b86296841b7d957f055e92beecf624f714a00 Mon Sep 17 00:00:00 2001 From: poveda_g Date: Mon, 17 Aug 2026 11:42:43 +0200 Subject: [PATCH 05/10] add missing new function (is_optional) to allocation problem --- src/discrete_optimization/tsp/problem.py | 7 +++++++ src/discrete_optimization/vrptw/problem.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/src/discrete_optimization/tsp/problem.py b/src/discrete_optimization/tsp/problem.py index 9d9b4d32f..b450b9fe9 100644 --- a/src/discrete_optimization/tsp/problem.py +++ b/src/discrete_optimization/tsp/problem.py @@ -16,6 +16,7 @@ import numpy.typing as npt from numba import njit +from discrete_optimization.generic_tasks_tools.base import Task from discrete_optimization.generic_tasks_tools.scheduling import ( SchedulingProblem, SchedulingSolution, @@ -99,6 +100,9 @@ def __init__( def get_end_time(self, task: Node) -> int: return self.get_start_time(task) + 1 + def is_present(self, task: Task) -> bool: + return True + def get_start_time(self, task: Node) -> int: return self.permutation.index(task) @@ -185,6 +189,9 @@ def __init__( self.original_indices_to_permutation_indices_dict[i] = counter counter += 1 + def is_optional(self, task: Task) -> bool: + return False + # for a given tsp kind of problem, you should provide a custom evaluate function, for now still abstract. @abstractmethod def evaluate_function( diff --git a/src/discrete_optimization/vrptw/problem.py b/src/discrete_optimization/vrptw/problem.py index f98b33766..ae9abdce0 100644 --- a/src/discrete_optimization/vrptw/problem.py +++ b/src/discrete_optimization/vrptw/problem.py @@ -161,6 +161,9 @@ class VRPTWProblem(SchedulingProblem[Task], AllocationProblem[Task, UnaryResourc - Objectives: 1) Minimize number of vehicles, 2) Minimize total distance. """ + def is_optional(self, task: Task) -> bool: + return False + def get_makespan_upper_bound(self) -> int: return round(1000 ** self.time_windows[self.depot_node][1]) From 16197fe8c8e255c85a877f18606e039a0059264c Mon Sep 17 00:00:00 2001 From: poveda_g Date: Mon, 17 Aug 2026 12:05:27 +0200 Subject: [PATCH 06/10] add missing new function (is_optional) to allocation problem --- src/discrete_optimization/singlemachine/problem.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/discrete_optimization/singlemachine/problem.py b/src/discrete_optimization/singlemachine/problem.py index 4dc7fc346..aff5ff7d3 100644 --- a/src/discrete_optimization/singlemachine/problem.py +++ b/src/discrete_optimization/singlemachine/problem.py @@ -112,6 +112,9 @@ def __init__( def tasks_list(self) -> list[Task]: return list(range(self.num_jobs)) + def is_optional(self, task: Task) -> bool: + return False + def __repr__(self): return ( f"WeightedTardinessProblem(num_jobs={self.num_jobs}, " From 107d855a8ea23bb8e6ffd3faf89c1561149fa91f Mon Sep 17 00:00:00 2001 From: poveda_g Date: Mon, 17 Aug 2026 13:25:34 +0200 Subject: [PATCH 07/10] add missing new function (is_optional) to various problems --- src/discrete_optimization/alb/rcalbp/problem.py | 10 ++++++++++ src/discrete_optimization/alb/rcalbp_l/problem.py | 7 +++++++ src/discrete_optimization/alb/salbp/problem.py | 7 ++++++- src/discrete_optimization/coloring/problem.py | 8 ++++++-- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/discrete_optimization/alb/rcalbp/problem.py b/src/discrete_optimization/alb/rcalbp/problem.py index 0b1682ef8..fee705a80 100644 --- a/src/discrete_optimization/alb/rcalbp/problem.py +++ b/src/discrete_optimization/alb/rcalbp/problem.py @@ -62,6 +62,7 @@ BaseALBSolution, ResourceTaskData, ) +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.allocation import ( UnaryResource, ) @@ -107,6 +108,12 @@ class RCALBPSolution( problem: "RCALBPProblem" + def is_present(self, task: Task) -> bool: + return task in self.task_assignment and self.task_assignment[task] not in { + None, + AbsentValue.ABSENT, + } + def get_renewable_resource_consumption(self, resource: Resource, task: Task) -> int: return self.problem.get_task_demand(task, resource) @@ -454,6 +461,9 @@ class RCALBPProblem( def renewable_resources_list(self) -> list[Resource]: return list(set(self.resources) | self.shared_resources) + def is_optional(self, task: Task) -> bool: + return False + def get_resource_availabilities( self, resource: Resource ) -> list[tuple[int, int, int]]: diff --git a/src/discrete_optimization/alb/rcalbp_l/problem.py b/src/discrete_optimization/alb/rcalbp_l/problem.py index aaca76b73..1e09b0cd6 100644 --- a/src/discrete_optimization/alb/rcalbp_l/problem.py +++ b/src/discrete_optimization/alb/rcalbp_l/problem.py @@ -10,6 +10,7 @@ from matplotlib import pyplot as plt from matplotlib.widgets import Slider +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.allocation import ( AllocationProblem, AllocationSolution, @@ -66,6 +67,12 @@ def __init__( self.ramp_up_duration = ramp_up_duration self.nb_adjustments = nb_adjustments + def is_present(self, task: Task) -> bool: + return task[0] in self.wks and self.wks[task[0]] not in { + None, + AbsentValue.ABSENT, + } + def is_allocated(self, task: Task, unary_resource: WorkStation) -> bool: return self.wks[task[0]] == unary_resource diff --git a/src/discrete_optimization/alb/salbp/problem.py b/src/discrete_optimization/alb/salbp/problem.py index 745438368..029bce38a 100644 --- a/src/discrete_optimization/alb/salbp/problem.py +++ b/src/discrete_optimization/alb/salbp/problem.py @@ -10,9 +10,11 @@ BaseALBSolution, TaskData, ) +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.allocation import ( UnaryResource, ) +from discrete_optimization.generic_tasks_tools.base import NoOptionalTasksProblem from discrete_optimization.generic_tools.do_problem import ( EncodingRegister, ModeOptim, @@ -47,6 +49,9 @@ def __init__(self, problem: "SalbpProblem", allocation_to_station: list[int]): self._nb_stations = len(set(self.allocation_to_station)) self._cached_schedule = None # Cache for greedy schedule + def is_present(self, task: Task) -> bool: + return self.allocation_to_station[task] not in {None, AbsentValue.ABSENT} + # BaseALBSolution interface implementation def get_station_index(self, task: Task) -> int: """Get the index of the station where task is assigned.""" @@ -138,7 +143,7 @@ def __eq__(self, other): return self.allocation_to_station == other.allocation_to_station -class SalbpProblem(BaseALBProblem[int, int]): +class SalbpProblem(BaseALBProblem[int, int], NoOptionalTasksProblem[Task]): """ Simple Assembly Line Balancing Problem. diff --git a/src/discrete_optimization/coloring/problem.py b/src/discrete_optimization/coloring/problem.py index 3cd2fc73e..16d9d08a6 100644 --- a/src/discrete_optimization/coloring/problem.py +++ b/src/discrete_optimization/coloring/problem.py @@ -15,11 +15,12 @@ import numpy as np +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.allocation import ( AllocationProblem, AllocationSolution, ) -from discrete_optimization.generic_tasks_tools.base import Task +from discrete_optimization.generic_tasks_tools.base import NoOptionalTasksProblem, Task from discrete_optimization.generic_tools.do_problem import ( ModeOptim, ObjectiveDoc, @@ -72,6 +73,9 @@ def __init__( self.nb_color = nb_color self.nb_violations = nb_violations + def is_present(self, task: Task) -> bool: + return self.colors[task] is not None and self.colors[task] != AbsentValue.ABSENT + def copy(self) -> ColoringSolution: """Efficient way of copying a coloring solution without deepcopying unnecessary attribute (problem). @@ -240,7 +244,7 @@ def nodes_fixed(self) -> set[Hashable]: return set() -class ColoringProblem(AllocationProblem[Node, Color]): +class ColoringProblem(AllocationProblem[Node, Color], NoOptionalTasksProblem[Node]): """Coloring problem class implementation. Attributes: From fac4deab13a3e9560a8ba548fab1ae77eb1e9706 Mon Sep 17 00:00:00 2001 From: poveda_g Date: Mon, 17 Aug 2026 13:53:54 +0200 Subject: [PATCH 08/10] add missing new function (is_optional) to various problems --- src/discrete_optimization/alb/rcalbp_l/problem.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/discrete_optimization/alb/rcalbp_l/problem.py b/src/discrete_optimization/alb/rcalbp_l/problem.py index 1e09b0cd6..02a81a50d 100644 --- a/src/discrete_optimization/alb/rcalbp_l/problem.py +++ b/src/discrete_optimization/alb/rcalbp_l/problem.py @@ -16,6 +16,7 @@ AllocationSolution, UnaryResource, ) +from discrete_optimization.generic_tasks_tools.base import NoOptionalTasksProblem from discrete_optimization.generic_tasks_tools.scheduling import ( SchedulingProblem, SchedulingSolution, @@ -147,7 +148,11 @@ def __init__( self.raw = sol.raw -class RCALBPLProblem(SchedulingProblem[Task], AllocationProblem[Task, WorkStation]): +class RCALBPLProblem( + SchedulingProblem[Task], + AllocationProblem[Task, WorkStation], + NoOptionalTasksProblem[Task], +): """ Problem definition for Resource-Constrained Assembly Line Balancing with Learning Effect (RC-ALBP/L). From 3854d85bf57049507027c3e07d39723866f72ff5 Mon Sep 17 00:00:00 2001 From: poveda_g Date: Mon, 17 Aug 2026 15:25:36 +0200 Subject: [PATCH 09/10] chasing bug in unit test --- src/discrete_optimization/binpack/problem.py | 14 +++++++++--- src/discrete_optimization/rcpsp/solution.py | 13 +++++++---- src/discrete_optimization/rcpsp/utils.py | 22 ++++++++++++++----- .../solvers/cpsat/test_auto.py | 2 ++ .../test_cumulative_resource.py | 4 +++- .../test_non_renewable_resource.py | 4 +++- 6 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/discrete_optimization/binpack/problem.py b/src/discrete_optimization/binpack/problem.py index 44c89149a..196433df7 100644 --- a/src/discrete_optimization/binpack/problem.py +++ b/src/discrete_optimization/binpack/problem.py @@ -9,11 +9,12 @@ from dataclasses import dataclass, field from typing import Hashable +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.allocation import ( AllocationProblem, AllocationSolution, ) -from discrete_optimization.generic_tasks_tools.base import Task +from discrete_optimization.generic_tasks_tools.base import NoOptionalTasksProblem, Task from discrete_optimization.generic_tasks_tools.scheduling import ( SchedulingProblem, SchedulingSolution, @@ -51,7 +52,10 @@ def copy(self) -> BinPackSolution: ) def is_present(self, task: Task) -> bool: - return self.allocation[task] is not None + return ( + self.allocation[task] is not None + and self.allocation[task] != AbsentValue.ABSENT + ) def get_end_time(self, task: Item) -> int: return self.allocation[task] + 1 @@ -85,7 +89,11 @@ class BinInstance: compatible_items: set[int] | None = field(default=None) -class BinPackProblemBinType(AllocationProblem[Item, BinPack], SchedulingProblem[Item]): +class BinPackProblemBinType( + AllocationProblem[Item, BinPack], + SchedulingProblem[Item], + NoOptionalTasksProblem[Task], +): def __init__( self, list_items: list[ItemBinPack], diff --git a/src/discrete_optimization/rcpsp/solution.py b/src/discrete_optimization/rcpsp/solution.py index 5b68daad7..25d42aee3 100644 --- a/src/discrete_optimization/rcpsp/solution.py +++ b/src/discrete_optimization/rcpsp/solution.py @@ -13,6 +13,7 @@ import numpy as np from numpy import typing as npt +from discrete_optimization.generic_tasks_tools import AbsentValue from discrete_optimization.generic_tasks_tools.allocation import ( NoUnaryResource, WithoutAllocationSolution, @@ -450,11 +451,15 @@ def generate_schedule_from_permutation_serial_sgs_2( def get_max_end_time(self) -> int: return self.rcpsp_schedule[self.problem.sink_task]["end_time"] - def get_start_time(self, task: Hashable) -> int: - return self.rcpsp_schedule[task]["start_time"] + def get_start_time(self, task: Hashable) -> int | AbsentValue: + if task in self.rcpsp_schedule: + return self.rcpsp_schedule[task]["start_time"] + return AbsentValue.ABSENT - def get_end_time(self, task: Hashable) -> int: - return self.rcpsp_schedule[task]["end_time"] + def get_end_time(self, task: Hashable) -> int | AbsentValue: + if task in self.rcpsp_schedule: + return self.rcpsp_schedule[task]["end_time"] + return AbsentValue.ABSENT def get_start_times_list(self, task: Hashable) -> list[int]: return [self.get_start_time(task)] diff --git a/src/discrete_optimization/rcpsp/utils.py b/src/discrete_optimization/rcpsp/utils.py index cf6b35040..995da3195 100644 --- a/src/discrete_optimization/rcpsp/utils.py +++ b/src/discrete_optimization/rcpsp/utils.py @@ -51,9 +51,14 @@ def compute_resource_consumption( consumptions = np.zeros((len(list_resources), makespan + 1), dtype=np.int_) for act_id in rcpsp_sol.get_present_tasks(): for ir in range(len(list_resources)): - use_ir = rcpsp_problem.get_cumulative_resource_consumption( - list_resources[ir], act_id, modes_dict[act_id] - ) + if list_resources[ir] in rcpsp_problem.cumulative_resources_list: + use_ir = rcpsp_problem.get_cumulative_resource_consumption( + list_resources[ir], act_id, modes_dict[act_id] + ) + else: + use_ir = rcpsp_problem.get_non_renewable_resource_consumption( + list_resources[ir], act_id, modes_dict[act_id] + ) if future_view: consumptions[ ir, @@ -127,9 +132,14 @@ def plot_ressource_view( time_start = rcpsp_sol.rcpsp_schedule[j]["start_time"] time_end = rcpsp_sol.rcpsp_schedule[j]["end_time"] for i in range(len(list_resource)): - cons = rcpsp_problem.get_cumulative_resource_consumption( - list_resource[i], j, modes_dict[j] - ) + if list_resource[i] in rcpsp_problem.cumulative_resources_list: + cons = rcpsp_problem.get_cumulative_resource_consumption( + list_resource[i], j, modes_dict[j] + ) + else: + cons = rcpsp_problem.get_non_renewable_resource_consumption( + list_resource[i], j, modes_dict[j] + ) if cons == 0: continue bound: int = int(rcpsp_problem.get_max_resource_capacity(list_resource[i])) diff --git a/tests/generic_tasks_tools/solvers/cpsat/test_auto.py b/tests/generic_tasks_tools/solvers/cpsat/test_auto.py index fccbbc37f..d44006055 100644 --- a/tests/generic_tasks_tools/solvers/cpsat/test_auto.py +++ b/tests/generic_tasks_tools/solvers/cpsat/test_auto.py @@ -11,6 +11,7 @@ import pytest +from discrete_optimization.generic_tasks_tools.base import NoOptionalTasksProblem from discrete_optimization.generic_tasks_tools.enums import StartOrEnd from discrete_optimization.generic_tasks_tools.generic_scheduling import ( GenericSchedulingProblem, @@ -94,6 +95,7 @@ class MyProblem( GenericSchedulingProblem[ Task, UnaryResource, Skill, NonSkillCumulativeResource, NonRenewableResource ], + NoOptionalTasksProblem[Task], WithoutNoOverlapProblem[Task], WithoutSkillProblem[Task, UnaryResource, NonSkillCumulativeResource, UnaryResource], ): diff --git a/tests/generic_tasks_tools/test_cumulative_resource.py b/tests/generic_tasks_tools/test_cumulative_resource.py index 9c11fcfc9..cc42b5115 100644 --- a/tests/generic_tasks_tools/test_cumulative_resource.py +++ b/tests/generic_tasks_tools/test_cumulative_resource.py @@ -4,6 +4,7 @@ import pytest +from discrete_optimization.generic_tasks_tools.base import NoOptionalTasksProblem from discrete_optimization.generic_tasks_tools.calendar_resource import ( convert_calendar_to_availability_intervals, ) @@ -21,7 +22,8 @@ class MyCumulativeResourceProblem( - CumulativeResourceProblem[Task, CumulativeResource, OtherRenewableResource] + CumulativeResourceProblem[Task, CumulativeResource, OtherRenewableResource], + NoOptionalTasksProblem[Task], ): resource_availabilities = dict( R1=[ diff --git a/tests/generic_tasks_tools/test_non_renewable_resource.py b/tests/generic_tasks_tools/test_non_renewable_resource.py index 0359b7000..8eb50e549 100644 --- a/tests/generic_tasks_tools/test_non_renewable_resource.py +++ b/tests/generic_tasks_tools/test_non_renewable_resource.py @@ -5,6 +5,7 @@ import logging +from discrete_optimization.generic_tasks_tools.base import NoOptionalTasksProblem from discrete_optimization.generic_tasks_tools.non_renewable_resource import ( NonRenewableResourceProblem, NonRenewableResourceSolution, @@ -16,7 +17,8 @@ class MyNonRenewableResourceProblem( - NonRenewableResourceProblem[Task, NonRenewableResource] + NonRenewableResourceProblem[Task, NonRenewableResource], + NoOptionalTasksProblem[Task], ): resource_capacities = {"R0": 2, "R1": 5} mode_details = { From f37a53c606e68c6522f5840e3427947bce0faa8f Mon Sep 17 00:00:00 2001 From: poveda_g Date: Mon, 17 Aug 2026 17:06:37 +0200 Subject: [PATCH 10/10] fix cpsat alternative rcpsp model to the updates, and fix various missing function updates in unittest --- .../rcpsp_alternative/solvers/cpsat.py | 24 +++---------------- .../test_cumulative_resource.py | 3 +++ .../test_non_renewable_resource.py | 3 +++ 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/discrete_optimization/rcpsp_alternative/solvers/cpsat.py b/src/discrete_optimization/rcpsp_alternative/solvers/cpsat.py index 14dd7d818..0c60a87d1 100644 --- a/src/discrete_optimization/rcpsp_alternative/solvers/cpsat.py +++ b/src/discrete_optimization/rcpsp_alternative/solvers/cpsat.py @@ -65,7 +65,9 @@ def init_model(self, **kwargs): model=model, is_present_var=is_present_var, interval_per_tasks={ - t: interval_per_tasks[t] for t in self.problem._tasks_list + t: interval_per_tasks[t] + for t in self.problem.tasks_list + if not self.problem.is_optional(t) }, ) is_done = self.create_is_done_variable() @@ -76,7 +78,6 @@ def init_model(self, **kwargs): interval_per_tasks={t: interval_per_tasks[t] for t in is_done}, ) self.create_precedence_constraints() - self.create_precedence_constraints_alternative() resources = self.problem.resources_list for resource in resources: self.create_cumulative_constraint( @@ -104,25 +105,6 @@ def init_model(self, **kwargs): objective = self.get_global_makespan_variable() self.minimize_variable(objective) - def create_precedence_constraints_alternative(self): - for t in self.problem.alternative_successors: - for tt in self.problem.alternative_successors[t]: - if tt in self.problem._tasks_list: - ( - self.cp_model.add( - self.variables["end"][t] <= self.variables["start"][tt] - ).only_enforce_if(self.additional_variables["is_done"][t]) - ) - if tt in self.problem.alternative_tasks: - ( - self.cp_model.add( - self.variables["end"][t] <= self.variables["start"][tt] - ).only_enforce_if( - self.additional_variables["is_done"][t], - self.additional_variables["is_done"][tt], - ) - ) - def create_alternative_path_constraint( self, alternative_problem: AlternativeSchedulingSubProblem, diff --git a/tests/generic_tasks_tools/test_cumulative_resource.py b/tests/generic_tasks_tools/test_cumulative_resource.py index cc42b5115..8cfdddfc6 100644 --- a/tests/generic_tasks_tools/test_cumulative_resource.py +++ b/tests/generic_tasks_tools/test_cumulative_resource.py @@ -132,6 +132,9 @@ def __init__( self.modes = modes self.starts = starts + def is_present(self, task: Task) -> bool: + return True + def get_mode(self, task: Task) -> int: return self.modes[task] diff --git a/tests/generic_tasks_tools/test_non_renewable_resource.py b/tests/generic_tasks_tools/test_non_renewable_resource.py index 8eb50e549..368eeaa16 100644 --- a/tests/generic_tasks_tools/test_non_renewable_resource.py +++ b/tests/generic_tasks_tools/test_non_renewable_resource.py @@ -75,6 +75,9 @@ def __init__( super().__init__(problem) self.modes = modes + def is_present(self, task: Task) -> bool: + return True + def get_mode(self, task: Task) -> int: return self.modes[task]