Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions cpmpy/expressions/globalconstraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ def my_circuit_decomp(self):
InDomain
Xor
Cumulative
CumulativeOptional
Precedence
NoOverlap
NoOverlapOptional
GlobalCardinalityCount
Increasing
Decreasing
Expand Down Expand Up @@ -662,6 +664,86 @@ def value(self):

return True

class CumulativeOptional(GlobalConstraint):
"""
Generalization of the Cumulative constraint which allows for optional tasks.
A task is only scheduled if the corresponing is_present variable is set to True.
"""

def __init__(self, start, duration, end, demand, capacity, is_present):
assert is_any_list(start), "start should be a list"
assert is_any_list(duration), "duration should be a list"
assert is_any_list(end), "end should be a list"

start = flatlist(start)
duration = flatlist(duration)
end = flatlist(end)
is_present = [cp.BoolVal(x) if is_bool(x) else x for x in flatlist(is_present)] # normalize
assert len(start) == len(duration) == len(end) == len(is_present), "Start, duration, end and is_present should have equal length"
n_jobs = len(start)

for lb in get_bounds(duration)[0]:
if lb < 0:
raise TypeError("Durations should be non-negative")

if is_any_list(demand):
demand = flatlist(demand)
assert len(demand) == n_jobs, "Demand should be supplied for each task or be single constant"
else: # constant demand
demand = [demand] * n_jobs

super().__init__("cumulative_optional", [start, duration, end, demand, capacity, is_present])

def decompose(self):
"""
Time-resource decomposition from:
Schutt, Andreas, et al. "Why cumulative decomposition is not as bad as it sounds."
International Conference on Principles and Practice of Constraint Programming. Springer, Berlin, Heidelberg, 2009.
"""

arr_args = (cpm_array(arg) if is_any_list(arg) else arg for arg in self.args)
start, duration, end, demand, capacity, is_present = arr_args
cons = []

# set duration of tasks
for t in range(len(start)):
cons += [is_present[t].implies(start[t] + duration[t] == end[t])]

# demand doesn't exceed capacity
lb, ub = min(get_bounds(start)[0]), max(get_bounds(end)[1])
for t in range(lb,ub+1):
demand_at_t = 0
for job in range(len(start)):
if is_num(demand):
demand_at_t += demand * ((start[job] <= t) & (t < end[job]) & is_present[job])
else:
demand_at_t += demand[job] * ((start[job] <= t) & (t < end[job]) & is_present[job])

cons += [demand_at_t <= capacity]

return cons, []

def value(self):
arg_vals = [np.array(argvals(arg)) if is_any_list(arg)
else argval(arg) for arg in self.args]

if any(a is None for a in arg_vals):
return None

# start, dur, end are np arrays
start, dur, end, demand, capacity, is_present = arg_vals
# start and end seperated by duration, if the tasks are present
if not (~is_present | (start + dur == end)).all():
return False

# demand of present tasks doesn't exceed capacity
lb, ub = min(start), max(end)
for t in range(lb, ub+1):
if capacity < sum(demand * ((start <= t) & (t < end) & is_present)):
return False

return True


class Precedence(GlobalConstraint):
"""
Expand Down Expand Up @@ -738,6 +820,49 @@ def value(self):
if e1 > s2 and e2 > s1:
return False
return True


class NoOverlapOptional(GlobalConstraint):
"""
Generalization of the NoOverlap constraint which allows for optional tasks.
A task is only scheduled if the corresponing is_present variable is set to True.
"""

def __init__(self, start, dur, end, is_present):
assert is_any_list(start), "start should be a list"
assert is_any_list(dur), "duration should be a list"
assert is_any_list(end), "end should be a list"
assert is_any_list(is_present), "is_present should be a list"

start = flatlist(start)
dur = flatlist(dur)
end = flatlist(end)
is_present = [cp.BoolVal(x) if is_bool(x) else x for x in flatlist(is_present)] # normalize
assert len(start) == len(dur) == len(end) == len(is_present), "Start, duration, end and is_present should have equal length " \
"in NoOverlap constraint"

super().__init__("no_overlap_optional", [start, dur, end, is_present])

def decompose(self):
start, dur, end, is_present = self.args
cons = [p.implies(s + d == e) for s,d,e,p in zip(start, dur, end, is_present)]
for (s1, e1,p1), (s2, e2,p2) in all_pairs(zip(start, end, is_present)):
cons += [(p1 & p2).implies((e1 <= s2) | (e2 <= s1))]
return cons, []

def value(self):
start, dur, end, is_present = argvals(self.args)
start, dur, end, is_present = np.array(start), np.array(dur), np.array(end), np.array(is_present)
# filter to the tasks which are present
start, dur, end = start[is_present], dur[is_present], end[is_present]

if any(s + d != e for s,d,e in zip(start, dur, end)):
return False
for (s1,d1, e1), (s2,d2, e2) in all_pairs(zip(start,dur, end)):
if e1 > s2 and e2 > s1:
return False
return True



class GlobalCardinalityCount(GlobalConstraint):
Expand Down
16 changes: 14 additions & 2 deletions cpmpy/solvers/ortools.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
from ..expressions.globalconstraints import DirectConstraint
from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, boolvar, intvar
from ..expressions.globalconstraints import GlobalConstraint
from ..expressions.utils import is_num, is_int, eval_comparison, flatlist, argval, argvals, get_bounds
from ..expressions.utils import is_bool, is_num, is_int, eval_comparison, flatlist, argval, argvals, get_bounds
from ..transformations.decompose_global import decompose_in_tree
from ..transformations.get_variables import get_variables
from ..transformations.flatten_model import flatten_constraint, flatten_objective, get_or_make_var
Expand Down Expand Up @@ -379,7 +379,9 @@ def transform(self, cpm_expr):
:return: list of Expression
"""
cpm_cons = toplevel_list(cpm_expr)
supported = {"min", "max", "abs", "element", "alldifferent", "xor", "table", "negative_table", "cumulative", "circuit", "inverse", "no_overlap"}
supported = {"min", "max", "abs", "element",
"alldifferent", "table", "negative_table", "xor", "circuit", "inverse",
"cumulative", "cumulative_optional", "no_overlap", "no_overlap_optional"}
cpm_cons = no_partial_functions(cpm_cons, safen_toplevel=frozenset({"div", "mod"})) # before decompose, assumes total decomposition for partial functions
cpm_cons = decompose_in_tree(cpm_cons, supported)
cpm_cons = flatten_constraint(cpm_cons) # flat normal form
Expand Down Expand Up @@ -544,10 +546,20 @@ def _post_constraint(self, cpm_expr, reifiable=False):
start, dur, end, demand, cap = self.solver_vars(cpm_expr.args)
intervals = [self.ort_model.NewIntervalVar(s,d,e,f"interval_{s}-{d}-{e}") for s,d,e in zip(start,dur,end)]
return self.ort_model.AddCumulative(intervals, demand, cap)
elif cpm_expr.name == "cumulative_optional":
start, dur, end, demand, cap, is_present = self.solver_vars(cpm_expr.args)
is_present = [bool(x) if is_bool(x) else x for x in is_present]
intervals = [self.ort_model.NewOptionalIntervalVar(s,d,e,p,f"interval_{s}-{d}-{e}-{p}") for s,d,e,p in zip(start,dur,end,is_present)]
return self.ort_model.AddCumulative(intervals, demand, cap)
elif cpm_expr.name == "no_overlap":
start, dur, end = self.solver_vars(cpm_expr.args)
intervals = [self.ort_model.NewIntervalVar(s,d,e, f"interval_{s}-{d}-{d}") for s,d,e in zip(start,dur,end)]
return self.ort_model.add_no_overlap(intervals)
elif cpm_expr.name == "no_overlap_optional":
start, dur, end, is_present = self.solver_vars(cpm_expr.args)
is_present = [bool(x) if is_bool(x) else x for x in is_present]
intervals = [self.ort_model.NewOptionalIntervalVar(s,d,e,p,f"interval_{s}-{d}-{e}-{p}") for s,d,e,p in zip(start,dur,end,is_present)]
return self.ort_model.add_no_overlap(intervals)
elif cpm_expr.name == "circuit":
# ortools has a constraint over the arcs, so we need to create these
# when using an objective over arcs, using these vars direclty is recommended
Expand Down
17 changes: 16 additions & 1 deletion tests/test_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@
# also add exclusions to the 3 EXCLUDE_* below as needed
SOLVERNAMES = [name for name, solver in SolverLookup.base_solvers() if solver.supported()]
ALL_SOLS = False # test wheter all solutions returned by the solver satisfy the constraint
SOLVERNAMES = ["ortools"]

# Exclude some global constraints for solvers
NUM_GLOBAL = {
"AllEqual", "AllDifferent", "AllDifferentExcept0",
"AllDifferentExceptN", "AllEqualExceptN",
"GlobalCardinalityCount", "InDomain", "Inverse", "Table", 'NegativeTable', "ShortTable", "Circuit",
"Increasing", "IncreasingStrict", "Decreasing", "DecreasingStrict",
"Precedence", "Cumulative", "NoOverlap",
"Precedence", "Cumulative", "NoOverlap", "CumulativeOptional", "NoOverlapOptional",
"LexLess", "LexLessEq", "LexChainLess", "LexChainLessEq",
# also global functions
"Abs", "Element", "Minimum", "Maximum", "Count", "Among", "NValue", "NValueExcept"
Expand Down Expand Up @@ -207,6 +208,14 @@ def global_constraints(solver):
demand = [4, 5, 7]
cap = 10
expr = Cumulative(s, dur, e, demand, cap)
elif name == "CumulativeOptional":
s = intvar(0, 10, shape=4, name="start")
e = intvar(0, 10, shape=4, name="end")
dur = [1, 4, 3, 2]
demand = [11, 4, 8, 7]
is_present = [cp.boolvar(), cp.boolvar(), True, False]
cap = 10
expr = cls(s, dur, e, demand, cap, is_present)
elif name == "GlobalCardinalityCount":
vals = [1, 2, 3]
cnts = intvar(0,10,shape=3)
Expand All @@ -223,6 +232,12 @@ def global_constraints(solver):
e = intvar(0, 10, shape=3, name="end")
dur = [1,4,3]
expr = cls(s, dur, e)
elif name == "NoOverlapOptional":
s = intvar(0, 10, shape=4, name="start")
e = intvar(0, 10, shape=4, name="end")
dur = [1, 4, 3, 2]
is_present = [cp.boolvar(), cp.boolvar(), True, False]
expr = cls(s, dur, e, is_present)
elif name == "GlobalCardinalityCount":
vals = [1, 2, 3]
cnts = intvar(0,10,shape=3)
Expand Down