diff --git a/CHANGELOG.md b/CHANGELOG.md index a4758d982..1b98f4c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added pre-commit hook for automatic stub regeneration (see .pre-commit-config.yaml) - Wrapped isObjIntegral() and test - Added structured_optimization_trace recipe for structured optimization progress tracking +- Expr and GenExpr support numpy unary func (`np.sin`, `np.cos`, `np.sqrt`, `np.exp`, `np.log`, `np.absolute`) - Added methods: getPrimalDualIntegral() ### Fixed - getBestSol() now returns None for infeasible problems instead of a Solution with NULL pointer diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index f782a46da..ad0f31e9e 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -43,7 +43,9 @@ # gets called (I guess) and so a copy is returned. # Modifying the expression directly would be a bug, given that the expression might be re-used by the user. import math -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal + +import numpy as np from cpython.dict cimport PyDict_Next, PyDict_GetItem from cpython.object cimport Py_TYPE @@ -51,8 +53,6 @@ from cpython.ref cimport PyObject from cpython.tuple cimport PyTuple_GET_ITEM from pyscipopt.scip cimport Variable, Solution -import numpy as np - if TYPE_CHECKING: double = float @@ -180,6 +180,7 @@ cdef class Term: CONST = Term() + # helper function def buildGenExprObj(expr): """helper function to generate an object of type GenExpr""" @@ -215,10 +216,45 @@ def buildGenExprObj(expr): assert isinstance(expr, GenExpr) return expr + +cdef class ExprLike: + + def __array_ufunc__( + self, + ufunc: np.ufunc, + method: Literal["__call__", "reduce", "reduceat", "accumulate", "outer", "at"], + *args, + **kwargs, + ): + if method == "__call__": + if ufunc in UNARY_MAPPER: + return getattr(args[0], UNARY_MAPPER[ufunc])() + + return NotImplemented + + def __abs__(self) -> GenExpr: + return UnaryExpr(Operator.fabs, buildGenExprObj(self)) + + def exp(self) -> GenExpr: + return UnaryExpr(Operator.exp, buildGenExprObj(self)) + + def log(self) -> GenExpr: + return UnaryExpr(Operator.log, buildGenExprObj(self)) + + def sqrt(self) -> GenExpr: + return UnaryExpr(Operator.sqrt, buildGenExprObj(self)) + + def sin(self) -> GenExpr: + return UnaryExpr(Operator.sin, buildGenExprObj(self)) + + def cos(self) -> GenExpr: + return UnaryExpr(Operator.cos, buildGenExprObj(self)) + + ##@details Polynomial expressions of variables with operator overloading. \n #See also the @ref ExprDetails "description" in the expr.pxi. -cdef class Expr: - +cdef class Expr(ExprLike): + def __init__(self, terms=None): '''terms is a dict of variables to coefficients. @@ -236,9 +272,6 @@ cdef class Expr: def __iter__(self): return iter(self.terms) - def __abs__(self): - return abs(buildGenExprObj(self)) - def __add__(self, other): left = self right = other @@ -502,7 +535,7 @@ Operator = Op() # so expr[x] will generate an error instead of returning the coefficient of x # #See also the @ref ExprDetails "description" in the expr.pxi. -cdef class GenExpr: +cdef class GenExpr(ExprLike): cdef public _op cdef public children @@ -510,9 +543,6 @@ cdef class GenExpr: def __init__(self): # do we need it ''' ''' - def __abs__(self): - return UnaryExpr(Operator.fabs, self) - def __add__(self, other): if isinstance(other, np.ndarray): return other + self @@ -816,55 +846,20 @@ cdef class Constant(GenExpr): return self.number -def exp(expr): - """returns expression with exp-function""" - if isinstance(expr, MatrixExpr): - unary_exprs = np.empty(shape=expr.shape, dtype=object) - for idx in np.ndindex(expr.shape): - unary_exprs[idx] = UnaryExpr(Operator.exp, buildGenExprObj(expr[idx])) - return unary_exprs.view(MatrixGenExpr) - else: - return UnaryExpr(Operator.exp, buildGenExprObj(expr)) - -def log(expr): - """returns expression with log-function""" - if isinstance(expr, MatrixExpr): - unary_exprs = np.empty(shape=expr.shape, dtype=object) - for idx in np.ndindex(expr.shape): - unary_exprs[idx] = UnaryExpr(Operator.log, buildGenExprObj(expr[idx])) - return unary_exprs.view(MatrixGenExpr) - else: - return UnaryExpr(Operator.log, buildGenExprObj(expr)) - -def sqrt(expr): - """returns expression with sqrt-function""" - if isinstance(expr, MatrixExpr): - unary_exprs = np.empty(shape=expr.shape, dtype=object) - for idx in np.ndindex(expr.shape): - unary_exprs[idx] = UnaryExpr(Operator.sqrt, buildGenExprObj(expr[idx])) - return unary_exprs.view(MatrixGenExpr) - else: - return UnaryExpr(Operator.sqrt, buildGenExprObj(expr)) - -def sin(expr): - """returns expression with sin-function""" - if isinstance(expr, MatrixExpr): - unary_exprs = np.empty(shape=expr.shape, dtype=object) - for idx in np.ndindex(expr.shape): - unary_exprs[idx] = UnaryExpr(Operator.sin, buildGenExprObj(expr[idx])) - return unary_exprs.view(MatrixGenExpr) - else: - return UnaryExpr(Operator.sin, buildGenExprObj(expr)) +exp = np.exp +log = np.log +sqrt = np.sqrt +sin = np.sin +cos = np.cos +cdef dict UNARY_MAPPER = { + np.absolute: "__abs__", + exp: "exp", + log: "log", + sqrt: "sqrt", + sin: "sin", + cos: "cos", +} -def cos(expr): - """returns expression with cos-function""" - if isinstance(expr, MatrixExpr): - unary_exprs = np.empty(shape=expr.shape, dtype=object) - for idx in np.ndindex(expr.shape): - unary_exprs[idx] = UnaryExpr(Operator.cos, buildGenExprObj(expr[idx])) - return unary_exprs.view(MatrixGenExpr) - else: - return UnaryExpr(Operator.cos, buildGenExprObj(expr)) def expr_to_nodes(expr): '''transforms tree to an array of nodes. each node is an operator and the position of the diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 2e78296e3..ac28b4a6d 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -2121,7 +2121,10 @@ cdef extern from "scip/scip_var.h": cdef extern from "tpi/tpi.h": int SCIPtpiGetNumThreads() -cdef class Expr: +cdef class ExprLike: + pass + +cdef class Expr(ExprLike): cdef public terms cpdef double _evaluate(self, Solution sol) diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 097235b78..afa711f9a 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1,6 +1,6 @@ from typing import ClassVar -import numpy +import numpy as np from _typeshed import Incomplete from typing_extensions import disjoint_base @@ -8,8 +8,8 @@ CONST: Term EventNames: dict MAJOR: int MINOR: int -Operator: Op PATCH: int +Operator: Op PY_SCIP_CALL: Incomplete StageNames: dict TYPE_CHECKING: bool @@ -20,18 +20,18 @@ _core_sum: Incomplete _expr_richcmp: Incomplete _is_number: Incomplete buildGenExprObj: Incomplete -cos: Incomplete exp: Incomplete +log: Incomplete +sin: Incomplete +cos: Incomplete +sqrt: Incomplete expr_to_array: Incomplete expr_to_nodes: Incomplete is_memory_freed: Incomplete -log: Incomplete print_memory_in_use: Incomplete quickprod: Incomplete quicksum: Incomplete readStatistics: Incomplete -sin: Incomplete -sqrt: Incomplete str_conversion: Incomplete value_to_array: Incomplete @@ -325,13 +325,27 @@ class Eventhdlr: def eventinit(self) -> Incomplete: ... def eventinitsol(self) -> Incomplete: ... +class ExprLike: + def __array_ufunc__( + self, + ufunc: np.ufunc, + method: str, + *args: Incomplete, + **kwargs: Incomplete, + ) -> Incomplete: ... + def __abs__(self) -> GenExpr: ... + def exp(self) -> GenExpr: ... + def log(self) -> GenExpr: ... + def sqrt(self) -> GenExpr: ... + def sin(self) -> GenExpr: ... + def cos(self) -> GenExpr: ... + @disjoint_base -class Expr: +class Expr(ExprLike): terms: Incomplete def __init__(self, terms: Incomplete = ...) -> None: ... def degree(self) -> Incomplete: ... def normalize(self) -> Incomplete: ... - def __abs__(self) -> Incomplete: ... def __add__(self, other: Incomplete) -> Incomplete: ... def __eq__(self, other: object) -> bool: ... def __ge__(self, other: object) -> bool: ... @@ -371,7 +385,7 @@ class ExprCons: def __ne__(self, other: object) -> bool: ... @disjoint_base -class GenExpr: +class GenExpr(ExprLike): _op: Incomplete children: Incomplete def __init__(self) -> None: ... @@ -496,7 +510,7 @@ class LP: def solve(self, dual: Incomplete = ...) -> Incomplete: ... def writeLP(self, filename: Incomplete) -> Incomplete: ... -class MatrixConstraint(numpy.ndarray): +class MatrixConstraint(np.ndarray): def getConshdlrName(self) -> Incomplete: ... def isActive(self) -> Incomplete: ... def isChecked(self) -> Incomplete: ... @@ -512,21 +526,21 @@ class MatrixConstraint(numpy.ndarray): def isSeparated(self) -> Incomplete: ... def isStickingAtNode(self) -> Incomplete: ... -class MatrixExpr(numpy.ndarray): +class MatrixExpr(np.ndarray): def _evaluate(self, sol: Incomplete) -> Incomplete: ... def __array_ufunc__( self, - ufunc: Incomplete, - method: Incomplete, + ufunc: np.ufunc, + method: str, *args: Incomplete, **kwargs: Incomplete, ) -> Incomplete: ... -class MatrixExprCons(numpy.ndarray): +class MatrixExprCons(np.ndarray): def __array_ufunc__( self, - ufunc: Incomplete, - method: Incomplete, + ufunc: np.ufunc, + method: str, *args: Incomplete, **kwargs: Incomplete, ) -> Incomplete: ... diff --git a/tests/test_expr.py b/tests/test_expr.py index a4e739b76..c1a8092c7 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -1,9 +1,10 @@ import math +import numpy as np import pytest -from pyscipopt import Model, sqrt, log, exp, sin, cos -from pyscipopt.scip import Expr, GenExpr, ExprCons, CONST +from pyscipopt import Model, cos, exp, log, sin, sqrt +from pyscipopt.scip import CONST, Expr, ExprCons, GenExpr @pytest.fixture(scope="module") @@ -117,10 +118,12 @@ def test_genexpr_op_genexpr(model): assert isinstance(1/x + genexpr, GenExpr) assert isinstance(1/x**1.5 - genexpr, GenExpr) assert isinstance(y/x - exp(genexpr), GenExpr) - # sqrt(2) is not a constant expression and - # we can only power to constant expressions! - with pytest.raises(NotImplementedError): - genexpr **= sqrt(2) + + genexpr **= sqrt(2) + assert isinstance(genexpr, GenExpr) + + with pytest.raises(TypeError): + genexpr **= sqrt("2") def test_degree(model): m, x, y, z = model @@ -219,6 +222,49 @@ def test_getVal_with_GenExpr(): m.getVal(1 / z) +def test_unary(model): + m, x, y, z = model + + res = "abs(sum(0.0,prod(1.0,x)))" + assert str(abs(x)) == res + assert str(np.absolute(x)) == res + + res = "[sin(sum(0.0,prod(1.0,x))) sin(sum(0.0,prod(1.0,y)))]" + assert str(sin([x, y])) == res + assert str(np.sin([x, y])) == res + + res = "[cos(sum(0.0,prod(1.0,x))) cos(sum(0.0,prod(1.0,y)))]" + assert str(cos([x, y])) == res + assert str(np.cos([x, y])) == res + + res = "[sqrt(sum(0.0,prod(1.0,x))) sqrt(sum(0.0,prod(1.0,y)))]" + assert str(sqrt([x, y])) == res + assert str(np.sqrt([x, y])) == res + + res = "[exp(sum(0.0,prod(1.0,x))) exp(sum(0.0,prod(1.0,y)))]" + assert str(exp([x, y])) == res + assert str(np.exp([x, y])) == res + + res = "[log(sum(0.0,prod(1.0,x))) log(sum(0.0,prod(1.0,y)))]" + assert str(log([x, y])) == res + assert str(np.log([x, y])) == res + + assert sqrt(4) == np.sqrt(4) + assert all(sqrt([4, 4]) == np.sqrt([4, 4])) + assert exp(3) == np.exp(3) + assert all(exp([3, 3]) == np.exp([3, 3])) + assert log(5) == np.log(5) + assert all(log([5, 5]) == np.log([5, 5])) + assert sin(1) == np.sin(1) + assert all(sin([1, 1]) == np.sin([1, 1])) + assert cos(1) == np.cos(1) + assert all(cos([1, 1]) == np.cos([1, 1])) + + # test invalid unary operations + with pytest.raises(TypeError): + np.arcsin(x) + + def test_mul(): m = Model() x = m.addVar(name="x")