diff --git a/cpmpy/expressions/globalconstraints.py b/cpmpy/expressions/globalconstraints.py index 0674010ea..6865246f2 100644 --- a/cpmpy/expressions/globalconstraints.py +++ b/cpmpy/expressions/globalconstraints.py @@ -112,8 +112,10 @@ def my_circuit_decomp(self): InDomain Xor Cumulative + CumulativeOptional Precedence NoOverlap + NoOverlapOptional GlobalCardinalityCount Increasing Decreasing @@ -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): """ @@ -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): diff --git a/cpmpy/solvers/ortools.py b/cpmpy/solvers/ortools.py index 9b754bc7d..ac0ec3306 100644 --- a/cpmpy/solvers/ortools.py +++ b/cpmpy/solvers/ortools.py @@ -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 @@ -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 @@ -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 diff --git a/tests/test_constraints.py b/tests/test_constraints.py index 8471c9f3f..9d4ce4895 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -15,6 +15,7 @@ # 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 = { @@ -22,7 +23,7 @@ "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" @@ -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) @@ -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)