From 2354ad69695ca7aace2d110888f73473a28422aa Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 20 May 2026 22:19:47 +0100 Subject: [PATCH 1/7] Expose captureCons and releaseCons on Model --- src/pyscipopt/scip.pxi | 26 +++++++++++++++++++++++++- src/pyscipopt/scip.pyi | 2 ++ tests/test_cons.py | 19 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index eed5339b0..ca4deae87 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -8568,7 +8568,31 @@ cdef class Model: """ PY_SCIP_CALL(SCIPdelConsLocal(self._scip, cons.scip_cons)) - + + def captureCons(self, Constraint cons): + """ + Increase the usage counter of a constraint. Must be matched by a releaseCons. + + Parameters + ---------- + cons : Constraint + constraint to capture + + """ + PY_SCIP_CALL(SCIPcaptureCons(self._scip, cons.scip_cons)) + + def releaseCons(self, Constraint cons): + """ + Decrease the usage counter of a constraint; frees it when the counter reaches zero. + + Parameters + ---------- + cons : Constraint + constraint to release + + """ + PY_SCIP_CALL(SCIPreleaseCons(self._scip, &cons.scip_cons)) + def getValsLinear(self, Constraint cons): """ Retrieve the coefficients of a linear constraint diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 2fbc976f4..f35fab837 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -922,6 +922,7 @@ class Model: def calcNodeselPriority( self, variable: Incomplete, branchdir: Incomplete, targetvalue: Incomplete ) -> Incomplete: ... + def captureCons(self, cons: Incomplete) -> Incomplete: ... def catchEvent( self, eventtype: Incomplete, eventhdlr: Incomplete ) -> Incomplete: ... @@ -1487,6 +1488,7 @@ class Model: def readSolFile(self, filename: Incomplete) -> Incomplete: ... def redirectOutput(self) -> Incomplete: ... def relax(self) -> Incomplete: ... + def releaseCons(self, cons: Incomplete) -> Incomplete: ... def releaseRow(self, row: Incomplete) -> Incomplete: ... def repropagateNode(self, node: Incomplete) -> Incomplete: ... def resetParam(self, name: Incomplete) -> Incomplete: ... diff --git a/tests/test_cons.py b/tests/test_cons.py index 623adf0d9..8ab1be971 100644 --- a/tests/test_cons.py +++ b/tests/test_cons.py @@ -368,3 +368,22 @@ def test_getValsLinear(): @pytest.mark.skip(reason="TODO: test getRowLinear()") def test_getRowLinear(): assert True + + +def test_captureCons_releaseCons(): + m = Model() + x = m.addVar("x") + c = m.addCons(x <= 1) + + # Pair capture+release: problem still holds its own capture, so this is safe. + m.captureCons(c) + m.releaseCons(c) + + # Model continues to function — problem's capture survived. + m.optimize() + assert m.getStatus() == "optimal" + + with pytest.raises(TypeError): + m.captureCons("not a constraint") + with pytest.raises(TypeError): + m.releaseCons("not a constraint") From d0d138168f02337f9969fa945085a5ca708e86b8 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 20 May 2026 22:35:46 +0100 Subject: [PATCH 2/7] Add CHANGELOG entry for captureCons/releaseCons --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6948ab2c7..10f95c988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ### Added +- Added `Model.captureCons()` and `Model.releaseCons()` ### Fixed ### Changed - Speed up `Expr.__add__` and `Expr.__iadd__` via the C-level API From 8c201e9246dc5776d937bc17ba539ac6450c8062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dion=C3=ADsio?= <57299939+Joao-Dionisio@users.noreply.github.com> Date: Thu, 21 May 2026 13:47:17 +0100 Subject: [PATCH 3/7] Wrapper remains valid Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/pyscipopt/scip.pxi | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index ca4deae87..f0c3ba4ee 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -8591,7 +8591,8 @@ cdef class Model: constraint to release """ - PY_SCIP_CALL(SCIPreleaseCons(self._scip, &cons.scip_cons)) + cdef SCIP_CONS* scip_cons = cons.scip_cons + PY_SCIP_CALL(SCIPreleaseCons(self._scip, &scip_cons)) def getValsLinear(self, Constraint cons): """ From 1eb023dad651b6ec6446a91546ad8ad39de1efd0 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Thu, 21 May 2026 14:39:36 +0100 Subject: [PATCH 4/7] Guard releaseCons against freeing constraints and expose getConsNUses --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 33 +++++++++++- src/pyscipopt/scip.pyi | 114 ++++++++++++++++++++--------------------- tests/test_cons.py | 5 ++ 5 files changed, 94 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f95c988..9489ca504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added `Model.captureCons()` and `Model.releaseCons()` +- Added `Model.captureCons()`, `Model.releaseCons()`, and `Model.getConsNUses()` ### Fixed ### Changed - Speed up `Expr.__add__` and `Expr.__iadd__` via the C-level API diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index af933fe19..b8061e740 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -913,6 +913,7 @@ cdef extern from "scip/scip.h": # Constraint Methods SCIP_RETCODE SCIPcaptureCons(SCIP* scip, SCIP_CONS* cons) SCIP_RETCODE SCIPreleaseCons(SCIP* scip, SCIP_CONS** cons) + int SCIPconsGetNUses(SCIP_CONS* cons) SCIP_RETCODE SCIPtransformCons(SCIP* scip, SCIP_CONS* cons, SCIP_CONS** transcons) SCIP_RETCODE SCIPgetTransformedCons(SCIP* scip, SCIP_CONS* cons, SCIP_CONS** transcons) SCIP_RETCODE SCIPgetConsVars(SCIP* scip, SCIP_CONS* cons, SCIP_VAR** vars, int varssize, SCIP_Bool* success) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index f0c3ba4ee..88befb66e 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -8583,17 +8583,48 @@ cdef class Model: def releaseCons(self, Constraint cons): """ - Decrease the usage counter of a constraint; frees it when the counter reaches zero. + Decrease the usage counter of a constraint. + + Unlike the underlying ``SCIPreleaseCons``, this wrapper refuses to + release the last reference: it must be paired with a prior + captureCons call. This guarantees the constraint is never freed via + this method and the wrapper's pointer stays valid. Parameters ---------- cons : Constraint constraint to release + Raises + ------ + Exception + if releasing would free the constraint (no matching captureCons). + """ cdef SCIP_CONS* scip_cons = cons.scip_cons + if SCIPconsGetNUses(scip_cons) <= 1: + raise Exception( + "releaseCons would free the constraint; must be paired with " + "a prior captureCons call." + ) PY_SCIP_CALL(SCIPreleaseCons(self._scip, &scip_cons)) + def getConsNUses(self, Constraint cons): + """ + Get the number of times the constraint is currently captured. + + Parameters + ---------- + cons : Constraint + constraint to query + + Returns + ------- + int + + """ + return SCIPconsGetNUses(cons.scip_cons) + def getValsLinear(self, Constraint cons): """ Retrieve the coefficients of a linear constraint diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index f35fab837..b94815136 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1,6 +1,6 @@ -from typing import ClassVar, Union, overload +from typing import ClassVar -import numpy as np +import numpy from _typeshed import Incomplete from typing_extensions import disjoint_base @@ -323,28 +323,12 @@ 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(ExprLike): terms: Incomplete def __init__(self, terms: Incomplete = ...) -> None: ... def degree(self) -> Incomplete: ... def normalize(self) -> Incomplete: ... - def __abs__(self) -> GenExpr: ... def __add__(self, other: Incomplete, /) -> Incomplete: ... def __eq__(self, other: object, /) -> bool: ... def __ge__(self, other: object, /) -> bool: ... @@ -383,6 +367,22 @@ class ExprCons: def __lt__(self, other: object, /) -> bool: ... def __ne__(self, other: object, /) -> bool: ... +class ExprLike: + def __init__(self) -> None: ... + def cos(self) -> Incomplete: ... + def exp(self) -> Incomplete: ... + def log(self) -> Incomplete: ... + def sin(self) -> Incomplete: ... + def sqrt(self) -> Incomplete: ... + def __abs__(self) -> Incomplete: ... + def __array_ufunc__( + self, + ufunc: Incomplete, + method: Incomplete, + *args: Incomplete, + **kwargs: Incomplete, + ) -> Incomplete: ... + @disjoint_base class GenExpr(ExprLike): _op: Incomplete @@ -390,7 +390,6 @@ class GenExpr(ExprLike): def __init__(self) -> None: ... def degree(self) -> Incomplete: ... def getOp(self) -> Incomplete: ... - def __abs__(self) -> GenExpr: ... def __add__(self, other: Incomplete, /) -> Incomplete: ... def __eq__(self, other: object, /) -> bool: ... def __ge__(self, other: object, /) -> bool: ... @@ -437,8 +436,8 @@ class IIS: @disjoint_base class IISfinder: - model: Incomplete iis: Incomplete + model: Incomplete def __init__(self) -> None: ... def iisfinderexec(self) -> Incomplete: ... def iisfinderfree(self) -> Incomplete: ... @@ -512,7 +511,7 @@ class LP: def solve(self, dual: Incomplete = ...) -> Incomplete: ... def writeLP(self, filename: Incomplete) -> Incomplete: ... -class MatrixConstraint(np.ndarray): +class MatrixConstraint(numpy.ndarray): def getConshdlrName(self) -> Incomplete: ... def isActive(self) -> Incomplete: ... def isChecked(self) -> Incomplete: ... @@ -528,21 +527,21 @@ class MatrixConstraint(np.ndarray): def isSeparated(self) -> Incomplete: ... def isStickingAtNode(self) -> Incomplete: ... -class MatrixExpr(np.ndarray): +class MatrixExpr(numpy.ndarray): def _evaluate(self, sol: Incomplete) -> Incomplete: ... def __array_ufunc__( self, - ufunc: np.ufunc, - method: str, + ufunc: Incomplete, + method: Incomplete, *args: Incomplete, **kwargs: Incomplete, ) -> Incomplete: ... -class MatrixExprCons(np.ndarray): +class MatrixExprCons(numpy.ndarray): def __array_ufunc__( self, - ufunc: np.ufunc, - method: str, + ufunc: Incomplete, + method: Incomplete, *args: Incomplete, **kwargs: Incomplete, ) -> Incomplete: ... @@ -889,16 +888,16 @@ class Model: def addVarToRow( self, row: Incomplete, var: Incomplete, value: Incomplete ) -> Incomplete: ... - def adjustedVarLb(self, var: Variable, lb: float) -> float: ... - def adjustedVarUb(self, var: Variable, ub: float) -> float: ... + def adjustedVarLb(self, var: Incomplete, lb: Incomplete) -> Incomplete: ... + def adjustedVarUb(self, var: Incomplete, ub: Incomplete) -> Incomplete: ... def aggregateVars( self, - varx: Variable, - vary: Variable, - coefx: float = ..., - coefy: float = ..., - rhs: float = ..., - ) -> tuple[bool, bool, bool]: ... + varx: Incomplete, + vary: Incomplete, + coefx: Incomplete = ..., + coefy: Incomplete = ..., + rhs: Incomplete = ..., + ) -> Incomplete: ... def allColsInLP(self) -> Incomplete: ... def allowNegSlackExact(self) -> Incomplete: ... def appendVarSOS1(self, cons: Incomplete, var: Incomplete) -> Incomplete: ... @@ -1074,7 +1073,7 @@ class Model: def fixVarProbing(self, var: Incomplete, fixedval: Incomplete) -> Incomplete: ... def flushRowExtensions(self, row: Incomplete) -> Incomplete: ... def frac(self, value: Incomplete) -> Incomplete: ... - def free(self) -> None: ... + def free(self) -> Incomplete: ... def freeBendersSubproblems(self) -> Incomplete: ... def freeProb(self) -> Incomplete: ... def freeReoptSolve(self) -> Incomplete: ... @@ -1114,6 +1113,7 @@ class Model: def getChildren(self) -> Incomplete: ... def getColRedCost(self, col: Incomplete) -> Incomplete: ... def getCondition(self, exact: Incomplete = ...) -> Incomplete: ... + def getConsNUses(self, cons: Incomplete) -> Incomplete: ... def getConsNVars(self, constraint: Incomplete) -> Incomplete: ... def getConsVals(self, constraint: Incomplete) -> Incomplete: ... def getConsVars(self, constraint: Incomplete) -> Incomplete: ... @@ -1153,6 +1153,9 @@ class Model: def getLowerbound(self) -> Incomplete: ... def getMajorVersion(self) -> Incomplete: ... def getMaxDepth(self) -> Incomplete: ... + def getMemExternEstim(self) -> Incomplete: ... + def getMemTotal(self) -> Incomplete: ... + def getMemUsed(self) -> Incomplete: ... def getMinorVersion(self) -> Incomplete: ... def getNBestSolsFound(self) -> Incomplete: ... def getNBinVars(self) -> Incomplete: ... @@ -1171,9 +1174,6 @@ class Model: def getNLPIterations(self) -> Incomplete: ... def getNLPRows(self) -> Incomplete: ... def getNLPs(self) -> Incomplete: ... - def getMemUsed(self) -> int: ... - def getMemTotal(self) -> int: ... - def getMemExternEstim(self) -> int: ... def getNLeaves(self) -> Incomplete: ... def getNLimSolsFound(self) -> Incomplete: ... def getNNlRows(self) -> Incomplete: ... @@ -1185,7 +1185,6 @@ class Model: def getNSols(self) -> Incomplete: ... def getNSolsFound(self) -> Incomplete: ... def getNStrongbranchLPIterations(self) -> Incomplete: ... - def getPrimalDualIntegral(self) -> Incomplete: ... def getNTotalNodes(self) -> Incomplete: ... def getNVars(self, transformed: Incomplete = ...) -> Incomplete: ... def getNVarsAnd(self, and_cons: Incomplete) -> Incomplete: ... @@ -1207,6 +1206,7 @@ class Model: def getParams(self) -> Incomplete: ... def getPlungeDepth(self) -> Incomplete: ... def getPresolvingTime(self) -> Incomplete: ... + def getPrimalDualIntegral(self) -> Incomplete: ... def getPrimalRay(self) -> Incomplete: ... def getPrimalRayVal(self, var: Incomplete) -> Incomplete: ... def getPrimalbound(self) -> Incomplete: ... @@ -1236,10 +1236,7 @@ class Model: self, sol: Incomplete, original: Incomplete = ... ) -> Incomplete: ... def getSolTime(self, sol: Incomplete) -> Incomplete: ... - @overload - def getSolVal(self, sol: Solution, expr: Union[Expr, GenExpr]) -> float: ... - @overload - def getSolVal(self, sol: Solution, expr: MatrixExpr) -> np.ndarray: ... + def getSolVal(self, sol: Incomplete, expr: Incomplete) -> Incomplete: ... def getSols(self) -> Incomplete: ... def getSolvingTime(self) -> Incomplete: ... def getStage(self) -> Incomplete: ... @@ -1251,10 +1248,7 @@ class Model: def getTransformedCons(self, cons: Incomplete) -> Incomplete: ... def getTransformedVar(self, var: Incomplete) -> Incomplete: ... def getTreesizeEstimation(self) -> Incomplete: ... - @overload - def getVal(self, expr: Union[Expr, GenExpr]) -> float: ... - @overload - def getVal(self, expr: MatrixExpr) -> np.ndarray: ... + def getVal(self, expr: Incomplete) -> Incomplete: ... def getValsLinear(self, cons: Incomplete) -> Incomplete: ... def getVarDict(self, transformed: Incomplete = ...) -> Incomplete: ... def getVarLbDive(self, var: Incomplete) -> Incomplete: ... @@ -1444,7 +1438,7 @@ class Model: def isGT(self, val1: Incomplete, val2: Incomplete) -> Incomplete: ... def isHugeValue(self, val: Incomplete) -> Incomplete: ... def isInfinity(self, value: Incomplete) -> Incomplete: ... - def isIntegral(self, value: float) -> bool: ... + def isIntegral(self, value: Incomplete) -> Incomplete: ... def isLE(self, val1: Incomplete, val2: Incomplete) -> Incomplete: ... def isLPSolBasic(self) -> Incomplete: ... def isLT(self, val1: Incomplete, val2: Incomplete) -> Incomplete: ... @@ -1453,7 +1447,7 @@ class Model: def isObjChangedProbing(self) -> Incomplete: ... def isObjIntegral(self) -> Incomplete: ... def isPositive(self, val: Incomplete) -> Incomplete: ... - def isReoptEnabled(self) -> bool: ... + def isReoptEnabled(self) -> Incomplete: ... def isZero(self, value: Incomplete) -> Incomplete: ... def lpiGetIterations(self) -> Incomplete: ... def markDoNotAggrVar(self, var: Incomplete) -> Incomplete: ... @@ -1648,6 +1642,7 @@ class Model: filename: Incomplete = ..., write_zeros: Incomplete = ..., ) -> Incomplete: ... + def __del__(self) -> Incomplete: ... def __eq__(self, other: object, /) -> bool: ... def __ge__(self, other: object, /) -> bool: ... def __gt__(self, other: object, /) -> bool: ... @@ -1729,6 +1724,13 @@ class Op: sqrt: ClassVar[str] = ... varidx: ClassVar[str] = ... +class PY_SCIP_BASESTAT: + BASIC: ClassVar[int] = ... + LOWER: ClassVar[int] = ... + UPPER: ClassVar[int] = ... + ZERO: ClassVar[int] = ... + def __init__(self) -> None: ... + class PY_SCIP_BENDERSENFOTYPE: CHECK: ClassVar[int] = ... LP: ClassVar[int] = ... @@ -1827,13 +1829,6 @@ class PY_SCIP_LOCKTYPE: MODEL: ClassVar[int] = ... def __init__(self) -> None: ... -class PY_SCIP_BASESTAT: - LOWER: ClassVar[int] = ... - BASIC: ClassVar[int] = ... - UPPER: ClassVar[int] = ... - ZERO: ClassVar[int] = ... - def __init__(self) -> None: ... - class PY_SCIP_LPPARAM: BARRIERCONVTOL: ClassVar[int] = ... CONDITIONLIMIT: ClassVar[int] = ... @@ -2228,7 +2223,6 @@ class SumExpr(GenExpr): class Term: vartuple: Incomplete def __init__(self, *vartuple: Incomplete) -> None: ... - def __mul__(self, other: Term, /) -> Term: ... def __eq__(self, other: object, /) -> bool: ... def __ge__(self, other: object, /) -> bool: ... def __getitem__(self, index: Incomplete, /) -> Incomplete: ... @@ -2237,11 +2231,13 @@ class Term: def __le__(self, other: object, /) -> bool: ... def __len__(self) -> int: ... def __lt__(self, other: object, /) -> bool: ... + def __mul__(self, other: Incomplete, /) -> Incomplete: ... def __ne__(self, other: object, /) -> bool: ... +@disjoint_base class UnaryExpr(GenExpr): def __init__(self, *args: Incomplete, **kwargs: Incomplete) -> None: ... - def __abs__(self) -> GenExpr: ... + def __abs__(self) -> Incomplete: ... @disjoint_base class VarExpr(GenExpr): diff --git a/tests/test_cons.py b/tests/test_cons.py index 8ab1be971..4d351bb27 100644 --- a/tests/test_cons.py +++ b/tests/test_cons.py @@ -376,8 +376,11 @@ def test_captureCons_releaseCons(): c = m.addCons(x <= 1) # Pair capture+release: problem still holds its own capture, so this is safe. + assert m.getConsNUses(c) == 1 m.captureCons(c) + assert m.getConsNUses(c) == 2 m.releaseCons(c) + assert m.getConsNUses(c) == 1 # Model continues to function — problem's capture survived. m.optimize() @@ -387,3 +390,5 @@ def test_captureCons_releaseCons(): m.captureCons("not a constraint") with pytest.raises(TypeError): m.releaseCons("not a constraint") + with pytest.raises(TypeError): + m.getConsNUses("not a constraint") From a2406d3b507e028e75c1a1d6e45b812bfe32087f Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Thu, 21 May 2026 14:50:05 +0100 Subject: [PATCH 5/7] Expose captureVar, releaseVar, and getVarNUses on Model --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 56 ++++++++++++++++++++++++++++++++++++++++++ src/pyscipopt/scip.pyi | 3 +++ tests/test_vars.py | 26 +++++++++++++++++++- 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9489ca504..6937b972b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added `Model.captureCons()`, `Model.releaseCons()`, and `Model.getConsNUses()` +- Added `Model.captureCons()`, `Model.releaseCons()`, `Model.getConsNUses()`, `Model.captureVar()`, `Model.releaseVar()`, and `Model.getVarNUses()` ### Fixed ### Changed - Speed up `Expr.__add__` and `Expr.__iadd__` via the C-level API diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index b8061e740..80d06236b 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -820,6 +820,7 @@ cdef extern from "scip/scip.h": SCIP_RETCODE SCIPcaptureVar(SCIP* scip, SCIP_VAR* var) SCIP_RETCODE SCIPaddPricedVar(SCIP* scip, SCIP_VAR* var, SCIP_Real score) SCIP_RETCODE SCIPreleaseVar(SCIP* scip, SCIP_VAR** var) + int SCIPvarGetNUses(SCIP_VAR* var) SCIP_RETCODE SCIPtransformVar(SCIP* scip, SCIP_VAR* var, SCIP_VAR** transvar) SCIP_RETCODE SCIPgetTransformedVar(SCIP* scip, SCIP_VAR* var, SCIP_VAR** transvar) SCIP_RETCODE SCIPaddVarLocks(SCIP* scip, SCIP_VAR* var, int nlocksdown, int nlocksup) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 88befb66e..1bb19ec43 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -4636,6 +4636,62 @@ cdef class Model: var.scip_var = NULL return deleted + def captureVar(self, Variable var): + """ + Increase the usage counter of a variable. Must be matched by a releaseVar. + + Parameters + ---------- + var : Variable + variable to capture + + """ + PY_SCIP_CALL(SCIPcaptureVar(self._scip, var.scip_var)) + + def releaseVar(self, Variable var): + """ + Decrease the usage counter of a variable. + + Unlike the underlying ``SCIPreleaseVar``, this wrapper refuses to + release the last reference: it must be paired with a prior + captureVar call. This guarantees the variable is never freed via + this method and the wrapper's pointer stays valid. + + Parameters + ---------- + var : Variable + variable to release + + Raises + ------ + Exception + if releasing would free the variable (no matching captureVar). + + """ + cdef SCIP_VAR* scip_var = var.scip_var + if SCIPvarGetNUses(scip_var) <= 1: + raise Exception( + "releaseVar would free the variable; must be paired with " + "a prior captureVar call." + ) + PY_SCIP_CALL(SCIPreleaseVar(self._scip, &scip_var)) + + def getVarNUses(self, Variable var): + """ + Get the number of times the variable is currently captured. + + Parameters + ---------- + var : Variable + variable to query + + Returns + ------- + int + + """ + return SCIPvarGetNUses(var.scip_var) + def aggregateVars(self, Variable varx, Variable vary, coefx=1.0, coefy=-1.0, rhs=0.0): """ Aggregate two variables by adding an aggregation constraint. diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index b94815136..e45d06569 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -922,6 +922,7 @@ class Model: self, variable: Incomplete, branchdir: Incomplete, targetvalue: Incomplete ) -> Incomplete: ... def captureCons(self, cons: Incomplete) -> Incomplete: ... + def captureVar(self, var: Incomplete) -> Incomplete: ... def catchEvent( self, eventtype: Incomplete, eventhdlr: Incomplete ) -> Incomplete: ... @@ -1252,6 +1253,7 @@ class Model: def getValsLinear(self, cons: Incomplete) -> Incomplete: ... def getVarDict(self, transformed: Incomplete = ...) -> Incomplete: ... def getVarLbDive(self, var: Incomplete) -> Incomplete: ... + def getVarNUses(self, var: Incomplete) -> Incomplete: ... def getVarPseudocost( self, var: Incomplete, branchdir: Incomplete ) -> Incomplete: ... @@ -1484,6 +1486,7 @@ class Model: def relax(self) -> Incomplete: ... def releaseCons(self, cons: Incomplete) -> Incomplete: ... def releaseRow(self, row: Incomplete) -> Incomplete: ... + def releaseVar(self, var: Incomplete) -> Incomplete: ... def repropagateNode(self, node: Incomplete) -> Incomplete: ... def resetParam(self, name: Incomplete) -> Incomplete: ... def resetParams(self) -> Incomplete: ... diff --git a/tests/test_vars.py b/tests/test_vars.py index 43f71586d..35bf11f76 100644 --- a/tests/test_vars.py +++ b/tests/test_vars.py @@ -1,3 +1,5 @@ +import pytest + from pyscipopt import Model, SCIP_PARAMSETTING, SCIP_BRANCHDIR, SCIP_IMPLINTTYPE from helpers.utils import random_mip_1 @@ -223,4 +225,26 @@ def test_adjustedVarUb(): # For continuous variables, values should generally stay the same x_cont = m.addVar(vtype='C', lb=-10.0, ub=10.0, name="x_cont") assert m.adjustedVarUb(x_cont, 5.5) == 5.5 - assert m.adjustedVarUb(x_cont, -3.2) == -3.2 \ No newline at end of file + assert m.adjustedVarUb(x_cont, -3.2) == -3.2 + + +def test_captureVar_releaseVar(): + m = Model() + x = m.addVar("x") + + # Pair capture+release: problem still holds its own capture, so this is safe. + assert m.getVarNUses(x) == 1 + m.captureVar(x) + assert m.getVarNUses(x) == 2 + m.releaseVar(x) + assert m.getVarNUses(x) == 1 + + m.optimize() + assert m.getStatus() == "optimal" + + with pytest.raises(TypeError): + m.captureVar("not a variable") + with pytest.raises(TypeError): + m.releaseVar("not a variable") + with pytest.raises(TypeError): + m.getVarNUses("not a variable") \ No newline at end of file From d4b190b6eba861616df0d3e43b4fcc7eb993dd50 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Thu, 21 May 2026 15:08:08 +0100 Subject: [PATCH 6/7] Remove @disjoint_base from UnaryExpr in stubs (re-added by generator) --- src/pyscipopt/scip.pyi | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index e45d06569..d783603f8 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -2237,7 +2237,6 @@ class Term: def __mul__(self, other: Incomplete, /) -> Incomplete: ... def __ne__(self, other: object, /) -> bool: ... -@disjoint_base class UnaryExpr(GenExpr): def __init__(self, *args: Incomplete, **kwargs: Incomplete) -> None: ... def __abs__(self) -> Incomplete: ... From 2034aaa7b1f8f48af29df4cb848ba3a79477edfb Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Thu, 21 May 2026 16:00:02 +0100 Subject: [PATCH 7/7] Apply review feedback: docstrings, pxd layout, surgical stubs --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxd | 2 +- src/pyscipopt/scip.pxi | 10 ++-- src/pyscipopt/scip.pyi | 112 ++++++++++++++++++++++------------------- tests/test_cons.py | 7 --- tests/test_vars.py | 11 +--- 6 files changed, 68 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6937b972b..c39281a8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added `Model.captureCons()`, `Model.releaseCons()`, `Model.getConsNUses()`, `Model.captureVar()`, `Model.releaseVar()`, and `Model.getVarNUses()` +- Wrapped `SCIPcaptureVar()`, `SCIPreleaseVar()`, `SCIPvarGetNUses()`, `SCIPcaptureCons()`, `SCIPreleaseCons()`, and `SCIPconsGetNUses()` ### Fixed ### Changed - Speed up `Expr.__add__` and `Expr.__iadd__` via the C-level API diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 80d06236b..f3e672563 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -818,9 +818,9 @@ cdef extern from "scip/scip.h": SCIP_RETCODE SCIPmarkDoNotAggrVar(SCIP* scip, SCIP_VAR* var) SCIP_RETCODE SCIPmarkDoNotMultaggrVar(SCIP* scip, SCIP_VAR* var) SCIP_RETCODE SCIPcaptureVar(SCIP* scip, SCIP_VAR* var) - SCIP_RETCODE SCIPaddPricedVar(SCIP* scip, SCIP_VAR* var, SCIP_Real score) SCIP_RETCODE SCIPreleaseVar(SCIP* scip, SCIP_VAR** var) int SCIPvarGetNUses(SCIP_VAR* var) + SCIP_RETCODE SCIPaddPricedVar(SCIP* scip, SCIP_VAR* var, SCIP_Real score) SCIP_RETCODE SCIPtransformVar(SCIP* scip, SCIP_VAR* var, SCIP_VAR** transvar) SCIP_RETCODE SCIPgetTransformedVar(SCIP* scip, SCIP_VAR* var, SCIP_VAR** transvar) SCIP_RETCODE SCIPaddVarLocks(SCIP* scip, SCIP_VAR* var, int nlocksdown, int nlocksup) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 1bb19ec43..0a8d90d83 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -4638,7 +4638,7 @@ cdef class Model: def captureVar(self, Variable var): """ - Increase the usage counter of a variable. Must be matched by a releaseVar. + Increase the usage counter of a variable. Must be paired with a later releaseVar. Parameters ---------- @@ -4653,7 +4653,7 @@ cdef class Model: Decrease the usage counter of a variable. Unlike the underlying ``SCIPreleaseVar``, this wrapper refuses to - release the last reference: it must be paired with a prior + release the last reference. It must be paired with a prior captureVar call. This guarantees the variable is never freed via this method and the wrapper's pointer stays valid. @@ -4688,6 +4688,7 @@ cdef class Model: Returns ------- int + the current usage count. """ return SCIPvarGetNUses(var.scip_var) @@ -8627,7 +8628,7 @@ cdef class Model: def captureCons(self, Constraint cons): """ - Increase the usage counter of a constraint. Must be matched by a releaseCons. + Increase the usage counter of a constraint. Must be paired with a later releaseCons. Parameters ---------- @@ -8642,7 +8643,7 @@ cdef class Model: Decrease the usage counter of a constraint. Unlike the underlying ``SCIPreleaseCons``, this wrapper refuses to - release the last reference: it must be paired with a prior + release the last reference. It must be paired with a prior captureCons call. This guarantees the constraint is never freed via this method and the wrapper's pointer stays valid. @@ -8677,6 +8678,7 @@ cdef class Model: Returns ------- int + the current usage count. """ return SCIPconsGetNUses(cons.scip_cons) diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index d783603f8..40478d9ac 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1,6 +1,6 @@ -from typing import ClassVar +from typing import ClassVar, Union, overload -import numpy +import numpy as np from _typeshed import Incomplete from typing_extensions import disjoint_base @@ -323,12 +323,28 @@ 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(ExprLike): terms: Incomplete def __init__(self, terms: Incomplete = ...) -> None: ... def degree(self) -> Incomplete: ... def normalize(self) -> Incomplete: ... + def __abs__(self) -> GenExpr: ... def __add__(self, other: Incomplete, /) -> Incomplete: ... def __eq__(self, other: object, /) -> bool: ... def __ge__(self, other: object, /) -> bool: ... @@ -367,22 +383,6 @@ class ExprCons: def __lt__(self, other: object, /) -> bool: ... def __ne__(self, other: object, /) -> bool: ... -class ExprLike: - def __init__(self) -> None: ... - def cos(self) -> Incomplete: ... - def exp(self) -> Incomplete: ... - def log(self) -> Incomplete: ... - def sin(self) -> Incomplete: ... - def sqrt(self) -> Incomplete: ... - def __abs__(self) -> Incomplete: ... - def __array_ufunc__( - self, - ufunc: Incomplete, - method: Incomplete, - *args: Incomplete, - **kwargs: Incomplete, - ) -> Incomplete: ... - @disjoint_base class GenExpr(ExprLike): _op: Incomplete @@ -390,6 +390,7 @@ class GenExpr(ExprLike): def __init__(self) -> None: ... def degree(self) -> Incomplete: ... def getOp(self) -> Incomplete: ... + def __abs__(self) -> GenExpr: ... def __add__(self, other: Incomplete, /) -> Incomplete: ... def __eq__(self, other: object, /) -> bool: ... def __ge__(self, other: object, /) -> bool: ... @@ -436,8 +437,8 @@ class IIS: @disjoint_base class IISfinder: - iis: Incomplete model: Incomplete + iis: Incomplete def __init__(self) -> None: ... def iisfinderexec(self) -> Incomplete: ... def iisfinderfree(self) -> Incomplete: ... @@ -511,7 +512,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: ... @@ -527,21 +528,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: ... @@ -888,16 +889,16 @@ class Model: def addVarToRow( self, row: Incomplete, var: Incomplete, value: Incomplete ) -> Incomplete: ... - def adjustedVarLb(self, var: Incomplete, lb: Incomplete) -> Incomplete: ... - def adjustedVarUb(self, var: Incomplete, ub: Incomplete) -> Incomplete: ... + def adjustedVarLb(self, var: Variable, lb: float) -> float: ... + def adjustedVarUb(self, var: Variable, ub: float) -> float: ... def aggregateVars( self, - varx: Incomplete, - vary: Incomplete, - coefx: Incomplete = ..., - coefy: Incomplete = ..., - rhs: Incomplete = ..., - ) -> Incomplete: ... + varx: Variable, + vary: Variable, + coefx: float = ..., + coefy: float = ..., + rhs: float = ..., + ) -> tuple[bool, bool, bool]: ... def allColsInLP(self) -> Incomplete: ... def allowNegSlackExact(self) -> Incomplete: ... def appendVarSOS1(self, cons: Incomplete, var: Incomplete) -> Incomplete: ... @@ -1074,7 +1075,7 @@ class Model: def fixVarProbing(self, var: Incomplete, fixedval: Incomplete) -> Incomplete: ... def flushRowExtensions(self, row: Incomplete) -> Incomplete: ... def frac(self, value: Incomplete) -> Incomplete: ... - def free(self) -> Incomplete: ... + def free(self) -> None: ... def freeBendersSubproblems(self) -> Incomplete: ... def freeProb(self) -> Incomplete: ... def freeReoptSolve(self) -> Incomplete: ... @@ -1154,9 +1155,6 @@ class Model: def getLowerbound(self) -> Incomplete: ... def getMajorVersion(self) -> Incomplete: ... def getMaxDepth(self) -> Incomplete: ... - def getMemExternEstim(self) -> Incomplete: ... - def getMemTotal(self) -> Incomplete: ... - def getMemUsed(self) -> Incomplete: ... def getMinorVersion(self) -> Incomplete: ... def getNBestSolsFound(self) -> Incomplete: ... def getNBinVars(self) -> Incomplete: ... @@ -1175,6 +1173,9 @@ class Model: def getNLPIterations(self) -> Incomplete: ... def getNLPRows(self) -> Incomplete: ... def getNLPs(self) -> Incomplete: ... + def getMemUsed(self) -> int: ... + def getMemTotal(self) -> int: ... + def getMemExternEstim(self) -> int: ... def getNLeaves(self) -> Incomplete: ... def getNLimSolsFound(self) -> Incomplete: ... def getNNlRows(self) -> Incomplete: ... @@ -1186,6 +1187,7 @@ class Model: def getNSols(self) -> Incomplete: ... def getNSolsFound(self) -> Incomplete: ... def getNStrongbranchLPIterations(self) -> Incomplete: ... + def getPrimalDualIntegral(self) -> Incomplete: ... def getNTotalNodes(self) -> Incomplete: ... def getNVars(self, transformed: Incomplete = ...) -> Incomplete: ... def getNVarsAnd(self, and_cons: Incomplete) -> Incomplete: ... @@ -1207,7 +1209,6 @@ class Model: def getParams(self) -> Incomplete: ... def getPlungeDepth(self) -> Incomplete: ... def getPresolvingTime(self) -> Incomplete: ... - def getPrimalDualIntegral(self) -> Incomplete: ... def getPrimalRay(self) -> Incomplete: ... def getPrimalRayVal(self, var: Incomplete) -> Incomplete: ... def getPrimalbound(self) -> Incomplete: ... @@ -1237,7 +1238,10 @@ class Model: self, sol: Incomplete, original: Incomplete = ... ) -> Incomplete: ... def getSolTime(self, sol: Incomplete) -> Incomplete: ... - def getSolVal(self, sol: Incomplete, expr: Incomplete) -> Incomplete: ... + @overload + def getSolVal(self, sol: Solution, expr: Union[Expr, GenExpr]) -> float: ... + @overload + def getSolVal(self, sol: Solution, expr: MatrixExpr) -> np.ndarray: ... def getSols(self) -> Incomplete: ... def getSolvingTime(self) -> Incomplete: ... def getStage(self) -> Incomplete: ... @@ -1249,7 +1253,10 @@ class Model: def getTransformedCons(self, cons: Incomplete) -> Incomplete: ... def getTransformedVar(self, var: Incomplete) -> Incomplete: ... def getTreesizeEstimation(self) -> Incomplete: ... - def getVal(self, expr: Incomplete) -> Incomplete: ... + @overload + def getVal(self, expr: Union[Expr, GenExpr]) -> float: ... + @overload + def getVal(self, expr: MatrixExpr) -> np.ndarray: ... def getValsLinear(self, cons: Incomplete) -> Incomplete: ... def getVarDict(self, transformed: Incomplete = ...) -> Incomplete: ... def getVarLbDive(self, var: Incomplete) -> Incomplete: ... @@ -1440,7 +1447,7 @@ class Model: def isGT(self, val1: Incomplete, val2: Incomplete) -> Incomplete: ... def isHugeValue(self, val: Incomplete) -> Incomplete: ... def isInfinity(self, value: Incomplete) -> Incomplete: ... - def isIntegral(self, value: Incomplete) -> Incomplete: ... + def isIntegral(self, value: float) -> bool: ... def isLE(self, val1: Incomplete, val2: Incomplete) -> Incomplete: ... def isLPSolBasic(self) -> Incomplete: ... def isLT(self, val1: Incomplete, val2: Incomplete) -> Incomplete: ... @@ -1449,7 +1456,7 @@ class Model: def isObjChangedProbing(self) -> Incomplete: ... def isObjIntegral(self) -> Incomplete: ... def isPositive(self, val: Incomplete) -> Incomplete: ... - def isReoptEnabled(self) -> Incomplete: ... + def isReoptEnabled(self) -> bool: ... def isZero(self, value: Incomplete) -> Incomplete: ... def lpiGetIterations(self) -> Incomplete: ... def markDoNotAggrVar(self, var: Incomplete) -> Incomplete: ... @@ -1645,7 +1652,6 @@ class Model: filename: Incomplete = ..., write_zeros: Incomplete = ..., ) -> Incomplete: ... - def __del__(self) -> Incomplete: ... def __eq__(self, other: object, /) -> bool: ... def __ge__(self, other: object, /) -> bool: ... def __gt__(self, other: object, /) -> bool: ... @@ -1727,13 +1733,6 @@ class Op: sqrt: ClassVar[str] = ... varidx: ClassVar[str] = ... -class PY_SCIP_BASESTAT: - BASIC: ClassVar[int] = ... - LOWER: ClassVar[int] = ... - UPPER: ClassVar[int] = ... - ZERO: ClassVar[int] = ... - def __init__(self) -> None: ... - class PY_SCIP_BENDERSENFOTYPE: CHECK: ClassVar[int] = ... LP: ClassVar[int] = ... @@ -1832,6 +1831,13 @@ class PY_SCIP_LOCKTYPE: MODEL: ClassVar[int] = ... def __init__(self) -> None: ... +class PY_SCIP_BASESTAT: + LOWER: ClassVar[int] = ... + BASIC: ClassVar[int] = ... + UPPER: ClassVar[int] = ... + ZERO: ClassVar[int] = ... + def __init__(self) -> None: ... + class PY_SCIP_LPPARAM: BARRIERCONVTOL: ClassVar[int] = ... CONDITIONLIMIT: ClassVar[int] = ... @@ -2226,6 +2232,7 @@ class SumExpr(GenExpr): class Term: vartuple: Incomplete def __init__(self, *vartuple: Incomplete) -> None: ... + def __mul__(self, other: Term, /) -> Term: ... def __eq__(self, other: object, /) -> bool: ... def __ge__(self, other: object, /) -> bool: ... def __getitem__(self, index: Incomplete, /) -> Incomplete: ... @@ -2234,12 +2241,11 @@ class Term: def __le__(self, other: object, /) -> bool: ... def __len__(self) -> int: ... def __lt__(self, other: object, /) -> bool: ... - def __mul__(self, other: Incomplete, /) -> Incomplete: ... def __ne__(self, other: object, /) -> bool: ... class UnaryExpr(GenExpr): def __init__(self, *args: Incomplete, **kwargs: Incomplete) -> None: ... - def __abs__(self) -> Incomplete: ... + def __abs__(self) -> GenExpr: ... @disjoint_base class VarExpr(GenExpr): diff --git a/tests/test_cons.py b/tests/test_cons.py index 4d351bb27..6fa592e57 100644 --- a/tests/test_cons.py +++ b/tests/test_cons.py @@ -385,10 +385,3 @@ def test_captureCons_releaseCons(): # Model continues to function — problem's capture survived. m.optimize() assert m.getStatus() == "optimal" - - with pytest.raises(TypeError): - m.captureCons("not a constraint") - with pytest.raises(TypeError): - m.releaseCons("not a constraint") - with pytest.raises(TypeError): - m.getConsNUses("not a constraint") diff --git a/tests/test_vars.py b/tests/test_vars.py index 35bf11f76..917a2daaa 100644 --- a/tests/test_vars.py +++ b/tests/test_vars.py @@ -1,5 +1,3 @@ -import pytest - from pyscipopt import Model, SCIP_PARAMSETTING, SCIP_BRANCHDIR, SCIP_IMPLINTTYPE from helpers.utils import random_mip_1 @@ -240,11 +238,4 @@ def test_captureVar_releaseVar(): assert m.getVarNUses(x) == 1 m.optimize() - assert m.getStatus() == "optimal" - - with pytest.raises(TypeError): - m.captureVar("not a variable") - with pytest.raises(TypeError): - m.releaseVar("not a variable") - with pytest.raises(TypeError): - m.getVarNUses("not a variable") \ No newline at end of file + assert m.getStatus() == "optimal" \ No newline at end of file