diff --git a/CHANGELOG.md b/CHANGELOG.md index d50a03be8..1d891c599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,10 @@ ### Changed - Add a PEP 735 dependency group for test dependencies in `pyproject.toml` - Speed up MatrixVariable.sum(axis=None) via quicksum +- MatrixVariable now supports comparison with Expr ### Removed -## v5.6.0 - 2025.08.26 +## 5.6.0 - 2025.08.26 ### Added - More support for AND-Constraints - Added support for knapsack constraints diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index 7806686db..431a4abd4 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -44,6 +44,7 @@ # Modifying the expression directly would be a bug, given that the expression might be re-used by the user. include "matrix.pxi" + def _is_number(e): try: f = float(e) @@ -52,7 +53,8 @@ def _is_number(e): return False except TypeError: # for other types (Variable, Expr) return False - + + def _expr_richcmp(self, other, op): if op == 1: # <= if isinstance(other, Expr) or isinstance(other, GenExpr): @@ -62,7 +64,7 @@ def _expr_richcmp(self, other, op): elif isinstance(other, MatrixExpr): return _expr_richcmp(other, self, 5) else: - raise NotImplementedError + raise TypeError(f"Unsupported type {type(other)}") elif op == 5: # >= if isinstance(other, Expr) or isinstance(other, GenExpr): return (self - other) >= 0.0 @@ -71,7 +73,7 @@ def _expr_richcmp(self, other, op): elif isinstance(other, MatrixExpr): return _expr_richcmp(other, self, 1) else: - raise NotImplementedError + raise TypeError(f"Unsupported type {type(other)}") elif op == 2: # == if isinstance(other, Expr) or isinstance(other, GenExpr): return (self - other) == 0.0 @@ -80,7 +82,7 @@ def _expr_richcmp(self, other, op): elif isinstance(other, MatrixExpr): return _expr_richcmp(other, self, 2) else: - raise NotImplementedError + raise TypeError(f"Unsupported type {type(other)}") else: raise NotImplementedError("Can only support constraints with '<=', '>=', or '=='.") @@ -201,7 +203,7 @@ cdef class Expr: elif isinstance(right, MatrixExpr): return right + left else: - raise NotImplementedError + raise TypeError(f"Unsupported type {type(right)}") return Expr(terms) @@ -218,7 +220,7 @@ cdef class Expr: # TypeError: Cannot convert pyscipopt.scip.SumExpr to pyscipopt.scip.Expr return buildGenExprObj(self) + other else: - raise NotImplementedError + raise TypeError(f"Unsupported type {type(other)}") return self @@ -337,26 +339,26 @@ cdef class ExprCons: def __richcmp__(self, other, op): '''turn it into a constraint''' if op == 1: # <= - if not self._rhs is None: - raise TypeError('ExprCons already has upper bound') - assert not self._lhs is None + if not self._rhs is None: + raise TypeError('ExprCons already has upper bound') + assert not self._lhs is None - if not _is_number(other): - raise TypeError('Ranged ExprCons is not well defined!') + if not _is_number(other): + raise TypeError('Ranged ExprCons is not well defined!') - return ExprCons(self.expr, lhs=self._lhs, rhs=float(other)) + return ExprCons(self.expr, lhs=self._lhs, rhs=float(other)) elif op == 5: # >= - if not self._lhs is None: - raise TypeError('ExprCons already has lower bound') - assert self._lhs is None - assert not self._rhs is None + if not self._lhs is None: + raise TypeError('ExprCons already has lower bound') + assert self._lhs is None + assert not self._rhs is None - if not _is_number(other): - raise TypeError('Ranged ExprCons is not well defined!') + if not _is_number(other): + raise TypeError('Ranged ExprCons is not well defined!') - return ExprCons(self.expr, lhs=float(other), rhs=self._rhs) + return ExprCons(self.expr, lhs=float(other), rhs=self._rhs) else: - raise TypeError + raise NotImplementedError("Ranged ExprCons can only support with '<=' or '>='.") def __repr__(self): return 'ExprCons(%s, %s, %s)' % (self.expr, self._lhs, self._rhs) diff --git a/src/pyscipopt/matrix.pxi b/src/pyscipopt/matrix.pxi index 51225f032..4c9329f55 100644 --- a/src/pyscipopt/matrix.pxi +++ b/src/pyscipopt/matrix.pxi @@ -6,6 +6,7 @@ import numpy as np from typing import Union + def _is_number(e): try: f = float(e) @@ -15,6 +16,34 @@ def _is_number(e): except TypeError: # for other types (Variable, Expr) return False + +def _matrixexpr_richcmp(self, other, op): + def _richcmp(self, other, op): + if op == 1: # <= + return self.__le__(other) + elif op == 5: # >= + return self.__ge__(other) + elif op == 2: # == + return self.__eq__(other) + else: + raise NotImplementedError("Can only support constraints with '<=', '>=', or '=='.") + + res = np.empty(self.shape, dtype=object) + if _is_number(other) or isinstance(other, Expr): + res.flat = [_richcmp(i, other, op) for i in self.flat] + + elif isinstance(other, np.ndarray): + if self.shape != other.shape: + raise ValueError("Shapes do not match for comparison.") + + res.flat = [_richcmp(i, j, op) for i, j in zip(self.flat, other.flat)] + + else: + raise TypeError(f"Unsupported type {type(other)}") + + return res.view(MatrixExprCons) + + class MatrixExpr(np.ndarray): def sum(self, **kwargs): """ @@ -27,51 +56,15 @@ class MatrixExpr(np.ndarray): return quicksum(self.flat) return super().sum(**kwargs) - def __le__(self, other: Union[float, int, Variable, np.ndarray, 'MatrixExpr']) -> np.ndarray: - - expr_cons_matrix = np.empty(self.shape, dtype=object) - if _is_number(other) or isinstance(other, Variable): - for idx in np.ndindex(self.shape): - expr_cons_matrix[idx] = self[idx] <= other - - elif isinstance(other, np.ndarray): - for idx in np.ndindex(self.shape): - expr_cons_matrix[idx] = self[idx] <= other[idx] - else: - raise TypeError(f"Unsupported type {type(other)}") - - return expr_cons_matrix.view(MatrixExprCons) - - def __ge__(self, other: Union[float, int, Variable, np.ndarray, 'MatrixExpr']) -> np.ndarray: - - expr_cons_matrix = np.empty(self.shape, dtype=object) - if _is_number(other) or isinstance(other, Variable): - for idx in np.ndindex(self.shape): - expr_cons_matrix[idx] = self[idx] >= other - - elif isinstance(other, np.ndarray): - for idx in np.ndindex(self.shape): - expr_cons_matrix[idx] = self[idx] >= other[idx] - else: - raise TypeError(f"Unsupported type {type(other)}") + def __le__(self, other: Union[float, int, "Expr", np.ndarray, "MatrixExpr"]) -> MatrixExprCons: + return _matrixexpr_richcmp(self, other, 1) - return expr_cons_matrix.view(MatrixExprCons) + def __ge__(self, other: Union[float, int, "Expr", np.ndarray, "MatrixExpr"]) -> MatrixExprCons: + return _matrixexpr_richcmp(self, other, 5) - def __eq__(self, other: Union[float, int, Variable, np.ndarray, 'MatrixExpr']) -> np.ndarray: - - expr_cons_matrix = np.empty(self.shape, dtype=object) - if _is_number(other) or isinstance(other, Variable): - for idx in np.ndindex(self.shape): - expr_cons_matrix[idx] = self[idx] == other - - elif isinstance(other, np.ndarray): - for idx in np.ndindex(self.shape): - expr_cons_matrix[idx] = self[idx] == other[idx] - else: - raise TypeError(f"Unsupported type {type(other)}") + def __eq__(self, other: Union[float, int, "Expr", np.ndarray, "MatrixExpr"]) -> MatrixExprCons: + return _matrixexpr_richcmp(self, other, 2) - return expr_cons_matrix.view(MatrixExprCons) - def __add__(self, other): return super().__add__(other).view(MatrixExpr) @@ -110,41 +103,11 @@ class MatrixGenExpr(MatrixExpr): class MatrixExprCons(np.ndarray): - def __le__(self, other: Union[float, int, Variable, MatrixExpr]) -> np.ndarray: - - if not _is_number(other) or not isinstance(other, MatrixExpr): - raise TypeError('Ranged MatrixExprCons is not well defined!') - - expr_cons_matrix = np.empty(self.shape, dtype=object) - if _is_number(other) or isinstance(other, Variable): - for idx in np.ndindex(self.shape): - expr_cons_matrix[idx] = self[idx] <= other - - elif isinstance(other, np.ndarray): - for idx in np.ndindex(self.shape): - expr_cons_matrix[idx] = self[idx] <= other[idx] - else: - raise TypeError(f"Unsupported type {type(other)}") - - return expr_cons_matrix.view(MatrixExprCons) - - def __ge__(self, other: Union[float, int, Variable, MatrixExpr]) -> np.ndarray: - - if not _is_number(other) or not isinstance(other, MatrixExpr): - raise TypeError('Ranged MatrixExprCons is not well defined!') - - expr_cons_matrix = np.empty(self.shape, dtype=object) - if _is_number(other) or isinstance(other, Variable): - for idx in np.ndindex(self.shape): - expr_cons_matrix[idx] = self[idx] >= other - - elif isinstance(other, np.ndarray): - for idx in np.ndindex(self.shape): - expr_cons_matrix[idx] = self[idx] >= other[idx] - else: - raise TypeError(f"Unsupported type {type(other)}") + def __le__(self, other: Union[float, int, np.ndarray]) -> MatrixExprCons: + return _matrixexpr_richcmp(self, other, 1) - return expr_cons_matrix.view(MatrixExprCons) + def __ge__(self, other: Union[float, int, np.ndarray]) -> MatrixExprCons: + return _matrixexpr_richcmp(self, other, 5) def __eq__(self, other): - raise TypeError + raise NotImplementedError("Cannot compare MatrixExprCons with '=='.") diff --git a/tests/test_matrix_variable.py b/tests/test_matrix_variable.py index b3a713306..a251515cc 100644 --- a/tests/test_matrix_variable.py +++ b/tests/test_matrix_variable.py @@ -449,6 +449,48 @@ def test_matrix_cons_indicator(): assert m.getVal(z) == 1 +def test_matrix_compare_with_expr(): + m = Model() + var = m.addVar(vtype="B", ub=0) + + # test "<=" and ">=" operator + x = m.addMatrixVar(3) + m.addMatrixCons(x <= var + 1) + m.addMatrixCons(x >= var + 1) + + # test "==" operator + y = m.addMatrixVar(3) + m.addMatrixCons(y == var + 1) + + m.setObjective(x.sum() + y.sum()) + m.optimize() + + assert (m.getVal(x) == np.ones(3)).all() + assert (m.getVal(y) == np.ones(3)).all() + + +def test_ranged_matrix_cons_with_expr(): + m = Model() + x = m.addMatrixVar(3) + var = m.addVar(vtype="B", ub=0) + + # test MatrixExprCons vs Variable + with pytest.raises(TypeError): + m.addMatrixCons((x <= 1) >= var) + + # test "==" operator + with pytest.raises(NotImplementedError): + m.addMatrixCons((x <= 1) == 1) + + # test "<=" and ">=" operator + m.addMatrixCons((x <= 1) >= 1) + + m.setObjective(x.sum()) + m.optimize() + + assert (m.getVal(x) == np.ones(3)).all() + + _binop_model = Model() def var():