Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7a9b83d
floatsum to still enable floats in the objective
tias Apr 24, 2026
3cf96c9
listlike: switch from invariant list[T] to covariant Sequence[t]
tias Apr 24, 2026
15b70a1
floatsum: type corrections
tias Apr 24, 2026
9ceca83
more FloatSum support
tias Apr 26, 2026
6fee25c
add floatsum for hexaly, untested
tias Apr 26, 2026
0c11f32
and minizinc, also untested
tias Apr 26, 2026
e1b5477
tests: floats are no longer (inconsistently) supported
tias Apr 26, 2026
9083f47
Merge remote-tracking branch 'origin/master' into floatsum
tias May 11, 2026
0801115
update warning on floats
tias May 11, 2026
6625ccd
remove match from test
tias May 11, 2026
2084005
Merge remote-tracking branch 'origin/master' into floatsum
IgnaceBleukx May 19, 2026
48cd0b5
Merge remote-tracking branch 'origin/master' into floatsum
IgnaceBleukx May 19, 2026
74736a4
Merge remote-tracking branch 'origin/master' into floatsum
tias May 29, 2026
612a0bc
floatsum only vars
tias May 29, 2026
0b9c61c
simplify floatsum handling
tias May 29, 2026
4cd2fcf
z3: remove rational, just get decimal
tias May 30, 2026
9d0a0bd
Merge branch 'master' into floatsum
tias May 30, 2026
b8eac2f
further tweaks
tias May 30, 2026
22e5a49
refactor: have const, rewrite negboolviews
tias May 30, 2026
c2fba65
type update
tias May 30, 2026
acd820a
Merge branch 'floatsum' of github.com:CPMpy/cpmpy into floatsum
IgnaceBleukx May 31, 2026
a70723f
always reshape, also if input is already ndvararr
IgnaceBleukx May 31, 2026
24cc4e5
add floatsum support for highs
IgnaceBleukx May 31, 2026
f4144f9
Merge remote-tracking branch 'refs/remotes/origin/floatsum' into floa…
tias May 31, 2026
cb215f4
mzn writing of floatsum: make sure its string
tias Jun 2, 2026
8935219
potential bug
tias Jun 2, 2026
5d7efbb
Merge branch 'master' into mypy_operator
tias Jun 3, 2026
83cde9d
handle wsum upfront
tias Jun 3, 2026
3e868ad
no more flatlist, plus fix bug
tias Jun 3, 2026
acc233a
float warning/test tweak
tias Jun 3, 2026
19cb740
some more int/type changes
tias Jun 3, 2026
804bc85
minor rewrite
tias Jun 3, 2026
b4ffa8d
globalfunc ndvararray flattening
tias Jun 3, 2026
9914c67
Merge remote-tracking branch 'origin/floatsum' into mypy_operator
tias Jun 3, 2026
bda08de
type fixes
tias Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cpmpy/expressions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
Among,
NValue,
NValueExcept,
FloatSum,
)
from .core import BoolVal
from .python_builtins import all, any, max, min, sum, abs
Expand All @@ -92,6 +93,8 @@
"Among",
"NValue",
"NValueExcept",
# Objective-only (not an Expression)
"FloatSum",
# Global constraints
"AllDifferent",
"AllDifferentExcept0",
Expand Down
53 changes: 31 additions & 22 deletions cpmpy/expressions/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
import numpy as np
import cpmpy as cp

from .utils import is_num, is_any_list, flatlist, get_bounds, is_boolexpr, is_true_cst, is_false_cst, argvals, is_bool
from .utils import is_num, is_int, is_any_list, get_bounds, is_boolexpr, is_true_cst, is_false_cst, argvals, is_bool
from ..exceptions import TypeError

# Common typing helpers
Expand Down Expand Up @@ -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}"
Expand All @@ -676,7 +690,8 @@ def __init__(self, name: str, arg_list: Sequence[ExprLike | ListLike[ExprLike]])
if not is_boolexpr(arg):
raise TypeError("{}-operator only accepts boolean arguments, not {}".format(name,arg))
if arity == 0:
arg_list = flatlist(arg_list)
if isinstance(arg_list, np.ndarray) and arg_list.ndim != 1:
arg_list = arg_list.reshape(-1).tolist()
assert (len(arg_list) >= 1), "Operator: n-ary operators require at least one argument"
else:
assert (len(arg_list) == arity), "Operator: {}, number of arguments must be {}".format(name, arity)
Expand All @@ -686,26 +701,20 @@ def __init__(self, name: str, arg_list: Sequence[ExprLike | ListLike[ExprLike]])
# and one of the args is a wsum,
# or a product of a constant and an expression,
# then create a wsum of weights,expressions over all
if name == 'sum' and \
all(not is_num(a) for a in arg_list) and \
any(_wsum_should(a) for a in arg_list):
we = [_wsum_make(a) for a in arg_list]
w: list[ExprLike] = [wi for w, _ in we for wi in w]
e: list[ExprLike] = [ei for _, e in we for ei in e]
name = 'wsum'
arg_list = (w, e)

# we have the requirement that weighted sums are [weights, expressions]
if name == 'wsum':
assert isinstance(arg_list[0], (list, tuple, np.ndarray)), "wsum: arg0 has to be a list-like"
assert all(is_num(a) for a in arg_list[0]), "wsum: arg0 has to be all constants but is: "+str(arg_list[0])
weights: list[ExprLike] = []
for a in arg_list[0]:
if isinstance(a, (bool, int, np.integer, np.bool_, BoolVal)):
weights.append(int(a)) # bool or int, simplifies things later on
else:
weights.append(a) # can be float
arg_list = (weights, arg_list[1])
if name == 'sum':
saw_wsum, all_expr = False, True
for a in arg_list:
if _wsum_should(a):
saw_wsum = True
if is_int(a):
all_expr = False
break
if saw_wsum and all_expr:
wvs = [_wsum_make(a) for a in arg_list]
ws2: list[ExprLike] = [w for w_list, _ in wvs for w in w_list]
vs2: list[ExprLike] = [v for _, v_list in wvs for v in v_list]
super().__init__("wsum", (ws2, vs2))
return

# small cleanup: nested n-ary operators are merged into the toplevel
# (this is actually against our design principle of creating
Expand Down
127 changes: 124 additions & 3 deletions cpmpy/expressions/globalfunctions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
===============
Expand All @@ -70,17 +77,18 @@ def decompose(self):
Among
NValue
NValueExcept
FloatSum

"""
import warnings # for deprecation warning
from typing import Optional, Iterable
from typing import Optional, Iterable, NoReturn, Final, cast
import numpy as np
import cpmpy as cp

from ..exceptions import CPMpyException, IncompleteFunctionError, TypeError
from .core import Expression, Operator, ExprLike, ListLike
from .variables import intvar, NDVarArray, _NumVarImpl, BoolVal
from .utils import argval, is_num, eval_comparison, is_any_list, is_boolexpr, get_bounds, argvals, implies, argvals_intexpr, get_bounds_intexpr, npint2int
from .variables import intvar, cpm_array, NDVarArray, _NumVarImpl, NegBoolView
from .utils import argval, is_num, is_int, eval_comparison, is_any_list, is_boolexpr, get_bounds, argvals, implies, argvals_intexpr, get_bounds_intexpr, npint2int


class GlobalFunction(Expression):
Expand Down Expand Up @@ -356,8 +364,18 @@ def __init__(self, x: ExprLike, y: ExprLike):
"""
is_lhs_num = False
if is_num(x):
if not is_int(x):
warnings.warn("Mul: float constants are deprecated and converted to int. Some solvers support the new FloatSum() in the objective.", DeprecationWarning)
if type(x) is not int:
assert not isinstance(x, Expression)
x = int(x)
is_lhs_num = True
elif is_num(y):
if not is_int(y):
warnings.warn("Mul: float constants are deprecated and converted to int. Some solvers support the new FloatSum() in the objective.", DeprecationWarning)
if type(y) is not int:
assert not isinstance(y, Expression)
y = int(y)
(x, y) = (y, x)
is_lhs_num = True

Expand All @@ -371,8 +389,16 @@ def update_args(self, args):
x, y = args
is_lhs_num = False
if is_num(x):
if not is_int(x):
warnings.warn("Mul: float constants are deprecated and converted to int. Some solvers support the new FloatSum() in the objective.", DeprecationWarning)
if type(x) is not int:
x = int(x)
is_lhs_num = True
elif is_num(y):
if not is_int(y):
warnings.warn("Mul: float constants are deprecated and converted to int. Some solvers support the new FloatSum() in the objective.", DeprecationWarning)
if type(y) is not int:
y = int(y)
(x, y) = (y, x)
is_lhs_num = True

Expand Down Expand Up @@ -853,6 +879,8 @@ def __init__(self, arr: ListLike[ExprLike], val: ExprLike):
raise TypeError(f"Count(arr, val) takes an array of expressions as first argument, not: {arr}")
if is_any_list(val):
raise TypeError(f"Count(arr, val) takes a numeric expression as second argument, not a list: {val}")
if isinstance(arr, np.ndarray) and arr.ndim != 1:
arr = arr.reshape(-1)
super().__init__("count", (arr, val))

def decompose(self) -> tuple[Expression, list[Expression]]:
Expand Down Expand Up @@ -914,6 +942,8 @@ def __init__(self, arr: ListLike[ExprLike], vals: ListLike[int|np.integer]):
raise TypeError(f"Among takes as input two arrays, not: {arr} and {vals}")
if any(isinstance(val, Expression) for val in vals):
raise TypeError(f"Among takes a set of integer values as input, not {vals}")
if isinstance(arr, np.ndarray) and arr.ndim != 1:
arr = arr.reshape(-1)
super().__init__("among", (arr, vals))

def decompose(self) -> tuple[Expression, list[Expression]]:
Expand Down Expand Up @@ -967,6 +997,8 @@ def __init__(self, arr: ListLike[ExprLike]):
"""
if not is_any_list(arr):
raise ValueError(f"NValue(arr) takes an array as input, not: {arr}")
if isinstance(arr, np.ndarray) and arr.ndim != 1:
arr = arr.reshape(-1)
super().__init__("nvalue", tuple(arr))

def decompose(self) -> tuple[Expression, list[Expression]]:
Expand Down Expand Up @@ -1032,6 +1064,8 @@ def __init__(self, arr: ListLike[ExprLike], n: int|np.integer):
raise ValueError("NValueExcept takes an array as input")
if not is_num(n):
raise ValueError(f"NValueExcept takes an integer as second argument, but got {n} of type {type(n)}")
if isinstance(arr, np.ndarray) and arr.ndim != 1:
arr = arr.reshape(-1)
super().__init__("nvalue_except", (arr, n))

def decompose(self) -> tuple[Expression, list[Expression]]:
Expand Down Expand Up @@ -1076,3 +1110,90 @@ def get_bounds(self) -> tuple[int, int]:
"""
arr, n = self.args
return 0, len(arr)


class FloatSum:
"""
Objective-only weighted sum with float coefficients over decision variables.

Does not inherit from Expression because it is objective only and has float .value()
Basically it is breaking all design rules of CPMpy...

It can only appear as the argument to :meth:`~cpmpy.model.Model.minimize` / :meth:`~cpmpy.model.Model.maximize`.

Accepts only (numpy) floats as coefficients, decision variables (including NegBoolView) as terms,
and an optional float constant term (default ``0.0``).
"""
name: Final = "floatsum"
coeffs: np.ndarray
vars: NDVarArray
const: float

def __init__(self, coeffs: ListLike[float|np.floating], vars: ListLike[_NumVarImpl], const: float|np.floating = 0.0):
self.coeffs = np.asarray(coeffs, dtype=float).reshape(-1)
self.const = float(const)
if isinstance(vars, NDVarArray):
self.vars = vars
else:
self.vars = cpm_array(vars)
if self.vars.ndim > 1: # must reshape to 1D
flat = self.vars.reshape(-1)
# typing is wrong: numpy preserves our ndarray subclass
self.vars = cast(NDVarArray, flat)

if self.coeffs.size != self.vars.size:
raise TypeError(f"FloatSum(coeffs, terms) expects equal lengths, got {self.coeffs.size} coefficients and {self.vars.size} terms")
if self.coeffs.size == 0:
raise TypeError("FloatSum(coeffs, terms) expects at least one term")

def __repr__(self) -> str:
if self.const:
return f"FloatSum({list(self.coeffs)}, {list(self.vars)}, constant={self.const})"
return f"FloatSum({list(self.coeffs)}, {list(self.vars)})"

def value(self) -> Optional[float]:
vals = argvals_intexpr(self.vars)
if vals is None:
return None
return float(np.dot(self.coeffs, vals) + self.const)

def components(self, negbool=False) -> tuple[np.ndarray, NDVarArray, float]:
"""
Return ``(coeffs, vars, const)``

if `negbool` is False (default), we will eliminate all :class:`~cpmpy.expressions.variables.NegBoolView`
``w * ~bv`` becomes ``w - w * bv`` (coeff ``-w`` on ``bv._bv``, constant ``+w``).
"""
if negbool or not any(isinstance(v, NegBoolView) for v in self.vars):
return self.coeffs, self.vars, self.const
else:
ws: list[float] = []
vs: list[_NumVarImpl] = []
const = self.const
for w, v in zip(self.coeffs, self.vars):
if isinstance(v, NegBoolView):
ws.append(-w)
vs.append(v._bv)
const += w
else:
ws.append(w)
vs.append(v)
return np.asarray(ws, dtype=float), cpm_array(vs), const

def _raise_objective_only(self) -> NoReturn:
raise TypeError("FloatSum is objective-only. Use it directly in Model.minimize()/maximize().")

def __eq__(self, other: object) -> NoReturn: self._raise_objective_only()
def __ne__(self, other: object) -> NoReturn: self._raise_objective_only()
def __lt__(self, other: object) -> NoReturn: self._raise_objective_only()
def __le__(self, other: object) -> NoReturn: self._raise_objective_only()
def __gt__(self, other: object) -> NoReturn: self._raise_objective_only()
def __ge__(self, other: object) -> NoReturn: self._raise_objective_only()
def __add__(self, other: object) -> NoReturn: self._raise_objective_only()
def __radd__(self, other: object) -> NoReturn: self._raise_objective_only()
def __sub__(self, other: object) -> NoReturn: self._raise_objective_only()
def __rsub__(self, other: object) -> NoReturn: self._raise_objective_only()
def __mul__(self, other: object) -> NoReturn: self._raise_objective_only()
def __rmul__(self, other: object) -> NoReturn: self._raise_objective_only()
def __neg__(self) -> NoReturn: self._raise_objective_only()
def __abs__(self) -> NoReturn: self._raise_objective_only()
47 changes: 27 additions & 20 deletions cpmpy/solvers/cplex.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,15 @@
from typing import Optional, List

from .solver_interface import SolverInterface, SolverStatus, ExitStatus, Callback
from ..expressions.core import Expression, Comparison, Operator, BoolVal
from ..expressions.utils import argvals, argval, eval_comparison, flatlist, is_any_list, is_bool, is_num, is_int
from ..expressions.variables import _BoolVarImpl, NegBoolView, _IntVarImpl, _NumVarImpl, intvar
from ..expressions.core import Comparison, Operator, BoolVal
from ..expressions.globalfunctions import FloatSum
from ..expressions.utils import eval_comparison, flatlist, is_bool, is_num, is_int
from ..expressions.variables import _BoolVarImpl, NegBoolView, _NumVarImpl, intvar
from ..expressions.globalconstraints import DirectConstraint
from ..transformations.comparison import only_numexpr_equality
from ..transformations.flatten_model import flatten_constraint, flatten_objective
from ..transformations.get_variables import get_variables
from ..transformations.linearize import linearize_constraint, linearize_reified_variables, only_positive_bv, only_positive_bv_wsum, \
from ..transformations.linearize import linearize_constraint, linearize_reified_variables, only_positive_bv, \
only_positive_bv_wsum_const, decompose_linear, decompose_linear_objective
from ..transformations.normalize import toplevel_list
from ..transformations.reification import only_implies, reify_rewrite, only_bv_reifies
Expand Down Expand Up @@ -304,22 +305,28 @@ def objective(self, expr, minimize=True):
technical side note: any constraints created during conversion of the objective
are premanently posted to the solver
"""
# save user vars
get_variables(expr, self.user_vars)

# transform objective
obj, safe_cons = safen_objective(expr)
obj, decomp_cons = decompose_linear_objective(obj,
supported=self.supported_global_constraints,
supported_reified=self.supported_reified_global_constraints,
csemap=self._csemap)
obj, flat_cons = flatten_objective(obj, csemap=self._csemap)
obj, self._obj_offset = only_positive_bv_wsum_const(obj) # remove negboolviews

self.add(safe_cons + decomp_cons + flat_cons)

# make objective function or variable and post
cplex_obj = self._make_numexpr(obj)
if isinstance(expr, FloatSum):
ws, vs, const = expr.components()
self.user_vars.update(vs) # save user variables
self._obj_offset = const
cplex_obj = self.cplex_model.scal_prod(self.solver_vars(vs), ws)
else:
# save user vars
get_variables(expr, self.user_vars)

# transform objective
obj, safe_cons = safen_objective(expr)
obj, decomp_cons = decompose_linear_objective(obj,
supported=self.supported_global_constraints,
supported_reified=self.supported_reified_global_constraints,
csemap=self._csemap)
obj, flat_cons = flatten_objective(obj, csemap=self._csemap)
obj, self._obj_offset = only_positive_bv_wsum_const(obj) # remove negboolviews

self.add(safe_cons + decomp_cons + flat_cons)

# make objective function or variable and post
cplex_obj = self._make_numexpr(obj)
if minimize:
self.cplex_model.set_objective('min', cplex_obj)
else:
Expand Down
Loading
Loading