From 34069e777902c4aa7062e4b3a0b944ada082f0ab Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Tue, 13 Oct 2026 15:50:57 +0100 Subject: [PATCH 1/9] Add getCountedSols() to retrieve solutions collected by count() --- CHANGELOG.md | 1 + src/pyscipopt/scip.pxd | 7 ++++ src/pyscipopt/scip.pxi | 79 ++++++++++++++++++++++++++++++++++++++++++ src/pyscipopt/scip.pyi | 1 + tests/test_model.py | 34 ++++++++++++++++++ 5 files changed, 122 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c19ccfa4f..70b43499a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ### Added +- Added `getCountedSols()` to retrieve the solutions collected by `count()` (#906) ### Fixed ### Changed ### Removed diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index fe5f3ac6b..88d8b017c 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -522,6 +522,9 @@ cdef extern from "scip/scip.h": ctypedef struct SCIP_DIVESET: pass + ctypedef struct SCIP_SPARSESOL: + pass + ctypedef struct SCIP_HASHMAP: pass @@ -824,6 +827,7 @@ cdef extern from "scip/scip.h": SCIP_RETCODE SCIPreleaseVar(SCIP* scip, 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 SCIPgetProbvarLinearSum(SCIP* scip, SCIP_VAR** vars, SCIP_Real* scalars, int* nvars, int varssize, SCIP_Real* constant, int* requiredsize) SCIP_RETCODE SCIPaddVarLocks(SCIP* scip, SCIP_VAR* var, int nlocksdown, int nlocksup) SCIP_RETCODE SCIPaddVarLocksType(SCIP* scip, SCIP_VAR* var, SCIP_LOCKTYPE locktype, int nlocksdown, int nlocksup) int SCIPvarGetNLocksDown(SCIP_VAR* var) @@ -2102,6 +2106,9 @@ cdef extern from "scip/cons_countsols.h": SCIP_RETCODE SCIPcount(SCIP* scip) SCIP_RETCODE SCIPsetParamsCountsols(SCIP* scip) SCIP_Longint SCIPgetNCountedSols(SCIP* scip, SCIP_Bool* valid) + void SCIPgetCountedSparseSols(SCIP* scip, SCIP_VAR*** vars, int* nvars, SCIP_SPARSESOL*** sols, int* nsols) + void SCIPsparseSolGetFirstSol(SCIP_SPARSESOL* sparsesol, SCIP_Longint* sol, int nvars) + SCIP_Bool SCIPsparseSolGetNextSol(SCIP_SPARSESOL* sparsesol, SCIP_Longint* sol, int nvars) cdef extern from "scip/paramset.h": diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 52123c2aa..2c9596e92 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -12545,6 +12545,85 @@ cdef class Model: print('total number of solutions found is not valid!') return nsols + def getCountedSols(self): + """ + Get the feasible solutions collected by `count()`. + + Solutions are only collected if the parameter `constraints/countsols/collect` + is set to True before calling `count()`. Only integral variables are counted, + so continuous variables are not part of the returned solutions. All solutions + are expanded, so their number can be huge. The solutions are freed together + with the transformed problem. + + Returns + ------- + list of dict + Each solution maps the names of the integral original variables to their values. + + """ + cdef SCIP_VAR** activevars + cdef SCIP_SPARSESOL** sparsesols + cdef SCIP_VAR** vars + cdef SCIP_Real* scalars + cdef SCIP_Longint* sol + cdef SCIP_VAR* transvar + cdef SCIP_Real constant + cdef SCIP_Bool valid + cdef int nactivevars + cdef int nsparsesols + cdef int nvars + cdef int requiredsize + cdef int i + cdef int s + + if SCIPgetStage(self._scip) < SCIP_STAGE_TRANSFORMED or SCIPgetStage(self._scip) == SCIP_STAGE_FREETRANS: + raise Warning("method cannot be called before count() or after the transformed problem is freed") + + nsols = SCIPgetNCountedSols(self._scip, &valid) + if not valid: + raise Warning("number of counted solutions does not fit into a long integer, cannot collect them") + + SCIPgetCountedSparseSols(self._scip, &activevars, &nactivevars, &sparsesols, &nsparsesols) + if nsparsesols == 0: + if nsols > 0: + raise Warning("no solutions were collected, set constraints/countsols/collect to True before calling count()") + return [] + + activeidx = {activevars[i]: i for i in range(nactivevars)} + + vars = malloc((nactivevars + 1) * sizeof(SCIP_VAR*)) + scalars = malloc((nactivevars + 1) * sizeof(SCIP_Real)) + sol = malloc((nactivevars + 1) * sizeof(SCIP_Longint)) + + try: + origvars = [] + for i in range(SCIPgetNOrigVars(self._scip)): + if not SCIPvarIsIntegral(SCIPgetOrigVars(self._scip)[i]): + continue + PY_SCIP_CALL(SCIPgetTransformedVar(self._scip, SCIPgetOrigVars(self._scip)[i], &transvar)) + vars[0] = transvar + scalars[0] = 1.0 + nvars = 1 + constant = 0.0 + PY_SCIP_CALL(SCIPgetProbvarLinearSum(self._scip, vars, scalars, &nvars, nactivevars + 1, &constant, &requiredsize)) + terms = [(activeidx[vars[j]], scalars[j]) for j in range(nvars)] + origvars.append((bytes(SCIPvarGetName(SCIPgetOrigVars(self._scip)[i])).decode("utf-8"), constant, terms)) + + sols = [] + for s in range(nsparsesols): + SCIPsparseSolGetFirstSol(sparsesols[s], sol, nactivevars) + while True: + sols.append({name: c + sum(scalar * sol[idx] for idx, scalar in terms) + for name, c, terms in origvars}) + if not SCIPsparseSolGetNextSol(sparsesols[s], sol, nactivevars): + break + finally: + free(vars) + free(scalars) + free(sol) + + return sols + def setParamsCountsols(self): """Sets SCIP parameters such that a valid counting process is possible.""" PY_SCIP_CALL(SCIPsetParamsCountsols(self._scip)) diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 316b13e9f..09ba93256 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1226,6 +1226,7 @@ class Model: def getConsVars(self, constraint: Constraint) -> list[Variable] | None: ... def getConss(self, transformed: bool = True) -> list[Constraint]: ... def getFocusNode(self) -> Node | None: ... + def getCountedSols(self) -> list[dict[str, float]]: ... def getCurrentNode(self) -> Node | None: ... def getCutEfficacy(self, cut: Row, sol: Solution | None = None) -> float: ... def getCutLPSolCutoffDistance(self, cut: Row, sol: Solution) -> float: ... diff --git a/tests/test_model.py b/tests/test_model.py index 6736fbb5f..db012bae9 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -662,3 +662,37 @@ def test_memory_methods(): assert m.getMemUsed() > 0 assert m.getMemTotal() > 0 + + +def test_getCountedSols(): + m = Model() + m.hideOutput() + m.setParam("constraints/countsols/collect", True) + x = m.addVar("x", vtype="I", lb=-2, ub=3) + y = m.addVar("y", vtype="I", lb=0, ub=4) + z = m.addVar("z", vtype="I", lb=0, ub=10) + w = m.addVar("w", vtype="B") + m.addVar("c", lb=0, ub=1) + m.addCons(z == 2*x + 3) + m.addCons(x + y <= 4) + m.addCons(w + y >= 1) + + with pytest.raises(Warning): + m.getCountedSols() + + m.count() + sols = m.getCountedSols() + expected = {(a, b, c, d) for a, b, c, d in itertools.product(range(-2, 4), range(5), range(11), range(2)) + if c == 2*a + 3 and a + b <= 4 and d + b >= 1} + assert len(sols) == m.getNCountedSols() == len(expected) + assert all(set(sol) == {"x", "y", "z", "w"} for sol in sols) + assert {tuple(round(sol[k]) for k in "xyzw") for sol in sols} == expected + + m.freeTransform() + with pytest.raises(Warning): + m.getCountedSols() + + m.setParam("constraints/countsols/collect", False) + m.count() + with pytest.raises(Warning): + m.getCountedSols() From 1001e92cc42dd3a8328f74c1cc7341d3c19a68c1 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Tue, 13 Oct 2026 15:57:22 +0100 Subject: [PATCH 2/9] Drop try/finally in getCountedSols() --- src/pyscipopt/scip.pxi | 54 +++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 2c9596e92..274787415 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -12593,34 +12593,34 @@ cdef class Model: vars = malloc((nactivevars + 1) * sizeof(SCIP_VAR*)) scalars = malloc((nactivevars + 1) * sizeof(SCIP_Real)) - sol = malloc((nactivevars + 1) * sizeof(SCIP_Longint)) - try: - origvars = [] - for i in range(SCIPgetNOrigVars(self._scip)): - if not SCIPvarIsIntegral(SCIPgetOrigVars(self._scip)[i]): - continue - PY_SCIP_CALL(SCIPgetTransformedVar(self._scip, SCIPgetOrigVars(self._scip)[i], &transvar)) - vars[0] = transvar - scalars[0] = 1.0 - nvars = 1 - constant = 0.0 - PY_SCIP_CALL(SCIPgetProbvarLinearSum(self._scip, vars, scalars, &nvars, nactivevars + 1, &constant, &requiredsize)) - terms = [(activeidx[vars[j]], scalars[j]) for j in range(nvars)] - origvars.append((bytes(SCIPvarGetName(SCIPgetOrigVars(self._scip)[i])).decode("utf-8"), constant, terms)) - - sols = [] - for s in range(nsparsesols): - SCIPsparseSolGetFirstSol(sparsesols[s], sol, nactivevars) - while True: - sols.append({name: c + sum(scalar * sol[idx] for idx, scalar in terms) - for name, c, terms in origvars}) - if not SCIPsparseSolGetNextSol(sparsesols[s], sol, nactivevars): - break - finally: - free(vars) - free(scalars) - free(sol) + origvars = [] + for i in range(SCIPgetNOrigVars(self._scip)): + if not SCIPvarIsIntegral(SCIPgetOrigVars(self._scip)[i]): + continue + PY_SCIP_CALL(SCIPgetTransformedVar(self._scip, SCIPgetOrigVars(self._scip)[i], &transvar)) + vars[0] = transvar + scalars[0] = 1.0 + nvars = 1 + constant = 0.0 + PY_SCIP_CALL(SCIPgetProbvarLinearSum(self._scip, vars, scalars, &nvars, nactivevars + 1, &constant, &requiredsize)) + terms = [(activeidx[vars[j]], scalars[j]) for j in range(nvars)] + origvars.append((bytes(SCIPvarGetName(SCIPgetOrigVars(self._scip)[i])).decode("utf-8"), constant, terms)) + + free(vars) + free(scalars) + + sol = malloc(nactivevars * sizeof(SCIP_Longint)) + sols = [] + for s in range(nsparsesols): + SCIPsparseSolGetFirstSol(sparsesols[s], sol, nactivevars) + while True: + sols.append({name: c + sum(scalar * sol[idx] for idx, scalar in terms) + for name, c, terms in origvars}) + if not SCIPsparseSolGetNextSol(sparsesols[s], sol, nactivevars): + break + + free(sol) return sols From d5a9a7b4da2e399abff9fd04214f44fdf3873b6b Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Tue, 13 Oct 2026 16:24:33 +0100 Subject: [PATCH 3/9] Check collect param instead of stage in getCountedSols() --- src/pyscipopt/scip.pxi | 13 +++++-------- tests/test_model.py | 6 ++---- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 274787415..023cb3cbb 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -12551,9 +12551,8 @@ cdef class Model: Solutions are only collected if the parameter `constraints/countsols/collect` is set to True before calling `count()`. Only integral variables are counted, - so continuous variables are not part of the returned solutions. All solutions - are expanded, so their number can be huge. The solutions are freed together - with the transformed problem. + so continuous variables are not part of the returned solutions. The solutions + are freed together with the transformed problem. Returns ------- @@ -12576,17 +12575,15 @@ cdef class Model: cdef int i cdef int s - if SCIPgetStage(self._scip) < SCIP_STAGE_TRANSFORMED or SCIPgetStage(self._scip) == SCIP_STAGE_FREETRANS: - raise Warning("method cannot be called before count() or after the transformed problem is freed") + if not self.getParam("constraints/countsols/collect"): + raise Warning("set constraints/countsols/collect to True before calling count()") - nsols = SCIPgetNCountedSols(self._scip, &valid) + SCIPgetNCountedSols(self._scip, &valid) if not valid: raise Warning("number of counted solutions does not fit into a long integer, cannot collect them") SCIPgetCountedSparseSols(self._scip, &activevars, &nactivevars, &sparsesols, &nsparsesols) if nsparsesols == 0: - if nsols > 0: - raise Warning("no solutions were collected, set constraints/countsols/collect to True before calling count()") return [] activeidx = {activevars[i]: i for i in range(nactivevars)} diff --git a/tests/test_model.py b/tests/test_model.py index db012bae9..8cc6a98ac 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -677,8 +677,7 @@ def test_getCountedSols(): m.addCons(x + y <= 4) m.addCons(w + y >= 1) - with pytest.raises(Warning): - m.getCountedSols() + assert m.getCountedSols() == [] m.count() sols = m.getCountedSols() @@ -689,8 +688,7 @@ def test_getCountedSols(): assert {tuple(round(sol[k]) for k in "xyzw") for sol in sols} == expected m.freeTransform() - with pytest.raises(Warning): - m.getCountedSols() + assert m.getCountedSols() == [] m.setParam("constraints/countsols/collect", False) m.count() From 161e1aedbbc077800ea39e6f3c6fb65dd314d69f Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Tue, 13 Oct 2026 16:46:02 +0100 Subject: [PATCH 4/9] Return counted solutions over transformed variables --- src/pyscipopt/scip.pxd | 1 - src/pyscipopt/scip.pxi | 54 ++++++++++-------------------------------- src/pyscipopt/scip.pyi | 2 +- tests/test_model.py | 5 ++-- 4 files changed, 17 insertions(+), 45 deletions(-) diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 88d8b017c..bf5479552 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -827,7 +827,6 @@ cdef extern from "scip/scip.h": SCIP_RETCODE SCIPreleaseVar(SCIP* scip, 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 SCIPgetProbvarLinearSum(SCIP* scip, SCIP_VAR** vars, SCIP_Real* scalars, int* nvars, int varssize, SCIP_Real* constant, int* requiredsize) SCIP_RETCODE SCIPaddVarLocks(SCIP* scip, SCIP_VAR* var, int nlocksdown, int nlocksup) SCIP_RETCODE SCIPaddVarLocksType(SCIP* scip, SCIP_VAR* var, SCIP_LOCKTYPE locktype, int nlocksdown, int nlocksup) int SCIPvarGetNLocksDown(SCIP_VAR* var) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 023cb3cbb..bb2df172e 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -12550,28 +12550,23 @@ cdef class Model: Get the feasible solutions collected by `count()`. Solutions are only collected if the parameter `constraints/countsols/collect` - is set to True before calling `count()`. Only integral variables are counted, - so continuous variables are not part of the returned solutions. The solutions - are freed together with the transformed problem. + is set to True before calling `count()`. They are given over the transformed + integral variables, so continuous variables and variables removed by presolving + are not part of them; turn presolving off to get every integral variable. + The solutions are freed together with the transformed problem. Returns ------- list of dict - Each solution maps the names of the integral original variables to their values. + Each solution maps the names of the transformed variables to their values. """ - cdef SCIP_VAR** activevars - cdef SCIP_SPARSESOL** sparsesols cdef SCIP_VAR** vars - cdef SCIP_Real* scalars + cdef SCIP_SPARSESOL** sparsesols cdef SCIP_Longint* sol - cdef SCIP_VAR* transvar - cdef SCIP_Real constant cdef SCIP_Bool valid - cdef int nactivevars - cdef int nsparsesols cdef int nvars - cdef int requiredsize + cdef int nsparsesols cdef int i cdef int s @@ -12582,39 +12577,16 @@ cdef class Model: if not valid: raise Warning("number of counted solutions does not fit into a long integer, cannot collect them") - SCIPgetCountedSparseSols(self._scip, &activevars, &nactivevars, &sparsesols, &nsparsesols) - if nsparsesols == 0: - return [] - - activeidx = {activevars[i]: i for i in range(nactivevars)} - - vars = malloc((nactivevars + 1) * sizeof(SCIP_VAR*)) - scalars = malloc((nactivevars + 1) * sizeof(SCIP_Real)) - - origvars = [] - for i in range(SCIPgetNOrigVars(self._scip)): - if not SCIPvarIsIntegral(SCIPgetOrigVars(self._scip)[i]): - continue - PY_SCIP_CALL(SCIPgetTransformedVar(self._scip, SCIPgetOrigVars(self._scip)[i], &transvar)) - vars[0] = transvar - scalars[0] = 1.0 - nvars = 1 - constant = 0.0 - PY_SCIP_CALL(SCIPgetProbvarLinearSum(self._scip, vars, scalars, &nvars, nactivevars + 1, &constant, &requiredsize)) - terms = [(activeidx[vars[j]], scalars[j]) for j in range(nvars)] - origvars.append((bytes(SCIPvarGetName(SCIPgetOrigVars(self._scip)[i])).decode("utf-8"), constant, terms)) - - free(vars) - free(scalars) + SCIPgetCountedSparseSols(self._scip, &vars, &nvars, &sparsesols, &nsparsesols) + names = [bytes(SCIPvarGetName(vars[i])).decode("utf-8") for i in range(nvars)] - sol = malloc(nactivevars * sizeof(SCIP_Longint)) + sol = malloc(nvars * sizeof(SCIP_Longint)) sols = [] for s in range(nsparsesols): - SCIPsparseSolGetFirstSol(sparsesols[s], sol, nactivevars) + SCIPsparseSolGetFirstSol(sparsesols[s], sol, nvars) while True: - sols.append({name: c + sum(scalar * sol[idx] for idx, scalar in terms) - for name, c, terms in origvars}) - if not SCIPsparseSolGetNextSol(sparsesols[s], sol, nactivevars): + sols.append({names[i]: sol[i] for i in range(nvars)}) + if not SCIPsparseSolGetNextSol(sparsesols[s], sol, nvars): break free(sol) diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 09ba93256..1cc29b1a4 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1226,7 +1226,7 @@ class Model: def getConsVars(self, constraint: Constraint) -> list[Variable] | None: ... def getConss(self, transformed: bool = True) -> list[Constraint]: ... def getFocusNode(self) -> Node | None: ... - def getCountedSols(self) -> list[dict[str, float]]: ... + def getCountedSols(self) -> list[dict[str, int]]: ... def getCurrentNode(self) -> Node | None: ... def getCutEfficacy(self, cut: Row, sol: Solution | None = None) -> float: ... def getCutLPSolCutoffDistance(self, cut: Row, sol: Solution) -> float: ... diff --git a/tests/test_model.py b/tests/test_model.py index 8cc6a98ac..347171f39 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -668,6 +668,7 @@ def test_getCountedSols(): m = Model() m.hideOutput() m.setParam("constraints/countsols/collect", True) + m.setPresolve(SCIP_PARAMSETTING.OFF) x = m.addVar("x", vtype="I", lb=-2, ub=3) y = m.addVar("y", vtype="I", lb=0, ub=4) z = m.addVar("z", vtype="I", lb=0, ub=10) @@ -684,8 +685,8 @@ def test_getCountedSols(): expected = {(a, b, c, d) for a, b, c, d in itertools.product(range(-2, 4), range(5), range(11), range(2)) if c == 2*a + 3 and a + b <= 4 and d + b >= 1} assert len(sols) == m.getNCountedSols() == len(expected) - assert all(set(sol) == {"x", "y", "z", "w"} for sol in sols) - assert {tuple(round(sol[k]) for k in "xyzw") for sol in sols} == expected + assert all(set(sol) == {"t_x", "t_y", "t_z", "t_w"} for sol in sols) + assert {tuple(sol["t_" + k] for k in "xyzw") for sol in sols} == expected m.freeTransform() assert m.getCountedSols() == [] From ea1dacf484459d3f7283040da01ddce4f42c8676 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Tue, 13 Oct 2026 16:48:40 +0100 Subject: [PATCH 5/9] Require presolving off in getCountedSols() --- src/pyscipopt/scip.pxi | 11 ++++++----- tests/test_model.py | 6 +++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index bb2df172e..f7b449022 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -12549,11 +12549,10 @@ cdef class Model: """ Get the feasible solutions collected by `count()`. - Solutions are only collected if the parameter `constraints/countsols/collect` - is set to True before calling `count()`. They are given over the transformed - integral variables, so continuous variables and variables removed by presolving - are not part of them; turn presolving off to get every integral variable. - The solutions are freed together with the transformed problem. + Requires `constraints/countsols/collect` to be True and presolving to be off + before calling `count()`. Only integral variables are counted, so continuous + variables are not part of the solutions. The solutions are freed together with + the transformed problem. Returns ------- @@ -12572,6 +12571,8 @@ cdef class Model: if not self.getParam("constraints/countsols/collect"): raise Warning("set constraints/countsols/collect to True before calling count()") + if self.getParam("presolving/maxrounds") != 0: + raise Warning("turn presolving off before calling count()") SCIPgetNCountedSols(self._scip, &valid) if not valid: diff --git a/tests/test_model.py b/tests/test_model.py index 347171f39..c4ee90b0e 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -692,6 +692,10 @@ def test_getCountedSols(): assert m.getCountedSols() == [] m.setParam("constraints/countsols/collect", False) - m.count() + with pytest.raises(Warning): + m.getCountedSols() + + m.setParam("constraints/countsols/collect", True) + m.setPresolve(SCIP_PARAMSETTING.DEFAULT) with pytest.raises(Warning): m.getCountedSols() From ed8a44fba1a867aa914104fd3e317e92b960b1ec Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Sat, 24 Oct 2026 19:13:17 +0100 Subject: [PATCH 6/9] Return Solution objects from getCountedSols() and reject continuous variables --- src/pyscipopt/scip.pxd | 3 +++ src/pyscipopt/scip.pxi | 60 +++++++++++++++++++++++++++++++----------- src/pyscipopt/scip.pyi | 2 +- tests/test_model.py | 16 ++++++++--- 4 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index bf5479552..73a7c3ec8 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -851,6 +851,7 @@ cdef extern from "scip/scip.h": SCIP_IMPLINTTYPE SCIPvarGetImplType(SCIP_VAR* var) SCIP_VARSTATUS SCIPvarGetStatus(SCIP_VAR* var) SCIP_Bool SCIPvarIsOriginal(SCIP_VAR* var) + SCIP_RETCODE SCIPvarGetOrigvarSum(SCIP_VAR** var, SCIP_Real* scalar, SCIP_Real* constant) SCIP_Bool SCIPvarIsTransformed(SCIP_VAR* var) SCIP_Bool SCIPvarIsActive(SCIP_VAR* var) SCIP_COL* SCIPvarGetCol(SCIP_VAR* var) @@ -2314,6 +2315,7 @@ cdef class Model: cdef _modelvars # map to store python constraints cdef _modelconss + cdef _countedsols # used to keep track of the number of event handlers generated cdef int _generated_event_handlers_count # store references to Benders subproblem Models for proper cleanup @@ -2330,6 +2332,7 @@ cdef class Model: cdef _getOrCreateCons(self, SCIP_CONS* scip_cons) cdef _getOrCreateVar(self, SCIP_VAR* scip_var) cdef _free_scip_instance(self) + cdef _freeCountedSols(self) @staticmethod cdef create(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index f7b449022..0801dae93 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -2840,6 +2840,7 @@ cdef class Model: self._freescip = True self._modelvars = {} self._modelconss = {} + self._countedsols = [] self._generated_event_handlers_count = 0 self._benders_subproblems = [] # Keep references to Benders subproblem Models self._plugins = [] # Keep references to plugins to break cycles in __dealloc__ @@ -2949,6 +2950,8 @@ cdef class Model: if SCIPgetStage(self._scip) == SCIP_STAGE_SOLVING and SCIPinProbing(self._scip): PY_SCIP_CALL(SCIPendProbing(self._scip)) + self._freeCountedSols() + # Ignore SCIPfree retcode: cleanup must not turn into a new failure. SCIPfree(&self._scip) self._scip = NULL @@ -3103,6 +3106,7 @@ cdef class Model: def freeProb(self): """Frees problem and solution process data.""" + self._freeCountedSols() PY_SCIP_CALL(SCIPfreeProb(self._scip)) def free(self): @@ -3140,6 +3144,7 @@ cdef class Model: # Clear Python-side caches self._modelvars = {} self._modelconss = {} + self._countedsols = [] self._benders_subproblems = [] self._bestSol = None @@ -3169,6 +3174,7 @@ cdef class Model: (cons).scip_cons = NULL self._modelconss = origconss + self._freeCountedSols() PY_SCIP_CALL(SCIPfreeTransform(self._scip)) def version(self): @@ -12550,19 +12556,22 @@ cdef class Model: Get the feasible solutions collected by `count()`. Requires `constraints/countsols/collect` to be True and presolving to be off - before calling `count()`. Only integral variables are counted, so continuous - variables are not part of the solutions. The solutions are freed together with - the transformed problem. + before calling `count()`, and a problem without continuous variables. The + solutions are freed together with the transformed problem. Returns ------- - list of dict - Each solution maps the names of the transformed variables to their values. + list of Solution """ cdef SCIP_VAR** vars + cdef SCIP_VAR** origvars + cdef SCIP_VAR* origvar cdef SCIP_SPARSESOL** sparsesols - cdef SCIP_Longint* sol + cdef SCIP_Longint* vals + cdef SCIP_SOL* sol + cdef SCIP_Real scalar + cdef SCIP_Real constant cdef SCIP_Bool valid cdef int nvars cdef int nsparsesols @@ -12573,26 +12582,47 @@ cdef class Model: raise Warning("set constraints/countsols/collect to True before calling count()") if self.getParam("presolving/maxrounds") != 0: raise Warning("turn presolving off before calling count()") + if SCIPgetNContVars(self._scip) > 0: + raise Warning("counted solutions are only available for problems without continuous variables") - SCIPgetNCountedSols(self._scip, &valid) + nsols = SCIPgetNCountedSols(self._scip, &valid) if not valid: raise Warning("number of counted solutions does not fit into a long integer, cannot collect them") + if len(self._countedsols) == nsols: + return list(self._countedsols) + self._freeCountedSols() SCIPgetCountedSparseSols(self._scip, &vars, &nvars, &sparsesols, &nsparsesols) - names = [bytes(SCIPvarGetName(vars[i])).decode("utf-8") for i in range(nvars)] + origvars = malloc(nvars * sizeof(SCIP_VAR*)) + for i in range(nvars): + origvar = vars[i] + scalar = 1.0 + constant = 0.0 + PY_SCIP_CALL(SCIPvarGetOrigvarSum(&origvar, &scalar, &constant)) + origvars[i] = origvar - sol = malloc(nvars * sizeof(SCIP_Longint)) - sols = [] + vals = malloc(nvars * sizeof(SCIP_Longint)) for s in range(nsparsesols): - SCIPsparseSolGetFirstSol(sparsesols[s], sol, nvars) + SCIPsparseSolGetFirstSol(sparsesols[s], vals, nvars) while True: - sols.append({names[i]: sol[i] for i in range(nvars)}) - if not SCIPsparseSolGetNextSol(sparsesols[s], sol, nvars): + PY_SCIP_CALL(SCIPcreateOrigSol(self._scip, &sol, NULL)) + for i in range(nvars): + PY_SCIP_CALL(SCIPsetSolVal(self._scip, sol, origvars[i], vals[i])) + self._countedsols.append(Solution.create(self._scip, sol)) + if not SCIPsparseSolGetNextSol(sparsesols[s], vals, nvars): break - free(sol) + free(vals) + free(origvars) + + return list(self._countedsols) - return sols + cdef _freeCountedSols(self): + cdef Solution sol + for sol in self._countedsols: + if sol.sol != NULL: + PY_SCIP_CALL(SCIPfreeSol(self._scip, &sol.sol)) + self._countedsols = [] def setParamsCountsols(self): """Sets SCIP parameters such that a valid counting process is possible.""" diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 1cc29b1a4..1f8dec875 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1226,7 +1226,7 @@ class Model: def getConsVars(self, constraint: Constraint) -> list[Variable] | None: ... def getConss(self, transformed: bool = True) -> list[Constraint]: ... def getFocusNode(self) -> Node | None: ... - def getCountedSols(self) -> list[dict[str, int]]: ... + def getCountedSols(self) -> list[Solution]: ... def getCurrentNode(self) -> Node | None: ... def getCutEfficacy(self, cut: Row, sol: Solution | None = None) -> float: ... def getCutLPSolCutoffDistance(self, cut: Row, sol: Solution) -> float: ... diff --git a/tests/test_model.py b/tests/test_model.py index c4ee90b0e..cc42b1a72 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -5,6 +5,7 @@ import pytest from pyscipopt import SCIP_BRANCHDIR, SCIP_PARAMSETTING, SCIP_STAGE, Model, quicksum +from pyscipopt.scip import Solution from helpers.utils import random_mip_1 @@ -673,7 +674,7 @@ def test_getCountedSols(): y = m.addVar("y", vtype="I", lb=0, ub=4) z = m.addVar("z", vtype="I", lb=0, ub=10) w = m.addVar("w", vtype="B") - m.addVar("c", lb=0, ub=1) + m.setObjective(x + 2*y) m.addCons(z == 2*x + 3) m.addCons(x + y <= 4) m.addCons(w + y >= 1) @@ -685,11 +686,15 @@ def test_getCountedSols(): expected = {(a, b, c, d) for a, b, c, d in itertools.product(range(-2, 4), range(5), range(11), range(2)) if c == 2*a + 3 and a + b <= 4 and d + b >= 1} assert len(sols) == m.getNCountedSols() == len(expected) - assert all(set(sol) == {"t_x", "t_y", "t_z", "t_w"} for sol in sols) - assert {tuple(sol["t_" + k] for k in "xyzw") for sol in sols} == expected + assert all(isinstance(sol, Solution) for sol in sols) + assert {tuple(sol[v] for v in (x, y, z, w)) for sol in sols} == expected + assert all(m.getSolObjVal(sol) == sol[x] + 2*sol[y] for sol in sols) + assert m.getCountedSols() == sols m.freeTransform() assert m.getCountedSols() == [] + with pytest.raises(Warning): + sols[0][x] m.setParam("constraints/countsols/collect", False) with pytest.raises(Warning): @@ -699,3 +704,8 @@ def test_getCountedSols(): m.setPresolve(SCIP_PARAMSETTING.DEFAULT) with pytest.raises(Warning): m.getCountedSols() + + m.setPresolve(SCIP_PARAMSETTING.OFF) + m.addVar("c", lb=0, ub=1) + with pytest.raises(Warning): + m.getCountedSols() From 89cf5ca6d4d7cac6650c60104de9938a515f7670 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Sat, 24 Oct 2026 20:38:54 +0100 Subject: [PATCH 7/9] Return counted solutions as dicts keyed by original variable name --- src/pyscipopt/scip.pxd | 2 -- src/pyscipopt/scip.pxi | 38 +++++++++----------------------------- src/pyscipopt/scip.pyi | 2 +- tests/test_model.py | 11 +++-------- 4 files changed, 13 insertions(+), 40 deletions(-) diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 73a7c3ec8..461900148 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -2315,7 +2315,6 @@ cdef class Model: cdef _modelvars # map to store python constraints cdef _modelconss - cdef _countedsols # used to keep track of the number of event handlers generated cdef int _generated_event_handlers_count # store references to Benders subproblem Models for proper cleanup @@ -2332,7 +2331,6 @@ cdef class Model: cdef _getOrCreateCons(self, SCIP_CONS* scip_cons) cdef _getOrCreateVar(self, SCIP_VAR* scip_var) cdef _free_scip_instance(self) - cdef _freeCountedSols(self) @staticmethod cdef create(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 0801dae93..f4a41989f 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -2840,7 +2840,6 @@ cdef class Model: self._freescip = True self._modelvars = {} self._modelconss = {} - self._countedsols = [] self._generated_event_handlers_count = 0 self._benders_subproblems = [] # Keep references to Benders subproblem Models self._plugins = [] # Keep references to plugins to break cycles in __dealloc__ @@ -2950,8 +2949,6 @@ cdef class Model: if SCIPgetStage(self._scip) == SCIP_STAGE_SOLVING and SCIPinProbing(self._scip): PY_SCIP_CALL(SCIPendProbing(self._scip)) - self._freeCountedSols() - # Ignore SCIPfree retcode: cleanup must not turn into a new failure. SCIPfree(&self._scip) self._scip = NULL @@ -3106,7 +3103,6 @@ cdef class Model: def freeProb(self): """Frees problem and solution process data.""" - self._freeCountedSols() PY_SCIP_CALL(SCIPfreeProb(self._scip)) def free(self): @@ -3144,7 +3140,6 @@ cdef class Model: # Clear Python-side caches self._modelvars = {} self._modelconss = {} - self._countedsols = [] self._benders_subproblems = [] self._bestSol = None @@ -3174,7 +3169,6 @@ cdef class Model: (cons).scip_cons = NULL self._modelconss = origconss - self._freeCountedSols() PY_SCIP_CALL(SCIPfreeTransform(self._scip)) def version(self): @@ -12557,19 +12551,18 @@ cdef class Model: Requires `constraints/countsols/collect` to be True and presolving to be off before calling `count()`, and a problem without continuous variables. The - solutions are freed together with the transformed problem. + objective value of a solution is `sum(var.getObj() * sol[var.name] for var in model.getVars())`. Returns ------- - list of Solution + list of dict + Each solution maps the variable names to their values. """ cdef SCIP_VAR** vars - cdef SCIP_VAR** origvars cdef SCIP_VAR* origvar cdef SCIP_SPARSESOL** sparsesols cdef SCIP_Longint* vals - cdef SCIP_SOL* sol cdef SCIP_Real scalar cdef SCIP_Real constant cdef SCIP_Bool valid @@ -12585,44 +12578,31 @@ cdef class Model: if SCIPgetNContVars(self._scip) > 0: raise Warning("counted solutions are only available for problems without continuous variables") - nsols = SCIPgetNCountedSols(self._scip, &valid) + SCIPgetNCountedSols(self._scip, &valid) if not valid: raise Warning("number of counted solutions does not fit into a long integer, cannot collect them") - if len(self._countedsols) == nsols: - return list(self._countedsols) - self._freeCountedSols() SCIPgetCountedSparseSols(self._scip, &vars, &nvars, &sparsesols, &nsparsesols) - origvars = malloc(nvars * sizeof(SCIP_VAR*)) + names = [] for i in range(nvars): origvar = vars[i] scalar = 1.0 constant = 0.0 PY_SCIP_CALL(SCIPvarGetOrigvarSum(&origvar, &scalar, &constant)) - origvars[i] = origvar + names.append(bytes(SCIPvarGetName(origvar)).decode("utf-8")) vals = malloc(nvars * sizeof(SCIP_Longint)) + sols = [] for s in range(nsparsesols): SCIPsparseSolGetFirstSol(sparsesols[s], vals, nvars) while True: - PY_SCIP_CALL(SCIPcreateOrigSol(self._scip, &sol, NULL)) - for i in range(nvars): - PY_SCIP_CALL(SCIPsetSolVal(self._scip, sol, origvars[i], vals[i])) - self._countedsols.append(Solution.create(self._scip, sol)) + sols.append({names[i]: vals[i] for i in range(nvars)}) if not SCIPsparseSolGetNextSol(sparsesols[s], vals, nvars): break free(vals) - free(origvars) - - return list(self._countedsols) - cdef _freeCountedSols(self): - cdef Solution sol - for sol in self._countedsols: - if sol.sol != NULL: - PY_SCIP_CALL(SCIPfreeSol(self._scip, &sol.sol)) - self._countedsols = [] + return sols def setParamsCountsols(self): """Sets SCIP parameters such that a valid counting process is possible.""" diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 1f8dec875..1cc29b1a4 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1226,7 +1226,7 @@ class Model: def getConsVars(self, constraint: Constraint) -> list[Variable] | None: ... def getConss(self, transformed: bool = True) -> list[Constraint]: ... def getFocusNode(self) -> Node | None: ... - def getCountedSols(self) -> list[Solution]: ... + def getCountedSols(self) -> list[dict[str, int]]: ... def getCurrentNode(self) -> Node | None: ... def getCutEfficacy(self, cut: Row, sol: Solution | None = None) -> float: ... def getCutLPSolCutoffDistance(self, cut: Row, sol: Solution) -> float: ... diff --git a/tests/test_model.py b/tests/test_model.py index cc42b1a72..bd1288cb2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -5,7 +5,6 @@ import pytest from pyscipopt import SCIP_BRANCHDIR, SCIP_PARAMSETTING, SCIP_STAGE, Model, quicksum -from pyscipopt.scip import Solution from helpers.utils import random_mip_1 @@ -674,7 +673,6 @@ def test_getCountedSols(): y = m.addVar("y", vtype="I", lb=0, ub=4) z = m.addVar("z", vtype="I", lb=0, ub=10) w = m.addVar("w", vtype="B") - m.setObjective(x + 2*y) m.addCons(z == 2*x + 3) m.addCons(x + y <= 4) m.addCons(w + y >= 1) @@ -686,15 +684,12 @@ def test_getCountedSols(): expected = {(a, b, c, d) for a, b, c, d in itertools.product(range(-2, 4), range(5), range(11), range(2)) if c == 2*a + 3 and a + b <= 4 and d + b >= 1} assert len(sols) == m.getNCountedSols() == len(expected) - assert all(isinstance(sol, Solution) for sol in sols) - assert {tuple(sol[v] for v in (x, y, z, w)) for sol in sols} == expected - assert all(m.getSolObjVal(sol) == sol[x] + 2*sol[y] for sol in sols) - assert m.getCountedSols() == sols + assert all(isinstance(sol, dict) and set(sol) == {"x", "y", "z", "w"} for sol in sols) + assert all(type(val) is int for sol in sols for val in sol.values()) + assert {tuple(sol[v.name] for v in (x, y, z, w)) for sol in sols} == expected m.freeTransform() assert m.getCountedSols() == [] - with pytest.raises(Warning): - sols[0][x] m.setParam("constraints/countsols/collect", False) with pytest.raises(Warning): From b61e7500ab4578e3cec51b828b700cf9d9189d14 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Sun, 25 Oct 2026 15:06:05 +0000 Subject: [PATCH 8/9] Trim getCountedSols docstring and drop Warning checks from its test --- src/pyscipopt/scip.pxi | 3 +-- tests/test_model.py | 14 -------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 0683c3bd8..98f2859e7 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -12659,8 +12659,7 @@ cdef class Model: Get the feasible solutions collected by `count()`. Requires `constraints/countsols/collect` to be True and presolving to be off - before calling `count()`, and a problem without continuous variables. The - objective value of a solution is `sum(var.getObj() * sol[var.name] for var in model.getVars())`. + before calling `count()`, and a problem without continuous variables. Returns ------- diff --git a/tests/test_model.py b/tests/test_model.py index 8befc7408..fdc9dc6f7 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -766,17 +766,3 @@ def test_getCountedSols(): m.freeTransform() assert m.getCountedSols() == [] - - m.setParam("constraints/countsols/collect", False) - with pytest.raises(Warning): - m.getCountedSols() - - m.setParam("constraints/countsols/collect", True) - m.setPresolve(SCIP_PARAMSETTING.DEFAULT) - with pytest.raises(Warning): - m.getCountedSols() - - m.setPresolve(SCIP_PARAMSETTING.OFF) - m.addVar("c", lb=0, ub=1) - with pytest.raises(Warning): - m.getCountedSols() From 56d6fec94b3337485fc7156d7a59d2518a7a04a1 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Sun, 25 Oct 2026 15:36:47 +0000 Subject: [PATCH 9/9] Get original names in getCountedSols() by stripping the t_ prefix --- src/pyscipopt/scip.pxd | 1 - src/pyscipopt/scip.pxi | 12 ++---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index d8973ba6f..c5053ebba 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -851,7 +851,6 @@ cdef extern from "scip/scip.h": SCIP_IMPLINTTYPE SCIPvarGetImplType(SCIP_VAR* var) SCIP_VARSTATUS SCIPvarGetStatus(SCIP_VAR* var) SCIP_Bool SCIPvarIsOriginal(SCIP_VAR* var) - SCIP_RETCODE SCIPvarGetOrigvarSum(SCIP_VAR** var, SCIP_Real* scalar, SCIP_Real* constant) SCIP_Bool SCIPvarIsTransformed(SCIP_VAR* var) SCIP_Bool SCIPvarIsActive(SCIP_VAR* var) SCIP_COL* SCIPvarGetCol(SCIP_VAR* var) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 98f2859e7..8fad5ae7f 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -12668,11 +12668,8 @@ cdef class Model: """ cdef SCIP_VAR** vars - cdef SCIP_VAR* origvar cdef SCIP_SPARSESOL** sparsesols cdef SCIP_Longint* vals - cdef SCIP_Real scalar - cdef SCIP_Real constant cdef SCIP_Bool valid cdef int nvars cdef int nsparsesols @@ -12691,13 +12688,8 @@ cdef class Model: raise Warning("number of counted solutions does not fit into a long integer, cannot collect them") SCIPgetCountedSparseSols(self._scip, &vars, &nvars, &sparsesols, &nsparsesols) - names = [] - for i in range(nvars): - origvar = vars[i] - scalar = 1.0 - constant = 0.0 - PY_SCIP_CALL(SCIPvarGetOrigvarSum(&origvar, &scalar, &constant)) - names.append(bytes(SCIPvarGetName(origvar)).decode("utf-8")) + # vars are transformed, strip the "t_" prefix to get the original names + names = [bytes(SCIPvarGetName(vars[i])).decode("utf-8")[2:] for i in range(nvars)] vals = malloc(nvars * sizeof(SCIP_Longint)) sols = []