diff --git a/CHANGELOG.md b/CHANGELOG.md index ae37c3270..2c312c7d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Unreleased ### Added -- Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` 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 diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 3a126f4dc..64bc5dc16 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) @@ -749,6 +750,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) @@ -969,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) @@ -1480,12 +1483,19 @@ 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) + 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) + SCIP_Real SCIPgetUpperbound(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 cc2722af9..36438253c 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -3285,6 +3285,28 @@ 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 getFirstPrimalBound(self): + """ + Gets the primal bound of the very first solution in the original space. + + Returns + ------- + float + + """ + return SCIPgetFirstPrimalBound(self._scip) + def getNLPIterations(self): """ Returns the total number of LP iterations so far. @@ -3373,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. @@ -3417,6 +3450,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. @@ -3462,6 +3507,28 @@ 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 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). @@ -3473,6 +3540,28 @@ 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 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. @@ -3495,6 +3584,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. @@ -11506,6 +11605,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 1e3164109..41aa032ab 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1196,6 +1196,8 @@ class Model: list[list[float]], 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]: ... @@ -1205,17 +1207,22 @@ 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: ... def getCutoffbound(self) -> float: ... + def getUpperbound(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 ) -> 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: ... @@ -1240,6 +1247,8 @@ class Model: def getLowerbound(self) -> float: ... 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: ... @@ -1252,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_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() diff --git a/tests/test_statistics.py b/tests/test_statistics.py index fef2f4027..951a199a7 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -1,16 +1,49 @@ +from pyscipopt.scip import Model import os from helpers.utils import random_mip_1 from json import load import pytest +import numpy as np @pytest.fixture def optimized_model(): - model = random_mip_1(small=True) # Using small=True for speed across tests + # Using small=True for speed across tests + model = random_mip_1(small=True, node_lim=2400) model.optimize() return model +# model factory for testing solution-related statistics +@pytest.fixture +def make_optimized_model_with_fixed_primal_solutions(): + def _make(solutions): + model = Model() + + 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 <= 2) + + model.setObjective(x + y + z, "maximize") + + for solution in solutions: + sol = model.createOrigSol() + + 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): optimized_model.writeStatisticsJson("statistics.json") @@ -21,6 +54,38 @@ def test_statistics_json(optimized_model): os.remove("statistics.json") +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): primal_dual_integral = optimized_model.getPrimalDualIntegral() @@ -41,9 +106,97 @@ 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) + 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): + 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 + + +def test_getNBacktracks(optimized_model): + n_backtracks = optimized_model.getNBacktracks() + + 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 = 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) + + +def test_getAvgDualbound(optimized_model): + avg_dualbound = optimized_model.getAvgDualbound() + avg_lowerbound = optimized_model.getAvgLowerbound() + + assert isinstance(avg_dualbound, float) + assert ( + optimized_model.isEQ(avg_dualbound, avg_lowerbound) + or optimized_model.isEQ(avg_dualbound, -avg_lowerbound) + ) + + +def test_getDeterministicTime(optimized_model): + det_time = optimized_model.getDeterministicTime() + + assert isinstance(det_time, float) + assert det_time >= 0.0 + + +def test_getUpperbound(optimized_model): + upperbound = optimized_model.getUpperbound() + lowerbound = optimized_model.getLowerbound() + + assert isinstance(upperbound, float) + assert optimized_model.isGE(upperbound, lowerbound) + + +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): + lowerbound_root = optimized_model.getLowerboundRoot() + lowerbound = optimized_model.getLowerbound() + + assert isinstance(lowerbound_root, float) + assert optimized_model.isLE(lowerbound_root, lowerbound)