diff --git a/cpmpy/expressions/__init__.py b/cpmpy/expressions/__init__.py index bb6b5952e..b053e1034 100644 --- a/cpmpy/expressions/__init__.py +++ b/cpmpy/expressions/__init__.py @@ -66,6 +66,7 @@ Among, NValue, NValueExcept, + FloatSum, ) from .core import BoolVal from .python_builtins import all, any, max, min, sum, abs @@ -92,6 +93,8 @@ "Among", "NValue", "NValueExcept", +# Objective-only (not an Expression) + "FloatSum", # Global constraints "AllDifferent", "AllDifferentExcept0", diff --git a/cpmpy/expressions/core.py b/cpmpy/expressions/core.py index ba5f36c67..245941316 100644 --- a/cpmpy/expressions/core.py +++ b/cpmpy/expressions/core.py @@ -98,7 +98,7 @@ import numpy as np import cpmpy as cp -from .utils import is_num, is_any_list, flatlist, get_bounds, is_boolexpr, is_true_cst, is_false_cst, argvals, is_bool +from .utils import is_num, is_int, is_any_list, get_bounds, is_boolexpr, is_true_cst, is_false_cst, argvals, is_bool from ..exceptions import TypeError # Common typing helpers @@ -666,6 +666,20 @@ def __init__(self, name: str, arg_list: Sequence[ExprLike | ListLike[ExprLike]]) arg_list (Sequence[ExprLike | ListLike[ExprLike]]): List of expressions/constants, or list of size 2 with list of weights and list of expressions for wsum. """ + if name == "wsum": # args = [ListLike[int|np.integer], ListLike[ExprLike]] + assert len(arg_list) == 2, f"Operator: wsum expects [weights, expressions] as arguments, got {arg_list}" + ws, vs = arg_list + if isinstance(ws, np.ndarray): + ws = ws.astype(int).reshape(-1).tolist() + else: + assert is_any_list(ws), f"Operator: wsum weights must be a sequence, got {type(ws)}: {ws}" + if not all(type(w) is int for w in ws): + ws = [int(w) for w in ws] + if isinstance(vs, np.ndarray) and vs.ndim != 1: + vs = vs.reshape(-1) + super().__init__(name, (ws, vs)) + return + # sanity checks assert (name in Operator.allowed), "Operator {} not allowed".format(name) assert is_any_list(arg_list), f"Operator: arg_list must be a list of expressions or constants, got {arg_list}" @@ -676,7 +690,8 @@ def __init__(self, name: str, arg_list: Sequence[ExprLike | ListLike[ExprLike]]) if not is_boolexpr(arg): raise TypeError("{}-operator only accepts boolean arguments, not {}".format(name,arg)) if arity == 0: - arg_list = flatlist(arg_list) + if isinstance(arg_list, np.ndarray) and arg_list.ndim != 1: + arg_list = arg_list.reshape(-1).tolist() assert (len(arg_list) >= 1), "Operator: n-ary operators require at least one argument" else: assert (len(arg_list) == arity), "Operator: {}, number of arguments must be {}".format(name, arity) @@ -686,26 +701,20 @@ def __init__(self, name: str, arg_list: Sequence[ExprLike | ListLike[ExprLike]]) # and one of the args is a wsum, # or a product of a constant and an expression, # then create a wsum of weights,expressions over all - if name == 'sum' and \ - all(not is_num(a) for a in arg_list) and \ - any(_wsum_should(a) for a in arg_list): - we = [_wsum_make(a) for a in arg_list] - w: list[ExprLike] = [wi for w, _ in we for wi in w] - e: list[ExprLike] = [ei for _, e in we for ei in e] - name = 'wsum' - arg_list = (w, e) - - # we have the requirement that weighted sums are [weights, expressions] - if name == 'wsum': - assert isinstance(arg_list[0], (list, tuple, np.ndarray)), "wsum: arg0 has to be a list-like" - assert all(is_num(a) for a in arg_list[0]), "wsum: arg0 has to be all constants but is: "+str(arg_list[0]) - weights: list[ExprLike] = [] - for a in arg_list[0]: - if isinstance(a, (bool, int, np.integer, np.bool_, BoolVal)): - weights.append(int(a)) # bool or int, simplifies things later on - else: - weights.append(a) # can be float - arg_list = (weights, arg_list[1]) + if name == 'sum': + saw_wsum, all_expr = False, True + for a in arg_list: + if _wsum_should(a): + saw_wsum = True + if is_int(a): + all_expr = False + break + if saw_wsum and all_expr: + wvs = [_wsum_make(a) for a in arg_list] + ws2: list[ExprLike] = [w for w_list, _ in wvs for w in w_list] + vs2: list[ExprLike] = [v for _, v_list in wvs for v in v_list] + super().__init__("wsum", (ws2, vs2)) + return # small cleanup: nested n-ary operators are merged into the toplevel # (this is actually against our design principle of creating diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index 567cca85f..6c7360626 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -51,6 +51,13 @@ def __init__(self, args): def decompose(self): return (self.args[0] + self.args[1]), [] # the decomposition + Objective-only :class:`FloatSum` + ------------------------------- + + :class:`FloatSum` is **not** an Expression nor a GlobalFunction. + It is only supported by some solvers (esp MIP solvers and ortools), and need to be passed direclty to + :meth:`~cpmpy.model.Model.minimize` / :meth:`~cpmpy.model.Model.maximize`. + =============== List of classes =============== @@ -70,17 +77,18 @@ def decompose(self): Among NValue NValueExcept + FloatSum """ import warnings # for deprecation warning -from typing import Optional, Iterable +from typing import Optional, Iterable, NoReturn, Final, cast import numpy as np import cpmpy as cp from ..exceptions import CPMpyException, IncompleteFunctionError, TypeError from .core import Expression, Operator, ExprLike, ListLike -from .variables import intvar, NDVarArray, _NumVarImpl, BoolVal -from .utils import argval, is_num, eval_comparison, is_any_list, is_boolexpr, get_bounds, argvals, implies, argvals_intexpr, get_bounds_intexpr, npint2int +from .variables import intvar, cpm_array, NDVarArray, _NumVarImpl, NegBoolView +from .utils import argval, is_num, is_int, eval_comparison, is_any_list, is_boolexpr, get_bounds, argvals, implies, argvals_intexpr, get_bounds_intexpr, npint2int class GlobalFunction(Expression): @@ -356,8 +364,18 @@ def __init__(self, x: ExprLike, y: ExprLike): """ is_lhs_num = False if is_num(x): + if not is_int(x): + warnings.warn("Mul: float constants are deprecated and converted to int. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) + if type(x) is not int: + assert not isinstance(x, Expression) + x = int(x) is_lhs_num = True elif is_num(y): + if not is_int(y): + warnings.warn("Mul: float constants are deprecated and converted to int. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) + if type(y) is not int: + assert not isinstance(y, Expression) + y = int(y) (x, y) = (y, x) is_lhs_num = True @@ -371,8 +389,16 @@ def update_args(self, args): x, y = args is_lhs_num = False if is_num(x): + if not is_int(x): + warnings.warn("Mul: float constants are deprecated and converted to int. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) + if type(x) is not int: + x = int(x) is_lhs_num = True elif is_num(y): + if not is_int(y): + warnings.warn("Mul: float constants are deprecated and converted to int. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) + if type(y) is not int: + y = int(y) (x, y) = (y, x) is_lhs_num = True @@ -853,6 +879,8 @@ def __init__(self, arr: ListLike[ExprLike], val: ExprLike): raise TypeError(f"Count(arr, val) takes an array of expressions as first argument, not: {arr}") if is_any_list(val): raise TypeError(f"Count(arr, val) takes a numeric expression as second argument, not a list: {val}") + if isinstance(arr, np.ndarray) and arr.ndim != 1: + arr = arr.reshape(-1) super().__init__("count", (arr, val)) def decompose(self) -> tuple[Expression, list[Expression]]: @@ -914,6 +942,8 @@ def __init__(self, arr: ListLike[ExprLike], vals: ListLike[int|np.integer]): raise TypeError(f"Among takes as input two arrays, not: {arr} and {vals}") if any(isinstance(val, Expression) for val in vals): raise TypeError(f"Among takes a set of integer values as input, not {vals}") + if isinstance(arr, np.ndarray) and arr.ndim != 1: + arr = arr.reshape(-1) super().__init__("among", (arr, vals)) def decompose(self) -> tuple[Expression, list[Expression]]: @@ -967,6 +997,8 @@ def __init__(self, arr: ListLike[ExprLike]): """ if not is_any_list(arr): raise ValueError(f"NValue(arr) takes an array as input, not: {arr}") + if isinstance(arr, np.ndarray) and arr.ndim != 1: + arr = arr.reshape(-1) super().__init__("nvalue", tuple(arr)) def decompose(self) -> tuple[Expression, list[Expression]]: @@ -1032,6 +1064,8 @@ def __init__(self, arr: ListLike[ExprLike], n: int|np.integer): raise ValueError("NValueExcept takes an array as input") if not is_num(n): raise ValueError(f"NValueExcept takes an integer as second argument, but got {n} of type {type(n)}") + if isinstance(arr, np.ndarray) and arr.ndim != 1: + arr = arr.reshape(-1) super().__init__("nvalue_except", (arr, n)) def decompose(self) -> tuple[Expression, list[Expression]]: @@ -1076,3 +1110,90 @@ def get_bounds(self) -> tuple[int, int]: """ arr, n = self.args return 0, len(arr) + + +class FloatSum: + """ + Objective-only weighted sum with float coefficients over decision variables. + + Does not inherit from Expression because it is objective only and has float .value() + Basically it is breaking all design rules of CPMpy... + + It can only appear as the argument to :meth:`~cpmpy.model.Model.minimize` / :meth:`~cpmpy.model.Model.maximize`. + + Accepts only (numpy) floats as coefficients, decision variables (including NegBoolView) as terms, + and an optional float constant term (default ``0.0``). + """ + name: Final = "floatsum" + coeffs: np.ndarray + vars: NDVarArray + const: float + + def __init__(self, coeffs: ListLike[float|np.floating], vars: ListLike[_NumVarImpl], const: float|np.floating = 0.0): + self.coeffs = np.asarray(coeffs, dtype=float).reshape(-1) + self.const = float(const) + if isinstance(vars, NDVarArray): + self.vars = vars + else: + self.vars = cpm_array(vars) + if self.vars.ndim > 1: # must reshape to 1D + flat = self.vars.reshape(-1) + # typing is wrong: numpy preserves our ndarray subclass + self.vars = cast(NDVarArray, flat) + + if self.coeffs.size != self.vars.size: + raise TypeError(f"FloatSum(coeffs, terms) expects equal lengths, got {self.coeffs.size} coefficients and {self.vars.size} terms") + if self.coeffs.size == 0: + raise TypeError("FloatSum(coeffs, terms) expects at least one term") + + def __repr__(self) -> str: + if self.const: + return f"FloatSum({list(self.coeffs)}, {list(self.vars)}, constant={self.const})" + return f"FloatSum({list(self.coeffs)}, {list(self.vars)})" + + def value(self) -> Optional[float]: + vals = argvals_intexpr(self.vars) + if vals is None: + return None + return float(np.dot(self.coeffs, vals) + self.const) + + def components(self, negbool=False) -> tuple[np.ndarray, NDVarArray, float]: + """ + Return ``(coeffs, vars, const)`` + + if `negbool` is False (default), we will eliminate all :class:`~cpmpy.expressions.variables.NegBoolView` + ``w * ~bv`` becomes ``w - w * bv`` (coeff ``-w`` on ``bv._bv``, constant ``+w``). + """ + if negbool or not any(isinstance(v, NegBoolView) for v in self.vars): + return self.coeffs, self.vars, self.const + else: + ws: list[float] = [] + vs: list[_NumVarImpl] = [] + const = self.const + for w, v in zip(self.coeffs, self.vars): + if isinstance(v, NegBoolView): + ws.append(-w) + vs.append(v._bv) + const += w + else: + ws.append(w) + vs.append(v) + return np.asarray(ws, dtype=float), cpm_array(vs), const + + def _raise_objective_only(self) -> NoReturn: + raise TypeError("FloatSum is objective-only. Use it directly in Model.minimize()/maximize().") + + def __eq__(self, other: object) -> NoReturn: self._raise_objective_only() + def __ne__(self, other: object) -> NoReturn: self._raise_objective_only() + def __lt__(self, other: object) -> NoReturn: self._raise_objective_only() + def __le__(self, other: object) -> NoReturn: self._raise_objective_only() + def __gt__(self, other: object) -> NoReturn: self._raise_objective_only() + def __ge__(self, other: object) -> NoReturn: self._raise_objective_only() + def __add__(self, other: object) -> NoReturn: self._raise_objective_only() + def __radd__(self, other: object) -> NoReturn: self._raise_objective_only() + def __sub__(self, other: object) -> NoReturn: self._raise_objective_only() + def __rsub__(self, other: object) -> NoReturn: self._raise_objective_only() + def __mul__(self, other: object) -> NoReturn: self._raise_objective_only() + def __rmul__(self, other: object) -> NoReturn: self._raise_objective_only() + def __neg__(self) -> NoReturn: self._raise_objective_only() + def __abs__(self) -> NoReturn: self._raise_objective_only() diff --git a/cpmpy/solvers/cplex.py b/cpmpy/solvers/cplex.py index fc4baca46..d7627c79d 100644 --- a/cpmpy/solvers/cplex.py +++ b/cpmpy/solvers/cplex.py @@ -54,14 +54,15 @@ from typing import Optional, List from .solver_interface import SolverInterface, SolverStatus, ExitStatus, Callback -from ..expressions.core import Expression, Comparison, Operator, BoolVal -from ..expressions.utils import argvals, argval, eval_comparison, flatlist, is_any_list, is_bool, is_num, is_int -from ..expressions.variables import _BoolVarImpl, NegBoolView, _IntVarImpl, _NumVarImpl, intvar +from ..expressions.core import Comparison, Operator, BoolVal +from ..expressions.globalfunctions import FloatSum +from ..expressions.utils import eval_comparison, flatlist, is_bool, is_num, is_int +from ..expressions.variables import _BoolVarImpl, NegBoolView, _NumVarImpl, intvar from ..expressions.globalconstraints import DirectConstraint from ..transformations.comparison import only_numexpr_equality from ..transformations.flatten_model import flatten_constraint, flatten_objective from ..transformations.get_variables import get_variables -from ..transformations.linearize import linearize_constraint, linearize_reified_variables, only_positive_bv, only_positive_bv_wsum, \ +from ..transformations.linearize import linearize_constraint, linearize_reified_variables, only_positive_bv, \ only_positive_bv_wsum_const, decompose_linear, decompose_linear_objective from ..transformations.normalize import toplevel_list from ..transformations.reification import only_implies, reify_rewrite, only_bv_reifies @@ -304,22 +305,28 @@ def objective(self, expr, minimize=True): technical side note: any constraints created during conversion of the objective are premanently posted to the solver """ - # save user vars - get_variables(expr, self.user_vars) - - # transform objective - obj, safe_cons = safen_objective(expr) - obj, decomp_cons = decompose_linear_objective(obj, - supported=self.supported_global_constraints, - supported_reified=self.supported_reified_global_constraints, - csemap=self._csemap) - obj, flat_cons = flatten_objective(obj, csemap=self._csemap) - obj, self._obj_offset = only_positive_bv_wsum_const(obj) # remove negboolviews - - self.add(safe_cons + decomp_cons + flat_cons) - - # make objective function or variable and post - cplex_obj = self._make_numexpr(obj) + if isinstance(expr, FloatSum): + ws, vs, const = expr.components() + self.user_vars.update(vs) # save user variables + self._obj_offset = const + cplex_obj = self.cplex_model.scal_prod(self.solver_vars(vs), ws) + else: + # save user vars + get_variables(expr, self.user_vars) + + # transform objective + obj, safe_cons = safen_objective(expr) + obj, decomp_cons = decompose_linear_objective(obj, + supported=self.supported_global_constraints, + supported_reified=self.supported_reified_global_constraints, + csemap=self._csemap) + obj, flat_cons = flatten_objective(obj, csemap=self._csemap) + obj, self._obj_offset = only_positive_bv_wsum_const(obj) # remove negboolviews + + self.add(safe_cons + decomp_cons + flat_cons) + + # make objective function or variable and post + cplex_obj = self._make_numexpr(obj) if minimize: self.cplex_model.set_objective('min', cplex_obj) else: diff --git a/cpmpy/solvers/gurobi.py b/cpmpy/solvers/gurobi.py index 07f734530..18541ffd9 100644 --- a/cpmpy/solvers/gurobi.py +++ b/cpmpy/solvers/gurobi.py @@ -49,6 +49,7 @@ from .solver_interface import SolverInterface, SolverStatus, ExitStatus, Callback from ..exceptions import NotSupportedError from ..expressions.core import Expression, Comparison, Operator, BoolVal +from ..expressions.globalfunctions import FloatSum from ..expressions.utils import argvals, argval, is_any_list, is_num, is_int from ..expressions.variables import _BoolVarImpl, NegBoolView, _IntVarImpl, _NumVarImpl, intvar from ..expressions.globalconstraints import DirectConstraint @@ -291,22 +292,30 @@ def objective(self, expr, minimize=True): """ from gurobipy import GRB - # save user variables - get_variables(expr, self.user_vars) + if isinstance(expr, FloatSum): + ws, vs, const = expr.components() + self.user_vars.update(vs) # save user variables - # transform objective - obj, safe_cons = safen_objective(expr) - obj, decomp_cons = decompose_linear_objective(obj, - supported=self.supported_global_constraints, - supported_reified=self.supported_reified_global_constraints, - csemap=self._csemap) - obj, flat_cons = flatten_objective(obj, csemap=self._csemap) - obj = only_positive_bv_wsum(obj) # remove negboolviews + import gurobipy as gp + grb_obj = gp.quicksum(w * sv for w, sv in zip(ws, self.solver_vars(vs))) + const + else: + # save user variables + get_variables(expr, self.user_vars) + + # transform objective + obj, safe_cons = safen_objective(expr) + obj, decomp_cons = decompose_linear_objective(obj, + supported=self.supported_global_constraints, + supported_reified=self.supported_reified_global_constraints, + csemap=self._csemap) + obj, flat_cons = flatten_objective(obj, csemap=self._csemap) + obj = only_positive_bv_wsum(obj) # remove negboolviews + + self.add(safe_cons + decomp_cons + flat_cons) - self.add(safe_cons + decomp_cons + flat_cons) + # make objective function or variable and post + grb_obj = self._make_numexpr(obj) - # make objective function or variable and post - grb_obj = self._make_numexpr(obj) if minimize: self.grb_model.setObjective(grb_obj, sense=GRB.MINIMIZE) else: diff --git a/cpmpy/solvers/hexaly.py b/cpmpy/solvers/hexaly.py index 55fda9c67..033589fc3 100644 --- a/cpmpy/solvers/hexaly.py +++ b/cpmpy/solvers/hexaly.py @@ -45,9 +45,9 @@ from .solver_interface import SolverInterface, SolverStatus, ExitStatus, Callback from ..expressions.core import Expression, Comparison, Operator, BoolVal from ..expressions.globalconstraints import GlobalConstraint, DirectConstraint -from ..expressions.globalfunctions import GlobalFunction +from ..expressions.globalfunctions import GlobalFunction, FloatSum from ..expressions.variables import _BoolVarImpl, NegBoolView, _IntVarImpl, _NumVarImpl -from ..expressions.utils import argval, argvals, is_num, is_int, is_any_list, eval_comparison, flatlist +from ..expressions.utils import argval, argvals, is_num, is_any_list, eval_comparison, flatlist from ..transformations.get_variables import get_variables from ..transformations.normalize import toplevel_list from ..transformations.decompose_global import decompose_in_tree, decompose_objective @@ -289,21 +289,25 @@ def objective(self, expr, minimize=True): """ from hexaly.optimizer import HxObjectiveDirection - # save user vars - get_variables(expr, collect=self.user_vars) - - # transform objective - obj, decomp_cons = decompose_objective(expr, - supported=self.supported_global_constraints, - supported_reified=self.supported_reified_global_constraints, - csemap=self._csemap) - self.add(decomp_cons) + if isinstance(expr, FloatSum): + ws, vs, const = expr.components() + self.user_vars.update(vs) # save user variables + hex_obj = self.hex_model.sum(float(c) * self._hex_expr(t) for c, t in zip(ws, vs)) + const + else: + get_variables(expr, collect=self.user_vars) + obj, decomp_cons = decompose_objective( + expr, + supported=self.supported_global_constraints, + supported_reified=self.supported_reified_global_constraints, + csemap=self._csemap, + ) + self.add(decomp_cons) + hex_obj = self._hex_expr(obj) # make objective function or variable and post while self.has_objective(): # remove prev objective(s) self.hex_model.remove_objective(0) self.is_satisfaction = False - hex_obj = self._hex_expr(obj) if minimize: self.hex_model.add_objective(hex_obj,HxObjectiveDirection.MINIMIZE) else: @@ -585,4 +589,4 @@ def __call__(self, optimizer, cb_type): - \ No newline at end of file + diff --git a/cpmpy/solvers/highs.py b/cpmpy/solvers/highs.py index 989162d06..5b31f7ff4 100644 --- a/cpmpy/solvers/highs.py +++ b/cpmpy/solvers/highs.py @@ -39,7 +39,6 @@ from typing import Optional -import time import warnings import numpy as np @@ -49,7 +48,8 @@ from ..exceptions import NotSupportedError from ..expressions.core import BoolVal, Comparison, Operator from ..expressions.utils import is_num, is_int -from ..expressions.variables import _BoolVarImpl, NegBoolView, _IntVarImpl, _NumVarImpl, intvar +from ..expressions.variables import NegBoolView, _NumVarImpl, intvar +from ..expressions.globalfunctions import FloatSum from ..expressions.globalconstraints import DirectConstraint from ..transformations.comparison import only_numexpr_equality from ..transformations.flatten_model import flatten_constraint, flatten_objective @@ -285,24 +285,36 @@ def objective(self, expr, minimize=True): """ import highspy - get_variables(expr, collect=self.user_vars) - - obj, safe_cons = safen_objective(expr) - obj, decomp_cons = decompose_linear_objective( - obj, - supported=self.supported_global_constraints, - supported_reified=self.supported_reified_global_constraints, - csemap=self._csemap, - ) - obj, flat_cons = flatten_objective(obj, csemap=self._csemap) - # only_positive_bv_wsum_const keeps the constant separate so it never ends up - # as a numeric element in the wsum vars list (which _row_from_linexpr cannot handle) - obj, obj_const = only_positive_bv_wsum_const(obj) - - self.add(safe_cons + decomp_cons + flat_cons) - - indices, values, const = self._row_from_linexpr(obj) - const += obj_const + if isinstance(expr, FloatSum): + ws, vs, const = expr.components() + self.user_vars.update(vs) + indices = np.array([self.solver_var(v) for v in vs.flat], dtype=np.int32) + values = np.asarray(ws, dtype=np.float64) + if np.unique(indices).size != indices.size: + # group duplicates + new_indices, group = np.unique(indices, return_inverse=True) + new_values = np.bincount(group, weights=values).astype(np.float64) + sel = (new_values != 0) + indices, values = new_indices[sel], new_values[sel] + else: + get_variables(expr, collect=self.user_vars) + + obj, safe_cons = safen_objective(expr) + obj, decomp_cons = decompose_linear_objective( + obj, + supported=self.supported_global_constraints, + supported_reified=self.supported_reified_global_constraints, + csemap=self._csemap, + ) + obj, flat_cons = flatten_objective(obj, csemap=self._csemap) + # only_positive_bv_wsum_const keeps the constant separate so it never ends up + # as a numeric element in the wsum vars list (which _row_from_linexpr cannot handle) + obj, obj_const = only_positive_bv_wsum_const(obj) + + self.add(safe_cons + decomp_cons + flat_cons) + + indices, values, const = self._row_from_linexpr(obj) + const += obj_const # reset only columns that carried cost in the previous objective, then set new ones if self._obj_cols is not None and len(self._obj_cols): diff --git a/cpmpy/solvers/minizinc.py b/cpmpy/solvers/minizinc.py index 638045bb8..01e858402 100644 --- a/cpmpy/solvers/minizinc.py +++ b/cpmpy/solvers/minizinc.py @@ -72,7 +72,7 @@ from ..expressions.python_builtins import any as cpm_any from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, cpm_array from ..expressions.globalconstraints import Cumulative, DirectConstraint, GlobalCardinalityCount -from ..expressions.globalfunctions import Multiplication +from ..expressions.globalfunctions import Multiplication, FloatSum from ..expressions.utils import is_int, is_any_list, argvals, argval, get_nonneg_args from ..transformations.decompose_global import decompose_in_tree, decompose_objective from ..exceptions import MinizincPathException, NotSupportedError @@ -574,18 +574,24 @@ def objective(self, expr, minimize): 'objective()' can be called multiple times, only the last one is stored """ - # save user variables - get_variables(expr, collect=self.user_vars) # add objvars to vars - - obj, decomp_cons = decompose_objective(expr, - supported=self.supported_global_constraints, - supported_reified=self.supported_reified_global_constraints, - csemap=self._csemap) - self.add(decomp_cons) - - # make objective function or variable and post + if isinstance(expr, FloatSum): + ws, vs, const = expr.components() + self.user_vars.update(vs) # save user variables + mzn_parts = [f"({float(w)}) * ({vv})" for w, vv in zip(ws, self.solver_vars(vs))] + mzn_obj = " + ".join(mzn_parts) + if const: + mzn_obj = f"{mzn_obj} + {const}" + else: + get_variables(expr, collect=self.user_vars) # add objvars to vars + obj, decomp_cons = decompose_objective( + expr, + supported=self.supported_global_constraints, + supported_reified=self.supported_reified_global_constraints, + csemap=self._csemap, + ) + self.add(decomp_cons) + mzn_obj = self._convert_expression(obj) - mzn_obj = self._convert_expression(obj) # do not add it to the mzn_model yet, supports only one 'solve' entry if minimize: self.mzn_txt_solve = "solve minimize {};\n".format(mzn_obj) @@ -780,7 +786,7 @@ def zero_based(array): # TODO: pretty printing of () as in Operator? # special case: unary - - if self.name == '-': + if expr.name == '-': return "-{}".format(args_str[0]) # very special case: weighted sum (before 2-ary) diff --git a/cpmpy/solvers/ortools.py b/cpmpy/solvers/ortools.py index 1cdca993e..0d891e6ba 100644 --- a/cpmpy/solvers/ortools.py +++ b/cpmpy/solvers/ortools.py @@ -52,6 +52,7 @@ from ..exceptions import NotSupportedError from ..expressions.core import Expression, Comparison, Operator, BoolVal from ..expressions.globalconstraints import DirectConstraint +from ..expressions.globalfunctions import FloatSum from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, boolvar, intvar from ..expressions.globalconstraints import GlobalConstraint from ..expressions.utils import is_bool, get_nonneg_args, is_num, is_int, eval_comparison, flatlist, argval, argvals, \ @@ -355,21 +356,25 @@ def objective(self, expr, minimize): are premanently posted to the solver """ - # save user varables - get_variables(expr, self.user_vars) + if isinstance(expr, FloatSum): + ws, vs, const = expr.components() + self.user_vars.update(vs) # save user variables + ort_obj = ort.LinearExpr.weighted_sum(self.solver_vars(vs), ws) + const + else: + # save user varables + get_variables(expr, self.user_vars) - # transform objective - obj, safe_cons = safen_objective(expr) - obj, decomp_cons = decompose_objective(obj, - supported=self.supported_global_constraints, - supported_reified=self.supported_reified_global_constraints, - csemap=self._csemap) - obj, flat_cons = flatten_objective(obj, csemap=self._csemap) + # transform objective + obj, safe_cons = safen_objective(expr) + obj, decomp_cons = decompose_objective(obj, + supported=self.supported_global_constraints, + supported_reified=self.supported_reified_global_constraints, + csemap=self._csemap) + obj, flat_cons = flatten_objective(obj, csemap=self._csemap) - self.add(safe_cons+decomp_cons+flat_cons) + self.add(safe_cons+decomp_cons+flat_cons) + ort_obj = self._make_numexpr(obj) - # make objective function or variable and post - ort_obj = self._make_numexpr(obj) if minimize: self.ort_model.Minimize(ort_obj) else: diff --git a/cpmpy/solvers/scip.py b/cpmpy/solvers/scip.py index e36eadbe9..f1a4d398f 100644 --- a/cpmpy/solvers/scip.py +++ b/cpmpy/solvers/scip.py @@ -30,7 +30,7 @@ from ..expressions.core import BoolVal, Comparison, Operator from ..expressions.variables import _BoolVarImpl, NegBoolView, _IntVarImpl, _NumVarImpl from ..expressions.globalconstraints import DirectConstraint, GlobalConstraint -from ..expressions.globalfunctions import GlobalFunction +from ..expressions.globalfunctions import GlobalFunction, FloatSum from ..expressions.utils import is_num, is_int, is_true_cst, is_false_cst from ..transformations.comparison import only_numexpr_equality from ..transformations.flatten_model import flatten_constraint, flatten_objective @@ -219,25 +219,33 @@ def solver_var(self, cpm_var): def objective(self, expr, minimize=True): - get_variables(expr, collect=self.user_vars) - # Ensure every user var has a solver variable (so we get values after solve even if the constraint was simplified away and the var never appears in transformed constraints) - self.solver_vars(list(self.user_vars)) + if isinstance(expr, FloatSum): + ws, vs, const = expr.components() + self.user_vars.update(vs) # save user variables + + import pyscipopt as scip + scip_obj = scip.quicksum(w * sv for w, sv in zip(ws, self.solver_vars(vs))) + const + else: + get_variables(expr, collect=self.user_vars) + # Ensure every user var has a solver variable (so we get values after solve even if the constraint was simplified away and the var never appears in transformed constraints) + self.solver_vars(list(self.user_vars)) + + obj, safe_cons = safen_objective(expr) + obj, decomp_cons = decompose_linear_objective( + obj, + supported=self.supported_global_constraints, + supported_reified=self.supported_reified_global_constraints, + csemap=self._csemap, + ) + obj, flat_cons = flatten_objective(obj, csemap=self._csemap) + obj = only_positive_bv_wsum(obj) + + # transform and add constraints (via `_add_transformed_constraint` as to not pollute `user_vars`) + for cpm_expr in self.transform(safe_cons + decomp_cons + flat_cons): + self._add_transformed_constraint(cpm_expr) + + scip_obj = self._make_numexpr(obj) - obj, safe_cons = safen_objective(expr) - obj, decomp_cons = decompose_linear_objective( - obj, - supported=self.supported_global_constraints, - supported_reified=self.supported_reified_global_constraints, - csemap=self._csemap, - ) - obj, flat_cons = flatten_objective(obj, csemap=self._csemap) - obj = only_positive_bv_wsum(obj) - - # transform and add constraints (via `_add_transformed_constraint` as to not pollute `user_vars`) - for cpm_expr in self.transform(safe_cons + decomp_cons + flat_cons): - self._add_transformed_constraint(cpm_expr) - - scip_obj = self._make_numexpr(obj) if minimize: self.scip_model.setObjective(scip_obj, sense='minimize') else: diff --git a/cpmpy/solvers/solver_interface.py b/cpmpy/solvers/solver_interface.py index f8dbee2d3..41cc4a043 100644 --- a/cpmpy/solvers/solver_interface.py +++ b/cpmpy/solvers/solver_interface.py @@ -133,7 +133,8 @@ def objective(self, expr, minimize): Post the given expression to the solver as objective to minimize/maximize Arguments: - expr: Expression, the CPMpy expression that represents the objective function + expr: a CPMpy :class:`~cpmpy.expressions.core.Expression`, or a + :class:`~cpmpy.expressions.globalfunctions.FloatSum` if supported minimize: Bool, whether it is a minimization problem (True) or maximization problem (False) ``objective()`` can be called multiple times, only the last one is stored diff --git a/cpmpy/solvers/z3.py b/cpmpy/solvers/z3.py index 03aa08b6f..51f3ec7a8 100644 --- a/cpmpy/solvers/z3.py +++ b/cpmpy/solvers/z3.py @@ -52,7 +52,7 @@ from ..exceptions import NotSupportedError from ..expressions.core import Expression, Comparison, Operator, BoolVal from ..expressions.globalconstraints import GlobalConstraint, DirectConstraint -from ..expressions.globalfunctions import GlobalFunction +from ..expressions.globalfunctions import GlobalFunction, FloatSum from ..expressions.variables import _BoolVarImpl, NegBoolView, _NumVarImpl, _IntVarImpl, intvar from ..expressions.utils import is_num, is_any_list, is_bool, is_int, is_boolexpr, eval_comparison from ..transformations.decompose_global import decompose_in_tree, decompose_objective @@ -256,7 +256,11 @@ def solve(self, time_limit:Optional[float]=None, assumptions:Optional[Iterable[_ # translate objective, for optimisation problems only if self.has_objective(): obj = self.z3_solver.objectives()[0] - self.objective_value_ = sol.evaluate(obj).as_long() + obj_val = sol.evaluate(obj) + if z3.is_int_value(obj_val): + self.objective_value_ = obj_val.as_long() + else: + self.objective_value_ = float(obj_val.as_decimal(20).rstrip("?")) if not self._minimize: self.objective_value_ = -1*self.objective_value_ # Z3 negates the objective function to turn a maximisation problem into a minimisation one, undoing negation here @@ -322,21 +326,27 @@ def objective(self, expr, minimize=True): if not isinstance(self.z3_solver, z3.Optimize): raise NotSupportedError("Use the z3 optimizer for optimization problems") - # save user variables - get_variables(expr, self.user_vars) + if isinstance(expr, FloatSum): + ws, vs, const = expr.components() + self.user_vars.update(vs) # update user variables + z3_obj = z3.Sum([z3.Sum([w*v for w,v in zip(ws,self.solver_vars(vs))]), const]) + else: + # save user variables + get_variables(expr, self.user_vars) - # transform objective - obj, safe_cons = safen_objective(expr) - obj, decomp_cons = decompose_objective(obj, - supported=self.supported_global_constraints, - supported_reified=self.supported_reified_global_constraints, - csemap=self._csemap) + # transform objective + obj, safe_cons = safen_objective(expr) + obj, decomp_cons = decompose_objective(obj, + supported=self.supported_global_constraints, + supported_reified=self.supported_reified_global_constraints, + csemap=self._csemap) - self.add(safe_cons + decomp_cons) + self.add(safe_cons + decomp_cons) + z3_obj = self._z3_expr(obj) - z3_obj = self._z3_expr(obj) if isinstance(z3_obj, z3.BoolRef): z3_obj = z3.If(z3_obj, 1, 0) # must be integer + if minimize: self.obj_handle = self.z3_solver.minimize(z3_obj) self._minimize = True # record direction of optimisation diff --git a/cpmpy/transformations/linearize.py b/cpmpy/transformations/linearize.py index 565bbe1cb..390813d64 100644 --- a/cpmpy/transformations/linearize.py +++ b/cpmpy/transformations/linearize.py @@ -75,7 +75,7 @@ from .normalize import toplevel_list, simplify_boolean from ..exceptions import TransformationNotImplementedError -from ..expressions.core import Comparison, Expression, Operator, BoolVal +from ..expressions.core import Comparison, Expression, Operator, BoolVal, _wsum_make from ..expressions.globalconstraints import GlobalConstraint, DirectConstraint, AllDifferent, Table from ..expressions.globalfunctions import GlobalFunction, Element from ..expressions.utils import is_bool, is_num, is_int, eval_comparison, get_bounds, is_true_cst, is_false_cst @@ -482,7 +482,18 @@ def canonical_comparison(lst_of_expr): # 2) add collected variables to lhs if isinstance(lhs, _NumVarImpl) or (isinstance(lhs, Operator) and (lhs.name == "sum" or lhs.name == "wsum")): - lhs = lhs + lhs2 + if lhs2: + if isinstance(lhs, Operator) and lhs.name == "sum": + lhs = Operator("sum", list(lhs.args) + lhs2) + elif isinstance(lhs, Operator) and lhs.name == "wsum": + w, e = list(lhs.args[0]), list(lhs.args[1]) + for term in lhs2: + tw, te = _wsum_make(term) + w.extend(tw) + e.extend(te) + lhs = Operator("wsum", [w, e]) + elif isinstance(lhs, _NumVarImpl): + lhs = Operator("sum", [lhs] + lhs2) else: raise ValueError(f"unexpected expression on lhs of expression, should be sum, wsum or intvar but got {lhs}") @@ -554,7 +565,7 @@ def only_positive_coefficients(lst_of_expr): # Simplify wsum to sum if all weights are 1 if all(w == 1 for w in nw): - lhs = Operator("sum", [list(na)]) + lhs = Operator("sum", list(na)) else: lhs = Operator("wsum", [list(nw), list(na)]) diff --git a/tests/test_expressions.py b/tests/test_expressions.py index f4df70016..c224f0882 100644 --- a/tests/test_expressions.py +++ b/tests/test_expressions.py @@ -223,10 +223,6 @@ def test_mul_is_lhs_num(self): expr = x * 3 assert expr.is_lhs_num is True assert expr.args[0] == 3 and expr.args[1] is x - # real coeff: 0.3 * x -> is_lhs_num True (for objectives) - expr = 0.3 * x - assert expr.is_lhs_num is True - assert expr.args[0] == 0.3 and expr.args[1] is x # var * var -> no constant, is_lhs_num False y = cp.intvar(0, 5, name="y") expr = x * y @@ -270,7 +266,7 @@ def test_prod(self): assert y.value() == res # with axis arg x = intvar(0,5,shape=(10,4), name="x") - y = intvar(0, 1000, shape=10, name="y") + y = intvar(0, 200, shape=10, name="y") model = cp.Model(y == x.prod(axis=1)) # y[i] = product(x[i,:]) model.solve() for i,vv in enumerate(x): @@ -491,11 +487,9 @@ def test_dtype(self): assert int == type(cp.sum(x[0]).value()) assert int == type(cp.sum(x).value()) assert int == type(cp.sum([1,2,3] * x[0]).value()) - assert float == type(cp.sum([0.1,0.2,0.3] * x[0]).value()) # also numpy should be converted to Python native when callig value() assert int == type(cp.sum(np.array([1, 2, 3]) * x[0]).value()) - assert float == type(cp.sum(np.array([0.1,0.2,0.3]) * x[0]).value()) # test binary operators a,b = x[0,[0,1]] diff --git a/tests/test_int2bool.py b/tests/test_int2bool.py index 6a489a79a..1a6caa74d 100644 --- a/tests/test_int2bool.py +++ b/tests/test_int2bool.py @@ -45,7 +45,7 @@ Comparison(cmp, c, 2), Comparison(cmp, x, 2), Comparison(cmp, x, 5), - Comparison(cmp, Operator("sum", [[x, y, z]]), 4), + Comparison(cmp, Operator("sum", [x, y, z]), 4), Comparison(cmp, Operator("wsum", [[2, 3, 5], [x, y, z]]), 12), Comparison(cmp, Operator("wsum", [[2, 3, 5], [x, y, z]]), -10), Comparison(cmp, Operator("wsum", [[2, 3, 5], [x, y, z]]), 100), # where ub(lhs)=9.6 using pip install --upgrade ortools - def test_ortools_real_coeff(self): - + def test_ortools_rejects_float_coefficients(self): m = cp.Model() - # this works in OR-Tools - x,y,z = cp.boolvar(shape=3, name=tuple("xyz")) - m.maximize(0.3 * x + 0.5 * y + 0.6 * z) - assert m.solve() - assert m.objective_value() == 1.4 - # this does not - m += 0.7 * x + 0.8 * y >= 1 - pytest.raises(TypeError, m.solve) + x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) + with pytest.warns(DeprecationWarning): + m += 0.7 * x + 0.8 * y >= 1 + #pytest.raises(TypeError, m.solve) + + def test_floatsum_objective_only(self): + x = cp.boolvar(name="x") + fs = cp.FloatSum([0.5], [x]) + with pytest.raises(CPMpyTypeError, match="objective-only"): + _ = fs >= 1 @pytest.mark.skipif(not CPM_pysat.supported(), reason="PySAT not installed") @@ -493,9 +494,9 @@ def test_z3(self): assert s.solve() x = cp.intvar(0, 1) - m = cp.Model((x >= 0.1) & (x != 1)) + m = cp.Model((x > 0) & (x != 1)) s = cp.SolverLookup.get("z3", m) - assert not s.solve()# upgrade z3 with pip install --upgrade z3-solver + assert not s.solve() def test_pow(self): iv1 = cp.intvar(2,9) @@ -712,30 +713,6 @@ def test_gurobi_element(self): assert s.solve() assert iv.value()[idx.value(), idx2.value()] == 8 - @pytest.mark.skipif(not CPM_gurobi.supported(), - reason="Gurobi not installed") - def test_gurobi_float_objective(self): - """Test that Gurobi properly handles float objective values.""" - # Test case with float coefficients that should result in a float objective value - x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) - - # Create a model with float coefficients - this can happen with DirectVar - # or when using floats as coefficients - m = cp.Model() - m.maximize(0.3 * x + 0.7 * y + 1.5 * z) - - s = cp.SolverLookup.get("gurobi", m) - assert s.solve() - - # The optimal solution should be x=True, y=True, z=True with objective = 2.5 - expected_obj = 2.5 - actual_obj = s.objective_value() - - # Verify that the objective value is returned as a float (not int) - assert isinstance(actual_obj, float) - assert actual_obj == pytest.approx(expected_obj, abs=1e-05) - - @pytest.mark.skipif(not CPM_cplex.supported(), reason="cplex not installed") def test_cplex(self): @@ -764,29 +741,6 @@ def test_cplex(self): assert s.solve() - @pytest.mark.skipif(not CPM_cplex.supported(), - reason="cplex not installed") - def test_cplex_float_objective(self): - """Test that cplex properly handles float objective values.""" - # Test case with float coefficients that should result in a float objective value - x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) - - # Create a model with float coefficients - this can happen with DirectVar - # or when using floats as coefficients - m = cp.Model() - m.maximize(0.3 * x + 0.7 * y + 1.5 * z) - - s = cp.SolverLookup.get("cplex", m) - assert s.solve() - - # The optimal solution should be x=True, y=True, z=True with objective = 2.5 - expected_obj = 2.5 - actual_obj = s.objective_value() - - # Verify that the objective value is returned as a float (not int) - assert isinstance(actual_obj, float) - assert actual_obj == pytest.approx(expected_obj, abs=1e-05) - @pytest.mark.skipif(not CPM_cplex.supported(), reason="cplex not installed") def test_cplex_solve_all(self): @@ -851,6 +805,8 @@ def test_pumpkin_proof(self): @pytest.mark.usefixtures("solver") class TestSupportedSolvers: + _floatsum_supported_solvers = frozenset({"ortools", "gurobi", "cplex", "scip", "z3", "hexaly", "minizinc", "highs"}) + def test_installed_solvers(self, solver): # basic model v = cp.boolvar(3) @@ -925,6 +881,31 @@ def test_objective(self, solver): assert m.solve(solver=solver) assert m.objective_value() == 5 + def test_floatsum_objective(self, solver): + if solver not in self._floatsum_supported_solvers: + pytest.skip(f"{solver} does not support FloatSum objective") + if solver == "z3": + solver = "z3:opt" + s = cp.SolverLookup.get(solver) + + x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) + s.maximize(cp.FloatSum([0.3, 0.5, 0.6], [x, y, z], const=1)) + assert s.solve() + assert s.objective_value() == pytest.approx(2.4, abs=1e-05) + + def test_floatsum_negboolview(self, solver): + if solver not in self._floatsum_supported_solvers: + pytest.skip(f"{solver} does not support FloatSum objective") + if solver == "z3": + solver = "z3:opt" + s = cp.SolverLookup.get(solver) + + x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) + s.maximize(cp.FloatSum([0.3, 0.5, 0.6], [~x, y, ~z], const=1)) + assert s.solve() + assert (x.value(), y.value(), z.value()) == (False, True, False) + assert s.objective_value() == pytest.approx(2.4, abs=1e-05) + def test_value_cleared(self, solver): x, y, z = cp.boolvar(shape=3) sat_model = cp.Model(cp.any([x,y,z])) diff --git a/tests/test_trans_simplify.py b/tests/test_trans_simplify.py index 69761e918..0d5c67ef4 100644 --- a/tests/test_trans_simplify.py +++ b/tests/test_trans_simplify.py @@ -49,16 +49,16 @@ def test_bool_in_comp(self): assert str(self.transform(expr)) == '[1 + (iv[0]) >= 0]' def test_boolvar_comps(self): - num_args = {"<0": -1, "0": 0, "]0..1[": 0.5, "1": 1, ">0": 2} + num_args = {"<0": -1, "0": 0, "1": 1, ">0": 2} # test table from github (#add url) bv = self.bvs[0] test_dict = { - "==": {"<0": False,"0": ~bv, "]0..1[":False,"1": bv, ">0": False}, - "!=": {"<0": True, "0": bv, "]0..1[":True, "1": ~bv, ">0": True}, - ">": {"<0": True, "0": bv, "]0..1[":bv, "1": False,">0": False}, - "<": {"<0": False,"0": False,"]0..1[":~bv, "1": ~bv, ">0": True}, - ">=": {"<0": True, "0": True, "]0..1[":bv, "1": bv, ">0": False}, - "<=": {"<0": False,"0": ~bv, "]0..1[":~bv, "1": True, ">0": True} + "==": {"<0": False, "0": ~bv, "1": bv, ">0": False}, + "!=": {"<0": True, "0": bv, "1": ~bv, ">0": True}, + ">": {"<0": True, "0": bv, "1": False, ">0": False}, + "<": {"<0": False, "0": False, "1": ~bv, ">0": True}, + ">=": {"<0": True, "0": True, "1": bv, ">0": False}, + "<=": {"<0": False, "0": ~bv, "1": True, ">0": True} } for op in test_dict: @@ -102,22 +102,6 @@ def test_simplify_expressions(self): expr = Operator("and", self.bvs[:1].tolist() + [BoolVal(False)]) == Operator("or", self.bvs) assert str(self.transform(expr)) == '[and(~bv[0], ~bv[1], ~bv[2])]' - # issue #322 - def test_with_floats(self): - expr = self.bvs[0] == 1.0 - assert str(self.transform(expr)) == "[bv[0]]" - expr = cp.AllDifferent(self.ivs) == 4.2 - assert str(self.transform(expr)) == '[boolval(False)]' - expr = 0.0 == cp.AllDifferent(self.ivs) - assert str(self.transform(expr)) == '[not(alldifferent(iv[0],iv[1],iv[2]))]' - - expr = (self.ivs[0] <= self.ivs[1]) < 0.8 - assert str(self.transform(expr)) == '[(iv[0]) > (iv[1])]' - - expr = (self.ivs[0] == self.ivs[1]) == 1.0 - assert str(self.transform(expr)) == '[(iv[0]) == (iv[1])]' - - def test_nested_boolval(self): bv = cp.boolvar(name="bv")