Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
7b52275
make end variables optional in cumulative
IgnaceBleukx Jun 18, 2025
901189f
test cumulative constraint without ends
IgnaceBleukx Jun 18, 2025
2c7e8cd
update choco interface
IgnaceBleukx Jun 18, 2025
0f0dc13
update cpo interface
IgnaceBleukx Jun 18, 2025
f37cbf2
update minizinc interface
IgnaceBleukx Jun 18, 2025
8f190f5
update ortools interface
IgnaceBleukx Jun 18, 2025
71b4315
make end in nooverlap optional
IgnaceBleukx Jun 18, 2025
053baee
update tests
IgnaceBleukx Jun 18, 2025
e46bbbc
undo whoops
IgnaceBleukx Jun 18, 2025
bec36c0
set bounds for end of intervals
IgnaceBleukx Jun 23, 2025
34eb52e
add nooverlap to supported
IgnaceBleukx Jun 30, 2025
8678655
remove redundant check
IgnaceBleukx Jun 30, 2025
812247b
Merge remote-tracking branch 'origin/master' into no_end_cumulative
IgnaceBleukx Aug 8, 2025
19c8509
allow for negative duration in interface
IgnaceBleukx Sep 5, 2025
6339037
merge with negative_duration branch
IgnaceBleukx Sep 5, 2025
11ab221
refactor end is None
IgnaceBleukx Sep 5, 2025
cd3b04f
update solver interfaces
IgnaceBleukx Sep 5, 2025
2d1b516
update tests (failing)
IgnaceBleukx Sep 5, 2025
c09bbda
update minizinc interface
IgnaceBleukx Sep 9, 2025
fd96cc2
update cpo interface
IgnaceBleukx Sep 9, 2025
d8d0891
update tests
IgnaceBleukx Sep 9, 2025
05ccd20
update docs
IgnaceBleukx Sep 9, 2025
4e0bf81
update choco interface
IgnaceBleukx Sep 9, 2025
bae7173
add test with negative demands
IgnaceBleukx Sep 9, 2025
63af96c
update cpo interface
IgnaceBleukx Sep 9, 2025
4bac809
update mzn interface
IgnaceBleukx Sep 9, 2025
e760171
add doc
IgnaceBleukx Sep 9, 2025
8394413
update value in cumulative
IgnaceBleukx Sep 9, 2025
b1e7984
update choco interface
IgnaceBleukx Sep 9, 2025
ca82b5b
exclude test for pindakaas
IgnaceBleukx Sep 9, 2025
890c68e
move assert to if-case
IgnaceBleukx Sep 9, 2025
74d70ed
Merge branch 'master' into no_end_cumulative
Dimosts Sep 9, 2025
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
147 changes: 105 additions & 42 deletions cpmpy/expressions/globalconstraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,30 +672,46 @@ def __repr__(self):
class Cumulative(GlobalConstraint):
"""
Global cumulative constraint. Used for resource aware scheduling.
Ensures that the capacity of the resource is never exceeded.
Ensures that the capacity of the resource is never exceeded and enforces:
duration >= 0
demand >= 0
start + duration == end

Equivalent to :class:`~cpmpy.expressions.globalconstraints.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):
def __init__(self, start, duration, end=None, demand=None, capacity=None):
"""
Arguments of constructor:

Arguments:
`start`: List of Expression objects representing the start times of the tasks
`duration`: List of Expression objects representing the durations of the tasks
`end`: optional, list of Expression objects representing the end times of the tasks
`demand`: List of Expression objects or single Expression to indicate constant demand for all tasks
`capacity`: Expression object representing the capacity of the resource
"""

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"
if end is not None:
assert is_any_list(end), "end should be a list if it is provided"

assert demand is not None, "demand should be provided but was None"
assert capacity is not None, "capacity should be provided but was None"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what should be done here, but I am not really a fan of giving them a default value (making them optional) and then not allowing the optional value.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I also agree it is quite annoying... For now this is the best I could come up with, as you cannot make an argument in the middle optional...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am still troubled by this. So, someone either has to actually use the end var after all, or has to use kwargs to define demand and capacity, which are optional but not that optional after all.

Both minizinc (https://docs.minizinc.dev/en/stable/predicates.html) and essence (https://arxiv.org/pdf/2201.03472) actually do not accept end variables at all, and create them for solvers that need them. Should we stick to having them after all?

The question is what if the user needs the end variables for something else I guess; In this case, if the users define themselves the end = start + duration constraints, it is ok. Common subexpression elimination should capture it during the decomposition if needed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yeah, but removing it all together will break every CPMpy model currently out there that uses a Cumulative constraint...
I don't really have too much of an opinion on whether we want to keep the end variables or not, but if we decide to remove them, we should wait a couple of releases and raise a deprecation warning here


start = flatlist(start)
duration = flatlist(duration)
end = flatlist(end)
assert len(start) == len(duration) == len(end), "Start, duration and end 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")
assert len(start) == len(duration), "Start and duration should have equal length"
if end is not None:
end = flatlist(end)
assert len(start) == len(end), "Start and end should have equal length"

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

super(Cumulative, self).__init__("cumulative", [start, duration, end, demand, capacity])

Expand All @@ -706,49 +722,65 @@ def decompose(self):
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 = arr_args
start, duration, end, demand, capacity = self.args

cons = []
cons = [d >= 0 for d in duration] # enforce non-negative durations
cons += [h >= 0 for h in demand] # enforce non-negative demand
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should maybe be in safening? It is defined as a restriction, not as an enforced constraint.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels more like safening code than constraining. It is a restriction of the cumulative constraint (https://sofdem.github.io/gccat/aux/pdf/cumulative.pdf) and not something that is enforced.

However, after in person discussion I agree that it will create more problems if we do it in safening instead. Note to revisit this if we encounter more such cases.


# set duration of tasks
for t in range(len(start)):
cons += [start[t] + duration[t] == end[t]]
# set duration of tasks, only if end is user-provided
if end is None:
end = [start[i] + duration[i] for i in range(len(start))]
else:
cons += [start[i] + duration[i] == end[i] for i in range(len(start))]

# demand doesn't exceed capacity
lb, ub = min(get_bounds(start)[0]), max(get_bounds(end)[1])
lbs, ubs = get_bounds(start)
lb, ub = min(lbs), max(ubs)
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]))
else:
demand_at_t += demand[job] * ((start[job] <= t) & (t < end[job]))
demand_at_t += demand[job] * ((start[job] <= t) & (end[job] > t))

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):
start, dur, end, demand, capacity = self.args

start, dur, demand, capacity = argvals([start, dur, demand, capacity])
if end is None:
end = [s + d for s,d in zip(start, dur)]
else:
end = argvals(end)

if any(a is None for a in flatlist([start, dur, end, demand, capacity])):
return None

if any(d < 0 for d in dur):
return False
if any(s + d != e for s,d,e in zip(start, dur, end)):
return False

# start, dur, end are np arrays
start, dur, end, demand, capacity = arg_vals
# start and end seperated by duration
if not (start + dur == end).all():
if any(d < 0 for d in demand):
return False

# demand doesn't exceed capacity
# ensure demand doesn't exceed capacity
lb, ub = min(start), max(end)
start, end = np.array(start), np.array(end) # eases check below
for t in range(lb, ub+1):
if capacity < sum(demand * ((start <= t) & (t < end))):
return False

return True

def __repr__(self):
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 Precedence(GlobalConstraint):
Expand Down Expand Up @@ -795,37 +827,68 @@ def value(self):

class NoOverlap(GlobalConstraint):
"""
NoOverlap constraint, enforcing that the intervals defined by start, duration and end do not overlap.
Global no-overlap constraint. Used for scheduling problems
Ensures no tasks overlap and enforces:
duration >= 0
demand >= 0
start + duration == end

Equivalent to :class:`~cpmpy.expressions.globalconstraints.Cumulative` with demand and capacity 1
"""

def __init__(self, start, dur, end):
def __init__(self, start, dur, end=None):
"""
Arguments:
`start`: List of Expression objects representing the start times of the tasks
`duration`: List of Expression objects representing the durations of the tasks
`end`: optional, list of Expression objects representing the end times of the tasks
"""

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"
if end is not None:
assert is_any_list(end), "end should be a list if it is provided"

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"

assert len(start) == len(dur), "start and duration should have equal length"
if end is not None:
end = flatlist(end)
assert len(start) == len(end), "start and end should have equal length"

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)]
cons = [d >= 0 for d in dur]

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)]

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)):
if any(d < 0 for d in dur):
return False
if end is not None and 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:
for (s1,d1), (s2,d2) in all_pairs(zip(start,dur)):
if s1 + d1 > s2 and s2 + d2 > s1:
return False
return True

def __repr__(self):
start, dur, end = self.args
if end is None:
return f"NoOverlap({start}, {dur})"
else:
return f"NoOverlap({start}, {dur}, {end})"



class GlobalCardinalityCount(GlobalConstraint):
Expand Down
21 changes: 21 additions & 0 deletions cpmpy/expressions/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,27 @@ def implies(expr, other):
else:
return expr.implies(other)

# Specific stuff for scheduling constraints

def get_nonneg_args(args):
"""
Replace arguments with negative lowerbound with their nonnegative counterpart
"""
lbs, ubs = zip(*[get_bounds(arg) for arg in args])
new_args = []
cons = []
for lb, ub, arg in zip(lbs, ubs, args):
if lb < 0:
if ub >= 0:
iv = cp.intvar(0, ub)
else: # ub < 0
iv = cp.intvar(0,0)
cons.append(arg == iv) # will always be False if ub < 0
new_args.append(iv)
else:
new_args.append(arg)
return new_args, cons

# Specific stuff for ShortTabel global (should this be in globalconstraints.py instead?)
STAR = "*" # define constant here
def is_star(arg):
Expand Down
27 changes: 22 additions & 5 deletions cpmpy/solvers/choco.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@
from ..transformations.normalize import toplevel_list
from .solver_interface import SolverInterface, SolverStatus, ExitStatus
from ..expressions.core import Expression, Comparison, Operator, BoolVal
from ..expressions.globalconstraints import DirectConstraint
from ..expressions.globalconstraints import Cumulative, DirectConstraint
from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, intvar
from ..expressions.globalconstraints import GlobalConstraint
from ..expressions.utils import is_num, is_int, is_boolexpr, is_any_list, get_bounds, argval, argvals, STAR
from ..expressions.utils import is_num, is_int, is_boolexpr, is_any_list, get_bounds, argval, argvals, STAR, \
get_nonneg_args
from ..transformations.decompose_global import decompose_in_tree
from ..transformations.get_variables import get_variables
from ..transformations.flatten_model import flatten_constraint
Expand Down Expand Up @@ -406,7 +407,7 @@ def transform(self, cpm_expr):
cpm_cons = toplevel_list(cpm_expr)
supported = {"min", "max", "abs", "count", "element", "alldifferent", "alldifferent_except0", "allequal",
"table", 'negative_table', "short_table", "regular", "InDomain", "cumulative", "circuit", "gcc", "inverse", "nvalue", "increasing",
"decreasing","strictly_increasing","strictly_decreasing","lex_lesseq", "lex_less", "among", "precedence"}
"decreasing","strictly_increasing","strictly_decreasing","lex_lesseq", "lex_less", "among", "precedence", "no_overlap"}

cpm_cons = no_partial_functions(cpm_cons)
cpm_cons = decompose_in_tree(cpm_cons, supported, supported, csemap=self._csemap) # choco supports any global also (half-) reified
Expand Down Expand Up @@ -631,13 +632,29 @@ def _get_constraint(self, cpm_expr):
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
demand, demand_cons = get_nonneg_args(demand)
extra_cons += demand_cons
# start, end, demand and cap should be var
start, end, demand, cap = self._to_vars([start, end, demand, cap])
if end is None:
start, demand, cap = self._to_vars([start, demand, cap])
end = [None for _ in range(len(start))]
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)]
return self.chc_model.cumulative(tasks, demand, cap)

chc_cumulative = self.chc_model.cumulative(tasks, demand, 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))
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
Loading
Loading