diff --git a/cpmpy/expressions/globalconstraints.py b/cpmpy/expressions/globalconstraints.py index 85551329b..a3cdc6951 100644 --- a/cpmpy/expressions/globalconstraints.py +++ b/cpmpy/expressions/globalconstraints.py @@ -1050,7 +1050,11 @@ def __init__(self, start: ListLike[ExprLike], duration: ListLike[ExprLike], end: else: # constant demand demand_list = [demand] * len(start) - super(Cumulative, self).__init__("cumulative", [list(start), list(duration), list(end) if end is not None else None, demand_list, capacity]) + self.end_is_none = end is None + if end is None: + super(Cumulative, self).__init__("cumulative", [list(start), list(duration), demand_list, capacity]) + else: + super(Cumulative, self).__init__("cumulative", [list(start), list(duration), list(end), demand_list, capacity]) def decompose(self, how:str="auto") -> tuple[list[Expression], list[Expression]]: @@ -1069,7 +1073,10 @@ def decompose(self, how:str="auto") -> tuple[list[Expression], list[Expression]] if how not in ["time", "task", "auto"]: raise ValueError(f"how can only be time, task, or auto (default), but got {how}") - start, duration, end, demand, capacity = self.args + if self.end_is_none: + start, duration, demand, capacity = self.args + else: + start, duration, end, demand, capacity = self.args lbs, ubs = get_bounds(start) horizon = max(ubs) - min(lbs) @@ -1088,17 +1095,18 @@ def _task_decomposition(self) -> tuple[list[Expression], list[Expression]]: Returns: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ - start, duration, end, demand, capacity = self.args - - cons = [d >= 0 for d in duration] # enforce non-negative durations - cons += [h >= 0 for h in demand] # enforce non-negative demand + cons = [] - # set duration of tasks, only if end is user-provided - if end is None: + if self.end_is_none: + start, duration, demand, capacity = self.args end = [start[i] + duration[i] for i in range(len(start))] else: + start, duration, end, demand, capacity = self.args cons += [start[i] + duration[i] == end[i] for i in range(len(start))] + cons += [d >= 0 for d in duration] # enforce non-negative durations + cons += [h >= 0 for h in demand] # enforce non-negative demand + # demand doesn't exceed capacity # tasks are uninterruptible, so we only need to check each starting point of each task # I.e., for each task, we check if it can be started, given the tasks that are already running. @@ -1121,15 +1129,13 @@ def _time_decomposition(self) -> tuple[list[Expression], list[Expression]]: Returns: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ - start, duration, end, demand, capacity = self.args - - cons = [d >= 0 for d in duration] # enforce non-negative durations - cons += [h >= 0 for h in demand] # enforce non-negative demand + cons = [] - # set duration of tasks, only if end is user-provided - if end is None: + if self.end_is_none: + start, duration, demand, capacity = self.args end = [start[i] + duration[i] for i in range(len(start))] else: + start, duration, end, demand, capacity = self.args cons += [start[i] + duration[i] == end[i] for i in range(len(start))] # demand doesn't exceed capacity @@ -1146,16 +1152,15 @@ def value(self) -> Optional[bool]: Returns: Optional[bool]: True if the global constraint is satisfied, False otherwise, or None if any argument is not assigned """ - start, dur, end, demand, capacity = self.args - - start, dur, demand, capacity = argvals([start, dur, demand, capacity]) - if any(a is None for a in flatlist([start, dur, demand, capacity])): - return None - if end is None: - end = [s + d for s,d in zip(start, dur)] + + if self.end_is_none: + start, dur, demand, capacity = argvals(self.args) + if any(a is None for a in [start, dur, demand, capacity]): + return None + end = [start[i] + dur[i] for i in range(len(start))] else: - end = argvals(end) - if any(a is None for a in end): + start, dur, end, demand, capacity = argvals(self.args) + if any(a is None for a in [start, dur, end, demand, capacity]): return None if any(d < 0 for d in dur): @@ -1168,23 +1173,12 @@ def value(self) -> Optional[bool]: # ensure demand doesn't exceed capacity lb, ub = min(start), max(end) - start, end = np.array(start), np.array(end) # eases check below + np_start, np_end = np.array(start), np.array(end) # eases check below for t in range(lb, ub+1): - if capacity < sum(demand * ((start <= t) & (end > t))): + if capacity < sum(demand * ((np_start <= t) & (np_end > t))): return False return True - - def __repr__(self) -> str: - """ - Returns: - str: String representation of the cumulative constraint - """ - start, dur, end, demand, capacity = self.args - if end is None: - return f"Cumulative({start}, {dur}, {demand}, {capacity})" - else: - return f"Cumulative({start}, {dur}, {end}, {demand}, {capacity})" class NoOverlap(GlobalConstraint): """ @@ -1213,7 +1207,11 @@ def __init__(self, start: ListLike[ExprLike], duration: ListLike[ExprLike], end: if end is not None and len(start) != len(end): raise ValueError(f"Start and end should have equal length, but got {len(start)} and {len(end)}") - super().__init__("no_overlap", [list(start), list(duration), list(end) if end is not None else None]) + self.end_is_none = end is None + if end is None: + super().__init__("no_overlap", [list(start), list(duration)]) + else: + super().__init__("no_overlap", [list(start), list(duration), list(end)]) def decompose(self) -> tuple[list[Expression], list[Expression]]: """ @@ -1222,13 +1220,16 @@ def decompose(self) -> tuple[list[Expression], list[Expression]]: Returns: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ - start, dur, end = self.args - cons = [d >= 0 for d in dur] + cons = [] + + if self.end_is_none: + start, duration = self.args + end = [start[i] + duration[i] for i in range(len(start))] + else: + start, duration, end = self.args + cons += [start[i] + duration[i] == end[i] for i in range(len(start))] - if end is None: - end = [s+d for s,d in zip(start, dur)] - else: # can use the expression directly below - cons += [s + d == e for s,d,e in zip(start, dur, end)] + cons += [d >= 0 for d in duration] for (s1, e1), (s2, e2) in all_pairs(zip(start, end)): cons += [(e1 <= s2) | (e2 <= s1)] @@ -1239,13 +1240,14 @@ def value(self) -> Optional[bool]: Returns: Optional[bool]: True if the global constraint is satisfied, False otherwise, or None if any argument is not assigned """ - start, dur, end = argvals(self.args) - if end is None: - if any(s is None for s in start) or any(d is None for d in dur): + if self.end_is_none: + start, dur = argvals(self.args) + if any(a is None for a in start+dur): return None end = [s + d for s,d in zip(start, dur)] else: - if any(s is None for s in start) or any(d is None for d in dur) or any(e is None for e in end): + start, dur, end = argvals(self.args) + if any(a is None for a in [start, dur, end]): return None if any(d < 0 for d in dur): @@ -1256,17 +1258,6 @@ def value(self) -> Optional[bool]: if s1 + d1 > s2 and s2 + d2 > s1: return False return True - - def __repr__(self) -> str: - """ - Returns: - str: String representation of the NoOverlap constraint - """ - start, dur, end = self.args - if end is None: - return f"NoOverlap({start}, {dur})" - else: - return f"NoOverlap({start}, {dur}, {end})" class Precedence(GlobalConstraint): """ diff --git a/cpmpy/solvers/choco.py b/cpmpy/solvers/choco.py index b1c420f13..45b657de6 100644 --- a/cpmpy/solvers/choco.py +++ b/cpmpy/solvers/choco.py @@ -651,30 +651,35 @@ def _get_constraint(self, cpm_expr): expr, table = self.solver_vars(cpm_expr.args) return self.chc_model.member(expr, table) elif cpm_expr.name == "cumulative": - start, dur, end, demand, cap = cpm_expr.args - # Choco allows negative durations, but this does not match CPMpy spec - dur, extra_cons = get_nonneg_args(dur) - # Choco allows negative demand, but this does not match CPMpy spec + assert isinstance(cpm_expr, Cumulative) # ensure hasattr end_is_none + if cpm_expr.end_is_none: + start, dur, demand, cap = cpm_expr.args + else: + start, dur, end, demand, cap = cpm_expr.args + + # Choco allows negative durations and demands, but this does not match CPMpy spec + dur, dur_cons = get_nonneg_args(dur) demand, demand_cons = get_nonneg_args(demand) - extra_cons += demand_cons - # start, end, demand and cap should be var - if end is None: - start, demand, cap = self._to_vars([start, demand, cap]) - end = [None for _ in range(len(start))] + extra_cons = dur_cons + demand_cons + + # make choco task variables + if cpm_expr.end_is_none: + tasks = [self.chc_model.task(s,d) for s,d in zip(self._to_vars(start), self.solver_vars(dur))] else: - start, end, demand, cap = self._to_vars([start, end, demand, cap]) - # duration can be var or int - dur = self.solver_vars(dur) - # Create task variables. Choco can create them only one by one - tasks = [self.chc_model.task(s, d, e) for s, d, e in zip(start, dur, end)] + tasks = [self.chc_model.task(s,d,e) for s,d,e in zip(self._to_vars(start), self.solver_vars(dur), self._to_vars(end))] - chc_cumulative = self.chc_model.cumulative(tasks, demand, cap) + # construct cumulative constraint with task objects + chc_cumulative = self.chc_model.cumulative(tasks, self._to_vars(demand), self._to_vars(cap)) if len(extra_cons): # replace some negative durations, part of constraint return self.chc_model.and_([chc_cumulative] + [self._get_constraint(c) for c in extra_cons]) return chc_cumulative elif cpm_expr.name == "no_overlap": # post as Cumulative with capacity 1 - start, dur, end = cpm_expr.args - return self._get_constraint(Cumulative(start, dur, end, demand=1, capacity=1)) + if cpm_expr.end_is_none: + start, dur = cpm_expr.args + return self._get_constraint(Cumulative(start, dur, demand=1, capacity=1)) + else: + start, dur, end = cpm_expr.args + return self._get_constraint(Cumulative(start, dur, end, demand=1, capacity=1)) elif cpm_expr.name == "precedence": return self.chc_model.int_value_precede_chain(self._to_vars(cpm_expr.args[0]), cpm_expr.args[1]) elif cpm_expr.name == "gcc": diff --git a/cpmpy/solvers/ortools.py b/cpmpy/solvers/ortools.py index 6f3913a6b..b2788f4cc 100644 --- a/cpmpy/solvers/ortools.py +++ b/cpmpy/solvers/ortools.py @@ -51,7 +51,7 @@ from .solver_interface import SolverInterface, SolverStatus, ExitStatus, Callback from ..exceptions import NotSupportedError from ..expressions.core import Expression, Comparison, Operator, BoolVal -from ..expressions.globalconstraints import DirectConstraint +from ..expressions.globalconstraints import DirectConstraint, Cumulative, NoOverlap from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, boolvar, intvar from ..expressions.globalconstraints import GlobalConstraint from ..expressions.utils import get_nonneg_args, is_num, is_int, eval_comparison, flatlist, argval, argvals, get_bounds, is_true_cst, \ @@ -589,15 +589,18 @@ def _post_constraint(self, cpm_expr, reifiable=False): return self.ort_model.AddAutomaton(array, cpm_expr.node_map[start], [cpm_expr.node_map[n] for n in accepting], [(cpm_expr.node_map[src], label, cpm_expr.node_map[dst]) for src, label, dst in transitions]) elif cpm_expr.name == "cumulative": - start, dur, end, demand, cap = cpm_expr.args + assert isinstance(cpm_expr, Cumulative) # ensure hasattr end_is_none + if cpm_expr.end_is_none: + start, dur, demand, cap = cpm_expr.args + end = [intvar(*get_bounds(s+d)) for s,d in zip(start, dur)] + self.add([s + d == e for s,d,e in zip(start, dur, end)]) + else: + start, dur, end, demand, cap = cpm_expr.args + # ensure duration is non-negative dur, dur_cons = get_nonneg_args(dur) self.add(dur_cons) - if end is None: # need to make the end-variables ourself - end = [intvar(*get_bounds(s+d)) for s,d in zip(start, dur)] - self.add([s + d == e for s,d,e in zip(start, dur, end)]) - # ensure demand is non-negative demand, demand_cons = get_nonneg_args(demand) self.add(demand_cons) @@ -607,14 +610,17 @@ def _post_constraint(self, cpm_expr, reifiable=False): return self.ort_model.AddCumulative(intervals, demand, cap) elif cpm_expr.name == "no_overlap": - start, dur, end = cpm_expr.args + assert isinstance(cpm_expr, NoOverlap) # ensure hasattr end_is_none + if cpm_expr.end_is_none: + start, dur = cpm_expr.args + end = [intvar(*get_bounds(s + d)) for s, d in zip(start, dur)] + self.add([s + d == e for s, d, e in zip(start, dur, end)]) + else: + start, dur, end = cpm_expr.args + dur, dur_cons = get_nonneg_args(dur) self.add(dur_cons) - if end is None: # need to make the end-variables ourself - end = [intvar(*get_bounds(s+d)) for s,d in zip(start, dur)] - self.add([s + d == e for s,d,e in zip(start, dur, end)]) - start, dur, end = self.solver_vars([start, dur, end]) intervals = [self.ort_model.NewIntervalVar(s, d, e, f"interval_{s}-{d}-{e}") for s, d, e in zip(start, dur, end)] diff --git a/cpmpy/transformations/safening.py b/cpmpy/transformations/safening.py index 626bcca7c..16341ca9d 100644 --- a/cpmpy/transformations/safening.py +++ b/cpmpy/transformations/safening.py @@ -78,6 +78,7 @@ def no_partial_functions(lst_of_expr, _toplevel=None, _nbc=None, safen_toplevel= new_lst = [] for cpm_expr in lst_of_expr: + # leaf node: constant, variable (or None, in case of Cumulative with no end provided) if is_num(cpm_expr) or isinstance(cpm_expr, _NumVarImpl): new_lst.append(cpm_expr) diff --git a/tests/test_globalconstraints.py b/tests/test_globalconstraints.py index 1957d30cd..06226d51b 100644 --- a/tests/test_globalconstraints.py +++ b/tests/test_globalconstraints.py @@ -963,6 +963,11 @@ def test_cumulative_single_demand(self): m += cp.Cumulative(start, duration, end, demand, capacity) assert m.solve() + def test_cumulative_subexpr(self): + start = cp.intvar(0,10, shape=3) + cons = cp.Cumulative(start+start, [1,2,3], None, [1,2,3], 3) + assert cp.Model(cons).solve() is True + def test_cumulative_decomposition_capacity(self): import numpy as np