diff --git a/cpmpy/expressions/__init__.py b/cpmpy/expressions/__init__.py index baa50b026..664b97204 100644 --- a/cpmpy/expressions/__init__.py +++ b/cpmpy/expressions/__init__.py @@ -22,7 +22,8 @@ from .variables import boolvar, intvar, cpm_array from .variables import BoolVar, IntVar, cparray # Old, to be deprecated from .globalconstraints import AllDifferent, AllDifferentExcept0, AllDifferentLists, AllEqual, Circuit, Inverse, Table, Xor, Cumulative, \ - IfThenElse, GlobalCardinalityCount, DirectConstraint, InDomain, Increasing, Decreasing, IncreasingStrict, DecreasingStrict, LexLess, LexLessEq, LexChainLess, LexChainLessEq + IfThenElse, GlobalCardinalityCount, DirectConstraint, InDomain, Increasing, Decreasing, IncreasingStrict, DecreasingStrict, \ + LexLess, LexLessEq, LexChainLess, LexChainLessEq, Precedence, NoOverlap from .globalconstraints import alldifferent, allequal, circuit # Old, to be deprecated from .globalfunctions import Maximum, Minimum, Abs, Element, Count, NValue, NValueExcept, Among from .core import BoolVal diff --git a/cpmpy/expressions/globalconstraints.py b/cpmpy/expressions/globalconstraints.py index ced2dc26c..7d229bc27 100644 --- a/cpmpy/expressions/globalconstraints.py +++ b/cpmpy/expressions/globalconstraints.py @@ -455,7 +455,8 @@ def __repr__(self): class Cumulative(GlobalConstraint): """ Global cumulative constraint. Used for resource aware scheduling. - Ensures no overlap between tasks and never exceeding the capacity of the resource + Ensures that the capacity of the resource is never exceeded + Equivalent to noOverlap when demand and capacity are equal to 1 Supports both varying demand across tasks or equal demand for all jobs """ def __init__(self, start, duration, end, demand, capacity): @@ -534,6 +535,76 @@ def value(self): return True +class Precedence(GlobalConstraint): + """ + Constraint enforcing some values have precedence over others. + Given an array of variables X and a list of precedences P: + Then in order to satisfy the constraint, if X[i] = P[j+1], then there exists a X[i'] = P[j] with i' < i + """ + def __init__(self, vars, precedence): + if not is_any_list(vars): + raise TypeError("Precedence expects a list of variables, but got", vars) + if not is_any_list(precedence) or any(isinstance(x, Expression) for x in precedence): + raise TypeError("Precedence expects a list of values as precedence, but got", precedence) + super().__init__("precedence", [vars, precedence]) + + def decompose(self): + """ + Decomposition based on: + Law, Yat Chiu, and Jimmy HM Lee. "Global constraints for integer and set value precedence." + Principles and Practice of Constraint Programming–CP 2004: 10th International Conference, CP 2004 + """ + from .python_builtins import any as cpm_any + + args, precedence = self.args + constraints = [] + for s,t in zip(precedence[:-1], precedence[1:]): + for j in range(len(args)): + constraints += [(args[j] == t).implies(cpm_any(args[:j] == s))] + return constraints, [] + + def value(self): + + args, precedence = self.args + vals = np.array(argvals(args)) + for s,t in zip(precedence[:-1], precedence[1:]): + if vals[0] == t: return False + for j in range(len(args)): + if vals[j] == t and sum(vals[:j] == s) == 0: + return False + return True + + +class NoOverlap(GlobalConstraint): + + def __init__(self, start, dur, end): + 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" + + start = flatlist(start) + dur = flatlist(dur) + end = flatlist(end) + assert len(start) == len(dur) == len(end), "Start, duration and end should have equal length in NoOverlap constraint" + + super().__init__("no_overlap", [start, dur, end]) + + def decompose(self): + start, dur, end = self.args + cons = [s + d == e for s,d,e in zip(start, dur, end)] + for (s1, e1), (s2, e2) in all_pairs(zip(start, end)): + cons += [(e1 <= s2) | (e2 <= s1)] + return cons, [] + def value(self): + start, dur, end = argvals(self.args) + 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): """ GlobalCardinalityCount(vars,vals,occ): The number of occurrences of each value vals[i] in the list of variables vars diff --git a/cpmpy/solvers/choco.py b/cpmpy/solvers/choco.py index 7f250a6c1..5fda6c5e3 100644 --- a/cpmpy/solvers/choco.py +++ b/cpmpy/solvers/choco.py @@ -314,7 +314,7 @@ def transform(self, cpm_expr): cpm_cons = toplevel_list(cpm_expr) supported = {"min", "max", "abs", "count", "element", "alldifferent", "alldifferent_except0", "allequal", "table", "InDomain", "cumulative", "circuit", "gcc", "inverse", "nvalue", "increasing", - "decreasing","strictly_increasing","strictly_decreasing","lex_lesseq", "lex_less", "among"} + "decreasing","strictly_increasing","strictly_decreasing","lex_lesseq", "lex_less", "among", "precedence"} # choco supports reification of any constraint, but has a bug in increasing and decreasing supported_reified = {"min", "max", "abs", "count", "element", "alldifferent", "alldifferent_except0", @@ -549,6 +549,8 @@ def _get_constraint(self, cpm_expr): # 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)] return self.chc_model.cumulative(tasks, demand, cap) + 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": vars, vals, occ = cpm_expr.args return self.chc_model.global_cardinality(*self.solver_vars([vars, vals]), self._to_vars(occ)) diff --git a/cpmpy/solvers/minizinc.py b/cpmpy/solvers/minizinc.py index 1ee90bf39..dae31cb95 100644 --- a/cpmpy/solvers/minizinc.py +++ b/cpmpy/solvers/minizinc.py @@ -418,11 +418,11 @@ def transform(self, cpm_expr): """ cpm_cons = toplevel_list(cpm_expr) supported = {"min", "max", "abs", "element", "count", "nvalue", "alldifferent", "alldifferent_except0", "allequal", - "inverse", "ite" "xor", "table", "cumulative", "circuit", "gcc", "increasing", - "decreasing", "strictly_increasing", "strictly_decreasing", "lex_lesseq", "lex_less", "lex_chain_less", + "inverse", "ite" "xor", "table", "cumulative", "circuit", "gcc", "increasing","decreasing", + "precedence","no_overlap", + "strictly_increasing", "strictly_decreasing", "lex_lesseq", "lex_less", "lex_chain_less", "lex_chain_lesseq","among"} - - return decompose_in_tree(cpm_cons, supported, supported_reified=supported - {"circuit"}) + return decompose_in_tree(cpm_cons, supported, supported_reified=supported - {"circuit", "precedence"}) def __add__(self, cpm_expr): @@ -600,6 +600,15 @@ def zero_based(array): return format_str.format(args_str[0], args_str[1], args_str[3], args_str[4]) + elif expr.name == "precedence": + return "value_precede_chain({},{})".format(args_str[1], args_str[0]) + + elif expr.name == "no_overlap": + start, dur, end = expr.args + durstr = self._convert_expression([s + d == e for s, d, e in zip(start, dur, end)]) + format_str = "forall(" + durstr + " ++ [disjunctive({},{})])" + return format_str.format(args_str[0], args_str[1]) + elif expr.name == 'ite': cond, tr, fal = expr.args return "if {} then {} else {} endif".format(self._convert_expression(cond), self._convert_expression(tr), diff --git a/cpmpy/solvers/ortools.py b/cpmpy/solvers/ortools.py index c70dcbe8f..8ee98269b 100644 --- a/cpmpy/solvers/ortools.py +++ b/cpmpy/solvers/ortools.py @@ -330,7 +330,7 @@ def transform(self, cpm_expr): :return: list of Expression """ cpm_cons = toplevel_list(cpm_expr) - supported = {"min", "max", "abs", "element", "alldifferent", "xor", "table", "cumulative", "circuit", "inverse"} + supported = {"min", "max", "abs", "element", "alldifferent", "xor", "table", "cumulative", "circuit", "inverse", "no_overlap"} cpm_cons = decompose_in_tree(cpm_cons, supported) cpm_cons = flatten_constraint(cpm_cons) # flat normal form cpm_cons = reify_rewrite(cpm_cons, supported=frozenset(['sum', 'wsum'])) # constraints that support reification @@ -470,6 +470,10 @@ 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 == "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 == "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 959520ded..dd59b6ffd 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -16,8 +16,10 @@ # Exclude some global constraints for solvers NUM_GLOBAL = { "AllEqual", "AllDifferent", "AllDifferentLists", "AllDifferentExcept0", - "Cumulative", "GlobalCardinalityCount", "InDomain", "Inverse", "Table", "Circuit", - "Increasing", "IncreasingStrict", "Decreasing", "DecreasingStrict", "LexLess", "LexLessEq", "LexChainLess", "LexChainLessEq", + "GlobalCardinalityCount", "InDomain", "Inverse", "Table", "Circuit", + "Increasing", "IncreasingStrict", "Decreasing", "DecreasingStrict", + "Precedence", "Cumulative", "NoOverlap", + "LexLess", "LexLessEq", "LexChainLess", "LexChainLessEq", # also global functions "Abs", "Element", "Minimum", "Maximum", "Count", "Among", "NValue", "NValueExcept" } @@ -196,6 +198,14 @@ def global_constraints(solver): demand = [4, 5, 7] cap = 10 expr = Cumulative(s, dur, e, demand, cap) + elif name == "Precedence": + x = intvar(0,5, shape=3, name="x") + expr = cls(x, [3,1,0]) + elif name == "NoOverlap": + s = intvar(0, 10, shape=3, name="start") + e = intvar(0, 10, shape=3, name="end") + dur = [1,4,3] + expr = cls(s, dur, e) elif name == "GlobalCardinalityCount": vals = [1, 2, 3] cnts = intvar(0,10,shape=3) diff --git a/tests/test_globalconstraints.py b/tests/test_globalconstraints.py index acf600251..03f4230f3 100644 --- a/tests/test_globalconstraints.py +++ b/tests/test_globalconstraints.py @@ -800,6 +800,32 @@ def check_true(): cp.Model(cons).solveAll(solver='minizinc') + + def test_precedence(self): + iv = cp.intvar(0,5, shape=6, name="x") + + cons = cp.Precedence(iv, [0,2,1]) + self.assertTrue(cp.Model([cons, iv == [5,0,2,0,0,1]]).solve()) + self.assertTrue(cons.value()) + self.assertTrue(cp.Model([cons, iv == [0,0,0,0,0,0]]).solve()) + self.assertTrue(cons.value()) + self.assertFalse(cp.Model([cons, iv == [0,1,2,0,0,0]]).solve()) + + + def test_no_overlap(self): + start = cp.intvar(0,5, shape=3) + end = cp.intvar(0,5, shape=3) + cons = cp.NoOverlap(start, [2,1,1], end) + self.assertTrue(cp.Model(cons).solve()) + self.assertTrue(cons.value()) + self.assertTrue(cp.Model(cons.decompose()).solve()) + self.assertTrue(cons.value()) + + def check_val(): + assert cons.value() is False + + cp.Model(~cons).solveAll(display=check_val) + class TestBounds(unittest.TestCase): def test_bounds_minimum(self): x = cp.intvar(-8, 8)