From d39c5693aa7197c564afe6e7359607b340fa3639 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Sat, 17 Jan 2026 10:32:07 +0100 Subject: [PATCH 01/10] check if arg is None --- cpmpy/transformations/safening.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cpmpy/transformations/safening.py b/cpmpy/transformations/safening.py index 626bcca7c..cc1a91c35 100644 --- a/cpmpy/transformations/safening.py +++ b/cpmpy/transformations/safening.py @@ -78,7 +78,8 @@ def no_partial_functions(lst_of_expr, _toplevel=None, _nbc=None, safen_toplevel= new_lst = [] for cpm_expr in lst_of_expr: - if is_num(cpm_expr) or isinstance(cpm_expr, _NumVarImpl): + # leaf node: constant, variable (or None, in case of Cumulative with no end provided) + if is_num(cpm_expr) or isinstance(cpm_expr, _NumVarImpl) or cpm_expr is None: new_lst.append(cpm_expr) elif isinstance(cpm_expr, (list,tuple)): From c9ad37c0814462e1e6246fcf66e6d41af3d44791 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Sat, 17 Jan 2026 10:32:42 +0100 Subject: [PATCH 02/10] update _is_flat_var to check for None --- cpmpy/transformations/flatten_model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpmpy/transformations/flatten_model.py b/cpmpy/transformations/flatten_model.py index c6898806b..746a630b6 100644 --- a/cpmpy/transformations/flatten_model.py +++ b/cpmpy/transformations/flatten_model.py @@ -312,13 +312,13 @@ def flatten_objective(expr, supported=frozenset(["sum", "wsum"]), csemap=None): def __is_flat_var(arg): """ True if the variable is a numeric constant, or a _NumVarImpl (incl subclasses) """ - return is_num(arg) or isinstance(arg, _NumVarImpl) + return is_num(arg) or isinstance(arg, _NumVarImpl) or arg is None def __is_flat_var_or_list(arg): """ True if the variable is a numeric constant, or a _NumVarImpl (incl subclasses) or a list of __is_flat_var_or_list, or it is a wildcard as used in the ShortTable global constraint """ - return is_num(arg) or isinstance(arg, _NumVarImpl) or \ + return is_num(arg) or isinstance(arg, _NumVarImpl) or arg is None or \ is_any_list(arg) and all(__is_flat_var_or_list(el) for el in arg) or \ is_star(arg) From f72c27e4cf6effc30391da4c19e0752c45c70824 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Sat, 17 Jan 2026 10:32:52 +0100 Subject: [PATCH 03/10] add test --- tests/test_globalconstraints.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_globalconstraints.py b/tests/test_globalconstraints.py index 3478e412d..1f5d1f158 100644 --- a/tests/test_globalconstraints.py +++ b/tests/test_globalconstraints.py @@ -872,6 +872,11 @@ def test_cumulative_single_demand(self): m += cp.Cumulative(start, duration, end, demand, capacity) self.assertTrue(m.solve()) + def test_cumulative_subexpr(self): + start = cp.intvar(0,10, shape=3) + cons = cp.Cumulative(start+start, [1,2,3], None, [1,2,3], 3) + self.assertTrue(cp.Model(cons).solve()) + def test_cumulative_decomposition_capacity(self): import numpy as np From 00cf2c0ee0722d6da6884e2259bf2604a1e5d038 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Mon, 23 Mar 2026 16:36:52 +0100 Subject: [PATCH 04/10] undo None hacks in transformations --- cpmpy/transformations/flatten_model.py | 4 ++-- cpmpy/transformations/safening.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cpmpy/transformations/flatten_model.py b/cpmpy/transformations/flatten_model.py index e18531058..567f65e93 100644 --- a/cpmpy/transformations/flatten_model.py +++ b/cpmpy/transformations/flatten_model.py @@ -312,13 +312,13 @@ def flatten_objective(expr, supported=frozenset(["sum", "wsum"]), csemap=None): def __is_flat_var(arg): """ True if the variable is a numeric constant, or a _NumVarImpl (incl subclasses) """ - return is_num(arg) or isinstance(arg, _NumVarImpl) or arg is None + return is_num(arg) or isinstance(arg, _NumVarImpl) def __is_flat_var_or_list(arg): """ True if the variable is a numeric constant, or a _NumVarImpl (incl subclasses) or a list of __is_flat_var_or_list, or it is a wildcard as used in the ShortTable global constraint """ - return is_num(arg) or isinstance(arg, _NumVarImpl) or arg is None or \ + return is_num(arg) or isinstance(arg, _NumVarImpl) or \ is_any_list(arg) and all(__is_flat_var_or_list(el) for el in arg) or \ is_star(arg) diff --git a/cpmpy/transformations/safening.py b/cpmpy/transformations/safening.py index cc1a91c35..16341ca9d 100644 --- a/cpmpy/transformations/safening.py +++ b/cpmpy/transformations/safening.py @@ -79,7 +79,7 @@ def no_partial_functions(lst_of_expr, _toplevel=None, _nbc=None, safen_toplevel= for cpm_expr in lst_of_expr: # leaf node: constant, variable (or None, in case of Cumulative with no end provided) - if is_num(cpm_expr) or isinstance(cpm_expr, _NumVarImpl) or cpm_expr is None: + if is_num(cpm_expr) or isinstance(cpm_expr, _NumVarImpl): new_lst.append(cpm_expr) elif isinstance(cpm_expr, (list,tuple)): From de75039d9802895531496cf0c739445df34408f0 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Mon, 23 Mar 2026 16:37:06 +0100 Subject: [PATCH 05/10] keep flag "end_is_none" in scheduling constraints --- cpmpy/expressions/globalconstraints.py | 98 +++++++++++--------------- 1 file changed, 43 insertions(+), 55 deletions(-) diff --git a/cpmpy/expressions/globalconstraints.py b/cpmpy/expressions/globalconstraints.py index 85551329b..8f49a086a 100644 --- a/cpmpy/expressions/globalconstraints.py +++ b/cpmpy/expressions/globalconstraints.py @@ -1050,7 +1050,11 @@ def __init__(self, start: ListLike[ExprLike], duration: ListLike[ExprLike], end: else: # constant demand demand_list = [demand] * len(start) - super(Cumulative, self).__init__("cumulative", [list(start), list(duration), list(end) if end is not None else None, demand_list, capacity]) + self.end_is_none = end is None + if end is None: + super(Cumulative, self).__init__("cumulative", [list(start), list(duration), demand_list, capacity]) + else: + super(Cumulative, self).__init__("cumulative", [list(start), list(duration), list(end), demand_list, capacity]) def decompose(self, how:str="auto") -> tuple[list[Expression], list[Expression]]: @@ -1088,17 +1092,18 @@ def _task_decomposition(self) -> tuple[list[Expression], list[Expression]]: Returns: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ - start, duration, end, demand, capacity = self.args - - cons = [d >= 0 for d in duration] # enforce non-negative durations - cons += [h >= 0 for h in demand] # enforce non-negative demand + cons = [] - # set duration of tasks, only if end is user-provided - if end is None: + if self.end_is_none: + start, duration, demand, capacity = self.args end = [start[i] + duration[i] for i in range(len(start))] else: + start, duration, end, demand, capacity = self.args cons += [start[i] + duration[i] == end[i] for i in range(len(start))] + cons += [d >= 0 for d in duration] # enforce non-negative durations + cons += [h >= 0 for h in demand] # enforce non-negative demand + # demand doesn't exceed capacity # tasks are uninterruptible, so we only need to check each starting point of each task # I.e., for each task, we check if it can be started, given the tasks that are already running. @@ -1121,15 +1126,13 @@ def _time_decomposition(self) -> tuple[list[Expression], list[Expression]]: Returns: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ - start, duration, end, demand, capacity = self.args - - cons = [d >= 0 for d in duration] # enforce non-negative durations - cons += [h >= 0 for h in demand] # enforce non-negative demand + cons = [] - # set duration of tasks, only if end is user-provided - if end is None: + if self.end_is_none: + start, duration, demand, capacity = self.args end = [start[i] + duration[i] for i in range(len(start))] else: + start, duration, end, demand, capacity = self.args cons += [start[i] + duration[i] == end[i] for i in range(len(start))] # demand doesn't exceed capacity @@ -1146,16 +1149,15 @@ def value(self) -> Optional[bool]: Returns: Optional[bool]: True if the global constraint is satisfied, False otherwise, or None if any argument is not assigned """ - start, dur, end, demand, capacity = self.args - - start, dur, demand, capacity = argvals([start, dur, demand, capacity]) - if any(a is None for a in flatlist([start, dur, demand, capacity])): - return None - if end is None: - end = [s + d for s,d in zip(start, dur)] + + if self.end_is_none: + start, dur, demand, capacity = argvals(self.args) + if any(a is None for a in [start, dur, demand, capacity]): + return None + end = [start[i] + dur[i] for i in range(len(start))] else: - end = argvals(end) - if any(a is None for a in end): + start, dur, end, demand, capacity = argvals(self.args) + if any(a is None for a in [start, dur, end, demand, capacity]): return None if any(d < 0 for d in dur): @@ -1174,17 +1176,6 @@ def value(self) -> Optional[bool]: return False return True - - def __repr__(self) -> str: - """ - Returns: - str: String representation of the cumulative constraint - """ - 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 NoOverlap(GlobalConstraint): """ @@ -1213,7 +1204,11 @@ def __init__(self, start: ListLike[ExprLike], duration: ListLike[ExprLike], end: if end is not None and len(start) != len(end): raise ValueError(f"Start and end should have equal length, but got {len(start)} and {len(end)}") - super().__init__("no_overlap", [list(start), list(duration), list(end) if end is not None else None]) + self.end_is_none = end is None + if end is None: + super().__init__("no_overlap", [list(start), list(duration)]) + else: + super().__init__("no_overlap", [list(start), list(duration), list(end)]) def decompose(self) -> tuple[list[Expression], list[Expression]]: """ @@ -1222,13 +1217,16 @@ def decompose(self) -> tuple[list[Expression], list[Expression]]: Returns: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ - start, dur, end = self.args - cons = [d >= 0 for d in dur] + cons = [] + + if self.end_is_none: + start, duration = self.args + end = [start[i] + duration[i] for i in range(len(start))] + else: + start, duration, end = self.args + cons += [start[i] + duration[i] == end[i] for i in range(len(start))] - 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)] + cons += [d >= 0 for d in duration] for (s1, e1), (s2, e2) in all_pairs(zip(start, end)): cons += [(e1 <= s2) | (e2 <= s1)] @@ -1239,13 +1237,14 @@ def value(self) -> Optional[bool]: Returns: Optional[bool]: True if the global constraint is satisfied, False otherwise, or None if any argument is not assigned """ - start, dur, end = argvals(self.args) - if end is None: - if any(s is None for s in start) or any(d is None for d in dur): + if self.end_is_none: + start, dur = argvals(self.args) + if any(a is None for a in start+dur): return None end = [s + d for s,d in zip(start, dur)] else: - if any(s is None for s in start) or any(d is None for d in dur) or any(e is None for e in end): + start, dur, end = argvals(self.args) + if any(a is None for a in [start, dur, end]): return None if any(d < 0 for d in dur): @@ -1256,17 +1255,6 @@ def value(self) -> Optional[bool]: if s1 + d1 > s2 and s2 + d2 > s1: return False return True - - def __repr__(self) -> str: - """ - Returns: - str: String representation of the NoOverlap constraint - """ - start, dur, end = self.args - if end is None: - return f"NoOverlap({start}, {dur})" - else: - return f"NoOverlap({start}, {dur}, {end})" class Precedence(GlobalConstraint): """ From 9b2ceaf057f93c2c011beb9e2f07d7211c453cc3 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Mon, 23 Mar 2026 16:40:23 +0100 Subject: [PATCH 06/10] update check in decomp heuristic --- cpmpy/expressions/globalconstraints.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cpmpy/expressions/globalconstraints.py b/cpmpy/expressions/globalconstraints.py index 8f49a086a..5eab48386 100644 --- a/cpmpy/expressions/globalconstraints.py +++ b/cpmpy/expressions/globalconstraints.py @@ -1073,7 +1073,10 @@ def decompose(self, how:str="auto") -> tuple[list[Expression], list[Expression]] if how not in ["time", "task", "auto"]: raise ValueError(f"how can only be time, task, or auto (default), but got {how}") - start, duration, end, demand, capacity = self.args + if self.end_is_none: + start, duration, demand, capacity = self.args + else: + start, duration, end, demand, capacity = self.args lbs, ubs = get_bounds(start) horizon = max(ubs) - min(lbs) From 7aa6becd553b0de1c7de4077d2df14d61652f4f2 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Mon, 23 Mar 2026 16:40:47 +0100 Subject: [PATCH 07/10] update ortools interface --- cpmpy/solvers/ortools.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/cpmpy/solvers/ortools.py b/cpmpy/solvers/ortools.py index 6f3913a6b..b2788f4cc 100644 --- a/cpmpy/solvers/ortools.py +++ b/cpmpy/solvers/ortools.py @@ -51,7 +51,7 @@ from .solver_interface import SolverInterface, SolverStatus, ExitStatus, Callback from ..exceptions import NotSupportedError from ..expressions.core import Expression, Comparison, Operator, BoolVal -from ..expressions.globalconstraints import DirectConstraint +from ..expressions.globalconstraints import DirectConstraint, Cumulative, NoOverlap from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, boolvar, intvar from ..expressions.globalconstraints import GlobalConstraint from ..expressions.utils import get_nonneg_args, is_num, is_int, eval_comparison, flatlist, argval, argvals, get_bounds, is_true_cst, \ @@ -589,15 +589,18 @@ def _post_constraint(self, cpm_expr, reifiable=False): return self.ort_model.AddAutomaton(array, cpm_expr.node_map[start], [cpm_expr.node_map[n] for n in accepting], [(cpm_expr.node_map[src], label, cpm_expr.node_map[dst]) for src, label, dst in transitions]) elif cpm_expr.name == "cumulative": - start, dur, end, demand, cap = cpm_expr.args + assert isinstance(cpm_expr, Cumulative) # ensure hasattr end_is_none + if cpm_expr.end_is_none: + start, dur, demand, cap = cpm_expr.args + end = [intvar(*get_bounds(s+d)) for s,d in zip(start, dur)] + self.add([s + d == e for s,d,e in zip(start, dur, end)]) + else: + start, dur, end, demand, cap = cpm_expr.args + # ensure duration is non-negative dur, dur_cons = get_nonneg_args(dur) self.add(dur_cons) - if end is None: # need to make the end-variables ourself - end = [intvar(*get_bounds(s+d)) for s,d in zip(start, dur)] - self.add([s + d == e for s,d,e in zip(start, dur, end)]) - # ensure demand is non-negative demand, demand_cons = get_nonneg_args(demand) self.add(demand_cons) @@ -607,14 +610,17 @@ def _post_constraint(self, cpm_expr, reifiable=False): return self.ort_model.AddCumulative(intervals, demand, cap) elif cpm_expr.name == "no_overlap": - start, dur, end = cpm_expr.args + assert isinstance(cpm_expr, NoOverlap) # ensure hasattr end_is_none + if cpm_expr.end_is_none: + start, dur = cpm_expr.args + end = [intvar(*get_bounds(s + d)) for s, d in zip(start, dur)] + self.add([s + d == e for s, d, e in zip(start, dur, end)]) + else: + start, dur, end = cpm_expr.args + dur, dur_cons = get_nonneg_args(dur) self.add(dur_cons) - if end is None: # need to make the end-variables ourself - end = [intvar(*get_bounds(s+d)) for s,d in zip(start, dur)] - self.add([s + d == e for s,d,e in zip(start, dur, end)]) - start, dur, end = self.solver_vars([start, dur, end]) intervals = [self.ort_model.NewIntervalVar(s, d, e, f"interval_{s}-{d}-{e}") for s, d, e in zip(start, dur, end)] From 52909645c6d61966a30a335160ae5647282f8043 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Mon, 23 Mar 2026 16:41:02 +0100 Subject: [PATCH 08/10] update choco interface --- cpmpy/solvers/choco.py | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/cpmpy/solvers/choco.py b/cpmpy/solvers/choco.py index b1c420f13..45b657de6 100644 --- a/cpmpy/solvers/choco.py +++ b/cpmpy/solvers/choco.py @@ -651,30 +651,35 @@ def _get_constraint(self, cpm_expr): expr, table = self.solver_vars(cpm_expr.args) 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 + assert isinstance(cpm_expr, Cumulative) # ensure hasattr end_is_none + if cpm_expr.end_is_none: + start, dur, demand, cap = cpm_expr.args + else: + start, dur, end, demand, cap = cpm_expr.args + + # Choco allows negative durations and demands, but this does not match CPMpy spec + dur, dur_cons = get_nonneg_args(dur) demand, demand_cons = get_nonneg_args(demand) - extra_cons += demand_cons - # start, end, demand and cap should be var - if end is None: - start, demand, cap = self._to_vars([start, demand, cap]) - end = [None for _ in range(len(start))] + extra_cons = dur_cons + demand_cons + + # make choco task variables + if cpm_expr.end_is_none: + tasks = [self.chc_model.task(s,d) for s,d in zip(self._to_vars(start), self.solver_vars(dur))] 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)] + tasks = [self.chc_model.task(s,d,e) for s,d,e in zip(self._to_vars(start), self.solver_vars(dur), self._to_vars(end))] - chc_cumulative = self.chc_model.cumulative(tasks, demand, cap) + # construct cumulative constraint with task objects + chc_cumulative = self.chc_model.cumulative(tasks, self._to_vars(demand), self._to_vars(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)) + if cpm_expr.end_is_none: + start, dur = cpm_expr.args + return self._get_constraint(Cumulative(start, dur, demand=1, capacity=1)) + else: + 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": From 2b7c5c8c1d758520fb0dc4867d5c984b708c418f Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Mon, 23 Mar 2026 16:41:10 +0100 Subject: [PATCH 09/10] fix test --- tests/test_globalconstraints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_globalconstraints.py b/tests/test_globalconstraints.py index dd7aeeca9..06226d51b 100644 --- a/tests/test_globalconstraints.py +++ b/tests/test_globalconstraints.py @@ -966,7 +966,7 @@ def test_cumulative_single_demand(self): def test_cumulative_subexpr(self): start = cp.intvar(0,10, shape=3) cons = cp.Cumulative(start+start, [1,2,3], None, [1,2,3], 3) - self.assertTrue(cp.Model(cons).solve()) + assert cp.Model(cons).solve() is True def test_cumulative_decomposition_capacity(self): import numpy as np From e436cde7a0d637f5df2442f2c0fd197c59fc81df Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Mon, 23 Mar 2026 16:43:22 +0100 Subject: [PATCH 10/10] mypy --- cpmpy/expressions/globalconstraints.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpmpy/expressions/globalconstraints.py b/cpmpy/expressions/globalconstraints.py index 5eab48386..a3cdc6951 100644 --- a/cpmpy/expressions/globalconstraints.py +++ b/cpmpy/expressions/globalconstraints.py @@ -1173,9 +1173,9 @@ def value(self) -> Optional[bool]: # ensure demand doesn't exceed capacity lb, ub = min(start), max(end) - start, end = np.array(start), np.array(end) # eases check below + np_start, np_end = np.array(start), np.array(end) # eases check below for t in range(lb, ub+1): - if capacity < sum(demand * ((start <= t) & (end > t))): + if capacity < sum(demand * ((np_start <= t) & (np_end > t))): return False return True