Skip to content
107 changes: 49 additions & 58 deletions cpmpy/expressions/globalconstraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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]]:
"""
Expand All @@ -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)]
Expand All @@ -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):
Expand All @@ -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):
"""
Expand Down
39 changes: 22 additions & 17 deletions cpmpy/solvers/choco.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
28 changes: 17 additions & 11 deletions cpmpy/solvers/ortools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, \
Expand Down Expand Up @@ -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)
Expand All @@ -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)]

Expand Down
1 change: 1 addition & 0 deletions cpmpy/transformations/safening.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions tests/test_globalconstraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading