Skip to content

Commit cb23189

Browse files
Merge branch 'master' into model-annotations-1
2 parents 613be71 + aa0e243 commit cb23189

6 files changed

Lines changed: 105 additions & 23 deletions

File tree

.github/workflows/stubs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ jobs:
7878
- name: Install Ruff
7979
uses: astral-sh/ruff-action@v3
8080
with:
81+
version: "0.14.11"
8182
args: "--version"
8283

8384
- name: Lint type stubs

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@
44
### Added
55
### Fixed
66
### Changed
7+
- Speed up `Expr.__add__` and `Expr.__iadd__` via the C-level API
78
### Removed
89

10+
## 6.2.1 - 2026.05.16
11+
### Fixed
12+
- Fixed `AttributeError` when comparing NumPy scalars (e.g. `np.float64`) or 0-dim NumPy arrays against `Expr`/`Variable` on NumPy 2.x (#1218)
13+
914
## 6.2.0 - 2026.05.11
1015
### Added
1116
- Added `solveProbingLPWithPricing()` and test

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@
133133

134134
setup(
135135
name="PySCIPOpt",
136-
version="6.2.0",
136+
version="6.2.1",
137137
description="Python interface and modeling environment for SCIP",
138138
long_description=long_description,
139139
long_description_content_type="text/markdown",

src/pyscipopt/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__: str = '6.2.0'
1+
__version__: str = '6.2.1'

src/pyscipopt/expr.pxi

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ from cpython.object cimport Py_LE, Py_EQ, Py_GE, Py_TYPE
5555
from cpython.ref cimport PyObject
5656
from cpython.tuple cimport PyTuple_GET_ITEM
5757

58+
cimport numpy as cnp
5859
from pyscipopt.scip cimport Variable, Solution
5960

6061

@@ -203,16 +204,25 @@ cdef class ExprLike:
203204
)
204205

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

213-
# Convert `np.generic` to native Python types to stop __array_ufunc__
214-
# recursion from `np.generic + MatrixExpr`.
215-
args = [a.item() if isinstance(a, np.generic) else a for a in args]
214+
# Convert `np.generic` and 0-dim `np.ndarray` to native Python types to stop
215+
# __array_ufunc__ recursion from `np.generic + MatrixExpr/Expr` or
216+
# `0-dim np.ndarray + MatrixExpr/Expr`.
217+
args = [
218+
a.item()
219+
if (
220+
isinstance(a, np.generic)
221+
or (isinstance(a, np.ndarray) and a.ndim == 0)
222+
)
223+
else a
224+
for a in args
225+
]
216226

217227
if ufunc is np.add:
218228
return args[0] + args[1]
@@ -291,29 +301,22 @@ cdef class Expr(ExprLike):
291301
if not _is_expr_compatible(other):
292302
return NotImplemented
293303

294-
left = self
295-
right = other
296-
terms = left.terms.copy()
304+
if _is_number(other):
305+
terms = self.terms.copy()
306+
terms[CONST] = terms.get(CONST, 0.0) + <double>other
307+
return Expr(terms)
297308

298-
if isinstance(right, Expr):
299-
# merge the terms by component-wise addition
300-
for v,c in right.terms.items():
301-
terms[v] = terms.get(v, 0.0) + c
302-
elif _is_number(right):
303-
c = float(right)
304-
terms[CONST] = terms.get(CONST, 0.0) + c
305-
return Expr(terms)
309+
return Expr(_to_dict(self, other, copy=True))
306310

307311
def __iadd__(self, other):
308312
if not _is_expr_compatible(other):
309313
return NotImplemented
310314

311-
if isinstance(other, Expr):
312-
for v,c in other.terms.items():
313-
self.terms[v] = self.terms.get(v, 0.0) + c
314-
elif _is_number(other):
315-
c = float(other)
316-
self.terms[CONST] = self.terms.get(CONST, 0.0) + c
315+
if _is_number(other):
316+
self.terms[CONST] = self.terms.get(CONST, 0.0) + <double>other
317+
else:
318+
_to_dict(self, other, copy=False)
319+
317320
return self
318321

319322
def __mul__(self, other):
@@ -1010,6 +1013,26 @@ cdef inline object _ensure_matrix(object arg):
10101013
matrix = MatrixExpr if isinstance(arg, Expr) else MatrixGenExpr
10111014
return np.array(arg, dtype=object).view(matrix)
10121015

1016+
cdef dict _to_dict(Expr expr, Expr other, bool copy = True):
1017+
cdef dict children = expr.terms.copy() if copy else expr.terms
1018+
cdef Py_ssize_t pos = <Py_ssize_t>0
1019+
cdef PyObject* k_ptr = NULL
1020+
cdef PyObject* v_ptr = NULL
1021+
cdef PyObject* old_v_ptr = NULL
1022+
cdef double other_v
1023+
cdef object k_obj
1024+
1025+
while PyDict_Next(other.terms, &pos, &k_ptr, &v_ptr):
1026+
other_v = <double>(<object>v_ptr)
1027+
k_obj = <object>k_ptr
1028+
old_v_ptr = PyDict_GetItem(children, k_obj)
1029+
if old_v_ptr != NULL:
1030+
children[k_obj] = <double>(<object>old_v_ptr) + other_v
1031+
else:
1032+
children[k_obj] = other_v
1033+
1034+
return children
1035+
10131036

10141037
def expr_to_nodes(expr):
10151038
'''transforms tree to an array of nodes. each node is an operator and the position of the

tests/test_expr.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,34 @@ def test_binary_ufunc(model):
328328
assert str(np.greater_equal(a, x)) == "[ExprCons(Expr({Term(x): 1.0}), None, 2.0)]"
329329

330330

331+
def test_np_generic_vs_expr():
332+
# test #1218
333+
m = Model()
334+
x = m.addVar(name="x")
335+
value = np.float64(5.0)
336+
337+
# test <=, np.generic vs Variable
338+
assert str(x <= -value) == "ExprCons(Expr({Term(x): 1.0}), None, -5.0)"
339+
assert str(x <= value) == "ExprCons(Expr({Term(x): 1.0}), None, 5.0)"
340+
assert str(-value <= x) == "ExprCons(Expr({Term(x): 1.0}), -5.0, None)"
341+
assert str(value <= x) == "ExprCons(Expr({Term(x): 1.0}), 5.0, None)"
342+
assert str(np.int64(5) <= x) == "ExprCons(Expr({Term(x): 1.0}), 5.0, None)"
343+
344+
# test >=, np.generic vs Variable
345+
assert str(value >= x) == "ExprCons(Expr({Term(x): 1.0}), None, 5.0)"
346+
assert str(-value >= x) == "ExprCons(Expr({Term(x): 1.0}), None, -5.0)"
347+
348+
# test ==, np.generic vs Variable
349+
assert str(value == x) == "ExprCons(Expr({Term(x): 1.0}), 5.0, 5.0)"
350+
351+
# test <=, 0-ndim int array vs Variable
352+
assert str(np.array(5) <= x) == "ExprCons(Expr({Term(x): 1.0}), 5.0, None)"
353+
354+
# test <=, 0-ndim Variable array vs Variable
355+
with pytest.raises(TypeError):
356+
1 <= np.array(x)
357+
358+
331359
def test_mul():
332360
m = Model()
333361
x = m.addVar(name="x")
@@ -494,3 +522,28 @@ def test_term_eq():
494522
assert t3 != t4 # same length, but different term
495523
assert t1 != t3 # different length
496524
assert t1 != "not a term" # different type
525+
526+
527+
def test_Expr_add_Expr():
528+
m = Model()
529+
x = m.addVar(name="x")
530+
y = m.addVar(name="y")
531+
532+
e1 = -x + 1
533+
e2 = y - 1
534+
e3 = e1 + e2
535+
assert str(e1) == "Expr({Term(x): -1.0, Term(): 1.0})"
536+
assert str(e2) == "Expr({Term(y): 1.0, Term(): -1.0})"
537+
assert str(e3) == "Expr({Term(x): -1.0, Term(): 0.0, Term(y): 1.0})"
538+
539+
540+
def test_Expr_iadd_Expr():
541+
m = Model()
542+
x = m.addVar(name="x")
543+
y = m.addVar(name="y")
544+
545+
e1 = -x + 1
546+
e2 = y - 1
547+
e1 += e2
548+
assert str(e1) == "Expr({Term(x): -1.0, Term(): 0.0, Term(y): 1.0})"
549+
assert str(e2) == "Expr({Term(y): 1.0, Term(): -1.0})"

0 commit comments

Comments
 (0)