From f730750352b6f24ba598965157d79ae2c978cc80 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 14 Jan 2026 14:07:56 +0000 Subject: [PATCH 1/2] change addConsLocal(), addConsNode() to accept ExprCons --- CHANGELOG.md | 1 + src/pyscipopt/scip.pxi | 114 +++++++++++++++++++++++++++++++---- tests/test_addconsnode.py | 123 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 tests/test_addconsnode.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cec5a1cdc..e2f92bef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Updated IIS result in PyiisfinderExec() ### Changed - changed default value of enablepricing flag to True +- changed addConsNode() and addConsLocal() to mirror addCons() and accept ExprCons instead of Constraint ### Removed ## 6.0.0 - 2025.xx.yy diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index da23028f9..15e8ad1ba 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -6013,7 +6013,7 @@ cdef class Model: Parameters ---------- cons : ExprCons - The expression constraint that is not yet an actual constraint + the constraint expression to add to the node (e.g., x + y <= 5) name : str, optional the name of the constraint, generic name if empty (Default value = "") initial : bool, optional @@ -6691,7 +6691,10 @@ cdef class Model: else: raise NotImplementedError("Adding coefficients to %s constraints is not implemented." % constype) - def addConsNode(self, Node node, Constraint cons, Node validnode=None): + def addConsNode(self, Node node, ExprCons cons, Node validnode=None, name='', + initial=True, separate=True, enforce=True, check=True, + propagate=True, local=True, dynamic=False, removable=True, + stickingatnode=True): """ Add a constraint to the given node. @@ -6699,35 +6702,120 @@ cdef class Model: ---------- node : Node node at which the constraint will be added - cons : Constraint - the constraint to add to the node + cons : ExprCons + the constraint expression to add to the node (e.g., x + y <= 5) validnode : Node or None, optional more global node where cons is also valid. (Default=None) + name : str, optional + name of the constraint (Default value = '') + initial : bool, optional + should the LP relaxation of constraint be in the initial LP? (Default value = True) + separate : bool, optional + should the constraint be separated during LP processing? (Default value = True) + enforce : bool, optional + should the constraint be enforced during node processing? (Default value = True) + check : bool, optional + should the constraint be checked for feasibility? (Default value = True) + propagate : bool, optional + should the constraint be propagated during node processing? (Default value = True) + local : bool, optional + is the constraint only valid locally? (Default value = True) + dynamic : bool, optional + is the constraint subject to aging? (Default value = False) + removable : bool, optional + should the relaxation be removed from the LP due to aging or cleanup? (Default value = True) + stickingatnode : bool, optional + should the constraint always be kept at the node where it was added? (Default value = True) + + Returns + ------- + Constraint + The added Constraint object. """ + assert isinstance(cons, ExprCons), "given constraint is not ExprCons but %s" % cons.__class__.__name__ + + cdef SCIP_CONS* scip_cons + + kwargs = dict(name=name, initial=initial, separate=separate, + enforce=enforce, check=check, propagate=propagate, + local=local, modifiable=False, dynamic=dynamic, + removable=removable, stickingatnode=stickingatnode) + pycons_initial = self.createConsFromExpr(cons, **kwargs) + scip_cons = (pycons_initial).scip_cons + if isinstance(validnode, Node): - PY_SCIP_CALL(SCIPaddConsNode(self._scip, node.scip_node, cons.scip_cons, validnode.scip_node)) + PY_SCIP_CALL(SCIPaddConsNode(self._scip, node.scip_node, scip_cons, validnode.scip_node)) else: - PY_SCIP_CALL(SCIPaddConsNode(self._scip, node.scip_node, cons.scip_cons, NULL)) - Py_INCREF(cons) + PY_SCIP_CALL(SCIPaddConsNode(self._scip, node.scip_node, scip_cons, NULL)) + + pycons = Constraint.create(scip_cons) + pycons.data = (pycons_initial).data + PY_SCIP_CALL(SCIPreleaseCons(self._scip, &scip_cons)) + + return pycons - def addConsLocal(self, Constraint cons, Node validnode=None): + def addConsLocal(self, ExprCons cons, Node validnode=None, name='', + initial=True, separate=True, enforce=True, check=True, + propagate=True, local=True, dynamic=False, removable=True, + stickingatnode=True): """ Add a constraint to the current node. Parameters ---------- - cons : Constraint - the constraint to add to the current node + cons : ExprCons + the constraint expression to add to the current node (e.g., x + y <= 5) validnode : Node or None, optional more global node where cons is also valid. (Default=None) + name : str, optional + name of the constraint (Default value = '') + initial : bool, optional + should the LP relaxation of constraint be in the initial LP? (Default value = True) + separate : bool, optional + should the constraint be separated during LP processing? (Default value = True) + enforce : bool, optional + should the constraint be enforced during node processing? (Default value = True) + check : bool, optional + should the constraint be checked for feasibility? (Default value = True) + propagate : bool, optional + should the constraint be propagated during node processing? (Default value = True) + local : bool, optional + is the constraint only valid locally? (Default value = True) + dynamic : bool, optional + is the constraint subject to aging? (Default value = False) + removable : bool, optional + should the relaxation be removed from the LP due to aging or cleanup? (Default value = True) + stickingatnode : bool, optional + should the constraint always be kept at the node where it was added? (Default value = True) + + Returns + ------- + Constraint + The added Constraint object. """ + assert isinstance(cons, ExprCons), "given constraint is not ExprCons but %s" % cons.__class__.__name__ + + cdef SCIP_CONS* scip_cons + + kwargs = dict(name=name, initial=initial, separate=separate, + enforce=enforce, check=check, propagate=propagate, + local=local, modifiable=False, dynamic=dynamic, + removable=removable, stickingatnode=stickingatnode) + pycons_initial = self.createConsFromExpr(cons, **kwargs) + scip_cons = (pycons_initial).scip_cons + if isinstance(validnode, Node): - PY_SCIP_CALL(SCIPaddConsLocal(self._scip, cons.scip_cons, validnode.scip_node)) + PY_SCIP_CALL(SCIPaddConsLocal(self._scip, scip_cons, validnode.scip_node)) else: - PY_SCIP_CALL(SCIPaddConsLocal(self._scip, cons.scip_cons, NULL)) - Py_INCREF(cons) + PY_SCIP_CALL(SCIPaddConsLocal(self._scip, scip_cons, NULL)) + + pycons = Constraint.create(scip_cons) + pycons.data = (pycons_initial).data + PY_SCIP_CALL(SCIPreleaseCons(self._scip, &scip_cons)) + + return pycons def addConsKnapsack(self, vars, weights, capacity, name="", initial=True, separate=True, enforce=True, check=True, diff --git a/tests/test_addconsnode.py b/tests/test_addconsnode.py new file mode 100644 index 000000000..a618493b8 --- /dev/null +++ b/tests/test_addconsnode.py @@ -0,0 +1,123 @@ +from pyscipopt import Branchrule, SCIP_RESULT +from helpers.utils import random_mip_1 + + +class MyBranchrule(Branchrule): + """ + A branching rule that tests addConsNode by adding constraints to child nodes. + """ + + def __init__(self, model): + self.model = model + self.addConsNode_called = False + self.addConsLocal_called = False + self.branch_var = None + + def branchexeclp(self, allowaddcons): + if not allowaddcons: + return {"result": SCIP_RESULT.DIDNOTRUN} + + # Branch on the first variable (just to test) + var = self.model.getVars()[0] + self.branch_var = var + + # Create two child nodes + child1 = self.model.createChild(1, self.model.getLPObjVal()) + child2 = self.model.createChild(1, self.model.getLPObjVal()) + + # Test addConsNode with ExprCons + cons1 = self.model.addConsNode(child1, var == var.getLbGlobal(), name="branch_down") + self.addConsNode_called = True + assert cons1 is not None, "addConsNode should return a Constraint" + + # Making it infeasible to ensure down branch is taken + cons2 = self.model.addConsNode(child2, var <= var.getUbGlobal()-1, name="branch_up") + assert cons2 is not None, "addConsNode should return a Constraint" + + return {"result": SCIP_RESULT.BRANCHED} + + def branchexecps(self, allowaddcons): + return {"result": SCIP_RESULT.DIDNOTRUN} + + +class MyBranchruleLocal(Branchrule): + """ + A branching rule that tests addConsLocal by adding constraints to the current node. + """ + + def __init__(self, model): + self.model = model + self.addConsLocal_called = False + self.call_count = 0 + + def branchexeclp(self, allowaddcons): + if not allowaddcons: + return {"result": SCIP_RESULT.DIDNOTRUN} + + self.call_count += 1 + + # Only test on the first call + if self.call_count > 1: + return {"result": SCIP_RESULT.DIDNOTRUN} + + # Get branching candidates + branch_cands, branch_cand_sols, branch_cand_fracs, ncands, npriocands, nimplcands = self.model.getLPBranchCands() + + if npriocands == 0: + return {"result": SCIP_RESULT.DIDNOTRUN} + + v = self.model.getVars()[0] + cons = self.model.addConsLocal(v <= v.getLbGlobal() - 1) + self.addConsLocal_called = True + assert cons is not None, "addConsLocal should return a Constraint" + + return {"result": SCIP_RESULT.BRANCHED} + + def branchexecps(self, allowaddcons): + return {"result": SCIP_RESULT.DIDNOTRUN} + + +def test_addConsNode(): + """Test that addConsNode works with ExprCons.""" + m = random_mip_1(node_lim=3, small=True) + + branchrule = MyBranchrule(m) + m.includeBranchrule( + branchrule, + "test_addConsNode", + "test addConsNode with ExprCons", + priority=10000000, + maxdepth=-1, + maxbounddist=1 + ) + + var_to_be_branched = m.getVars()[0] + var_to_be_branched_lb = var_to_be_branched.getLbGlobal() + + m.optimize() + + assert branchrule.addConsNode_called, "addConsNode should have been called" + + var_to_be_branched_val = m.getSolVal(expr=var_to_be_branched, sol=None) + assert var_to_be_branched_val == var_to_be_branched_lb, \ + f"Variable should be equal to its lower bound {var_to_be_branched_lb}, but got {var_to_be_branched_val}" + + + +def test_addConsLocal(): + """Test that addConsLocal works with ExprCons.""" + m = random_mip_1(node_lim=500, small=True) + + branchrule = MyBranchruleLocal(m) + m.includeBranchrule( + branchrule, + "test_addConsLocal", + "test addConsLocal with ExprCons", + priority=10000000, + maxdepth=-1, + maxbounddist=1 + ) + + m.optimize() + assert branchrule.addConsLocal_called, "addConsLocal should have been called" + assert m.getStatus() == "infeasible", "The problem should be infeasible after adding the local constraint" From 92c15923361a7a392d1786a106d0b2c4194658c5 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 14 Jan 2026 15:30:31 +0000 Subject: [PATCH 2/2] copilot suggestions --- src/pyscipopt/scip.pxi | 2 +- tests/test_addconsnode.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 15e8ad1ba..3ecc17b90 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -6013,7 +6013,7 @@ cdef class Model: Parameters ---------- cons : ExprCons - the constraint expression to add to the node (e.g., x + y <= 5) + the constraint expression to add to the model (e.g., x + y <= 5) name : str, optional the name of the constraint, generic name if empty (Default value = "") initial : bool, optional diff --git a/tests/test_addconsnode.py b/tests/test_addconsnode.py index a618493b8..5943c0568 100644 --- a/tests/test_addconsnode.py +++ b/tests/test_addconsnode.py @@ -10,8 +10,6 @@ class MyBranchrule(Branchrule): def __init__(self, model): self.model = model self.addConsNode_called = False - self.addConsLocal_called = False - self.branch_var = None def branchexeclp(self, allowaddcons): if not allowaddcons: @@ -31,7 +29,7 @@ def branchexeclp(self, allowaddcons): assert cons1 is not None, "addConsNode should return a Constraint" # Making it infeasible to ensure down branch is taken - cons2 = self.model.addConsNode(child2, var <= var.getUbGlobal()-1, name="branch_up") + cons2 = self.model.addConsNode(child2, var <= var.getLbGlobal()-1, name="branch_up") assert cons2 is not None, "addConsNode should return a Constraint" return {"result": SCIP_RESULT.BRANCHED}