Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased
### Added
### Fixed
- `Expr.__array_ufunc__` can't handle 0-dim array
### Changed
### Removed

Expand Down
17 changes: 13 additions & 4 deletions src/pyscipopt/expr.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -203,16 +203,25 @@ cdef class ExprLike:
)

if method == "__call__":
if arrays := [a for a in args if isinstance(a, np.ndarray)]:
if arrays := [a for a in args if isinstance(a, np.ndarray) and a.ndim >= 1]:
if any(a.dtype.kind not in "fiub" for a in arrays):
return NotImplemented
# If the np.ndarray is of numeric type, all arguments are converted to
# MatrixExpr or MatrixGenExpr and then the ufunc is applied.
return ufunc(*[_ensure_matrix(a) for a in args], **kwargs)

# Convert `np.generic` to native Python types to stop __array_ufunc__
# recursion from `np.generic + MatrixExpr`.
args = [a.item() if isinstance(a, np.generic) else a for a in args]
# Convert `np.generic` and 0-dim `np.ndarray` to native Python types to stop
# __array_ufunc__ recursion from `np.generic + MatrixExpr/Expr` or
# `0-dim np.ndarray + MatrixExpr/Expr`.
args = [
a.item()
if (
isinstance(a, np.generic)
or (isinstance(a, np.ndarray) and a.ndim == 0)
)
else a
for a in args
]
Comment on lines +213 to +224
Copy link
Copy Markdown
Contributor Author

@Zeroto521 Zeroto521 May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, 1 <= np.array(x) gets recursion and crushes the Python kernel.

The 1-d+ path returns NotImplemented for non-numeric dtypes, but the new 0-d unwrap calls .item() unconditionally. So np.array(some_object, dtype=object) <= x would now silently extract the Python
object instead of returning NotImplemented. Probably never hit in practice, but easy to keep consistent:

args = [
    a.item()
    if isinstance(a, np.generic)
    or (isinstance(a, np.ndarray) and a.ndim == 0 and a.dtype.kind in "fiub")
    else a
    for a in args
]


if ufunc is np.add:
return args[0] + args[1]
Expand Down
28 changes: 28 additions & 0 deletions tests/test_expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,34 @@ def test_binary_ufunc(model):
assert str(np.greater_equal(a, x)) == "[ExprCons(Expr({Term(x): 1.0}), None, 2.0)]"


def test_np_generic_vs_expr():
# test #1218
m = Model()
x = m.addVar(name="x")
value = np.float64(5.0)

# test <=, np.generic vs Variable
assert str(x <= -value) == "ExprCons(Expr({Term(x): 1.0}), None, -5.0)"
assert str(x <= value) == "ExprCons(Expr({Term(x): 1.0}), None, 5.0)"
assert str(-value <= x) == "ExprCons(Expr({Term(x): 1.0}), -5.0, None)"
assert str(value <= x) == "ExprCons(Expr({Term(x): 1.0}), 5.0, None)"
assert str(np.int64(5) <= x) == "ExprCons(Expr({Term(x): 1.0}), 5.0, None)"

# test >=, np.generic vs Variable
assert str(value >= x) == "ExprCons(Expr({Term(x): 1.0}), None, 5.0)"
assert str(-value >= x) == "ExprCons(Expr({Term(x): 1.0}), None, -5.0)"

# test ==, np.generic vs Variable
assert str(value == x) == "ExprCons(Expr({Term(x): 1.0}), 5.0, 5.0)"

# test <=, 0-ndim int array vs Variable
assert str(np.array(5) <= x) == "ExprCons(Expr({Term(x): 1.0}), 5.0, None)"

# test <=, 0-ndim Variable array vs Variable
with pytest.raises(TypeError):
1 <= np.array(x)


def test_mul():
m = Model()
x = m.addVar(name="x")
Expand Down
Loading