From 7a9b83dd33d564cffc97e63fac5105e380687d1f Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Fri, 24 Apr 2026 23:00:30 +0200 Subject: [PATCH 01/26] floatsum to still enable floats in the objective also deprecates the use of floats in mul --- cpmpy/expressions/__init__.py | 2 + cpmpy/expressions/globalfunctions.py | 87 +++++++++++++++++++++++++++- cpmpy/solvers/ortools.py | 40 +++++++++---- tests/test_expressions.py | 10 ++-- tests/test_solvers.py | 20 ++++++- 5 files changed, 137 insertions(+), 22 deletions(-) diff --git a/cpmpy/expressions/__init__.py b/cpmpy/expressions/__init__.py index 30f55a84d..277d38d18 100644 --- a/cpmpy/expressions/__init__.py +++ b/cpmpy/expressions/__init__.py @@ -65,6 +65,7 @@ Among, NValue, NValueExcept, + FloatSum, ) from .core import BoolVal from .python_builtins import all, any, max, min, sum, abs @@ -91,6 +92,7 @@ "Among", "NValue", "NValueExcept", + "FloatSum", # Global constraints "AllDifferent", "AllDifferentExcept0", diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index b45063bdb..8d8edb930 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -70,16 +70,17 @@ def decompose(self): Among NValue NValueExcept + FloatSum """ import warnings # for deprecation warning -from typing import Optional +from typing import Optional, NoReturn import numpy as np import cpmpy as cp from ..exceptions import CPMpyException, IncompleteFunctionError, TypeError from .core import Expression, Operator, ExprLike, ListLike -from .variables import boolvar, intvar, cpm_array +from .variables import intvar, cpm_array, NDVarArray from .utils import flatlist, argval, is_num, is_int, eval_comparison, is_any_list, is_boolexpr, get_bounds, argvals, implies @@ -327,8 +328,20 @@ def __init__(self, x: ExprLike, y: ExprLike): """ is_lhs_num = False if is_num(x): + if not is_int(x): + warnings.warn( + "Non-integer scalar multiplication is deprecated and will become an error in a future release. " + "Use FloatSum(coeffs, terms) for float-weight objectives.", + DeprecationWarning, + ) is_lhs_num = True elif is_num(y): + if not is_int(y): + warnings.warn( + "Non-integer scalar multiplication is deprecated and will become an error in a future release. " + "Use FloatSum(coeffs, terms) for float-weight objectives.", + DeprecationWarning, + ) (x, y) = (y, x) is_lhs_num = True @@ -342,8 +355,20 @@ def update_args(self, args): x, y = args is_lhs_num = False if is_num(x): + if not is_int(x): + warnings.warn( + "Non-integer scalar multiplication is deprecated and will become an error in a future release. " + "Use FloatSum(coeffs, terms) for float-weight objectives.", + DeprecationWarning, + ) is_lhs_num = True elif is_num(y): + if not is_int(y): + warnings.warn( + "Non-integer scalar multiplication is deprecated and will become an error in a future release. " + "Use FloatSum(coeffs, terms) for float-weight objectives.", + DeprecationWarning, + ) (x, y) = (y, x) is_lhs_num = True @@ -1047,3 +1072,61 @@ 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 integer expressions. + + Does not inherit from Expression because it is objective only and has float .value() + Basically it is breaking all design rules of CPMpy... + + Accepts only (numpy) floats as coefficients, and Expressions as terms. + """ + name: str = "floatsum" + coeffs: np.ndarray + terms: NDVarArray + + def __init__(self, coeffs: ListLike[float|np.floating], terms: ListLike[ExprLike]): + self.coeffs = np.asarray(coeffs, dtype=float).reshape(-1) + if isinstance(terms, NDVarArray): + self.terms = terms + else: + self.terms = cpm_array(terms) + if self.terms.ndim > 1: # must reshape to 1D + # typing is wrong: numpy preserves our ndarray subclass + flat = self.terms.reshape(-1) + assert isinstance(flat, NDVarArray) # true: numpy preserves our ndarray subclass + self.terms = flat + + if self.coeffs.size != self.terms.size: + raise TypeError(f"FloatSum(coeffs, terms) expects equal lengths, got {self.coeffs.size} coefficients and {self.terms.size} terms") + if self.coeffs.size == 0: + raise TypeError("FloatSum(coeffs, terms) expects at least one term") + + def __repr__(self) -> str: + return f"FloatSum({list(self.coeffs)}, {list(self.terms)})" + + def value(self) -> Optional[float]: + vals = argvals(self.terms) + if any(v is None for v in vals): + return None + return sum(c * v for c, v in zip(self.coeffs, vals)) + + 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/ortools.py b/cpmpy/solvers/ortools.py index 1cf72c548..80b7e5652 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, \ @@ -349,21 +350,36 @@ def objective(self, expr, minimize): are premanently posted to the solver """ - # save user varables - get_variables(expr, self.user_vars) + # special case: a FloatSum objective + if isinstance(expr, FloatSum): + # save user varables + get_variables(expr.terms, self.user_vars) + + # transform objective + vars_ = [] + flat_cons = [] + for term in expr.terms: + var, cons = get_or_make_var(term, csemap=self._csemap) + vars_.append(var) + flat_cons.extend(cons) + self.add(flat_cons) + ort_obj = ort.LinearExpr.weighted_sum(self.solver_vars(vars_), expr.coeffs) + + else: # normal case, a CPMpy Expression + # 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/tests/test_expressions.py b/tests/test_expressions.py index c16cb226a..31cb593e5 100644 --- a/tests/test_expressions.py +++ b/tests/test_expressions.py @@ -269,13 +269,13 @@ def test_prod(self): res *= v.value() assert y.value() == res # with axis arg - x = intvar(0,5,shape=(10,10), name="x") - y = intvar(0, 1000, shape=10, name="y") - model = cp.Model(y == x.prod(axis=0)) + x = intvar(0,5,shape=(10,4), name="x") + y = intvar(0, 200, shape=10, name="y") + model = cp.Model(y == x.prod(axis=1)) model.solve() - for i,vv in enumerate(x): + for i in range(y.shape[0]): res = 1 - for v in vv: + for v in x[i, :]: res *= v.value() assert y[i].value() == res diff --git a/tests/test_solvers.py b/tests/test_solvers.py index c7d9e6c41..18e445c22 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -20,7 +20,7 @@ from cpmpy.solvers.cplex import CPM_cplex from cpmpy.solvers.scip import CPM_scip from cpmpy import SolverLookup -from cpmpy.exceptions import MinizincNameException, NotSupportedError +from cpmpy.exceptions import MinizincNameException, NotSupportedError, TypeError as CPMpyTypeError from test_constraints import numexprs from utils import skip_on_missing_pblib @@ -283,13 +283,27 @@ def test_ortools_real_coeff(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) + with pytest.warns(DeprecationWarning, match="Non-integer scalar multiplication is deprecated"): + 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 + with pytest.warns(DeprecationWarning, match="Non-integer scalar multiplication is deprecated"): + m += 0.7 * x + 0.8 * y >= 1 pytest.raises(TypeError, m.solve) + def test_ortools_floatsum_objective(self): + x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) + m = cp.Model(maximize=cp.FloatSum([0.3, 0.5, 0.6], [x, y, z])) + assert m.solve(solver="ortools") + assert m.objective_value() == pytest.approx(1.4, abs=1e-05) + + 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") def test_pysat(self): From 3cf96c926b857dfb652c32165feaeb4214c5fbc7 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Fri, 24 Apr 2026 23:13:01 +0200 Subject: [PATCH 02/26] listlike: switch from invariant list[T] to covariant Sequence[t] so list[Expression] was not a ListLike[ExprLike]... because list is mutable, and mutable collections are type 'invariant'; what we want is 'covariant' behaviour which would treat Expression as a subclass of ExprLike https://mypy.readthedocs.io/en/stable/common_issues.html#invariance-vs-covariance The Sequence type covers tuple and list (and others, like iterators I think) and it is covariant, so that resolves that (and accepts other things we also accept, because we just use generic sequence (iterate, select element) functions on it --- cpmpy/expressions/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpmpy/expressions/core.py b/cpmpy/expressions/core.py index 4d21e613f..e6c9a1214 100644 --- a/cpmpy/expressions/core.py +++ b/cpmpy/expressions/core.py @@ -103,7 +103,7 @@ # Common typing helpers T = TypeVar("T") -ListLike: TypeAlias = Union[list[T], tuple[T, ...], np.ndarray] # matches is_any_list() check +ListLike: TypeAlias = Union[Sequence[T], np.ndarray] # similar to is_any_list() check (Sequence a bit more general than list/tuple) ExprLike: TypeAlias = Union["Expression", int, np.integer, np.bool_] # expression or int (incl np variants, e.g. user facing) From 15b70a1ae9b022f0c45990b88c701e4459573e75 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Fri, 24 Apr 2026 23:51:21 +0200 Subject: [PATCH 03/26] floatsum: type corrections --- cpmpy/expressions/globalfunctions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index 8d8edb930..58ad84dc9 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -74,7 +74,7 @@ def decompose(self): """ import warnings # for deprecation warning -from typing import Optional, NoReturn +from typing import Optional, NoReturn, Final import numpy as np import cpmpy as cp @@ -1083,11 +1083,11 @@ class FloatSum: Accepts only (numpy) floats as coefficients, and Expressions as terms. """ - name: str = "floatsum" + name: Final = "floatsum" coeffs: np.ndarray terms: NDVarArray - def __init__(self, coeffs: ListLike[float|np.floating], terms: ListLike[ExprLike]): + def __init__(self, coeffs: ListLike[float|np.floating], terms: ListLike[Expression]): self.coeffs = np.asarray(coeffs, dtype=float).reshape(-1) if isinstance(terms, NDVarArray): self.terms = terms From 9ceca831272bd46a6dc2d62a2a30da53c1eae4f5 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Sun, 26 Apr 2026 22:39:24 +0200 Subject: [PATCH 04/26] more FloatSum support --- cpmpy/solvers/cplex.py | 48 +++++++++++++++++++++------------ cpmpy/solvers/gurobi.py | 42 +++++++++++++++++++---------- cpmpy/solvers/scip.py | 60 +++++++++++++++++++++++++++-------------- cpmpy/solvers/z3.py | 55 +++++++++++++++++++++++++++---------- tests/test_solvers.py | 11 ++++++++ 5 files changed, 151 insertions(+), 65 deletions(-) diff --git a/cpmpy/solvers/cplex.py b/cpmpy/solvers/cplex.py index 43ec44052..31916c399 100644 --- a/cpmpy/solvers/cplex.py +++ b/cpmpy/solvers/cplex.py @@ -55,11 +55,12 @@ from .solver_interface import SolverInterface, SolverStatus, ExitStatus, Callback from ..expressions.core import Expression, Comparison, Operator, BoolVal +from ..expressions.globalfunctions import FloatSum from ..expressions.utils import argvals, argval, eval_comparison, flatlist, is_any_list, is_bool, is_num from ..expressions.variables import _BoolVarImpl, NegBoolView, _IntVarImpl, _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.flatten_model import flatten_constraint, flatten_objective, get_or_make_var from ..transformations.get_variables import get_variables from ..transformations.linearize import linearize_constraint, linearize_reified_variables, only_positive_bv, only_positive_bv_wsum, \ only_positive_bv_wsum_const, decompose_linear, decompose_linear_objective @@ -301,22 +302,35 @@ 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): + # save user variables + get_variables(expr.terms, self.user_vars) + vars_ = [] + flat_cons = [] + for term in expr.terms: + var, cons = get_or_make_var(term, csemap=self._csemap) + vars_.append(var) + flat_cons.extend(cons) + self.add(flat_cons) + self._obj_offset = 0 + cplex_obj = self.cplex_model.scal_prod(self.solver_vars(vars_), [float(w) for w in expr.coeffs]) + 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 d6e33d682..364cdbc9e 100644 --- a/cpmpy/solvers/gurobi.py +++ b/cpmpy/solvers/gurobi.py @@ -48,11 +48,12 @@ 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 from ..expressions.variables import _BoolVarImpl, NegBoolView, _IntVarImpl, _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.flatten_model import flatten_constraint, flatten_objective, get_or_make_var from ..transformations.get_variables import get_variables from ..transformations.linearize import linearize_constraint, linearize_reified_variables, only_positive_bv, only_positive_bv_wsum, decompose_linear, decompose_linear_objective from ..transformations.normalize import toplevel_list @@ -288,22 +289,35 @@ def objective(self, expr, minimize=True): """ from gurobipy import GRB - # save user variables - get_variables(expr, self.user_vars) + if isinstance(expr, FloatSum): + # save user variables + get_variables(expr.terms, self.user_vars) + vars_ = [] + flat_cons = [] + for term in expr.terms: + var, cons = get_or_make_var(term, csemap=self._csemap) + vars_.append(var) + flat_cons.extend(cons) + self.add(flat_cons) + grb_obj = gp.quicksum(float(w) * self.solver_var(var) for w, var in zip(expr.coeffs, vars_)) + 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 - # 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/scip.py b/cpmpy/solvers/scip.py index a4b542c6e..d13b446d7 100644 --- a/cpmpy/solvers/scip.py +++ b/cpmpy/solvers/scip.py @@ -30,10 +30,10 @@ 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_true_cst, is_false_cst from ..transformations.comparison import only_numexpr_equality -from ..transformations.flatten_model import flatten_constraint, flatten_objective +from ..transformations.flatten_model import flatten_constraint, flatten_objective, get_or_make_var from ..transformations.get_variables import get_variables from ..transformations.linearize import decompose_linear, decompose_linear_objective, linearize_constraint, linearize_reified_variables, only_positive_bv, only_positive_bv_wsum from ..transformations.normalize import toplevel_list @@ -212,25 +212,45 @@ 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): + get_variables(expr.terms, 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)) + + vars_ = [] + flat_cons = [] + for term in expr.terms: + var, cons = get_or_make_var(term, csemap=self._csemap) + vars_.append(var) + flat_cons.extend(cons) + + # transform and add constraints (via `_add_transformed_constraint` as to not pollute `user_vars`) + for cpm_expr in self.transform(flat_cons): + self._add_transformed_constraint(cpm_expr) + + import pyscipopt as scip + scip_obj = scip.quicksum(float(w) * self.solver_var(var) for w, var in zip(expr.coeffs, vars_)) + 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/z3.py b/cpmpy/solvers/z3.py index 0fd3a8eec..0b1cc679e 100644 --- a/cpmpy/solvers/z3.py +++ b/cpmpy/solvers/z3.py @@ -52,10 +52,11 @@ 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 +from ..transformations.flatten_model import get_or_make_var from ..transformations.normalize import toplevel_list from ..transformations.safening import no_partial_functions, safen_objective @@ -255,7 +256,13 @@ def solve(self, time_limit:Optional[float]=None, assumptions:Optional[List[_Bool # 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() + elif z3.is_rational_value(obj_val): + self.objective_value_ = obj_val.numerator_as_long() / obj_val.denominator_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 @@ -318,21 +325,41 @@ 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): + # save user variables + get_variables(expr.terms, self.user_vars) + vars_ = [] + flat_cons = [] + for term in expr.terms: + var, cons = get_or_make_var(term, csemap=self._csemap) + vars_.append(var) + flat_cons.extend(cons) + self.add(flat_cons) + + weighted_terms = [] + for coeff, var in zip(expr.coeffs, vars_): + z3_term = self._z3_expr(var) + if isinstance(z3_term, z3.BoolRef): + z3_term = z3.If(z3_term, 1, 0) + weighted_terms.append(float(coeff) * z3_term) + z3_obj = z3.Sum(weighted_terms) + 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) + if isinstance(z3_obj, z3.BoolRef): + z3_obj = z3.If(z3_obj, 1, 0) # must be integer - 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/tests/test_solvers.py b/tests/test_solvers.py index 18e445c22..0d8c58829 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -863,6 +863,8 @@ def test_pumpkin_proof(self): @pytest.mark.usefixtures("solver") class TestSupportedSolvers: + _floatsum_supported_solvers = frozenset({"ortools", "gurobi", "cplex", "scip", "z3"}) + def test_installed_solvers(self, solver): # basic model v = cp.boolvar(3) @@ -937,6 +939,15 @@ 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") + + x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) + m = cp.Model(maximize=cp.FloatSum([0.3, 0.5, 0.6], [x, y, z])) + assert m.solve(solver=solver) + assert m.objective_value() == pytest.approx(1.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])) From 6fee25ccaa93a6a9a7bdd09801ea11b878b06640 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Sun, 26 Apr 2026 22:49:20 +0200 Subject: [PATCH 05/26] add floatsum for hexaly, untested --- cpmpy/solvers/hexaly.py | 37 +++++++++++++++++++++++++------------ tests/test_direct.py | 6 ++++++ tests/test_solvers.py | 2 +- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/cpmpy/solvers/hexaly.py b/cpmpy/solvers/hexaly.py index 5997e2979..883d51d6b 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_any_list, eval_comparison, flatlist +from ..expressions.utils import 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 @@ -285,21 +285,34 @@ 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): + get_variables(expr.terms, self.user_vars) + hex_parts = [] + for c, t in zip(expr.coeffs, expr.terms): + obj_t, decomp_cons = decompose_objective( + t, + supported=self.supported_global_constraints, + supported_reified=self.supported_reified_global_constraints, + csemap=self._csemap, + ) + self.add(decomp_cons) + hex_parts.append(float(c) * self._hex_expr(obj_t)) + hex_obj = self.hex_model.sum(hex_parts) + 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: diff --git a/tests/test_direct.py b/tests/test_direct.py index e0d0f9167..d48c9a95d 100644 --- a/tests/test_direct.py +++ b/tests/test_direct.py @@ -154,3 +154,9 @@ def test_direct_distance(self): assert model.solve() assert abs(a.value() - b.value()) >=3 + + def test_floatsum_objective(self): + x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) + m = cp.Model(maximize=cp.FloatSum([0.3, 0.5, 0.6], [x, y, z])) + assert m.solve(solver="hexaly") + assert m.objective_value() == pytest.approx(1.4, abs=1e-05) diff --git a/tests/test_solvers.py b/tests/test_solvers.py index 0d8c58829..4ad14f9d0 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -863,7 +863,7 @@ def test_pumpkin_proof(self): @pytest.mark.usefixtures("solver") class TestSupportedSolvers: - _floatsum_supported_solvers = frozenset({"ortools", "gurobi", "cplex", "scip", "z3"}) + _floatsum_supported_solvers = frozenset({"ortools", "gurobi", "cplex", "scip", "z3", "hexaly"}) def test_installed_solvers(self, solver): # basic model From 0c11f32d110984a55da4278b801f3cf7dcb30dd0 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Sun, 26 Apr 2026 22:54:52 +0200 Subject: [PATCH 06/26] and minizinc, also untested --- cpmpy/solvers/minizinc.py | 37 +++++++++++++++++++++++++------------ tests/test_solvers.py | 2 +- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/cpmpy/solvers/minizinc.py b/cpmpy/solvers/minizinc.py index 2200ec616..66f259f27 100644 --- a/cpmpy/solvers/minizinc.py +++ b/cpmpy/solvers/minizinc.py @@ -70,7 +70,7 @@ from ..expressions.python_builtins import any as cpm_any from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, cpm_array from ..expressions.globalconstraints import DirectConstraint, GlobalCardinalityCount -from ..expressions.globalfunctions import Multiplication +from ..expressions.globalfunctions import Multiplication, FloatSum from ..expressions.utils import is_num, is_any_list, argvals, argval, get_nonneg_args from ..transformations.decompose_global import decompose_in_tree, decompose_objective from ..exceptions import MinizincPathException, NotSupportedError @@ -570,18 +570,31 @@ 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): + get_variables(expr.terms, self.user_vars) + mzn_parts = [] + for c, t in zip(expr.coeffs, expr.terms): + obj_t, decomp_cons = decompose_objective( + t, + supported=self.supported_global_constraints, + supported_reified=self.supported_reified_global_constraints, + csemap=self._csemap, + ) + self.add(decomp_cons) + mzn_sub = self._convert_expression(obj_t) + mzn_parts.append("({0:g}) * ({1})".format(float(c), mzn_sub)) + mzn_obj = " + ".join(mzn_parts) + 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) diff --git a/tests/test_solvers.py b/tests/test_solvers.py index 4ad14f9d0..bfe662eb8 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -863,7 +863,7 @@ def test_pumpkin_proof(self): @pytest.mark.usefixtures("solver") class TestSupportedSolvers: - _floatsum_supported_solvers = frozenset({"ortools", "gurobi", "cplex", "scip", "z3", "hexaly"}) + _floatsum_supported_solvers = frozenset({"ortools", "gurobi", "cplex", "scip", "z3", "hexaly", "minizinc"}) def test_installed_solvers(self, solver): # basic model From e1b5477b07ab5d4a9a13405c3356f98e9acabb80 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Sun, 26 Apr 2026 23:01:09 +0200 Subject: [PATCH 07/26] tests: floats are no longer (inconsistently) supported --- tests/test_expressions.py | 6 ---- tests/test_solvers.py | 68 +++--------------------------------- tests/test_trans_simplify.py | 30 ++++------------ 3 files changed, 11 insertions(+), 93 deletions(-) diff --git a/tests/test_expressions.py b/tests/test_expressions.py index 31cb593e5..3a578dad7 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 @@ -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_solvers.py b/tests/test_solvers.py index bfe662eb8..17f0e1bdf 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -278,26 +278,13 @@ def test_ortools_version(self): assert model.solve(solver="ortools")# this is a bug in ortools version 9.5, upgrade to version >=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")) - with pytest.warns(DeprecationWarning, match="Non-integer scalar multiplication is deprecated"): - m.maximize(0.3 * x + 0.5 * y + 0.6 * z) - assert m.solve() - assert m.objective_value() == 1.4 - # this does not + x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) with pytest.warns(DeprecationWarning, match="Non-integer scalar multiplication is deprecated"): m += 0.7 * x + 0.8 * y >= 1 pytest.raises(TypeError, m.solve) - def test_ortools_floatsum_objective(self): - x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) - m = cp.Model(maximize=cp.FloatSum([0.3, 0.5, 0.6], [x, y, z])) - assert m.solve(solver="ortools") - assert m.objective_value() == pytest.approx(1.4, abs=1e-05) - def test_floatsum_objective_only(self): x = cp.boolvar(name="x") fs = cp.FloatSum([0.5], [x]) @@ -505,9 +492,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) @@ -724,30 +711,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): @@ -776,29 +739,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): 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") From 08011150f100f494848a7d829ecadd3dbc2f1d58 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Mon, 11 May 2026 15:58:00 +0200 Subject: [PATCH 08/26] update warning on floats --- cpmpy/expressions/globalfunctions.py | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index f65b03a04..5e24046b0 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -358,19 +358,11 @@ def __init__(self, x: ExprLike, y: ExprLike): is_lhs_num = False if is_num(x): if not is_int(x): - warnings.warn( - "Non-integer scalar multiplication is deprecated and will become an error in a future release. " - "Use FloatSum(coeffs, terms) for float-weight objectives.", - DeprecationWarning, - ) + warnings.warn("Mul: float constants are deprecated. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) is_lhs_num = True elif is_num(y): if not is_int(y): - warnings.warn( - "Non-integer scalar multiplication is deprecated and will become an error in a future release. " - "Use FloatSum(coeffs, terms) for float-weight objectives.", - DeprecationWarning, - ) + warnings.warn("Mul: float constants are deprecated. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) (x, y) = (y, x) is_lhs_num = True @@ -385,19 +377,11 @@ def update_args(self, args): is_lhs_num = False if is_num(x): if not is_int(x): - warnings.warn( - "Non-integer scalar multiplication is deprecated and will become an error in a future release. " - "Use FloatSum(coeffs, terms) for float-weight objectives.", - DeprecationWarning, - ) + warnings.warn("Mul: float constants are deprecated. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) is_lhs_num = True elif is_num(y): if not is_int(y): - warnings.warn( - "Non-integer scalar multiplication is deprecated and will become an error in a future release. " - "Use FloatSum(coeffs, terms) for float-weight objectives.", - DeprecationWarning, - ) + warnings.warn("Mul: float constants are deprecated. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) (x, y) = (y, x) is_lhs_num = True From 6625ccd9d83d5bcf22bc972fcbd9b25bdd99a826 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Mon, 11 May 2026 16:02:02 +0200 Subject: [PATCH 09/26] remove match from test --- tests/test_solvers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_solvers.py b/tests/test_solvers.py index 69803e734..05c7c9dbc 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -281,7 +281,7 @@ def test_ortools_version(self): def test_ortools_rejects_float_coefficients(self): m = cp.Model() x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) - with pytest.warns(DeprecationWarning, match="Non-integer scalar multiplication is deprecated"): + with pytest.warns(DeprecationWarning): m += 0.7 * x + 0.8 * y >= 1 pytest.raises(TypeError, m.solve) From 612a0bc8db3a17fa69e6c5ca94448f206d8f9bbc Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Sat, 30 May 2026 00:38:18 +0200 Subject: [PATCH 10/26] floatsum only vars --- cpmpy/expressions/globalfunctions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index 5e24046b0..9cfc5071b 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -80,8 +80,8 @@ def decompose(self): from ..exceptions import CPMpyException, IncompleteFunctionError, TypeError from .core import Expression, Operator, ExprLike, ListLike -from .variables import intvar, cpm_array, NDVarArray, _NumVarImpl, BoolVal -from .utils import flatlist, argval, is_num, is_int, 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 +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): @@ -1100,7 +1100,7 @@ class FloatSum: coeffs: np.ndarray terms: NDVarArray - def __init__(self, coeffs: ListLike[float|np.floating], terms: ListLike[Expression]): + def __init__(self, coeffs: ListLike[float|np.floating], terms: ListLike[_NumVarImpl]): self.coeffs = np.asarray(coeffs, dtype=float).reshape(-1) if isinstance(terms, NDVarArray): self.terms = terms @@ -1121,10 +1121,10 @@ def __repr__(self) -> str: return f"FloatSum({list(self.coeffs)}, {list(self.terms)})" def value(self) -> Optional[float]: - vals = argvals(self.terms) - if any(v is None for v in vals): + vals = argvals_intexpr(self.terms) + if vals is None: return None - return sum(c * v for c, v in zip(self.coeffs, vals)) + return int(np.dot(self.coeffs, vals)) def _raise_objective_only(self) -> NoReturn: raise TypeError("FloatSum is objective-only. Use it directly in Model.minimize()/maximize().") From 0b9c61cfba83a68a66423e7b6c3e047a9eecbf13 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Sat, 30 May 2026 01:18:08 +0200 Subject: [PATCH 11/26] simplify floatsum handling --- cpmpy/expressions/globalfunctions.py | 2 +- cpmpy/solvers/cplex.py | 22 ++++++++-------------- cpmpy/solvers/gurobi.py | 15 ++++----------- cpmpy/solvers/hexaly.py | 15 +++------------ cpmpy/solvers/minizinc.py | 16 ++++------------ cpmpy/solvers/ortools.py | 16 +++------------- cpmpy/solvers/scip.py | 20 ++++---------------- cpmpy/solvers/z3.py | 16 ++++------------ tests/test_direct.py | 6 ------ 9 files changed, 31 insertions(+), 97 deletions(-) diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index 9cfc5071b..8149d5f47 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -1124,7 +1124,7 @@ def value(self) -> Optional[float]: vals = argvals_intexpr(self.terms) if vals is None: return None - return int(np.dot(self.coeffs, vals)) + return float(np.dot(self.coeffs, vals)) def _raise_objective_only(self) -> NoReturn: raise TypeError("FloatSum is objective-only. Use it directly in Model.minimize()/maximize().") diff --git a/cpmpy/solvers/cplex.py b/cpmpy/solvers/cplex.py index 4bbd7bfde..9fa1459fd 100644 --- a/cpmpy/solvers/cplex.py +++ b/cpmpy/solvers/cplex.py @@ -54,15 +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.core import Comparison, Operator, BoolVal from ..expressions.globalfunctions import FloatSum -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.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, get_or_make_var +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 @@ -307,16 +307,10 @@ def objective(self, expr, minimize=True): """ if isinstance(expr, FloatSum): # save user variables - get_variables(expr.terms, self.user_vars) - vars_ = [] - flat_cons = [] - for term in expr.terms: - var, cons = get_or_make_var(term, csemap=self._csemap) - vars_.append(var) - flat_cons.extend(cons) - self.add(flat_cons) + vs, ws = expr.terms, expr.coeffs + self.user_vars.update(vs) self._obj_offset = 0 - cplex_obj = self.cplex_model.scal_prod(self.solver_vars(vars_), [float(w) for w in expr.coeffs]) + cplex_obj = self.cplex_model.scal_prod(self.solver_vars(vs), ws) else: # save user vars get_variables(expr, self.user_vars) diff --git a/cpmpy/solvers/gurobi.py b/cpmpy/solvers/gurobi.py index 77c4f715f..ef78a0496 100644 --- a/cpmpy/solvers/gurobi.py +++ b/cpmpy/solvers/gurobi.py @@ -54,7 +54,7 @@ from ..expressions.variables import _BoolVarImpl, NegBoolView, _IntVarImpl, _NumVarImpl, intvar from ..expressions.globalconstraints import DirectConstraint from ..transformations.comparison import only_numexpr_equality -from ..transformations.flatten_model import flatten_constraint, flatten_objective, get_or_make_var +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, decompose_linear, decompose_linear_objective from ..transformations.normalize import toplevel_list @@ -293,16 +293,9 @@ def objective(self, expr, minimize=True): from gurobipy import GRB if isinstance(expr, FloatSum): - # save user variables - get_variables(expr.terms, self.user_vars) - vars_ = [] - flat_cons = [] - for term in expr.terms: - var, cons = get_or_make_var(term, csemap=self._csemap) - vars_.append(var) - flat_cons.extend(cons) - self.add(flat_cons) - grb_obj = gp.quicksum(float(w) * self.solver_var(var) for w, var in zip(expr.coeffs, vars_)) + vs, ws = expr.terms, expr.coeffs + self.user_vars.update(vs) + grb_obj = gp.quicksum(w * sv for w, sv in zip(ws, self.solver_vars(vs))) else: # save user variables get_variables(expr, self.user_vars) diff --git a/cpmpy/solvers/hexaly.py b/cpmpy/solvers/hexaly.py index 0775c3015..4c3d09d9e 100644 --- a/cpmpy/solvers/hexaly.py +++ b/cpmpy/solvers/hexaly.py @@ -290,18 +290,9 @@ def objective(self, expr, minimize=True): from hexaly.optimizer import HxObjectiveDirection if isinstance(expr, FloatSum): - get_variables(expr.terms, self.user_vars) - hex_parts = [] - for c, t in zip(expr.coeffs, expr.terms): - obj_t, decomp_cons = decompose_objective( - t, - supported=self.supported_global_constraints, - supported_reified=self.supported_reified_global_constraints, - csemap=self._csemap, - ) - self.add(decomp_cons) - hex_parts.append(float(c) * self._hex_expr(obj_t)) - hex_obj = self.hex_model.sum(hex_parts) + vs, ws = expr.terms, expr.coeffs + self.user_vars.update(vs) + hex_obj = self.hex_model.sum(float(c) * self._hex_expr(t) for c, t in zip(ws, vs)) else: get_variables(expr, collect=self.user_vars) obj, decomp_cons = decompose_objective( diff --git a/cpmpy/solvers/minizinc.py b/cpmpy/solvers/minizinc.py index 342756d1d..37b2a834c 100644 --- a/cpmpy/solvers/minizinc.py +++ b/cpmpy/solvers/minizinc.py @@ -575,18 +575,10 @@ def objective(self, expr, minimize): """ if isinstance(expr, FloatSum): - get_variables(expr.terms, self.user_vars) - mzn_parts = [] - for c, t in zip(expr.coeffs, expr.terms): - obj_t, decomp_cons = decompose_objective( - t, - supported=self.supported_global_constraints, - supported_reified=self.supported_reified_global_constraints, - csemap=self._csemap, - ) - self.add(decomp_cons) - mzn_sub = self._convert_expression(obj_t) - mzn_parts.append("({0:g}) * ({1})".format(float(c), mzn_sub)) + vs, ws = expr.terms, expr.coeffs + self.user_vars.update(vs) + mzn_parts = ["({0:g}) * ({1})".format(float(c), self._convert_expression(t)) + for c, t in zip(ws, vs)] mzn_obj = " + ".join(mzn_parts) else: get_variables(expr, collect=self.user_vars) # add objvars to vars diff --git a/cpmpy/solvers/ortools.py b/cpmpy/solvers/ortools.py index 3077326cd..d23a3315e 100644 --- a/cpmpy/solvers/ortools.py +++ b/cpmpy/solvers/ortools.py @@ -356,21 +356,11 @@ def objective(self, expr, minimize): are premanently posted to the solver """ - # special case: a FloatSum objective if isinstance(expr, FloatSum): - # save user varables - get_variables(expr.terms, self.user_vars) + vs, ws = expr.terms, expr.coeffs + self.user_vars.update(vs) + ort_obj = ort.LinearExpr.weighted_sum(self.solver_vars(vs), ws) - # transform objective - vars_ = [] - flat_cons = [] - for term in expr.terms: - var, cons = get_or_make_var(term, csemap=self._csemap) - vars_.append(var) - flat_cons.extend(cons) - self.add(flat_cons) - ort_obj = ort.LinearExpr.weighted_sum(self.solver_vars(vars_), expr.coeffs) - else: # normal case, a CPMpy Expression # save user varables get_variables(expr, self.user_vars) diff --git a/cpmpy/solvers/scip.py b/cpmpy/solvers/scip.py index e4ddf0ec6..5b98ae842 100644 --- a/cpmpy/solvers/scip.py +++ b/cpmpy/solvers/scip.py @@ -33,7 +33,7 @@ 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, get_or_make_var +from ..transformations.flatten_model import flatten_constraint, flatten_objective from ..transformations.get_variables import get_variables from ..transformations.linearize import decompose_linear, decompose_linear_objective, linearize_constraint, linearize_reified_variables, only_positive_bv, only_positive_bv_wsum from ..transformations.normalize import toplevel_list @@ -220,23 +220,11 @@ def solver_var(self, cpm_var): def objective(self, expr, minimize=True): if isinstance(expr, FloatSum): - get_variables(expr.terms, 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)) - - vars_ = [] - flat_cons = [] - for term in expr.terms: - var, cons = get_or_make_var(term, csemap=self._csemap) - vars_.append(var) - flat_cons.extend(cons) - - # transform and add constraints (via `_add_transformed_constraint` as to not pollute `user_vars`) - for cpm_expr in self.transform(flat_cons): - self._add_transformed_constraint(cpm_expr) + vs, ws = expr.terms, expr.coeffs + self.user_vars.update(vs) import pyscipopt as scip - scip_obj = scip.quicksum(float(w) * self.solver_var(var) for w, var in zip(expr.coeffs, vars_)) + scip_obj = scip.quicksum(w * sv for w, sv in zip(ws, self.solver_vars(vs))) 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) diff --git a/cpmpy/solvers/z3.py b/cpmpy/solvers/z3.py index af54b2b17..e388e1819 100644 --- a/cpmpy/solvers/z3.py +++ b/cpmpy/solvers/z3.py @@ -56,7 +56,6 @@ 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 -from ..transformations.flatten_model import get_or_make_var from ..transformations.normalize import toplevel_list from ..transformations.safening import no_partial_functions, safen_objective @@ -330,22 +329,15 @@ def objective(self, expr, minimize=True): raise NotSupportedError("Use the z3 optimizer for optimization problems") if isinstance(expr, FloatSum): - # save user variables - get_variables(expr.terms, self.user_vars) - vars_ = [] - flat_cons = [] - for term in expr.terms: - var, cons = get_or_make_var(term, csemap=self._csemap) - vars_.append(var) - flat_cons.extend(cons) - self.add(flat_cons) + vs, ws = expr.terms, expr.coeffs + self.user_vars.update(vs) weighted_terms = [] - for coeff, var in zip(expr.coeffs, vars_): + for coeff, var in zip(ws, vs): z3_term = self._z3_expr(var) if isinstance(z3_term, z3.BoolRef): z3_term = z3.If(z3_term, 1, 0) - weighted_terms.append(float(coeff) * z3_term) + weighted_terms.append(coeff * z3_term) z3_obj = z3.Sum(weighted_terms) else: # save user variables diff --git a/tests/test_direct.py b/tests/test_direct.py index d48c9a95d..e0d0f9167 100644 --- a/tests/test_direct.py +++ b/tests/test_direct.py @@ -154,9 +154,3 @@ def test_direct_distance(self): assert model.solve() assert abs(a.value() - b.value()) >=3 - - def test_floatsum_objective(self): - x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) - m = cp.Model(maximize=cp.FloatSum([0.3, 0.5, 0.6], [x, y, z])) - assert m.solve(solver="hexaly") - assert m.objective_value() == pytest.approx(1.4, abs=1e-05) From 4cd2fcfe6e774a21fe2e7eaf51804f87cbd9abf3 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Sat, 30 May 2026 18:01:55 +0200 Subject: [PATCH 12/26] z3: remove rational, just get decimal --- cpmpy/solvers/z3.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cpmpy/solvers/z3.py b/cpmpy/solvers/z3.py index e388e1819..8a20a29ce 100644 --- a/cpmpy/solvers/z3.py +++ b/cpmpy/solvers/z3.py @@ -259,8 +259,6 @@ def solve(self, time_limit:Optional[float]=None, assumptions:Optional[Iterable[_ obj_val = sol.evaluate(obj) if z3.is_int_value(obj_val): self.objective_value_ = obj_val.as_long() - elif z3.is_rational_value(obj_val): - self.objective_value_ = obj_val.numerator_as_long() / obj_val.denominator_as_long() else: self.objective_value_ = float(obj_val.as_decimal(20).rstrip("?")) if not self._minimize: From b8eac2fef1f09d814cb51a29756d86b7407ddd0a Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Sat, 30 May 2026 18:21:06 +0200 Subject: [PATCH 13/26] further tweaks --- cpmpy/solvers/cplex.py | 3 +-- cpmpy/solvers/gurobi.py | 2 +- cpmpy/solvers/hexaly.py | 2 +- cpmpy/solvers/minizinc.py | 5 ++--- cpmpy/solvers/ortools.py | 2 +- cpmpy/solvers/scip.py | 2 +- cpmpy/solvers/z3.py | 17 +++++------------ tests/test_solvers.py | 2 +- 8 files changed, 13 insertions(+), 22 deletions(-) diff --git a/cpmpy/solvers/cplex.py b/cpmpy/solvers/cplex.py index 9fa1459fd..08786ed5d 100644 --- a/cpmpy/solvers/cplex.py +++ b/cpmpy/solvers/cplex.py @@ -306,9 +306,8 @@ def objective(self, expr, minimize=True): are premanently posted to the solver """ if isinstance(expr, FloatSum): - # save user variables vs, ws = expr.terms, expr.coeffs - self.user_vars.update(vs) + self.user_vars.update(vs) # save user variables self._obj_offset = 0 cplex_obj = self.cplex_model.scal_prod(self.solver_vars(vs), ws) else: diff --git a/cpmpy/solvers/gurobi.py b/cpmpy/solvers/gurobi.py index ef78a0496..45664351d 100644 --- a/cpmpy/solvers/gurobi.py +++ b/cpmpy/solvers/gurobi.py @@ -294,7 +294,7 @@ def objective(self, expr, minimize=True): if isinstance(expr, FloatSum): vs, ws = expr.terms, expr.coeffs - self.user_vars.update(vs) + self.user_vars.update(vs) # save user variables grb_obj = gp.quicksum(w * sv for w, sv in zip(ws, self.solver_vars(vs))) else: # save user variables diff --git a/cpmpy/solvers/hexaly.py b/cpmpy/solvers/hexaly.py index 4c3d09d9e..cc1dc3a3b 100644 --- a/cpmpy/solvers/hexaly.py +++ b/cpmpy/solvers/hexaly.py @@ -291,7 +291,7 @@ def objective(self, expr, minimize=True): if isinstance(expr, FloatSum): vs, ws = expr.terms, expr.coeffs - self.user_vars.update(vs) + 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)) else: get_variables(expr, collect=self.user_vars) diff --git a/cpmpy/solvers/minizinc.py b/cpmpy/solvers/minizinc.py index 37b2a834c..6292f1a1a 100644 --- a/cpmpy/solvers/minizinc.py +++ b/cpmpy/solvers/minizinc.py @@ -576,9 +576,8 @@ def objective(self, expr, minimize): if isinstance(expr, FloatSum): vs, ws = expr.terms, expr.coeffs - self.user_vars.update(vs) - mzn_parts = ["({0:g}) * ({1})".format(float(c), self._convert_expression(t)) - for c, t in zip(ws, vs)] + 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) else: get_variables(expr, collect=self.user_vars) # add objvars to vars diff --git a/cpmpy/solvers/ortools.py b/cpmpy/solvers/ortools.py index d23a3315e..cabeac642 100644 --- a/cpmpy/solvers/ortools.py +++ b/cpmpy/solvers/ortools.py @@ -358,7 +358,7 @@ def objective(self, expr, minimize): if isinstance(expr, FloatSum): vs, ws = expr.terms, expr.coeffs - self.user_vars.update(vs) + self.user_vars.update(vs) # save user varables ort_obj = ort.LinearExpr.weighted_sum(self.solver_vars(vs), ws) else: # normal case, a CPMpy Expression diff --git a/cpmpy/solvers/scip.py b/cpmpy/solvers/scip.py index 5b98ae842..da536a3a2 100644 --- a/cpmpy/solvers/scip.py +++ b/cpmpy/solvers/scip.py @@ -221,7 +221,7 @@ def solver_var(self, cpm_var): def objective(self, expr, minimize=True): if isinstance(expr, FloatSum): vs, ws = expr.terms, expr.coeffs - self.user_vars.update(vs) + self.user_vars.update(vs) # save user varables import pyscipopt as scip scip_obj = scip.quicksum(w * sv for w, sv in zip(ws, self.solver_vars(vs))) diff --git a/cpmpy/solvers/z3.py b/cpmpy/solvers/z3.py index 8a20a29ce..df70081f3 100644 --- a/cpmpy/solvers/z3.py +++ b/cpmpy/solvers/z3.py @@ -328,15 +328,8 @@ def objective(self, expr, minimize=True): if isinstance(expr, FloatSum): vs, ws = expr.terms, expr.coeffs - self.user_vars.update(vs) - - weighted_terms = [] - for coeff, var in zip(ws, vs): - z3_term = self._z3_expr(var) - if isinstance(z3_term, z3.BoolRef): - z3_term = z3.If(z3_term, 1, 0) - weighted_terms.append(coeff * z3_term) - z3_obj = z3.Sum(weighted_terms) + self.user_vars.update(vs) # update user variables + z3_obj = z3.Sum([w*v for w,v in zip(ws,self.solver_vars(vs))]) else: # save user variables get_variables(expr, self.user_vars) @@ -349,10 +342,10 @@ def objective(self, expr, minimize=True): csemap=self._csemap) self.add(safe_cons + decomp_cons) - z3_obj = self._z3_expr(obj) - if isinstance(z3_obj, z3.BoolRef): - z3_obj = z3.If(z3_obj, 1, 0) # must be integer + + 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) diff --git a/tests/test_solvers.py b/tests/test_solvers.py index f71b99d8f..da7c7bb4d 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -888,7 +888,7 @@ def test_floatsum_objective(self, solver): x, y, z = cp.boolvar(shape=3, name=tuple("xyz")) m = cp.Model(maximize=cp.FloatSum([0.3, 0.5, 0.6], [x, y, z])) assert m.solve(solver=solver) - assert m.objective_value() == pytest.approx(1.4, abs=1e-05) + assert m.objective_value() == 1.4 def test_value_cleared(self, solver): x, y, z = cp.boolvar(shape=3) From 22e5a49b635cd224cedc80ac00ff18c0de607542 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Sat, 30 May 2026 21:40:35 +0200 Subject: [PATCH 14/26] refactor: have const, rewrite negboolviews --- cpmpy/expressions/__init__.py | 1 + cpmpy/expressions/globalfunctions.py | 76 ++++++++++++++++++++-------- cpmpy/solvers/cplex.py | 6 +-- cpmpy/solvers/gurobi.py | 6 ++- cpmpy/solvers/hexaly.py | 4 +- cpmpy/solvers/highs.py | 52 +++++++++++-------- cpmpy/solvers/minizinc.py | 4 +- cpmpy/solvers/ortools.py | 9 ++-- cpmpy/solvers/scip.py | 6 +-- cpmpy/solvers/solver_interface.py | 3 +- cpmpy/solvers/z3.py | 4 +- tests/test_solvers.py | 24 +++++++-- 12 files changed, 132 insertions(+), 63 deletions(-) diff --git a/cpmpy/expressions/__init__.py b/cpmpy/expressions/__init__.py index 2558580ea..b053e1034 100644 --- a/cpmpy/expressions/__init__.py +++ b/cpmpy/expressions/__init__.py @@ -93,6 +93,7 @@ "Among", "NValue", "NValueExcept", +# Objective-only (not an Expression) "FloatSum", # Global constraints "AllDifferent", diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index 8149d5f47..1968d6b2e 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 =============== @@ -74,13 +81,13 @@ def decompose(self): """ import warnings # for deprecation warning -from typing import Optional, Iterable, NoReturn, Final +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, cpm_array, NDVarArray, _NumVarImpl +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 @@ -1089,42 +1096,71 @@ def get_bounds(self) -> tuple[int, int]: class FloatSum: """ - Objective-only weighted sum with float coefficients over integer expressions. + 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... - Accepts only (numpy) floats as coefficients, and Expressions as terms. + 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 - terms: NDVarArray + vars: NDVarArray + const: float - def __init__(self, coeffs: ListLike[float|np.floating], terms: ListLike[_NumVarImpl]): + 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) - if isinstance(terms, NDVarArray): - self.terms = terms + self.const = float(const) + if isinstance(vars, NDVarArray): + self.vars = vars else: - self.terms = cpm_array(terms) - if self.terms.ndim > 1: # must reshape to 1D - # typing is wrong: numpy preserves our ndarray subclass - flat = self.terms.reshape(-1) - assert isinstance(flat, NDVarArray) # true: numpy preserves our ndarray subclass - self.terms = flat - - if self.coeffs.size != self.terms.size: - raise TypeError(f"FloatSum(coeffs, terms) expects equal lengths, got {self.coeffs.size} coefficients and {self.terms.size} terms") + 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: - return f"FloatSum({list(self.coeffs)}, {list(self.terms)})" + 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.terms) + vals = argvals_intexpr(self.vars) if vals is None: return None - return float(np.dot(self.coeffs, vals)) + return float(np.dot(self.coeffs, vals) + self.const) + + def components(self, negbool=False) -> tuple[NDVarArray, np.ndarray, 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().") diff --git a/cpmpy/solvers/cplex.py b/cpmpy/solvers/cplex.py index 08786ed5d..abe748899 100644 --- a/cpmpy/solvers/cplex.py +++ b/cpmpy/solvers/cplex.py @@ -306,9 +306,9 @@ def objective(self, expr, minimize=True): are premanently posted to the solver """ if isinstance(expr, FloatSum): - vs, ws = expr.terms, expr.coeffs - self.user_vars.update(vs) # save user variables - self._obj_offset = 0 + 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 diff --git a/cpmpy/solvers/gurobi.py b/cpmpy/solvers/gurobi.py index 45664351d..7f2ee569d 100644 --- a/cpmpy/solvers/gurobi.py +++ b/cpmpy/solvers/gurobi.py @@ -293,9 +293,11 @@ def objective(self, expr, minimize=True): from gurobipy import GRB if isinstance(expr, FloatSum): - vs, ws = expr.terms, expr.coeffs + ws, vs, const = expr.components() self.user_vars.update(vs) # save user variables - grb_obj = gp.quicksum(w * sv for w, sv in zip(ws, self.solver_vars(vs))) + + 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) diff --git a/cpmpy/solvers/hexaly.py b/cpmpy/solvers/hexaly.py index cc1dc3a3b..3faf9f477 100644 --- a/cpmpy/solvers/hexaly.py +++ b/cpmpy/solvers/hexaly.py @@ -290,9 +290,9 @@ def objective(self, expr, minimize=True): from hexaly.optimizer import HxObjectiveDirection if isinstance(expr, FloatSum): - vs, ws = expr.terms, expr.coeffs + 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)) + 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( diff --git a/cpmpy/solvers/highs.py b/cpmpy/solvers/highs.py index 289d636e9..b86291de2 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 6292f1a1a..577f67b6d 100644 --- a/cpmpy/solvers/minizinc.py +++ b/cpmpy/solvers/minizinc.py @@ -575,10 +575,12 @@ def objective(self, expr, minimize): """ if isinstance(expr, FloatSum): - vs, ws = expr.terms, expr.coeffs + 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 = mzn_obj + " + " + const else: get_variables(expr, collect=self.user_vars) # add objvars to vars obj, decomp_cons = decompose_objective( diff --git a/cpmpy/solvers/ortools.py b/cpmpy/solvers/ortools.py index cabeac642..4e638c504 100644 --- a/cpmpy/solvers/ortools.py +++ b/cpmpy/solvers/ortools.py @@ -357,11 +357,10 @@ def objective(self, expr, minimize): """ if isinstance(expr, FloatSum): - vs, ws = expr.terms, expr.coeffs - self.user_vars.update(vs) # save user varables - ort_obj = ort.LinearExpr.weighted_sum(self.solver_vars(vs), ws) - - else: # normal case, a CPMpy Expression + 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) diff --git a/cpmpy/solvers/scip.py b/cpmpy/solvers/scip.py index da536a3a2..f1a4d398f 100644 --- a/cpmpy/solvers/scip.py +++ b/cpmpy/solvers/scip.py @@ -220,11 +220,11 @@ def solver_var(self, cpm_var): def objective(self, expr, minimize=True): if isinstance(expr, FloatSum): - vs, ws = expr.terms, expr.coeffs - self.user_vars.update(vs) # save user varables + 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))) + 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) 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 df70081f3..51f3ec7a8 100644 --- a/cpmpy/solvers/z3.py +++ b/cpmpy/solvers/z3.py @@ -327,9 +327,9 @@ def objective(self, expr, minimize=True): raise NotSupportedError("Use the z3 optimizer for optimization problems") if isinstance(expr, FloatSum): - vs, ws = expr.terms, expr.coeffs + ws, vs, const = expr.components() self.user_vars.update(vs) # update user variables - z3_obj = z3.Sum([w*v for w,v in zip(ws,self.solver_vars(vs))]) + 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) diff --git a/tests/test_solvers.py b/tests/test_solvers.py index da7c7bb4d..8de1ca142 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -805,7 +805,7 @@ def test_pumpkin_proof(self): @pytest.mark.usefixtures("solver") class TestSupportedSolvers: - _floatsum_supported_solvers = frozenset({"ortools", "gurobi", "cplex", "scip", "z3", "hexaly", "minizinc"}) + _floatsum_supported_solvers = frozenset({"ortools", "gurobi", "cplex", "scip", "z3", "hexaly", "minizinc", "highs"}) def test_installed_solvers(self, solver): # basic model @@ -884,11 +884,27 @@ def test_objective(self, solver): 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")) - m = cp.Model(maximize=cp.FloatSum([0.3, 0.5, 0.6], [x, y, z])) - assert m.solve(solver=solver) - assert m.objective_value() == 1.4 + 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) From c2fba6501aea44d65fad14ef44d3945b8c631ef7 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Sat, 30 May 2026 21:49:29 +0200 Subject: [PATCH 15/26] type update --- cpmpy/expressions/globalfunctions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index 1968d6b2e..06fa86ced 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -1139,7 +1139,7 @@ def value(self) -> Optional[float]: return None return float(np.dot(self.coeffs, vals) + self.const) - def components(self, negbool=False) -> tuple[NDVarArray, np.ndarray, float]: + def components(self, negbool=False) -> tuple[np.ndarray, NDVarArray, float]: """ Return ``(coeffs, vars, const)`` From a70723f5e46b6a19dcd87934988d2de986360490 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Sun, 31 May 2026 17:35:04 +0200 Subject: [PATCH 16/26] always reshape, also if input is already ndvararr --- cpmpy/expressions/globalfunctions.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index 8149d5f47..0a44801ac 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -1089,7 +1089,7 @@ def get_bounds(self) -> tuple[int, int]: class FloatSum: """ - Objective-only weighted sum with float coefficients over integer expressions. + Objective-only weighted sum with float coefficients over integer variables. Does not inherit from Expression because it is objective only and has float .value() Basically it is breaking all design rules of CPMpy... @@ -1106,11 +1106,13 @@ def __init__(self, coeffs: ListLike[float|np.floating], terms: ListLike[_NumVarI self.terms = terms else: self.terms = cpm_array(terms) - if self.terms.ndim > 1: # must reshape to 1D - # typing is wrong: numpy preserves our ndarray subclass - flat = self.terms.reshape(-1) - assert isinstance(flat, NDVarArray) # true: numpy preserves our ndarray subclass - self.terms = flat + + # ensure terms is a 1D array + if self.terms.ndim > 1: + # typing is wrong: numpy preserves our ndarray subclass + flat = self.terms.reshape(-1) + assert isinstance(flat, NDVarArray) # true: numpy preserves our ndarray subclass + self.terms = flat if self.coeffs.size != self.terms.size: raise TypeError(f"FloatSum(coeffs, terms) expects equal lengths, got {self.coeffs.size} coefficients and {self.terms.size} terms") From 24cc4e5dd216ee5de3d49fb3a6b3198b65d7f1e3 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Sun, 31 May 2026 17:35:28 +0200 Subject: [PATCH 17/26] add floatsum support for highs --- cpmpy/solvers/highs.py | 49 +++++++++++++++++++++++++++++------------- tests/test_solvers.py | 2 +- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/cpmpy/solvers/highs.py b/cpmpy/solvers/highs.py index 289d636e9..962953490 100644 --- a/cpmpy/solvers/highs.py +++ b/cpmpy/solvers/highs.py @@ -51,6 +51,7 @@ from ..expressions.utils import is_num, is_int from ..expressions.variables import _BoolVarImpl, NegBoolView, _IntVarImpl, _NumVarImpl, intvar from ..expressions.globalconstraints import DirectConstraint +from ..expressions.globalfunctions import FloatSum from ..transformations.comparison import only_numexpr_equality from ..transformations.flatten_model import flatten_constraint, flatten_objective from ..transformations.get_variables import get_variables @@ -285,24 +286,42 @@ def objective(self, expr, minimize=True): """ import highspy - get_variables(expr, collect=self.user_vars) + if isinstance(expr, FloatSum): + # HiGHS' column costs are float-native, so post coeffs directly + vs, ws = expr.terms, expr.coeffs + self.user_vars.update(vs) - 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) + solvars = self.solver_vars(list(vs)) + indices = np.asarray(solvars, dtype=np.int32) + values = np.asarray(ws, dtype=np.float64) - self.add(safe_cons + decomp_cons + flat_cons) + # merge duplicate columns (same var used multiple times in the FloatSum) + if np.unique(indices).size != indices.size: + new_idx, group = np.unique(indices, return_inverse=True) + new_coeffs = np.bincount(group, weights=values).astype(np.float64) + sel = new_coeffs != 0 # drop columns whose aggregated coeff cancels to 0 + indices, values = new_idx[sel], new_coeffs[sel] - indices, values, const = self._row_from_linexpr(obj) - const += obj_const + const = 0.0 + 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/tests/test_solvers.py b/tests/test_solvers.py index f71b99d8f..c1b05f65a 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -805,7 +805,7 @@ def test_pumpkin_proof(self): @pytest.mark.usefixtures("solver") class TestSupportedSolvers: - _floatsum_supported_solvers = frozenset({"ortools", "gurobi", "cplex", "scip", "z3", "hexaly", "minizinc"}) + _floatsum_supported_solvers = frozenset({"ortools", "gurobi", "cplex", "scip", "z3", "hexaly", "minizinc", "highs"}) def test_installed_solvers(self, solver): # basic model From cb215f4be68fbcd4ef7cb843db25c5562814417d Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Tue, 2 Jun 2026 10:35:19 +0200 Subject: [PATCH 18/26] mzn writing of floatsum: make sure its string --- cpmpy/solvers/minizinc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpmpy/solvers/minizinc.py b/cpmpy/solvers/minizinc.py index 577f67b6d..df7ace585 100644 --- a/cpmpy/solvers/minizinc.py +++ b/cpmpy/solvers/minizinc.py @@ -580,7 +580,7 @@ def objective(self, expr, minimize): 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 = mzn_obj + " + " + const + mzn_obj = f"{mzn_obj} + {const}" else: get_variables(expr, collect=self.user_vars) # add objvars to vars obj, decomp_cons = decompose_objective( From 893521954fd7085dfc962825e9365f0cc07d8b31 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Tue, 2 Jun 2026 10:37:15 +0200 Subject: [PATCH 19/26] potential bug --- cpmpy/solvers/minizinc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpmpy/solvers/minizinc.py b/cpmpy/solvers/minizinc.py index df7ace585..01e858402 100644 --- a/cpmpy/solvers/minizinc.py +++ b/cpmpy/solvers/minizinc.py @@ -786,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) From 83cde9d6499035e547a95195b3f5275d8e9119a3 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Wed, 3 Jun 2026 08:50:47 +0200 Subject: [PATCH 20/26] handle wsum upfront --- cpmpy/expressions/core.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cpmpy/expressions/core.py b/cpmpy/expressions/core.py index ba5f36c67..3640a7e85 100644 --- a/cpmpy/expressions/core.py +++ b/cpmpy/expressions/core.py @@ -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}" From 3e868ad8c212a90af858a6d739834b1caec4b140 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Wed, 3 Jun 2026 09:09:49 +0200 Subject: [PATCH 21/26] no more flatlist, plus fix bug --- cpmpy/expressions/core.py | 5 +++-- cpmpy/transformations/linearize.py | 17 ++++++++++++++--- tests/test_int2bool.py | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/cpmpy/expressions/core.py b/cpmpy/expressions/core.py index 3640a7e85..b063c1873 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_any_list, get_bounds, is_boolexpr, is_true_cst, is_false_cst, argvals, is_bool from ..exceptions import TypeError # Common typing helpers @@ -690,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) 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_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) Date: Wed, 3 Jun 2026 10:30:08 +0200 Subject: [PATCH 22/26] float warning/test tweak --- cpmpy/expressions/core.py | 2 +- cpmpy/expressions/globalfunctions.py | 4 ++-- tests/test_solvers.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cpmpy/expressions/core.py b/cpmpy/expressions/core.py index b063c1873..b646b63c5 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, 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 diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index 06fa86ced..d4ddba4a7 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -365,11 +365,11 @@ 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. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) + warnings.warn("Mul: float constants are deprecated and converted to int. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) is_lhs_num = True elif is_num(y): if not is_int(y): - warnings.warn("Mul: float constants are deprecated. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) + warnings.warn("Mul: float constants are deprecated and converted to int. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) (x, y) = (y, x) is_lhs_num = True diff --git a/tests/test_solvers.py b/tests/test_solvers.py index a09fccdb9..69c23125a 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -285,7 +285,7 @@ def test_ortools_rejects_float_coefficients(self): 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) + #pytest.raises(TypeError, m.solve) def test_floatsum_objective_only(self): x = cp.boolvar(name="x") From 19cb740f95dcd26c271147f9e35e199a8ea8b137 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Wed, 3 Jun 2026 14:06:00 +0200 Subject: [PATCH 23/26] some more int/type changes --- cpmpy/expressions/core.py | 41 +++++++++++++--------------- cpmpy/expressions/globalfunctions.py | 12 ++++++-- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/cpmpy/expressions/core.py b/cpmpy/expressions/core.py index b646b63c5..9bc17982c 100644 --- a/cpmpy/expressions/core.py +++ b/cpmpy/expressions/core.py @@ -701,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] + ws: list[ExprLike] = [w for ws, _ in wvs for w in ws] + vs: list[ExprLike] = [v for _, vs in wvs for v in vs] + super().__init__("wsum", (ws, vs)) + return # small cleanup: nested n-ary operators are merged into the toplevel # (this is actually against our design principle of creating @@ -844,8 +838,11 @@ def _wsum_should(arg) -> bool: (negation '-' does not mean it SHOULD be a wsum, because then all subtractions are transformed into less readable wsums) """ - name = getattr(arg, 'name', None) - return name == 'wsum' or (name == 'mul' and arg.is_lhs_num) + if isinstance(arg, Operator): + return arg.name == 'wsum' + if getattr(arg, 'name', None) == 'mul': + return arg.is_lhs_num + return False def _wsum_make(arg) -> tuple[list[int], list[ExprLike]]: """ Internal helper: prep the arg for wsum diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index d4ddba4a7..971a2b008 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -366,10 +366,14 @@ def __init__(self, x: ExprLike, y: ExprLike): 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 @@ -384,11 +388,15 @@ def update_args(self, args): is_lhs_num = False if is_num(x): if not is_int(x): - warnings.warn("Mul: float constants are deprecated. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) + 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. Some solvers support the new FloatSum() in the objective.", DeprecationWarning) + 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 From 804bc85f86b4e34eb821d8fe0f2814adfb6edabc Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Wed, 3 Jun 2026 14:27:57 +0200 Subject: [PATCH 24/26] minor rewrite --- cpmpy/expressions/core.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cpmpy/expressions/core.py b/cpmpy/expressions/core.py index 9bc17982c..e858e5980 100644 --- a/cpmpy/expressions/core.py +++ b/cpmpy/expressions/core.py @@ -838,11 +838,8 @@ def _wsum_should(arg) -> bool: (negation '-' does not mean it SHOULD be a wsum, because then all subtractions are transformed into less readable wsums) """ - if isinstance(arg, Operator): - return arg.name == 'wsum' - if getattr(arg, 'name', None) == 'mul': - return arg.is_lhs_num - return False + name = getattr(arg, 'name', None) + return name == 'wsum' or (name == 'mul' and arg.is_lhs_num) def _wsum_make(arg) -> tuple[list[int], list[ExprLike]]: """ Internal helper: prep the arg for wsum From b4ffa8de08c739ff3b882b493000069e8a60c0df Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Wed, 3 Jun 2026 14:28:27 +0200 Subject: [PATCH 25/26] globalfunc ndvararray flattening --- cpmpy/expressions/globalfunctions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index 971a2b008..2463d6bed 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -877,6 +877,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]]: @@ -938,6 +940,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]]: @@ -991,6 +995,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]]: @@ -1056,6 +1062,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]]: From bda08de2b3e416f8b949b2e86d3cb0a4bae37fb3 Mon Sep 17 00:00:00 2001 From: Tias Guns Date: Wed, 3 Jun 2026 23:09:22 +0200 Subject: [PATCH 26/26] type fixes --- cpmpy/expressions/core.py | 6 +++--- cpmpy/expressions/globalfunctions.py | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cpmpy/expressions/core.py b/cpmpy/expressions/core.py index e858e5980..245941316 100644 --- a/cpmpy/expressions/core.py +++ b/cpmpy/expressions/core.py @@ -711,9 +711,9 @@ def __init__(self, name: str, arg_list: Sequence[ExprLike | ListLike[ExprLike]]) break if saw_wsum and all_expr: wvs = [_wsum_make(a) for a in arg_list] - ws: list[ExprLike] = [w for ws, _ in wvs for w in ws] - vs: list[ExprLike] = [v for _, vs in wvs for v in vs] - super().__init__("wsum", (ws, vs)) + 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 diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py index 2463d6bed..6c7360626 100644 --- a/cpmpy/expressions/globalfunctions.py +++ b/cpmpy/expressions/globalfunctions.py @@ -367,12 +367,14 @@ def __init__(self, x: ExprLike, y: ExprLike): 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