Skip to content

Commit

Permalink
Scheduling constraints (#486)
Browse files Browse the repository at this point in the history
Add Precedence and NoOverlap global constraints.
  • Loading branch information
IgnaceBleukx authored May 27, 2024
1 parent 94c3bff commit da47802
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 10 deletions.
3 changes: 2 additions & 1 deletion cpmpy/expressions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 72 additions & 1 deletion cpmpy/expressions/globalconstraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion cpmpy/solvers/choco.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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))
Expand Down
17 changes: 13 additions & 4 deletions cpmpy/solvers/minizinc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 5 additions & 1 deletion cpmpy/solvers/ortools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions tests/test_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions tests/test_globalconstraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit da47802

Please sign in to comment.