From e969506241b4157412116505ac39bf8a5d893447 Mon Sep 17 00:00:00 2001 From: nu Date: Thu, 23 Jul 2026 19:45:41 +0200 Subject: [PATCH 01/16] add getMaxTotalDepth method --- CHANGELOG.md | 1 + src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 11 +++++++++++ src/pyscipopt/scip.pyi | 1 + tests/test_statistics.py | 9 +++++++++ 5 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae37c3270..d03d9b0d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ### Added +- Added methods: `getMaxTotalDepth` with tests - Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` with tests - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 3a126f4dc..f85bd35e4 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -1486,6 +1486,7 @@ cdef extern from "scip/scip.h": SCIP_Real SCIPgetLowerbound(SCIP* scip) SCIP_Real SCIPgetCutoffbound(SCIP* scip) int SCIPgetMaxDepth(SCIP* scip) + int SCIPgetMaxTotalDepth(SCIP* scip) int SCIPgetPlungeDepth(SCIP* scip) SCIP_Longint SCIPgetNNodeLPIterations(SCIP* scip) SCIP_Longint SCIPgetNStrongbranchLPIterations(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index cc2722af9..02391270b 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3462,6 +3462,17 @@ cdef class Model: """ return SCIPgetMaxDepth(self._scip) + def getMaxTotalDepth(self): + """ + Gets maximal depth of all processed nodes over all branch and bound runs. + + Returns + ------- + int + + """ + return SCIPgetMaxTotalDepth(self._scip) + def getPlungeDepth(self): """ Gets current plunging depth (successive selections of child/sibling nodes). diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 1e3164109..4859208ed 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1240,6 +1240,7 @@ class Model: def getLowerbound(self) -> float: ... def getMajorVersion(self) -> int: ... def getMaxDepth(self) -> int: ... + def getMaxTotalDepth(self) -> int: ... def getMinorVersion(self) -> int: ... def getNBestSolsFound(self) -> int: ... def getNBinVars(self) -> int: ... diff --git a/tests/test_statistics.py b/tests/test_statistics.py index fef2f4027..422517e61 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -47,3 +47,12 @@ def test_addNNodes(optimized_model): new_n_nodes = optimized_model.getNTotalNodes() assert new_n_nodes == initial_n_nodes + 5 + + +def test_getMaxTotalDepth(optimized_model): + max_total_depth = optimized_model.getMaxTotalDepth() + total_depth = optimized_model.getMaxDepth() + + assert isinstance(max_total_depth, int) + assert max_total_depth >= 0 + assert max_total_depth >= total_depth From 33c3ce77919da30eeec09198d63e8e10c9e9eddb Mon Sep 17 00:00:00 2001 From: nu Date: Thu, 23 Jul 2026 22:02:09 +0200 Subject: [PATCH 02/16] add getNBacktracks and getFocusNode method --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxd | 2 ++ src/pyscipopt/scip.pxi | 23 +++++++++++++++++++++++ src/pyscipopt/scip.pyi | 2 ++ tests/test_statistics.py | 7 +++++++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d03d9b0d9..f9e409958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added methods: `getMaxTotalDepth` with tests +- Added methods: `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()` with tests - Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` with tests - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index f85bd35e4..77a65dea4 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -749,6 +749,7 @@ cdef extern from "scip/scip.h": SCIP_RETCODE SCIPpresolve(SCIP* scip) # Node Methods + SCIP_NODE* SCIPgetFocusNode(SCIP* scip) SCIP_NODE* SCIPgetCurrentNode(SCIP* scip) SCIP_NODE* SCIPnodeGetParent(SCIP_NODE* node) SCIP_Longint SCIPnodeGetNumber(SCIP_NODE* node) @@ -1487,6 +1488,7 @@ cdef extern from "scip/scip.h": SCIP_Real SCIPgetCutoffbound(SCIP* scip) int SCIPgetMaxDepth(SCIP* scip) int SCIPgetMaxTotalDepth(SCIP* scip) + SCIP_Longint SCIPgetNBacktracks(SCIP* scip) int SCIPgetPlungeDepth(SCIP* scip) SCIP_Longint SCIPgetNNodeLPIterations(SCIP* scip) SCIP_Longint SCIPgetNStrongbranchLPIterations(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 02391270b..8d0085827 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3417,6 +3417,18 @@ cdef class Model: """ return SCIPgetNSiblings(self._scip) + def getFocusNode(self): + """ + Gets focus node in the tree. + If we are in probing/diving mode this method returns the node in the tree where the probing/diving mode was started. + + Returns + ------- + Node + + """ + return Node.create(SCIPgetFocusNode(self._scip)) + def getCurrentNode(self): """ Retrieve current node. @@ -3473,6 +3485,17 @@ cdef class Model: """ return SCIPgetMaxTotalDepth(self._scip) + def getNBacktracks(self): + """ + Gets total number of backtracks, i.e. number of times, the new node was selected from the leaves queue. + + Returns + ------- + int + + """ + return SCIPgetNBacktracks(self._scip) + def getPlungeDepth(self): """ Gets current plunging depth (successive selections of child/sibling nodes). diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 4859208ed..711707022 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1205,6 +1205,7 @@ class Model: def getConsVals(self, constraint: Constraint) -> list[float] | None: ... def getConsVars(self, constraint: Constraint) -> list[Variable] | None: ... def getConss(self, transformed: bool = True) -> list[Constraint]: ... + def getFocusNode(self) -> Node | None: ... def getCurrentNode(self) -> Node | None: ... def getCutEfficacy(self, cut: Row, sol: Solution | None = None) -> float: ... def getCutLPSolCutoffDistance(self, cut: Row, sol: Solution) -> float: ... @@ -1241,6 +1242,7 @@ class Model: def getMajorVersion(self) -> int: ... def getMaxDepth(self) -> int: ... def getMaxTotalDepth(self) -> int: ... + def getNBacktracks(self) -> int: ... def getMinorVersion(self) -> int: ... def getNBestSolsFound(self) -> int: ... def getNBinVars(self) -> int: ... diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 422517e61..37a7f4408 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -56,3 +56,10 @@ def test_getMaxTotalDepth(optimized_model): assert isinstance(max_total_depth, int) assert max_total_depth >= 0 assert max_total_depth >= total_depth + + +def test_getNBacktracks(optimized_model): + n_backtracks = optimized_model.getNBacktracks() + + assert isinstance(n_backtracks, int) + assert n_backtracks >= 0 From 1d1bcf05e75659a57b9159fa15e517693bbba95e Mon Sep 17 00:00:00 2001 From: nu Date: Sat, 25 Jul 2026 11:57:27 +0200 Subject: [PATCH 03/16] add getAvgLowerbound method --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 11 +++++++++++ src/pyscipopt/scip.pyi | 1 + tests/test_statistics.py | 15 +++++++++++++++ 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e409958..f378efb9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added methods: `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()` with tests +- Added methods: `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()` with tests - Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` with tests - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 77a65dea4..84406a508 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -1484,6 +1484,7 @@ cdef extern from "scip/scip.h": SCIP_Longint SCIPgetNLPs(SCIP* scip) SCIP_Longint SCIPgetNLPIterations(SCIP* scip) int SCIPgetNSepaRounds(SCIP* scip) + SCIP_Real SCIPgetAvgLowerbound(SCIP* scip) SCIP_Real SCIPgetLowerbound(SCIP* scip) SCIP_Real SCIPgetCutoffbound(SCIP* scip) int SCIPgetMaxDepth(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 8d0085827..9f0a2d5b0 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3507,6 +3507,17 @@ cdef class Model: """ return SCIPgetPlungeDepth(self._scip) + def getAvgLowerbound(self): + """ + Gets average lower (dual) bound of all unprocessed nodes in transformed problem. + + Returns + ------- + float + + """ + return SCIPgetAvgLowerbound(self._scip) + def getLowerbound(self): """ Gets global lower (dual) bound of the transformed problem. diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 711707022..128a1bd88 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1196,6 +1196,7 @@ class Model: list[list[float]], dict[str, dict[str, int]], ]: ... + def getAvgLowerbound(self) -> float: ... def getBranchScoreMultiple(self, var: Variable, gains: list[float]) -> float: ... def getCapacityKnapsack(self, cons: Constraint) -> int: ... def getChildren(self) -> list[Node]: ... diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 37a7f4408..6298f2cf6 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -2,6 +2,7 @@ from helpers.utils import random_mip_1 from json import load import pytest +import numpy as np @pytest.fixture @@ -63,3 +64,17 @@ def test_getNBacktracks(optimized_model): assert isinstance(n_backtracks, int) assert n_backtracks >= 0 + + +def test_getAvgLowerbound(optimized_model): + avg_lowerbound = optimized_model.getAvgLowerbound() + leaves, children, siblings = optimized_model.getOpenNodes() + open_nodes = leaves + children + siblings + manual_avg_lowerbound = 0.0 + if len(open_nodes) > 0: + manual_avg_lowerbound = np.mean( + [node.getLowerbound() for node in open_nodes] + [optimized_model.getFocusNode().getLowerbound()] + ) + + assert isinstance(avg_lowerbound, float) + assert np.isclose(manual_avg_lowerbound, avg_lowerbound) From 7f0566e91dea710ee954df7184e0bd3d3121488a Mon Sep 17 00:00:00 2001 From: nu Date: Sat, 25 Jul 2026 12:33:14 +0200 Subject: [PATCH 04/16] add getAvgDualbound method --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 11 +++++++++++ src/pyscipopt/scip.pyi | 1 + tests/test_statistics.py | 14 +++++++++++--- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f378efb9e..a8538db2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added methods: `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()` with tests +- Added methods: `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()` with tests - Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` with tests - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 84406a508..a8308ba3c 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -1485,6 +1485,7 @@ cdef extern from "scip/scip.h": SCIP_Longint SCIPgetNLPIterations(SCIP* scip) int SCIPgetNSepaRounds(SCIP* scip) SCIP_Real SCIPgetAvgLowerbound(SCIP* scip) + SCIP_Real SCIPgetAvgDualbound(SCIP* scip) SCIP_Real SCIPgetLowerbound(SCIP* scip) SCIP_Real SCIPgetCutoffbound(SCIP* scip) int SCIPgetMaxDepth(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 9f0a2d5b0..0ad454258 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3518,6 +3518,17 @@ cdef class Model: """ return SCIPgetAvgLowerbound(self._scip) + def getAvgDualbound(self): + """ + Gets average dual bound of all unprocessed nodes for original problem. + + Returns + ------- + float + + """ + return SCIPgetAvgDualbound(self._scip) + def getLowerbound(self): """ Gets global lower (dual) bound of the transformed problem. diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 128a1bd88..eae75837a 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1197,6 +1197,7 @@ class Model: dict[str, dict[str, int]], ]: ... def getAvgLowerbound(self) -> float: ... + def getAvgDualbound(self) -> float: ... def getBranchScoreMultiple(self, var: Variable, gains: list[float]) -> float: ... def getCapacityKnapsack(self, cons: Constraint) -> int: ... def getChildren(self) -> list[Node]: ... diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 6298f2cf6..e67197334 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -73,8 +73,16 @@ def test_getAvgLowerbound(optimized_model): manual_avg_lowerbound = 0.0 if len(open_nodes) > 0: manual_avg_lowerbound = np.mean( - [node.getLowerbound() for node in open_nodes] + [optimized_model.getFocusNode().getLowerbound()] + [node.getLowerbound() for node in open_nodes] + [optimized_model.getFocusNode().getLowerbound()] ) - + assert isinstance(avg_lowerbound, float) - assert np.isclose(manual_avg_lowerbound, avg_lowerbound) + assert manual_avg_lowerbound == pytest.approx(avg_lowerbound) + + +def test_getAvgDualbound(optimized_model): + avg_dualbound = optimized_model.getAvgDualbound() + avg_lowerbound = optimized_model.getAvgLowerbound() + + assert isinstance(avg_dualbound, float) + assert avg_dualbound == pytest.approx(avg_lowerbound) or avg_dualbound == pytest.approx(-avg_lowerbound) From d00054fcf1a2bda3dda9c7a9f68f443eb0f688d2 Mon Sep 17 00:00:00 2001 From: nu Date: Sat, 25 Jul 2026 12:35:32 +0200 Subject: [PATCH 05/16] add getDeterministicTime method --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 11 +++++++++++ src/pyscipopt/scip.pyi | 1 + tests/test_statistics.py | 7 +++++++ 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8538db2b..a0f35fbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added methods: `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()` with tests +- Added methods: `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()` with tests - Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` with tests - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index a8308ba3c..a4c258f6e 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -625,6 +625,7 @@ cdef extern from "scip/scip.h": SCIP_Real SCIPgetSolvingTime(SCIP* scip) SCIP_Real SCIPgetReadingTime(SCIP* scip) SCIP_Real SCIPgetPresolvingTime(SCIP* scip) + SCIP_Real SCIPgetDeterministicTime(SCIP* scip) SCIP_STAGE SCIPgetStage(SCIP* scip) SCIP_RETCODE SCIPsetProbName(SCIP* scip, char* name) const char* SCIPgetProbName(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 0ad454258..b988f7725 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3285,6 +3285,17 @@ cdef class Model: """ return SCIPgetPresolvingTime(self._scip) + def getDeterministicTime(self): + """ + Computes a deterministic measure of time from statistics. + + Returns + ------- + float + + """ + return SCIPgetDeterministicTime(self._scip) + def getNLPIterations(self): """ Returns the total number of LP iterations so far. diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index eae75837a..f5bd16c39 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1213,6 +1213,7 @@ class Model: def getCutLPSolCutoffDistance(self, cut: Row, sol: Solution) -> float: ... def getCutoffbound(self) -> float: ... def getDepth(self) -> int: ... + def getDeterministicTime(self) -> float: ... def getDualMultiplier(self, cons: Constraint) -> float: ... def getDualSolVal( self, cons: Constraint, boundconstraint: bool = False diff --git a/tests/test_statistics.py b/tests/test_statistics.py index e67197334..5dc417020 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -86,3 +86,10 @@ def test_getAvgDualbound(optimized_model): assert isinstance(avg_dualbound, float) assert avg_dualbound == pytest.approx(avg_lowerbound) or avg_dualbound == pytest.approx(-avg_lowerbound) + + +def test_getDeterministicTime(optimized_model): + det_time = optimized_model.getDeterministicTime() + + assert isinstance(det_time, float) + assert det_time >= 0.0 From 7ca8620cd04a6d17944ba6472b671b6b4c8552c4 Mon Sep 17 00:00:00 2001 From: nu Date: Sat, 25 Jul 2026 12:54:47 +0200 Subject: [PATCH 06/16] add getFirstPrimalBound method --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 11 +++++++++++ src/pyscipopt/scip.pyi | 1 + tests/test_statistics.py | 8 +++++++- 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f35fbcb..fbfaea3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added methods: `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()` with tests +- Added methods: `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()`, `getFirstPrimalBound()` with tests - Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` with tests - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index a4c258f6e..ed8b3b41a 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -971,6 +971,7 @@ cdef extern from "scip/scip.h": SCIP_RETCODE SCIPprintBestTransSol(SCIP* scip, FILE* outfile, SCIP_Bool printzeros) SCIP_RETCODE SCIPprintSol(SCIP* scip, SCIP_SOL* sol, FILE* outfile, SCIP_Bool printzeros) SCIP_RETCODE SCIPprintTransSol(SCIP* scip, SCIP_SOL* sol, FILE* outfile, SCIP_Bool printzeros) + SCIP_Real SCIPgetFirstPrimalBound(SCIP* scip) SCIP_Real SCIPgetPrimalbound(SCIP* scip) SCIP_Real SCIPgetGap(SCIP* scip) int SCIPgetDepth(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index b988f7725..5953ab457 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3296,6 +3296,17 @@ cdef class Model: """ return SCIPgetDeterministicTime(self._scip) + def getFirstPrimalBound(self): + """ + Gets the primal bound of the very first solution. + + Returns + ------- + float + + """ + return SCIPgetFirstPrimalBound(self._scip) + def getNLPIterations(self): """ Returns the total number of LP iterations so far. diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index f5bd16c39..7d8c5aaec 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1214,6 +1214,7 @@ class Model: def getCutoffbound(self) -> float: ... def getDepth(self) -> int: ... def getDeterministicTime(self) -> float: ... + def getFirstPrimalBound(self) -> float: ... def getDualMultiplier(self, cons: Constraint) -> float: ... def getDualSolVal( self, cons: Constraint, boundconstraint: bool = False diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 5dc417020..682098b15 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -75,7 +75,7 @@ def test_getAvgLowerbound(optimized_model): manual_avg_lowerbound = np.mean( [node.getLowerbound() for node in open_nodes] + [optimized_model.getFocusNode().getLowerbound()] ) - + assert isinstance(avg_lowerbound, float) assert manual_avg_lowerbound == pytest.approx(avg_lowerbound) @@ -93,3 +93,9 @@ def test_getDeterministicTime(optimized_model): assert isinstance(det_time, float) assert det_time >= 0.0 + + +def test_getFirstPrimalBound(optimized_model): + first_primal = optimized_model.getFirstPrimalBound() + + assert isinstance(first_primal, float) From 9de5f776bc0cf6b6b1e8f1ecb1259cef9220d46b Mon Sep 17 00:00:00 2001 From: nu Date: Sat, 25 Jul 2026 13:19:55 +0200 Subject: [PATCH 07/16] add getLowerboundRoot method --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 11 +++++++++++ src/pyscipopt/scip.pyi | 1 + tests/test_statistics.py | 8 ++++++++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbfaea3b4..05abf5e5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added methods: `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()`, `getFirstPrimalBound()` with tests +- Added methods: `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()`, `getFirstPrimalBound()`, `getLowerboundRoot()` with tests - Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` with tests - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index ed8b3b41a..77625d3e8 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -1489,6 +1489,7 @@ cdef extern from "scip/scip.h": SCIP_Real SCIPgetAvgLowerbound(SCIP* scip) SCIP_Real SCIPgetAvgDualbound(SCIP* scip) SCIP_Real SCIPgetLowerbound(SCIP* scip) + SCIP_Real SCIPgetLowerboundRoot(SCIP* scip) SCIP_Real SCIPgetCutoffbound(SCIP* scip) int SCIPgetMaxDepth(SCIP* scip) int SCIPgetMaxTotalDepth(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 5953ab457..f7969ba5e 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -11584,6 +11584,17 @@ cdef class Model: """ return SCIPgetDualboundRoot(self._scip) + def getLowerboundRoot(self): + """ + Gets lower (dual) bound in transformed problem of the root node. + + Returns + ------- + float + + """ + return SCIPgetLowerboundRoot(self._scip) + def writeName(self, Variable var): """ Write the name of the variable to the std out. diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 7d8c5aaec..1e6434a8e 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1221,6 +1221,7 @@ class Model: ) -> float: ... def getDualbound(self) -> float: ... def getDualboundRoot(self) -> float: ... + def getLowerboundRoot(self) -> float: ... def getDualfarkasKnapsack(self, cons: Constraint) -> float: ... def getDualfarkasLinear(self, cons: Constraint) -> float: ... def getDualsolKnapsack(self, cons: Constraint) -> float: ... diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 682098b15..108242cd6 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -99,3 +99,11 @@ def test_getFirstPrimalBound(optimized_model): first_primal = optimized_model.getFirstPrimalBound() assert isinstance(first_primal, float) + + +def test_getLowerboundRoot(optimized_model): + lowerbound_root = optimized_model.getLowerboundRoot() + lowerbound = optimized_model.getLowerbound() + + assert isinstance(lowerbound_root, float) + assert lowerbound_root <= lowerbound From dbbeb9a6704b025017ab4c377b07640935257548 Mon Sep 17 00:00:00 2001 From: nu Date: Sat, 25 Jul 2026 14:37:02 +0200 Subject: [PATCH 08/16] add getUpperbound method --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 10 ++++++++++ src/pyscipopt/scip.pyi | 1 + tests/test_statistics.py | 12 +++++++++++- 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05abf5e5c..9abaebe0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added methods: `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()`, `getFirstPrimalBound()`, `getLowerboundRoot()` with tests +- Added methods: `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()`, `getFirstPrimalBound()`, `getLowerboundRoot()`, `getUpperbound()` with tests - Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` with tests - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 77625d3e8..fc2c04ce8 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -1491,6 +1491,7 @@ cdef extern from "scip/scip.h": SCIP_Real SCIPgetLowerbound(SCIP* scip) SCIP_Real SCIPgetLowerboundRoot(SCIP* scip) SCIP_Real SCIPgetCutoffbound(SCIP* scip) + SCIP_Real SCIPgetUpperbound(SCIP* scip) int SCIPgetMaxDepth(SCIP* scip) int SCIPgetMaxTotalDepth(SCIP* scip) SCIP_Longint SCIPgetNBacktracks(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index f7969ba5e..a70313890 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3573,6 +3573,16 @@ cdef class Model: """ return SCIPgetCutoffbound(self._scip) + def getUpperbound(self): + """ + Gets global upper (primal) bound in transformed problem (objective value of best solution or user objective limit). + + Returns + ------- + float + """ + return SCIPgetUpperbound(self._scip) + def getNNodeLPIterations(self): """ Gets number of LP iterations used for solving node relaxations so far. diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 1e6434a8e..fa9b626be 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1212,6 +1212,7 @@ class Model: def getCutEfficacy(self, cut: Row, sol: Solution | None = None) -> float: ... def getCutLPSolCutoffDistance(self, cut: Row, sol: Solution) -> float: ... def getCutoffbound(self) -> float: ... + def getUpperbound(self) -> float: ... def getDepth(self) -> int: ... def getDeterministicTime(self) -> float: ... def getFirstPrimalBound(self) -> float: ... diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 108242cd6..1cb7a203b 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -95,10 +95,20 @@ def test_getDeterministicTime(optimized_model): assert det_time >= 0.0 +def test_getUpperbound(optimized_model): + upperbound = optimized_model.getUpperbound() + lowerbound = optimized_model.getLowerbound() + + assert isinstance(upperbound, float) + assert upperbound >= lowerbound + + def test_getFirstPrimalBound(optimized_model): first_primal = optimized_model.getFirstPrimalBound() - + upperbound = optimized_model.getUpperbound() + assert isinstance(first_primal, float) + assert first_primal >= upperbound def test_getLowerboundRoot(optimized_model): From d3ae10ec87f9743465751cc5a3bc49f1712cc7e9 Mon Sep 17 00:00:00 2001 From: nu Date: Sat, 25 Jul 2026 14:47:21 +0200 Subject: [PATCH 09/16] add getNObjlimLeaves method --- CHANGELOG.md | 4 ++-- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 11 +++++++++++ src/pyscipopt/scip.pyi | 1 + tests/test_statistics.py | 6 ++++++ 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9abaebe0c..5ac406e32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## Unreleased ### Added -- Added methods: `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`, `getFocusNode()`, `getAvgLowerbound()`, `getFirstPrimalBound()`, `getLowerboundRoot()`, `getUpperbound()` with tests -- Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` with tests +- Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()`, `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`,\ +`getFocusNode()`, `getAvgLowerbound()`, `getFirstPrimalBound()`, `getLowerboundRoot()`, `getUpperbound()`, `getNObjlimLeaves()` with tests - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` - Added type annotations to most methods on the `Model` class diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index fc2c04ce8..64bc5dc16 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -1483,6 +1483,7 @@ cdef extern from "scip/scip.h": SCIP_Longint SCIPgetNTotalNodes(SCIP* scip) SCIP_Longint SCIPgetNFeasibleLeaves(SCIP* scip) SCIP_Longint SCIPgetNInfeasibleLeaves(SCIP* scip) + SCIP_Longint SCIPgetNObjlimLeaves(SCIP* scip) SCIP_Longint SCIPgetNLPs(SCIP* scip) SCIP_Longint SCIPgetNLPIterations(SCIP* scip) int SCIPgetNSepaRounds(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index a70313890..7ab979f91 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3395,6 +3395,17 @@ cdef class Model: """ return SCIPgetNInfeasibleLeaves(self._scip) + def getNObjlimLeaves(self): + """ + Gets number of processed leaf nodes that hit LP objective limit. + + Returns + ------- + int + + """ + return SCIPgetNObjlimLeaves(self._scip) + def getNLeaves(self): """ Gets number of leaves in the tree. diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index fa9b626be..41aa032ab 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1261,6 +1261,7 @@ class Model: def getNFeasibleLeaves(self) -> int: ... def getNImplVars(self) -> int: ... def getNInfeasibleLeaves(self) -> int: ... + def getNObjlimLeaves(self) -> int: ... def getNIntVars(self) -> int: ... def getNLPBranchCands(self) -> int: ... def getNLPCols(self) -> int: ... diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 1cb7a203b..7059a8fc2 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -42,6 +42,12 @@ def test_getNReoptRuns(optimized_model): assert n_reopt_runs >= 0 +def test_getNObjlimLeaves(optimized_model): + n_objlim_leaves = optimized_model.getNObjlimLeaves() + + assert isinstance(n_objlim_leaves, int) + + def test_addNNodes(optimized_model): initial_n_nodes = optimized_model.getNTotalNodes() optimized_model.addNNodes(5) From a39137e84e2a1cda4d368744576c88bc118ca848 Mon Sep 17 00:00:00 2001 From: nu Date: Sat, 25 Jul 2026 21:30:09 +0200 Subject: [PATCH 10/16] increase optimized_model's node_lim to get primal solutions --- tests/test_statistics.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 7059a8fc2..8f16fa399 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -7,7 +7,7 @@ @pytest.fixture def optimized_model(): - model = random_mip_1(small=True) # Using small=True for speed across tests + model = random_mip_1(small=True, node_lim=2400) # Using small=True for speed across tests model.optimize() return model @@ -22,6 +22,12 @@ def test_statistics_json(optimized_model): os.remove("statistics.json") +def test_getNSolsFound(optimized_model): + sols = optimized_model.getNSolsFound() + + assert sols >= 1 + + def test_getPrimalDualIntegral(optimized_model): primal_dual_integral = optimized_model.getPrimalDualIntegral() From 2307b0ae7a58510e949ddf09e317bb38b2e11562 Mon Sep 17 00:00:00 2001 From: Adam Jemielita Date: Tue, 1 Sep 2026 17:49:44 +0200 Subject: [PATCH 11/16] update CHANGELOG.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: João Dionísio <57299939+Joao-Dionisio@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac406e32..2c312c7d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## Unreleased ### Added -- Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()`, `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`,\ -`getFocusNode()`, `getAvgLowerbound()`, `getFirstPrimalBound()`, `getLowerboundRoot()`, `getUpperbound()`, `getNObjlimLeaves()` with tests +- Added the following methods with tests: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()`, `getDeterministicTime()`, `getAvgDualbound()`, `getMaxTotalDepth()`, `getNBacktracks()`,\ +`getFocusNode()`, `getAvgLowerbound()`, `getFirstPrimalBound()`, `getLowerboundRoot()`, `getUpperbound()`, `getNObjlimLeaves()` - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` - Added type annotations to most methods on the `Model` class From 9a3d45027442472d2bef2c058e31cfeda4c46485 Mon Sep 17 00:00:00 2001 From: Adam Jemielita Date: Tue, 1 Sep 2026 17:58:56 +0200 Subject: [PATCH 12/16] update the description of getNBacktracks --- src/pyscipopt/scip.pxi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 7ab979f91..aef5599a2 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3520,7 +3520,7 @@ cdef class Model: def getNBacktracks(self): """ - Gets total number of backtracks, i.e. number of times, the new node was selected from the leaves queue. + Gets total number of backtracks, i.e., number of times the new node was selected from the leaves queue. Returns ------- From 6ba6467781e89eab5b6194dce682ff641021b50f Mon Sep 17 00:00:00 2001 From: Adam Jemielita Date: Tue, 1 Sep 2026 19:05:08 +0200 Subject: [PATCH 13/16] refactor statistics tests --- tests/test_statistics.py | 54 ++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 8f16fa399..894dd4bf4 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -1,3 +1,4 @@ +from pyscipopt.scip import Model import os from helpers.utils import random_mip_1 from json import load @@ -7,11 +8,34 @@ @pytest.fixture def optimized_model(): - model = random_mip_1(small=True, node_lim=2400) # Using small=True for speed across tests + # Using small=True for speed across tests + # finds 1 primal solution + model = random_mip_1(small=True, node_lim=2400) model.optimize() return model +# very simple model with 2 primal solutions +# created because getting more than 1 primal solution from random_mip_1 requres setting a large node limit, which slows down the tests +@pytest.fixture +def optimized_model_with_primal_solutions(): + model = Model() + + x = model.addVar(vtype="I", lb=0, ub=5, name="x") + y = model.addVar(vtype="I", lb=0, ub=5, name="y") + z = model.addVar(vtype="I", lb=0, ub=5, name="z") + + model.addCons(x + y + z <= 5) + model.addCons(x >= 1) + model.addCons(y >= 1) + + model.setObjective(x + y + z, "maximize") + + model.optimize() + + return model + + def test_statistics_json(optimized_model): optimized_model.writeStatisticsJson("statistics.json") @@ -22,11 +46,15 @@ def test_statistics_json(optimized_model): os.remove("statistics.json") -def test_getNSolsFound(optimized_model): +def test_getNSolsFound(optimized_model, optimized_model_with_primal_solutions): sols = optimized_model.getNSolsFound() assert sols >= 1 + sols_model_with_primals = optimized_model_with_primal_solutions.getNSolsFound() + + assert sols_model_with_primals >= 2 + def test_getPrimalDualIntegral(optimized_model): primal_dual_integral = optimized_model.getPrimalDualIntegral() @@ -85,11 +113,12 @@ def test_getAvgLowerbound(optimized_model): manual_avg_lowerbound = 0.0 if len(open_nodes) > 0: manual_avg_lowerbound = np.mean( - [node.getLowerbound() for node in open_nodes] + [optimized_model.getFocusNode().getLowerbound()] + [node.getLowerbound() for node in open_nodes] + + [optimized_model.getFocusNode().getLowerbound()] ) assert isinstance(avg_lowerbound, float) - assert manual_avg_lowerbound == pytest.approx(avg_lowerbound) + assert optimized_model.isEQ(manual_avg_lowerbound, avg_lowerbound) def test_getAvgDualbound(optimized_model): @@ -97,7 +126,10 @@ def test_getAvgDualbound(optimized_model): avg_lowerbound = optimized_model.getAvgLowerbound() assert isinstance(avg_dualbound, float) - assert avg_dualbound == pytest.approx(avg_lowerbound) or avg_dualbound == pytest.approx(-avg_lowerbound) + assert ( + optimized_model.isEQ(avg_dualbound, avg_lowerbound) + or optimized_model.isEQ(avg_dualbound, -avg_lowerbound) + ) def test_getDeterministicTime(optimized_model): @@ -112,15 +144,15 @@ def test_getUpperbound(optimized_model): lowerbound = optimized_model.getLowerbound() assert isinstance(upperbound, float) - assert upperbound >= lowerbound + assert optimized_model.isGE(upperbound, lowerbound) -def test_getFirstPrimalBound(optimized_model): - first_primal = optimized_model.getFirstPrimalBound() - upperbound = optimized_model.getUpperbound() +def test_getFirstPrimalBound(optimized_model_with_primal_solutions): + first_primal = optimized_model_with_primal_solutions.getFirstPrimalBound() + upperbound = optimized_model_with_primal_solutions.getUpperbound() assert isinstance(first_primal, float) - assert first_primal >= upperbound + assert optimized_model_with_primal_solutions.isGT(first_primal, upperbound) def test_getLowerboundRoot(optimized_model): @@ -128,4 +160,4 @@ def test_getLowerboundRoot(optimized_model): lowerbound = optimized_model.getLowerbound() assert isinstance(lowerbound_root, float) - assert lowerbound_root <= lowerbound + assert optimized_model.isLE(lowerbound_root, lowerbound) From e1caacde2c29d599aa8b495180aa0d1ecbb3bb4a Mon Sep 17 00:00:00 2001 From: Adam Jemielita Date: Tue, 1 Sep 2026 22:27:21 +0200 Subject: [PATCH 14/16] add test for getFocusNode --- tests/test_node.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/test_node.py b/tests/test_node.py index ce901eb8e..bf2b4dbf2 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -65,4 +65,39 @@ def test_tree_methods(): m.optimize() - assert m.getNSols() == 0 \ No newline at end of file + assert m.getNSols() == 0 + +class ProbingNodeChecker(Eventhdlr): + def eventinit(self): + self.model.catchEvent(SCIP_EVENTTYPE.NODEFOCUSED, self) + + def eventexec(self, event): + m = self.model + focus = m.getFocusNode() + current = m.getCurrentNode() + + assert isinstance(focus, scip.Node) + assert isinstance(current, scip.Node) + + # focus and current node should be the same before probing + assert focus.getNumber() == current.getNumber() + + m.startProbing() + m.newProbingNode() + m.newProbingNode() + + # after starting probing, the focus node should remain the same, but the current node should change + assert m.getProbingDepth() == 2 + assert m.getFocusNode().getNumber() == focus.getNumber() + assert m.getCurrentNode().getNumber() != current.getNumber() + + m.endProbing() + + return {'result': SCIP_RESULT.SUCCESS} + +def test_getFocusNode_and_getCurrentNode(): + + m = random_mip_1(small=True) + + m.includeEventhdlr(ProbingNodeChecker(), "Probing Node Checker", "test if getFocusNode and getCurrentNode work correctly") + m.optimize() From 8c0c6f8a48bfec306c787a52d1409507db2cd81a Mon Sep 17 00:00:00 2001 From: Adam Jemielita Date: Wed, 2 Sep 2026 22:30:58 +0200 Subject: [PATCH 15/16] update getFirstPrimalBound description --- src/pyscipopt/scip.pxi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index aef5599a2..36438253c 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3298,7 +3298,7 @@ cdef class Model: def getFirstPrimalBound(self): """ - Gets the primal bound of the very first solution. + Gets the primal bound of the very first solution in the original space. Returns ------- From 5139b576fa7505917458c1f9c5782608a0997f8b Mon Sep 17 00:00:00 2001 From: Adam Jemielita Date: Thu, 3 Sep 2026 17:22:51 +0200 Subject: [PATCH 16/16] improve statistics tests --- tests/test_statistics.py | 111 ++++++++++++++++++++++++++------------- 1 file changed, 75 insertions(+), 36 deletions(-) diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 894dd4bf4..951a199a7 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -9,31 +9,39 @@ @pytest.fixture def optimized_model(): # Using small=True for speed across tests - # finds 1 primal solution model = random_mip_1(small=True, node_lim=2400) model.optimize() return model -# very simple model with 2 primal solutions -# created because getting more than 1 primal solution from random_mip_1 requres setting a large node limit, which slows down the tests +# model factory for testing solution-related statistics @pytest.fixture -def optimized_model_with_primal_solutions(): - model = Model() +def make_optimized_model_with_fixed_primal_solutions(): + def _make(solutions): + model = Model() - x = model.addVar(vtype="I", lb=0, ub=5, name="x") - y = model.addVar(vtype="I", lb=0, ub=5, name="y") - z = model.addVar(vtype="I", lb=0, ub=5, name="z") + x = model.addVar(vtype="I", lb=0, ub=2) + y = model.addVar(vtype="I", lb=0, ub=2) + z = model.addVar(vtype="I", lb=0, ub=2) - model.addCons(x + y + z <= 5) - model.addCons(x >= 1) - model.addCons(y >= 1) + model.addCons(x + y + z <= 2) - model.setObjective(x + y + z, "maximize") + model.setObjective(x + y + z, "maximize") - model.optimize() + for solution in solutions: + sol = model.createOrigSol() - return model + x_val, y_val, z_val = solution + sol[x] = x_val + sol[y] = y_val + sol[z] = z_val + + model.addSol(sol) + + model.optimize() + return model + + return _make def test_statistics_json(optimized_model): @@ -46,14 +54,36 @@ def test_statistics_json(optimized_model): os.remove("statistics.json") -def test_getNSolsFound(optimized_model, optimized_model_with_primal_solutions): - sols = optimized_model.getNSolsFound() - - assert sols >= 1 - - sols_model_with_primals = optimized_model_with_primal_solutions.getNSolsFound() - - assert sols_model_with_primals >= 2 +def test_getNSolsFound(make_optimized_model_with_fixed_primal_solutions): + all_feasible_solutions = [ + (0, 0, 0), + (1, 0, 0), + (0, 1, 0), + (0, 0, 1), + (1, 1, 0), + (1, 0, 1), + (0, 1, 1), + (2, 0, 0), + (0, 2, 0), + (0, 0, 2) + ] + full_model = make_optimized_model_with_fixed_primal_solutions(all_feasible_solutions) + n_sols_found = full_model.getNSolsFound() + + # here we now the exact number of feasible soutions, none will be found during optimization + assert n_sols_found == len(all_feasible_solutions) + + # Test with a subset of (non-optimal) feasible solutions + feasible_solutions = [ + (0, 0, 0), + (1, 0, 0), + (0, 1, 0), + (0, 0, 1), + ] + + subset_model = make_optimized_model_with_fixed_primal_solutions(feasible_solutions) + # none of the provided feasible solutions is optimal, so we expect at least one more solution to be found during optimization + assert subset_model.getNSolsFound() >= len(feasible_solutions) + 1 def test_getPrimalDualIntegral(optimized_model): @@ -80,14 +110,16 @@ def test_getNObjlimLeaves(optimized_model): n_objlim_leaves = optimized_model.getNObjlimLeaves() assert isinstance(n_objlim_leaves, int) + assert n_objlim_leaves >= 0 def test_addNNodes(optimized_model): + n_nodes_to_add = 5 initial_n_nodes = optimized_model.getNTotalNodes() - optimized_model.addNNodes(5) + optimized_model.addNNodes(n_nodes_to_add) new_n_nodes = optimized_model.getNTotalNodes() - assert new_n_nodes == initial_n_nodes + 5 + assert new_n_nodes == initial_n_nodes + n_nodes_to_add def test_getMaxTotalDepth(optimized_model): @@ -110,12 +142,10 @@ def test_getAvgLowerbound(optimized_model): avg_lowerbound = optimized_model.getAvgLowerbound() leaves, children, siblings = optimized_model.getOpenNodes() open_nodes = leaves + children + siblings - manual_avg_lowerbound = 0.0 - if len(open_nodes) > 0: - manual_avg_lowerbound = np.mean( - [node.getLowerbound() for node in open_nodes] - + [optimized_model.getFocusNode().getLowerbound()] - ) + manual_avg_lowerbound = np.mean( + [node.getLowerbound() for node in open_nodes] + + [optimized_model.getFocusNode().getLowerbound()] + ) assert isinstance(avg_lowerbound, float) assert optimized_model.isEQ(manual_avg_lowerbound, avg_lowerbound) @@ -147,12 +177,21 @@ def test_getUpperbound(optimized_model): assert optimized_model.isGE(upperbound, lowerbound) -def test_getFirstPrimalBound(optimized_model_with_primal_solutions): - first_primal = optimized_model_with_primal_solutions.getFirstPrimalBound() - upperbound = optimized_model_with_primal_solutions.getUpperbound() - - assert isinstance(first_primal, float) - assert optimized_model_with_primal_solutions.isGT(first_primal, upperbound) +def test_getFirstPrimalBound(make_optimized_model_with_fixed_primal_solutions): + # Test with a subset of (non-optimal) feasible solutions + feasible_solutions = [ + (1, 0, 0), + (0, 1, 0), + (0, 0, 1), + ] + model = make_optimized_model_with_fixed_primal_solutions(feasible_solutions) + first_primalbound = model.getFirstPrimalBound() + primalbound = model.getPrimalbound() + + assert isinstance(first_primalbound, float) + # SCIP will evaluate provided feasible solutions first and find the first feasible solution with objective value equal to 1 + # subsequently, SCIP will find the optimal solution with objective value equal to 2, which is the upper bound of the model + assert model.isLT(first_primalbound, primalbound) def test_getLowerboundRoot(optimized_model):