diff --git a/examples/rcpsp_alternative/run_cpsat.py b/examples/rcpsp_alternative/run_cpsat.py new file mode 100644 index 000000000..fa3b8da7a --- /dev/null +++ b/examples/rcpsp_alternative/run_cpsat.py @@ -0,0 +1,68 @@ +# 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.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.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]) + 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() + + +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_auto() 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..02a81a50d 100644 --- a/src/discrete_optimization/alb/rcalbp_l/problem.py +++ b/src/discrete_optimization/alb/rcalbp_l/problem.py @@ -10,11 +10,13 @@ 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, UnaryResource, ) +from discrete_optimization.generic_tasks_tools.base import NoOptionalTasksProblem from discrete_optimization.generic_tasks_tools.scheduling import ( SchedulingProblem, SchedulingSolution, @@ -66,6 +68,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 @@ -140,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). 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/binpack/problem.py b/src/discrete_optimization/binpack/problem.py index f3c63a78b..196433df7 100644 --- a/src/discrete_optimization/binpack/problem.py +++ b/src/discrete_optimization/binpack/problem.py @@ -9,10 +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 NoOptionalTasksProblem, Task from discrete_optimization.generic_tasks_tools.scheduling import ( SchedulingProblem, SchedulingSolution, @@ -49,6 +51,12 @@ 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 + and self.allocation[task] != AbsentValue.ABSENT + ) + def get_end_time(self, task: Item) -> int: return self.allocation[task] + 1 @@ -81,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/coloring/problem.py b/src/discrete_optimization/coloring/problem.py index 46c7046c4..16d9d08a6 100644 --- a/src/discrete_optimization/coloring/problem.py +++ b/src/discrete_optimization/coloring/problem.py @@ -15,10 +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 NoOptionalTasksProblem, Task from discrete_optimization.generic_tools.do_problem import ( ModeOptim, ObjectiveDoc, @@ -71,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). @@ -239,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: @@ -277,6 +282,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/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/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 59326e9cd..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. @@ -560,8 +563,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 +849,30 @@ 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" - ] + 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 + ) index_mode += 1 task_index = {rcpsp_problem.tasks_list[i]: i for i in range(rcpsp_problem.n_jobs)} @@ -876,12 +890,13 @@ 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] - 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..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, @@ -276,7 +277,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 ] @@ -449,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/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/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 24997f9f9..995da3195 100644 --- a/src/discrete_optimization/rcpsp/utils.py +++ b/src/discrete_optimization/rcpsp/utils.py @@ -49,11 +49,16 @@ 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.mode_details[act_id][modes_dict[act_id]].get( - list_resources[ir], 0 - ) + 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, @@ -120,14 +125,21 @@ 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: 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) + 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])) @@ -206,29 +218,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 @@ -250,7 +262,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/__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..c7d77b4b0 --- /dev/null +++ b/src/discrete_optimization/rcpsp_alternative/problem.py @@ -0,0 +1,113 @@ +# 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 typing import Any, Hashable, Optional, Union + +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, +) + +logger = logging.getLogger(__name__) + + +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, + 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.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, + ) + + 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) + 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"] + return 0 + + def get_task_modes(self, task: Task) -> set[int]: + return set(self.mode_details[task]) + + +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..0c60a87d1 --- /dev/null +++ b/src/discrete_optimization/rcpsp_alternative/solvers/cpsat.py @@ -0,0 +1,237 @@ +# 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 + if not self.problem.is_optional(t) + }, + ) + 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() + 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_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/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 new file mode 100644 index 000000000..d6c21f515 --- /dev/null +++ b/src/discrete_optimization/rcpsp_alternative/utils.py @@ -0,0 +1,94 @@ +# 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 copy import deepcopy + +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), + factor_makespan: float = 3.0, +) -> 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: + 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) + list_alternative_subproblem.append( + AlternativeSchedulingSubProblem( + source_task=source, + sink_task=sink, + list_paths=list_paths, + is_path_successors=True, + 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=mode_details, + successors=problem.successors, + 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, + 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..aff5ff7d3 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 @@ -109,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}, " 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 e395af069..ae9abdce0 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] @@ -158,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]) diff --git a/src/discrete_optimization/workforce/allocation/problem.py b/src/discrete_optimization/workforce/allocation/problem.py index 417bbd409..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, @@ -217,6 +218,13 @@ 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 + 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] i_team = self.problem.index_teams_name[unary_resource] @@ -395,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/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 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..8cfdddfc6 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=[ @@ -130,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 0359b7000..368eeaa16 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 = { @@ -73,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] diff --git a/tests/rcpsp_alternative/test_cpsat.py b/tests/rcpsp_alternative/test_cpsat.py new file mode 100644 index 000000000..0e490e65d --- /dev/null +++ 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)