From dab441577499f49c0cb0789823b5b50f24c4a066 Mon Sep 17 00:00:00 2001 From: Mohit Prajapati Date: Fri, 11 Sep 2026 03:54:51 +0530 Subject: [PATCH 1/2] Interface SCIP logicor constraints Adds addConsLogicor, addCoefLogicor, getNVarsLogicor, getVarsLogicor, getDualsolLogicor and getDualfarkasLogicor, following the same shape as the existing AND and OR handlers. Part of the checklist in #977. --- CHANGELOG.md | 1 + src/pyscipopt/scip.pxd | 22 ++++++ src/pyscipopt/scip.pxi | 157 +++++++++++++++++++++++++++++++++++++++++ src/pyscipopt/scip.pyi | 20 ++++++ tests/test_cons.py | 25 +++++++ 5 files changed, 225 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44af6c4b0..8e98c7398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Added methods: `getNNodesLeft()`, `getNRuns()`, `getNReoptRuns()`, `addNNodes()` with tests - Added `addConsCumulative()` for SCIP cumulative constraints (#1222) +- Added `addConsLogicor()`, `addCoefLogicor()`, `getNVarsLogicor()`, `getVarsLogicor()`, `getDualsolLogicor()` and `getDualfarkasLogicor()` for SCIP logicor constraints (#977) - `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr` - Added type annotations to most methods on the `Model` class - Added tests for `getRowLinear()` and extended existing testing for `isActive()` diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 3a126f4dc..0e47861c3 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -1840,6 +1840,28 @@ cdef extern from "scip/cons_xor.h": SCIP_Bool removable, SCIP_Bool stickingatnode) +cdef extern from "scip/cons_logicor.h": + SCIP_RETCODE SCIPcreateConsLogicor(SCIP* scip, + SCIP_CONS** cons, + const char* name, + int nvars, + SCIP_VAR** vars, + SCIP_Bool initial, + SCIP_Bool separate, + SCIP_Bool enforce, + SCIP_Bool check, + SCIP_Bool propagate, + SCIP_Bool local, + SCIP_Bool modifiable, + SCIP_Bool dynamic, + SCIP_Bool removable, + SCIP_Bool stickingatnode) + SCIP_RETCODE SCIPaddCoefLogicor(SCIP* scip, SCIP_CONS* cons, SCIP_VAR* var) + int SCIPgetNVarsLogicor(SCIP* scip, SCIP_CONS* cons) + SCIP_VAR** SCIPgetVarsLogicor(SCIP* scip, SCIP_CONS* cons) + SCIP_Real SCIPgetDualsolLogicor(SCIP* scip, SCIP_CONS* cons) + SCIP_Real SCIPgetDualfarkasLogicor(SCIP* scip, SCIP_CONS* cons) + cdef extern from "scip/scip_cons.h": SCIP_RETCODE SCIPprintCons(SCIP* scip, SCIP_CONS* cons, diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index cc2722af9..9837de416 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -7013,6 +7013,103 @@ cdef class Model: PY_SCIP_CALL(SCIPsortAndCons(self._scip, and_cons.scip_cons)) + def getNVarsLogicor(self, Constraint logicor_cons): + """ + Gets number of variables in logicor constraint. + + Parameters + ---------- + logicor_cons : Constraint + logicor constraint to get the number of variables from. + + Returns + ------- + int + + """ + + return SCIPgetNVarsLogicor(self._scip, logicor_cons.scip_cons) + + def getVarsLogicor(self, Constraint logicor_cons): + """ + Gets variables in logicor constraint. + + Parameters + ---------- + logicor_cons : Constraint + logicor constraint to get the variables from. + + Returns + ------- + list of Variable + + """ + + cdef SCIP_VAR** _vars + cdef int nvars + cdef int i + + constype = bytes(SCIPconshdlrGetName(SCIPconsGetHdlr(logicor_cons.scip_cons))).decode('UTF-8') + assert constype == 'logicor', "The constraint handler %s does not have this functionality." % constype + + nvars = SCIPgetNVarsLogicor(self._scip, logicor_cons.scip_cons) + _vars = SCIPgetVarsLogicor(self._scip, logicor_cons.scip_cons) + + vars = [] + for i in range(nvars): + vars.append(self._getOrCreateVar(_vars[i])) + + return vars + + def addCoefLogicor(self, Constraint logicor_cons, Variable var): + """ + Adds a variable to a logicor constraint. + + Parameters + ---------- + logicor_cons : Constraint + logicor constraint to add the variable to. + var : Variable + BINARY variable to add. + + """ + + PY_SCIP_CALL(SCIPaddCoefLogicor(self._scip, logicor_cons.scip_cons, var.scip_var)) + + def getDualsolLogicor(self, Constraint logicor_cons): + """ + Gets the dual solution of a logicor constraint in the current LP. + + Parameters + ---------- + logicor_cons : Constraint + logicor constraint to get the dual solution from. + + Returns + ------- + float + + """ + + return SCIPgetDualsolLogicor(self._scip, logicor_cons.scip_cons) + + def getDualfarkasLogicor(self, Constraint logicor_cons): + """ + Gets the dual Farkas value of a logicor constraint in the current infeasible LP. + + Parameters + ---------- + logicor_cons : Constraint + logicor constraint to get the dual Farkas value from. + + Returns + ------- + float + + """ + + return SCIPgetDualfarkasLogicor(self._scip, logicor_cons.scip_cons) + def printCons(self, Constraint constraint): """ Print the constraint @@ -7681,6 +7778,66 @@ cdef class Model: return pyCons + def addConsLogicor(self, vars, name="", + initial=True, separate=True, enforce=True, check=True, + propagate=True, local=False, modifiable=False, dynamic=False, + removable=False, stickingatnode=False): + """ + Add a logicor constraint: at least one of the given binary variables must be one. + + Parameters + ---------- + vars : list of Variable + list of BINARY variables, at least one of which must take value one + 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 = False) + modifiable : bool, optional + is the constraint modifiable (subject to column generation)? (Default value = False) + 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 = False) + stickingatnode : bool, optional + should the constraint always be kept at the node where it was added, + even if it may be moved to a more global node? (Default value = False) + + Returns + ------- + Constraint + The newly created logicor constraint + + """ + cdef int nvars = len(vars) + cdef SCIP_VAR** _vars + cdef _VarArray vars_wrapper = _VarArray(vars) + cdef SCIP_CONS* scip_cons + + _vars = vars_wrapper.ptr + + if name == '': + name = 'c'+str(SCIPgetNConss(self._scip)+1) + + PY_SCIP_CALL(SCIPcreateConsLogicor(self._scip, &scip_cons, str_conversion(name), nvars, _vars, + initial, separate, enforce, check, propagate, local, modifiable, dynamic, removable, stickingatnode)) + + PY_SCIP_CALL(SCIPaddCons(self._scip, scip_cons)) + pyCons = self._getOrCreateCons(scip_cons) + PY_SCIP_CALL(SCIPreleaseCons(self._scip, &scip_cons)) + + return pyCons + def addConsCardinality(self, consvars, cardval, indvars=None, weights=None, name="", initial=True, separate=True, enforce=True, check=True, propagate=True, local=False, dynamic=False, diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 1e3164109..2f7cd17f6 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -685,6 +685,7 @@ class Model: self, cons: Constraint, var: Variable, weight: float ) -> None: ... def addCoefLinear(self, cons: Constraint, var: Variable, value: float) -> None: ... + def addCoefLogicor(self, logicor_cons: Constraint, var: Variable) -> None: ... def addCons( self, cons: Incomplete, @@ -815,6 +816,21 @@ class Model: removable: bool = True, stickingatnode: bool = True, ) -> Constraint: ... + def addConsLogicor( + self, + vars: Sequence[Variable], + name: str = "", + initial: bool = True, + separate: bool = True, + enforce: bool = True, + check: bool = True, + propagate: bool = True, + local: bool = False, + modifiable: bool = False, + dynamic: bool = False, + removable: bool = False, + stickingatnode: bool = False, + ) -> Constraint: ... def addConsNode( self, node: Node, @@ -1218,8 +1234,10 @@ class Model: def getDualboundRoot(self) -> float: ... def getDualfarkasKnapsack(self, cons: Constraint) -> float: ... def getDualfarkasLinear(self, cons: Constraint) -> float: ... + def getDualfarkasLogicor(self, logicor_cons: Constraint) -> float: ... def getDualsolKnapsack(self, cons: Constraint) -> float: ... def getDualsolLinear(self, cons: Constraint) -> float: ... + def getDualsolLogicor(self, logicor_cons: Constraint) -> float: ... def getGap(self) -> float: ... def getHeurTiming(self, heurname: str) -> Incomplete: ... def getIIS(self) -> IIS: ... @@ -1280,6 +1298,7 @@ class Model: def getNTotalNodes(self) -> int: ... def getNVars(self, transformed: bool = True) -> int: ... def getNVarsAnd(self, and_cons: Constraint) -> int: ... + def getNVarsLogicor(self, logicor_cons: Constraint) -> int: ... def getNlRowActivityBounds(self, nlrow: NLRow) -> tuple[float, float]: ... def getNlRowSolActivity( self, nlrow: NLRow, sol: Solution | None = None @@ -1393,6 +1412,7 @@ class Model: def getVarUbDive(self, var: Variable) -> float: ... def getVars(self, transformed: bool = False) -> list[Variable]: ... def getVarsAnd(self, and_cons: Constraint) -> list[Variable]: ... + def getVarsLogicor(self, logicor_cons: Constraint) -> list[Variable]: ... def getWeightsKnapsack(self, cons: Constraint) -> dict[str, int]: ... def hasPrimalRay(self) -> bool: ... def hideOutput(self, quiet: bool = True) -> None: ... diff --git a/tests/test_cons.py b/tests/test_cons.py index 8f577118c..34ae9da09 100644 --- a/tests/test_cons.py +++ b/tests/test_cons.py @@ -107,6 +107,31 @@ def test_cons_and(): m.sortAndCons(and_cons) assert m.isAndConsSorted(and_cons) +def test_cons_logicor(): + m = Model() + x1 = m.addVar(vtype="B") + x2 = m.addVar(vtype="B") + x3 = m.addVar(vtype="B") + + logicor_cons = m.addConsLogicor([x1, x2]) + + assert m.getNVarsLogicor(logicor_cons) == 2 + assert m.getVarsLogicor(logicor_cons) == [x1, x2] + + m.addCoefLogicor(logicor_cons, x3) + assert m.getNVarsLogicor(logicor_cons) == 3 + assert m.getVarsLogicor(logicor_cons) == [x1, x2, x3] + + # the constraint forces one of the three to be set, so minimising their + # sum costs exactly one rather than zero + m.setObjective(x1 + x2 + x3, "minimize") + m.hideOutput() + m.optimize() + + assert m.getStatus() == "optimal" + assert m.isEQ(m.getObjVal(), 1.0) + + def test_cons_logical_fail(): m = Model() x1 = m.addVar(vtype="B") From 559e8365e2ca8a2902fccd957bd966c256d72c4c Mon Sep 17 00:00:00 2001 From: Mohit Prajapati Date: Mon, 14 Sep 2026 18:46:32 +0530 Subject: [PATCH 2/2] Look up the transformed constraint before reading logicor duals SCIPgetDualsolLogicor and SCIPgetDualfarkasLogicor need the transformed constraint, so follow getDualsolLinear and resolve it when an original one is passed in. Both now also reject a constraint of the wrong type rather than returning a meaningless number. --- src/pyscipopt/scip.pxi | 22 ++++++++++++++++++++-- tests/test_cons.py | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 9837de416..936d9ef4c 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -7091,7 +7091,16 @@ cdef class Model: """ - return SCIPgetDualsolLogicor(self._scip, logicor_cons.scip_cons) + constype = bytes(SCIPconshdlrGetName(SCIPconsGetHdlr(logicor_cons.scip_cons))).decode('UTF-8') + if not constype == 'logicor': + raise Warning("dual solution values not available for constraints of type ", constype) + + if logicor_cons.isOriginal(): + transcons = self.getTransformedCons(logicor_cons) + else: + transcons = logicor_cons + + return SCIPgetDualsolLogicor(self._scip, transcons.scip_cons) def getDualfarkasLogicor(self, Constraint logicor_cons): """ @@ -7108,7 +7117,16 @@ cdef class Model: """ - return SCIPgetDualfarkasLogicor(self._scip, logicor_cons.scip_cons) + constype = bytes(SCIPconshdlrGetName(SCIPconsGetHdlr(logicor_cons.scip_cons))).decode('UTF-8') + if not constype == 'logicor': + raise Warning("dual solution values not available for constraints of type ", constype) + + if logicor_cons.isOriginal(): + transcons = self.getTransformedCons(logicor_cons) + else: + transcons = logicor_cons + + return SCIPgetDualfarkasLogicor(self._scip, transcons.scip_cons) def printCons(self, Constraint constraint): """ diff --git a/tests/test_cons.py b/tests/test_cons.py index 34ae9da09..e29f46773 100644 --- a/tests/test_cons.py +++ b/tests/test_cons.py @@ -132,6 +132,29 @@ def test_cons_logicor(): assert m.isEQ(m.getObjVal(), 1.0) +def test_cons_logicor_duals(): + m = Model() + x1 = m.addVar(vtype="B") + x2 = m.addVar(vtype="B") + + # passed as an original constraint, so the accessors have to look the + # transformed one up before asking SCIP for a dual value + logicor_cons = m.addConsLogicor([x1, x2]) + linear_cons = m.addCons(x1 + x2 <= 2) + + m.setObjective(x1 + x2, "minimize") + m.hideOutput() + m.optimize() + + assert isinstance(m.getDualsolLogicor(logicor_cons), float) + assert isinstance(m.getDualfarkasLogicor(logicor_cons), float) + + with pytest.raises(Warning): + m.getDualsolLogicor(linear_cons) + with pytest.raises(Warning): + m.getDualfarkasLogicor(linear_cons) + + def test_cons_logical_fail(): m = Model() x1 = m.addVar(vtype="B")