diff --git a/cpmpy/expressions/core.py b/cpmpy/expressions/core.py index ef5d59554..714bf3c97 100644 --- a/cpmpy/expressions/core.py +++ b/cpmpy/expressions/core.py @@ -116,7 +116,7 @@ class Expression(object): - any ``__op__`` python operator overloading """ - def __init__(self, name: str, arg_list: tuple[Any, ...]): + def __init__(self, name: str, arg_list: tuple[Any, ...], has_subexpr: Optional[bool] = None): """ Constructor of the Expression class @@ -138,10 +138,6 @@ def __init__(self, name: str, arg_list: tuple[Any, ...]): def args(self) -> tuple[Any, ...]: return self._args - @args.setter - def args(self, args: Iterable[Any]) -> None: - raise AttributeError("Cannot modify read-only attribute 'args', use 'update_args()'") - def update_args(self, args: Iterable[Any]) -> None: """ Allows in-place update of the expression's arguments. Resets all cached computations which depend on the expression tree. @@ -226,10 +222,10 @@ def is_bool(self): """ return True - def value(self): - return None # default + def value(self) -> Optional[int]: + return None # default - def get_bounds(self): + def get_bounds(self) -> tuple[int, int]: if self.is_bool(): return 0, 1 #default for boolean expressions raise NotImplementedError(f"`get_bounds` is not implemented for type {self}") diff --git a/cpmpy/expressions/globalconstraints.py b/cpmpy/expressions/globalconstraints.py index 678607f25..71c9f4cf5 100644 --- a/cpmpy/expressions/globalconstraints.py +++ b/cpmpy/expressions/globalconstraints.py @@ -132,7 +132,6 @@ def my_circuit_decomp(self): DirectConstraint """ -import copy import warnings from typing import cast, Literal, Optional, Iterable, Any, TYPE_CHECKING import numpy as np @@ -141,8 +140,8 @@ def my_circuit_decomp(self): from ..exceptions import TypeError from .core import Expression, BoolVal, ExprLike, ListLike -from .variables import cpm_array, intvar, boolvar, _BoolVarImpl -from .utils import all_pairs, is_int, is_bool, STAR, get_bounds, argvals, is_any_list, flatlist, is_num, is_boolexpr, implies +from .variables import cpm_array, intvar, boolvar, _BoolVarImpl, NDVarArray +from .utils import all_pairs, is_int, is_bool, STAR, get_bounds, argvals, is_any_list, flatlist, is_num, is_boolexpr, implies, get_minimax_bounds_listlike, argvals_listlike, clean_bool if TYPE_CHECKING: from cpmpy.solvers.solver_interface import SolverInterface @@ -217,12 +216,32 @@ class AllDifferent(GlobalConstraint): Enforces that all arguments have a different (distinct) value """ - def __init__(self, *args: ExprLike|ListLike[ExprLike]): + def __init__(self, *args: ExprLike | ListLike[ExprLike]): """ Arguments: args (ExprLike|ListLike[ExprLike]): List of expressions or constants to be different from each other """ - super().__init__("alldifferent", tuple(flatlist(args))) + # shortcut + if len(args) == 1 and isinstance(args[0], NDVarArray): + arr = args[0] + super().__init__("alldifferent", tuple(arr.flat), has_subexpr=arr.has_subexpr()) + return + + flat: list[ExprLike] = [] + for a in args: + if isinstance(a, np.ndarray): + flat.extend(a.flat) + elif isinstance(a, (list, tuple)): + flat.extend(a) + else: + flat.append(a) + # args: tuple[ExprLike, ...] + super().__init__("alldifferent", tuple(flat)) + + @property + def args(self) -> tuple[ExprLike, ...]: + """ READ-ONLY, well-tuped arguments of this global constraint """ + return self._args def decompose(self) -> tuple[list[Expression], list[Expression]]: """ @@ -240,8 +259,7 @@ def decompose_linear(self) -> tuple[list[Expression], list[Expression]]: For use with integer linear programming and pb/sat solvers. """ - lbs, ubs = get_bounds(self.args) - lb, ub = min(lbs), max(ubs) + lb, ub = get_minimax_bounds_listlike(self.args) return [cp.sum((arg_i == val) for arg_i in self.args) <= 1 for val in range(lb, ub + 1)], [] def value(self) -> Optional[bool]: @@ -249,10 +267,10 @@ 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 """ - vals = argvals(self.args) - if any(v is None for v in vals): + vals = argvals_listlike(self.args) + if vals is None: return None - return len(set(vals)) == len(self.args) + return len(set(vals)) == len(vals) class AllDifferentExceptN(GlobalConstraint): @@ -260,7 +278,7 @@ class AllDifferentExceptN(GlobalConstraint): Enforces that all arguments, except those equal to a value in n, have a different (distinct) value. Arguments: - arr (Sequence[Expression]): List of expressions to be different from each other, except those equal to a value in n + arr (ListLike[ExprLike]): List of expressions to be different from each other, except those equal to a value in n n (int or list[int]): Value or list of values that are excluded from satisfying the alldifferent condition """ @@ -314,7 +332,20 @@ def __init__(self, *args: ExprLike | ListLike[ExprLike]): Arguments: args (ListLike[ExprLike]): List of expressions or constants to be different from each other, except those equal to 0 """ - super().__init__(flatlist(args), 0) + # shortcut + if len(args) == 1 and isinstance(args[0], NDVarArray): + super().__init__(args[0], 0) + return + + flat: list[ExprLike] = [] + for a in args: + if isinstance(a, np.ndarray): + flat.extend(a.flat) + elif isinstance(a, (list, tuple)): + flat.extend(a) + else: + flat.append(a) + super().__init__(flat, 0) def allequal(args): @@ -336,7 +367,27 @@ def __init__(self, *args: ExprLike | ListLike[ExprLike]): Arguments: args (ListLike[ExprLike]): List of expressions or constants to have the same value """ - super().__init__("allequal", tuple(flatlist(args))) + # shortcut + if len(args) == 1 and isinstance(args[0], NDVarArray): + arr = args[0] + super().__init__("allequal", tuple(arr.flat), has_subexpr=arr.has_subexpr()) + return + + flat: list[ExprLike] = [] + for a in args: + if isinstance(a, np.ndarray): + flat.extend(a.flat) + elif isinstance(a, (list, tuple)): + flat.extend(a) + else: + flat.append(a) + # args: tuple[ExprLike, ...] + super().__init__("allequal", tuple(flat)) + + @property + def args(self) -> tuple[ExprLike, ...]: + """ READ-ONLY, well-tuped arguments of this global constraint """ + return self._args def decompose(self) -> tuple[list[Expression], list[Expression]]: """ @@ -346,15 +397,16 @@ def decompose(self) -> tuple[list[Expression], list[Expression]]: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ # arg0 == arg1, arg1 == arg2, arg2 == arg3... no need to post n^2 equalities - return [x == y for x, y in zip(self.args[:-1], self.args[1:])], [] + decomp = clean_bool([x == y for x, y in zip(self.args[:-1], self.args[1:])]) + return decomp, [] 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 """ - vals = argvals(self.args) - if any(v is None for v in vals): + vals = argvals_listlike(self.args) + if vals is None: return None return len(set(vals)) == 1 @@ -422,10 +474,31 @@ def __init__(self, *args: ExprLike | ListLike[ExprLike]): Arguments: args (ListLike[ExprLike]): List of expressions or constants representing the successors of the nodes to form the circuit """ - flatargs = flatlist(args) - if len(flatargs) < 2: + has_subexpr = None + # shortcut + if len(args) == 1 and isinstance(args[0], NDVarArray): + arr = args[0] + newargs = tuple(arr.flat) + has_subexpr = arr.has_subexpr() + else: + flatargs: list[ExprLike] = [] + for a in args: + if isinstance(a, np.ndarray): + flatargs.extend(a.flat) + elif isinstance(a, (list, tuple)): + flatargs.extend(a) + else: + flatargs.append(a) + newargs = tuple(flatargs) + if len(newargs) < 2: raise ValueError('Circuit constraint must be given a minimum of 2 variables') - super().__init__("circuit", tuple(flatargs)) + # args: tuple[ExprLike, ...] + super().__init__("circuit", newargs, has_subexpr=has_subexpr) + + @property + def args(self) -> tuple[ExprLike, ...]: + """ READ-ONLY, well-tuped arguments of this global constraint """ + return self._args def decompose(self) -> tuple[list[Expression], list[Expression]]: """ @@ -438,7 +511,13 @@ def decompose(self) -> tuple[list[Expression], list[Expression]]: # construct the chain of neighbors succ = cp.cpm_array(self.args) - order = [succ[0]] + tmp: ExprLike = succ[0] + if isinstance(tmp, Expression): + succ_0: Expression = tmp + else: + # type incompatible side-case, succ[0] should not be a constant + succ_0 = intvar(int(tmp), int(tmp)) + order: list[Expression] = [succ_0] for i in range(1, len(succ)): order.append(succ[order[i - 1]]) @@ -1465,7 +1544,7 @@ def decompose(self) -> tuple[list[Expression], list[Expression]]: Decomposition of the NoOverlap constraint, using pairwise no-overlap constraints. Returns: - tuple[Sequence[Expression], Sequence[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints + tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ start, dur, end, is_present = self.args cons = [implies(p, d >= 0) for d, p in zip(dur, is_present)] @@ -1632,7 +1711,27 @@ def __init__(self, *args: ExprLike | ListLike[ExprLike]): Arguments: args (ListLike[ExprLike]): List of expressions or constants to be assigned to increasing values """ - super().__init__("increasing", tuple(flatlist(args))) + # shortcut + if len(args) == 1 and isinstance(args[0], NDVarArray): + arr = args[0] + super().__init__("increasing", tuple(arr.flat), has_subexpr=arr.has_subexpr()) + return + + flat: list[ExprLike] = [] + for a in args: + if isinstance(a, np.ndarray): + flat.extend(a.flat) + elif isinstance(a, (list, tuple)): + flat.extend(a) + else: + flat.append(a) + # args: tuple[ExprLike, ...] + super().__init__("increasing", tuple(flat)) + + @property + def args(self) -> tuple[ExprLike, ...]: + """ READ-ONLY, well-tuped arguments of this global constraint """ + return self._args def decompose(self) -> tuple[list[Expression], list[Expression]]: """ @@ -1642,15 +1741,15 @@ def decompose(self) -> tuple[list[Expression], list[Expression]]: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ args = self.args - return [args[i] <= args[i+1] for i in range(len(args)-1)], [] + return clean_bool([args[i] <= args[i+1] for i in range(len(args)-1)]), [] 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 """ - args = argvals(self.args) - if any(x is None for x in args): + args = argvals_listlike(self.args) + if args is None: return None return all(args[i] <= args[i+1] for i in range(len(args)-1)) @@ -1665,7 +1764,27 @@ def __init__(self, *args: ExprLike | ListLike[ExprLike]): Arguments: args (ListLike[ExprLike]): List of expressions or constants to be assigned to decreasing values """ - super().__init__("decreasing", tuple(flatlist(args))) + # shortcut + if len(args) == 1 and isinstance(args[0], NDVarArray): + arr = args[0] + super().__init__("decreasing", tuple(arr.flat), has_subexpr=arr.has_subexpr()) + return + + flat: list[ExprLike] = [] + for a in args: + if isinstance(a, np.ndarray): + flat.extend(a.flat) + elif isinstance(a, (list, tuple)): + flat.extend(a) + else: + flat.append(a) + # args: tuple[ExprLike, ...] + super().__init__("decreasing", tuple(flat)) + + @property + def args(self) -> tuple[ExprLike, ...]: + """ READ-ONLY, well-tuped arguments of this global constraint """ + return self._args def decompose(self) -> tuple[list[Expression], list[Expression]]: """ @@ -1675,15 +1794,15 @@ def decompose(self) -> tuple[list[Expression], list[Expression]]: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ args = self.args - return [args[i] >= args[i+1] for i in range(len(args)-1)], [] + return clean_bool([args[i] >= args[i+1] for i in range(len(args)-1)]), [] 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 """ - args = argvals(self.args) - if any(x is None for x in args): + args = argvals_listlike(self.args) + if args is None: return None return all(args[i] >= args[i+1] for i in range(len(args)-1)) @@ -1698,7 +1817,27 @@ def __init__(self, *args: ExprLike | ListLike[ExprLike]): Arguments: args (ListLike[ExprLike]): List of expressions or constants to be assigned to strictly increasing values """ - super().__init__("strictly_increasing", tuple(flatlist(args))) + # shortcut + if len(args) == 1 and isinstance(args[0], NDVarArray): + arr = args[0] + super().__init__("strictly_increasing", tuple(arr.flat), has_subexpr=arr.has_subexpr()) + return + + flat: list[ExprLike] = [] + for a in args: + if isinstance(a, np.ndarray): + flat.extend(a.flat) + elif isinstance(a, (list, tuple)): + flat.extend(a) + else: + flat.append(a) + # args: tuple[ExprLike, ...] + super().__init__("strictly_increasing", tuple(flat)) + + @property + def args(self) -> tuple[ExprLike, ...]: + """ READ-ONLY, well-tuped arguments of this global constraint """ + return self._args def decompose(self) -> tuple[list[Expression], list[Expression]]: """ @@ -1708,15 +1847,15 @@ def decompose(self) -> tuple[list[Expression], list[Expression]]: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ args = self.args - return [args[i] < args[i+1] for i in range(len(args)-1)], [] + return clean_bool([args[i] < args[i+1] for i in range(len(args)-1)]), [] 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 """ - args = argvals(self.args) - if any(x is None for x in args): + args = argvals_listlike(self.args) + if args is None: return None args = argvals(self.args) return all(args[i] < args[i+1] for i in range(len(args)-1)) @@ -1732,7 +1871,27 @@ def __init__(self, *args: ExprLike | ListLike[ExprLike]): Arguments: args (ListLike[ExprLike]): List of expressions or constants to be assigned to strictly decreasing values """ - super().__init__("strictly_decreasing", tuple(flatlist(args))) + # shortcut + if len(args) == 1 and isinstance(args[0], NDVarArray): + arr = args[0] + super().__init__("strictly_decreasing", tuple(arr.flat), has_subexpr=arr.has_subexpr()) + return + + flat: list[ExprLike] = [] + for a in args: + if isinstance(a, np.ndarray): + flat.extend(a.flat) + elif isinstance(a, (list, tuple)): + flat.extend(a) + else: + flat.append(a) + # args: tuple[ExprLike, ...] + super().__init__("strictly_decreasing", tuple(flat)) + + @property + def args(self) -> tuple[ExprLike, ...]: + """ READ-ONLY, well-tuped arguments of this global constraint """ + return self._args def decompose(self) -> tuple[list[Expression], list[Expression]]: """ @@ -1742,17 +1901,16 @@ def decompose(self) -> tuple[list[Expression], list[Expression]]: tuple[list[Expression], list[Expression]]: A tuple containing the constraints representing the constraint value and the defining constraints """ args = self.args - return [(args[i] > args[i+1]) for i in range(len(args)-1)], [] + return clean_bool([args[i] > args[i+1] for i in range(len(args)-1)]), [] 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 """ - args = argvals(self.args) - if any(x is None for x in args): + args = argvals_listlike(self.args) + if args is None: return None - args = argvals(self.args) return all(args[i] > args[i+1] for i in range(len(args)-1)) diff --git a/cpmpy/expressions/utils.py b/cpmpy/expressions/utils.py index 6a4890a1b..3df467968 100644 --- a/cpmpy/expressions/utils.py +++ b/cpmpy/expressions/utils.py @@ -35,12 +35,12 @@ import math from collections.abc import Iterable # for flatten from itertools import combinations -from typing import TYPE_CHECKING, TypeGuard, Union, Optional +from typing import TYPE_CHECKING, TypeGuard, Optional, cast from cpmpy.exceptions import IncompleteFunctionError if TYPE_CHECKING: # only import for type checking - from cpmpy.expressions.core import ListLike, ExprLike + from cpmpy.expressions.core import Expression, ListLike, ExprLike def is_bool(arg): @@ -153,6 +153,23 @@ def argvals(arr): return argval(arr) +def argvals_listlike(lst: ListLike[ExprLike]) -> Optional[list[int]]: + """ The well-typed way to get the values of a list of ExprLike's, or None if any expression is not assigned """ + _Expr = cp.expressions.core.Expression + + vals: list[int] = [] + for e in lst: + if isinstance(e, _Expr): + v = e.value() + if v is None: + return None + vals.append(v) + elif isinstance(e, int): + vals.append(e) + else: # only np.integer is still in ExprLike + vals.append(int(e)) + return vals + def eval_comparison(str_op, lhs, rhs): """ Internal function: evaluates the textual `str_op` comparison operator @@ -187,6 +204,46 @@ def eval_comparison(str_op, lhs, rhs): return lhs <= rhs else: raise Exception("Not a known comparison:", str_op) + +def get_bounds_listlike(lst: ListLike[ExprLike]) -> tuple[list[int], list[int]]: + """ The well-typed way to get the bounds of a list of ExprLike's """ + _Expr = cp.expressions.core.Expression + + lbs: list[int] = [] + ubs: list[int] = [] + for e in lst: + if isinstance(e, _Expr): + (lb, ub) = e.get_bounds() + lbs.append(lb) + ubs.append(ub) + elif isinstance(e, int): + lbs.append(e) + ubs.append(e) + else: # only np.integer is still in ExprLike + lbs.append(int(e)) + ubs.append(int(e)) + return lbs, ubs + +def get_minimax_bounds_listlike(lst: ListLike[ExprLike]) -> tuple[int, int]: + """ The well-typed way to get the minimax bounds of a list of ExprLike's """ + _Expr = cp.expressions.core.Expression + + glb: Optional[int] = None + gub: Optional[int] = None + for e in lst: + if isinstance(e, _Expr): + (lb, ub) = e.get_bounds() + glb = min(glb, lb) if glb is not None else lb + gub = max(gub, ub) if gub is not None else ub + elif isinstance(e, int): + glb = min(glb, e) if glb is not None else e + gub = max(gub, e) if gub is not None else e + else: # only np.integer is still in ExprLike + glb = min(glb, int(e)) if glb is not None else int(e) + gub = max(gub, int(e)) if gub is not None else int(e) + + assert glb is not None and gub is not None, f"No bounds found, empty list? `{lst}`" + return glb, gub def get_bounds(expr): """ return the bounds of the expression @@ -255,3 +312,18 @@ def is_star(arg): Check if arg is star as used in the ShortTable global constraint """ return isinstance(arg, type(STAR)) and arg == STAR + +def clean_bool(exprs: list[ExprLike]) -> list[Expression]: + """ Clean up a list of Boolean expressions representing a conjunction of Booleans + + It only cleans up Python 'bool' instances + it returns [BoolVal(False)] if it contains a 'False' + """ + new_exprs: list[Expression] = [] + for e in exprs: + if isinstance(e, bool): + if e is False: + return [cp.BoolVal(False)] + else: + new_exprs.append(cast(Expression, e)) # we silently assume its Expression + return new_exprs \ No newline at end of file