From 70d39e2cd36df3e93f6ed44fc134085bdd3fa2e1 Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 26 Sep 2025 19:56:28 +0200 Subject: [PATCH 01/15] Add SCIPvarIsActive function and corresponding test for variable activity --- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 10 ++++++++++ tests/test_vars.py | 9 +++++++++ 3 files changed, 20 insertions(+) diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 444ea743f..264f7bf05 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -816,6 +816,7 @@ cdef extern from "scip/scip.h": SCIP_Longint SCIPvarGetNBranchingsCurrentRun(SCIP_VAR* var, SCIP_BRANCHDIR dir) SCIP_Bool SCIPvarMayRoundUp(SCIP_VAR* var) SCIP_Bool SCIPvarMayRoundDown(SCIP_VAR* var) + SCIP_Bool SCIPvarIsActive(SCIP_VAR* var) # LP Methods SCIP_RETCODE SCIPgetLPColsData(SCIP* scip, SCIP_COL*** cols, int* ncols) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index c2603d2c3..a341bb47f 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -1727,6 +1727,16 @@ cdef class Variable(Expr): """ return SCIPvarIsDeletable(self.scip_var) + def isActive(self): + """ + Returns whether variable is an active (neither fixed nor aggregated) variable. + + Returns + ------- + boolean + """ + return SCIPvarIsActive(self.scip_var) + def getNLocksDown(self): """ Returns the number of locks for rounding down. diff --git a/tests/test_vars.py b/tests/test_vars.py index 604c7870b..05b18e8ea 100644 --- a/tests/test_vars.py +++ b/tests/test_vars.py @@ -111,3 +111,12 @@ def test_getNBranchingsCurrentRun(): n_branchings += var.getNBranchingsCurrentRun(SCIP_BRANCHDIR.DOWNWARDS) assert n_branchings == m.getNNodes() - 1 + +def test_isActive(): + m = Model() + x = m.addVar(vtype='C', lb=0.0, ub=1.0) + # newly added variables should be active + assert x.isActive() + m.freeProb() + + # TODO lacks tests for cases when returned false due to fixed (probably during probing) or aggregated From 9b69d6f6eb6c0cfaaac0ef78b90336b19829a72f Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Sat, 27 Sep 2025 10:24:39 +0200 Subject: [PATCH 02/15] Add SCIPaggregateVars function and aggregateVars method for variable aggregation --- src/pyscipopt/scip.pxd | 9 +++++++++ src/pyscipopt/scip.pxi | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 264f7bf05..659cc64de 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -817,6 +817,15 @@ cdef extern from "scip/scip.h": SCIP_Bool SCIPvarMayRoundUp(SCIP_VAR* var) SCIP_Bool SCIPvarMayRoundDown(SCIP_VAR* var) SCIP_Bool SCIPvarIsActive(SCIP_VAR* var) + SCIP_RETCODE SCIPaggregateVars(SCIP* scip, + SCIP_VAR* varx, + SCIP_VAR* vary, + SCIP_Real scalarx, + SCIP_Real scalary, + SCIP_Real rhs, + SCIP_Bool* infeasible, + SCIP_Bool* redundant, + SCIP_Bool* aggregated) # LP Methods SCIP_RETCODE SCIPgetLPColsData(SCIP* scip, SCIP_COL*** cols, int* ncols) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index a341bb47f..39c089039 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -4130,6 +4130,39 @@ cdef class Model: PY_SCIP_CALL(SCIPdelVar(self._scip, var.scip_var, &deleted)) return deleted + def aggregateVars(self, Variable varx, Variable vary, scalarx=1.0, scalary=-1.0, rhs=0.0): + """ + Aggregate varx and vary: calls SCIPaggregateVars and returns + (infeasible, redundant, aggregated) as Python bools. + + Parameters + ---------- + varx : Variable + vary : Variable + scalarx : float + scalary : float + rhs : float + + Returns + ------- + infeasible : bool + redundant : bool + aggregated : bool + """ + cdef SCIP_Bool infeasible + cdef SCIP_Bool redundant + cdef SCIP_Bool aggregated + PY_SCIP_CALL(SCIPaggregateVars(self._scip, + varx.scip_var, + vary.scip_var, + scalarx, + scalary, + rhs, + &infeasible, + &redundant, + &aggregated)) + return infeasible, redundant, aggregated + def tightenVarLb(self, Variable var, lb, force=False): """ Tighten the lower bound in preprocessing or current node, if the bound is tighter. From 5100b7613a545e3d590b0b8bca304fa20d0964c4 Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 15:15:54 +0200 Subject: [PATCH 03/15] Add knapsack function for modeling the knapsack problem --- examples/finished/shiftbound.py | 76 +++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 examples/finished/shiftbound.py diff --git a/examples/finished/shiftbound.py b/examples/finished/shiftbound.py new file mode 100644 index 000000000..dc752e078 --- /dev/null +++ b/examples/finished/shiftbound.py @@ -0,0 +1,76 @@ +from pyscipopt import ( + Model +) +from typing import List, Optional + + +def knapsack( + instance_name: str, + sizes: List[int], + values: List[int], + upper_bound: List[int], + lower_bound: List[int], + capacity: int, + vtypes: Optional[List[str]], +) -> tuple[Model, dict]: + """ + Model an instance of the knapsack problem + + Parameters: + sizes: List[int] - the sizes of the items + values: List[int] - the values of the items + upper_bound: List[int] - upper bounds per variable + lower_bound: List[int] - lower bounds per variable + capacity: int - the knapsack capacity + vtypes: Optional[List[str]] - variable types ("B", "I", "C") + + Returns: + tuple[Model, dict] - the SCIP model and the variables dictionary + """ + + m = Model(f"Knapsack: {instance_name}") + x = {} + for i in range(len(sizes)): + assert isinstance(sizes[i], int) + assert isinstance(values[i], int) + assert isinstance(upper_bound[i], int) + assert isinstance(lower_bound[i], int) + + vt = "I" + if vtypes is not None: + assert len(vtypes) == len(sizes) + assert isinstance(vtypes[i], str) or (vtypes[i] is None) + vt = vtypes[i] + + x[i] = m.addVar( + vtype=vt, + obj=values[i], + lb=lower_bound[i], + ub=upper_bound[i], + name=f"x{i}", + ) + + assert isinstance(capacity, int) + m.addCons(sum(sizes[i] * x[i] for i in range(len(sizes))) <= capacity) + + m.setMaximize() + + return m, x + + +if __name__ == "__main__": + instance_name = "Knapsack" + sizes = [2, 1, 3] + values = [2, 3, 1] + upper_bounds = [1, 4, 1] + lower_bounds = [0, 2, 0] + capacity = 3 + + model, var_list = knapsack( + instance_name, sizes, values, upper_bounds, lower_bounds, capacity + ) + + model = Model() + + # run presolve on instance + model.presolve() \ No newline at end of file From fd41439c7b5461c0fc0ac95e39285079e3e9429b Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 15:17:38 +0200 Subject: [PATCH 04/15] Add parameter settings to disable automatic presolvers and propagators in the knapsack model --- examples/finished/shiftbound.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/examples/finished/shiftbound.py b/examples/finished/shiftbound.py index dc752e078..1203acf1d 100644 --- a/examples/finished/shiftbound.py +++ b/examples/finished/shiftbound.py @@ -1,5 +1,6 @@ from pyscipopt import ( - Model + Model, + SCIP_PARAMSETTING ) from typing import List, Optional @@ -72,5 +73,25 @@ def knapsack( model = Model() + # isolate test: disable many automatic presolvers/propagators + model.setSeparating(SCIP_PARAMSETTING.OFF) + model.setHeuristics(SCIP_PARAMSETTING.OFF) + model.disablePropagation() + for key in ( + "presolving/boundshift/maxrounds", + "presolving/domcol/maxrounds", + "presolving/dualsparsify/maxrounds", + "presolving/implics/maxrounds", + "presolving/inttobinary/maxrounds", + "presolving/milp/maxrounds", + "presolving/sparsify/maxrounds", + "presolving/trivial/maxrounds", + "propagating/dualfix/maxprerounds", + "propagating/probing/maxprerounds", + "propagating/symmetry/maxprerounds", + "constraints/linear/maxprerounds", + ): + model.setParam(key, 0) + # run presolve on instance model.presolve() \ No newline at end of file From 1c9ab3cf9682466dbdfe7a0a75275a12df71eafe Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 15:20:40 +0200 Subject: [PATCH 05/15] Add ShiftboundPresolver for variable domain transformation in SCIP --- examples/finished/shiftbound.py | 183 +++++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 1 deletion(-) diff --git a/examples/finished/shiftbound.py b/examples/finished/shiftbound.py index 1203acf1d..1af1046dd 100644 --- a/examples/finished/shiftbound.py +++ b/examples/finished/shiftbound.py @@ -1,10 +1,178 @@ +import math from pyscipopt import ( Model, - SCIP_PARAMSETTING + SCIP_PARAMSETTING, + SCIP_PRESOLTIMING, + Presol, + SCIP_RESULT ) from typing import List, Optional +class ShiftboundPresolver(Presol): + """ + A presolver that converts variable domains from [a, b] to [0, b - a]. + + Attributes: + maxshift: float - Maximum absolute shift allowed. + flipping: bool - Whether to allow flipping (multiplying by -1) for + differentiation. + integer: bool - Whether to shift only integer ranges. + """ + + def __init__( + self, + maxshift: float = float("inf"), + flipping: bool = True, + integer: bool = True, + ): + self.maxshift = maxshift + self.flipping = flipping + self.integer = integer + + def presolexec(self, nrounds, presoltiming): + # the greatest absolute value by which bounds can be shifted to avoid + # large constant offsets + MAXABSBOUND = 1000.0 + + scip = self.model + + def REALABS(x): + return math.fabs(x) + + # scip.isIntegral() not implemented in wrapper. Work-around: + # compute integrality using epsilon from SCIP settings. + def SCIPisIntegral(val): + return val - math.floor(val + scip.epsilon()) <= scip.epsilon() + + # SCIPadjustedVarLb() not implemented in wrapper. Work-around: + # return the adjusted (i.e., rounded, if var is integral type) bound. + # Does not change the bounds of the variable. + def SCIPadjustedVarBound(var, val): + if val < 0 and -val >= scip.infinity(): + return -scip.infinity() + elif val > 0 and val >= scip.infinity(): + return scip.infinity() + elif var.vtype() != "CONTINUOUS": + return scip.feasCeil(val) + elif REALABS(val) <= scip.epsilon(): + return 0.0 + else: + return val + + # check whether aggregation of variables is not allowed + if scip.getParam("presolving/donotaggr"): + return {"result": SCIP_RESULT.DIDNOTRUN} + + scipvars = scip.getVars() + nbinvars = scip.getNBinVars() # number of binary variables + # infer number of non-binary variables + nvars = scip.getNVars() - nbinvars + + # if number of non-binary variables equals zero + if nvars == 0: + return {"result": SCIP_RESULT.DIDNOTRUN} + + # copy the non-binary variables into a separate list. + # this slice works because SCIP orders variables by type, starting with + # binary variables + vars = scipvars[nbinvars:] + + # loop over the non-binary variables + for var in reversed(vars): + # sanity check that variable is indeed not binary + assert var.vtype() != "BINARY" + + # do not shift non-active (fixed or (multi-)aggregated) variables + if not var.isActive(): + continue + + # get current variable's bounds + lb = var.getLbGlobal() + ub = var.getUbGlobal() + + # It can happen that integer variable bounds have not been + # propagated yet or contain small noise. This could result in an + # aggregation that might trigger assertions when updating bounds of + # aggregated variables (floating-point rounding errors). + # check if variable is integer + if var.vtype != "CONTINUOUS": + # assert if bounds are integral + assert SCIPisIntegral(lb) + assert SCIPisIntegral(ub) + + # round the bound values for integral variables + lb = SCIPadjustedVarBound(var, lb) + ub = SCIPadjustedVarBound(var, ub) + + # sanity check lb < ub + assert scip.isLE(lb, ub) + # check if variable is already fixed + if scip.isEQ(lb, ub): + continue + # only operate on integer variables + if self.integer and not SCIPisIntegral(ub - lb): + continue + + # bounds are shiftable if all following conditions hold + cases = [ + not scip.isEQ(lb, 0.0), + scip.isLT(ub, scip.infinity()), + scip.isGT(lb, -scip.infinity()), + scip.isLT(ub - lb, self.maxshift), + scip.isLE(REALABS(lb), MAXABSBOUND), + scip.isLE(REALABS(ub), MAXABSBOUND), + ] + if all(cases): + # indicators for status of aggregation + infeasible = False + redundant = False + aggregated = False + + # create new variable with same properties as the current + # variable but with an added "_shift" suffix + orig_name = var.name + newvar = scip.addVar( + name=f"{orig_name}_shift", + vtype=f"{var.vtype()}", + lb=0.0, + ub=(ub - lb), + obj=0.0, + ) + + # aggregate old variable with new variable + # check if self.flipping is True + if self.flipping: + # check if |ub| < |lb| + if REALABS(ub) < REALABS(lb): + infeasible, redundant, aggregated = scip.aggregateVars( + var, newvar, 1.0, 1.0, ub + ) + else: + infeasible, redundant, aggregated = scip.aggregateVars( + var, newvar, 1.0, -1.0, lb + ) + else: + infeasible, redundant, aggregated = scip.aggregateVars( + var, newvar, 1.0, -1.0, lb + ) + + # problem has now become infeasible + if infeasible: + result = SCIP_RESULT.CUTOFF + else: + # sanity check flags + assert redundant + assert aggregated + + result = SCIP_RESULT.SUCCESS + + else: + result = SCIP_RESULT.DIDNOTFIND + + return {"result": result} + + def knapsack( instance_name: str, sizes: List[int], @@ -93,5 +261,18 @@ def knapsack( ): model.setParam(key, 0) + # register and apply custom boundshift presolver + presolver = ShiftboundPresolver( + maxshift=float("inf"), flipping=True, integer=True + ) + model.includePresol( + presolver, + "shiftbound", + "converts variables with domain [a,b] to variables with domain [0,b-a]", + priority=7900000, + maxrounds=-1, + timing=SCIP_PRESOLTIMING.FAST, + ) + # run presolve on instance model.presolve() \ No newline at end of file From aa037f7f58409b3f132df3172155d7dfba8c5147 Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 15:21:52 +0200 Subject: [PATCH 06/15] Add tests for Shiftbound presolver with parametrised knapsack instances --- tests/test_shiftbound.py | 216 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 tests/test_shiftbound.py diff --git a/tests/test_shiftbound.py b/tests/test_shiftbound.py new file mode 100644 index 000000000..845dbb200 --- /dev/null +++ b/tests/test_shiftbound.py @@ -0,0 +1,216 @@ +""" +Tests for the Shiftbound presolver. +Parametrised knapsack instances + presolver option combitions. +""" + +import logging +import pytest +from pyscipopt import ( + SCIP_PARAMSETTING, + SCIP_PRESOLTIMING +) +from typing import List, Tuple, Optional +from PySCIPOpt.examples.finished import ShiftboundPresolver, knapsack + +# define a few small, fast instances to exercise different shapes and types +INSTANCES: List[ + Tuple[ + List[int], # item sizes (i.e., constraint vector) + List[int], # item values (i.e., coefficient vector) + List[int], # variable upper bound + List[int], # variable lower bound + int, # capacity constraint value + Optional[List[str]], # variable types + ] +] = [ + # small integer knapsack + ([2, 1, 3], [2, 3, 1], [1, 4, 1], [0, 2, 0], 3, None), + # small integer knapsack (flipped) + ([2, 1, 3], [2, 3, 1], [1, -2, 1], [0, -4, 0], 3, None), + # vtype continuous + ([2, 1, 3], [2, 3, 1], [1, 4, 1], [0, 2, 0], 3, ["C", "C", "C"]), + # vtype integer + ([2, 1, 3], [2, 3, 1], [1, 4, 1], [0, 2, 0], 3, ["I", "I", "I"]), + # above MAXABSBOUND + ([2, 1, 3], [2, 3, 1], [1, 4, 1001], [0, 2, 1000], 3, None), + # no variables to shift + ([2, 1, 3], [2, 3, 1], [1, 2, 1], [0, 0, 0], 3, None), +] + +INSTANCE_IDS = [ + "small-integer", + "small-integer-flipped", + "vtype continuous", + "vtype integer", + "above MAXABSBOUND", + "no variables to shift", +] + +# presolver option combinations; parametrise doNotAggr, maxshift, and +# flipping and integer flags +PRESOLVER_OPTIONS = [ + (True, None, True, True), + (False, 0, True, True), + (False, None, True, True), + (False, None, True, False), + (False, None, False, True), + (False, None, False, False), +] + +# pre-define the amount of variables that should be aggregated for each +# presolver option +EXPECTED_VALUE = [ + # small integer knapsack + (0, 0, 1, 1, 1, 1), + # small integer knapsack (flipped) + (0, 0, 1, 1, 1, 1), + # vtype continuous + (0, 0, 1, 1, 1, 1), + # vtype integer + (0, 0, 1, 1, 1, 1), + # above MAXABSBOUND + (0, 0, 1, 1, 1, 1), + # no variables to shift + (0, 0, 0, 0, 0, 0), +] + +# build explicit (instance, options, expected_value) test cases +TEST_CASES = [] +TEST_IDS = [] +for inst_idx, inst in enumerate(INSTANCES): + for opt_idx, opt in enumerate(PRESOLVER_OPTIONS): + expected_for_inst = EXPECTED_VALUE[inst_idx][opt_idx] + TEST_CASES.append((inst, opt, expected_for_inst)) + TEST_IDS.append(f"{INSTANCE_IDS[inst_idx]}") + +@pytest.fixture +def instance(request): + # get human-readable instance id that comes from `ids=INSTANCE_IDS` + instance_name = None + if ( + hasattr(request, "node") + and hasattr(request.node, "callspec") + and request.node.callspec is not None + ): + instance_name = request.node.callspec.id[:-1] + else: + # fallback when callspec/id is not available + instance_name = "instance-" + str(hash(request.param))[:8] + + sizes, values, ubs, lbs, capacity, vtypes = request.param + model, vars_list = knapsack( + instance_name, sizes, values, ubs, lbs, capacity, vtypes + ) + + # return a tuple so tests can inspect variables easily + try: + yield ( + model, + vars_list, + { + "sizes": sizes, + "values": values, + "ubs": ubs, + "lbs": lbs, + "capacity": capacity, + "vtypes": vtypes, + "instance_name": instance_name, + }, + ) + finally: + # cleanup + try: + model.freeProb() + except Exception: + pass + + +@pytest.mark.parametrize( + "instance, options, expected_value", + TEST_CASES, + ids=TEST_IDS, + indirect=["instance"], +) +def test_shiftbound(instance, options, expected_value): + model, vars_list, meta = instance + doNotAggr, maxshift, flipping, integer = options + + # silence solver output + model.hideOutput() + + # isolate test: disable many automatic presolvers/propagators + model.setSeparating(SCIP_PARAMSETTING.OFF) + model.setHeuristics(SCIP_PARAMSETTING.OFF) + model.disablePropagation() + for key in ( + "presolving/boundshift/maxrounds", + "presolving/domcol/maxrounds", + "presolving/dualsparsify/maxrounds", + "presolving/implics/maxrounds", + "presolving/inttobinary/maxrounds", + "presolving/milp/maxrounds", + "presolving/sparsify/maxrounds", + "presolving/trivial/maxrounds", + "propagating/dualfix/maxprerounds", + "propagating/probing/maxprerounds", + "propagating/symmetry/maxprerounds", + "constraints/linear/maxprerounds", + ): + try: + model.setParam(key, 0) + except Exception: + # parameter might not exist on older/newer SCIP builds; ignore + pass + + if isinstance(doNotAggr, bool): + try: + model.setParam("presolving/donotaggr", doNotAggr) + except Exception: + # parameter might not exist on older/newer SCIP builds; ignore + pass + + # Register and apply custom boundshift presolver + if not (isinstance(maxshift, float) or isinstance(maxshift, int)): + maxshift = float("inf") + presolver = ShiftboundPresolver( + maxshift=maxshift, flipping=flipping, integer=integer + ) + model.includePresol( + presolver, + "shiftbound", + "converts variables with domain [a,b] to variables with domain [0,b-a]", + priority=7900000, + maxrounds=1, + timing=SCIP_PRESOLTIMING.FAST, + ) + # set presolver calls to one (maxrounds=1) to keep tests deterministic + + # run presolve on instance + model.presolve() + if logging.getLogger(__name__).isEnabledFor(logging.DEBUG): + model.printStatistics() + model.printProblem() + model.printProblem(trans=True) + + # count shifted variables created by the presolver (names ending with + # "_shift") + shifted_names = [] + for v in model.getVars(transformed=True): + name = None + if hasattr(v, "name"): + name = v.name + else: + try: + name = v.getName() + except Exception: + name = None + if name.endswith("_shift"): + shifted_names.append(name) + + shifted_count = len(shifted_names) + + assert shifted_count == expected_value, ( + f"expected {expected_value} shifted variables for test " + f'"{meta.get("instance_name")}" ' + f"with options {options}, got {shifted_count}" + ) \ No newline at end of file From 94983e61d678cb321470bc1b19dc4ed3cd4d364f Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 15:22:24 +0200 Subject: [PATCH 07/15] Update docstring in shiftbound.py to clarify presolver example and its functionality --- examples/finished/shiftbound.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/examples/finished/shiftbound.py b/examples/finished/shiftbound.py index 1af1046dd..34cc0cd71 100644 --- a/examples/finished/shiftbound.py +++ b/examples/finished/shiftbound.py @@ -1,3 +1,18 @@ +""" +Example showing a custom presolver using PySCIPOpt's Presol plugin. + +This example reproduces the logic of boundshift.c from the SCIP source +as closely as possible. Some additional wrappers were required to expose +SCIP functionality to Python (e.g., scip.aggregateVars()). Other logic +(e.g., REALABS()) can be implemented in Python to mirror SCIP behaviour +without extra wrappers. This illustrates that users can implement +presolvers in Python by combining existing PySCIPOpt wrappers with +Python code and SCIP documentation. + +A simple knapsack problem was chosen to let the presolver plugin +operate on. +""" + import math from pyscipopt import ( Model, From 908b47bf5342a6fd1e4be1ac901c541473939132 Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 18:35:12 +0200 Subject: [PATCH 08/15] Add tests for Model.aggregateVars to verify aggregation functionality --- tests/test_aggregate_vars.py | 69 ++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_aggregate_vars.py diff --git a/tests/test_aggregate_vars.py b/tests/test_aggregate_vars.py new file mode 100644 index 000000000..0d6309ab4 --- /dev/null +++ b/tests/test_aggregate_vars.py @@ -0,0 +1,69 @@ +""" +Tests for Model.aggregateVars (wrapper around SCIPaggregateVars). +""" + +from pyscipopt import ( + Model, + Presol, + SCIP_PRESOLTIMING, + SCIP_RESULT, +) + + +class _AggPresol(Presol): + """ + Minimal presolver that aggregates two given variables and records the flags. + """ + + def __init__(self, varx, vary, scalarx, scalary, rhs): + self._args = (varx, vary, scalarx, scalary, rhs) + self.last = None # (infeasible, redundant, aggregated) + + def presolexec(self, nrounds, presoltiming): + x, y, ax, ay, rhs = self._args + infeas, redun, aggr = self.model.aggregateVars(x, y, ax, ay, rhs) + self.last = (bool(infeas), bool(redun), bool(aggr)) + # return SUCCESS to indicate presolver did work + return {"result": SCIP_RESULT.SUCCESS} + + +def _build_model_xy(vtype="C", lbx=0.0, ubx=10.0, lby=0.0, uby=10.0): + """ + Build a tiny model with two variables x and y. + """ + m = Model("agg-vars-test") + m.hideOutput() + x = m.addVar(name="x", vtype=vtype, lb=lbx, ub=ubx) + y = m.addVar(name="y", vtype=vtype, lb=lby, ub=uby) + # trivial objective to have a complete model + m.setMaximize() + return m, x, y + + +def test_aggregate_vars_success(): + """ + Aggregation succeeds for x - y = 0 on continuous variables with + compatible bounds, when called from a presolver. + """ + model, x, y = _build_model_xy( + vtype="C", lbx=0.0, ubx=10.0, lby=0.0, uby=10.0 + ) + + presol = _AggPresol(x, y, 1.0, -1.0, 0.0) + model.includePresol( + presol, + "agg-test", + "aggregate x and y", + priority=10**7, + maxrounds=1, + timing=SCIP_PRESOLTIMING.FAST, + ) + + model.presolve() + assert presol.last is not None + infeasible, redundant, aggregated = presol.last + + assert not infeasible + assert aggregated + # model should stay consistent + model.optimize() \ No newline at end of file From e7d34cb970b401695dc4d005fa015479c6c267aa Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 18:35:35 +0200 Subject: [PATCH 09/15] Add test for aggregation infeasibility in binary variables --- tests/test_aggregate_vars.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test_aggregate_vars.py b/tests/test_aggregate_vars.py index 0d6309ab4..b4069b27c 100644 --- a/tests/test_aggregate_vars.py +++ b/tests/test_aggregate_vars.py @@ -66,4 +66,30 @@ def test_aggregate_vars_success(): assert not infeasible assert aggregated # model should stay consistent - model.optimize() \ No newline at end of file + model.optimize() + + +def test_aggregate_vars_infeasible_binary_sum_exceeds_domain(): + """ + Aggregation detects infeasibility for x + y = 3 on binary variables, + since max(x + y) = 2 in {0,1} x {0,1}. + """ + model, x, y = _build_model_xy( + vtype="B", lbx=0.0, ubx=1.0, lby=0.0, uby=1.0 + ) + + presol = _AggPresol(x, y, 1.0, 1.0, 3.0) + model.includePresol( + presol, + "agg-infeas", + "aggregate x and y to infeasibility", + priority=10**7, + maxrounds=1, + timing=SCIP_PRESOLTIMING.FAST, + ) + + model.presolve() + assert presol.last is not None + infeasible, redundant, aggregated = presol.last + + assert infeasible \ No newline at end of file From 7560e5f9f2c801e751cf2663f09b7cd6520fffd6 Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 18:37:35 +0200 Subject: [PATCH 10/15] Remove Shiftbound presolver tests from test_shiftbound.py --- tests/test_shiftbound.py | 216 --------------------------------------- 1 file changed, 216 deletions(-) delete mode 100644 tests/test_shiftbound.py diff --git a/tests/test_shiftbound.py b/tests/test_shiftbound.py deleted file mode 100644 index 845dbb200..000000000 --- a/tests/test_shiftbound.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -Tests for the Shiftbound presolver. -Parametrised knapsack instances + presolver option combitions. -""" - -import logging -import pytest -from pyscipopt import ( - SCIP_PARAMSETTING, - SCIP_PRESOLTIMING -) -from typing import List, Tuple, Optional -from PySCIPOpt.examples.finished import ShiftboundPresolver, knapsack - -# define a few small, fast instances to exercise different shapes and types -INSTANCES: List[ - Tuple[ - List[int], # item sizes (i.e., constraint vector) - List[int], # item values (i.e., coefficient vector) - List[int], # variable upper bound - List[int], # variable lower bound - int, # capacity constraint value - Optional[List[str]], # variable types - ] -] = [ - # small integer knapsack - ([2, 1, 3], [2, 3, 1], [1, 4, 1], [0, 2, 0], 3, None), - # small integer knapsack (flipped) - ([2, 1, 3], [2, 3, 1], [1, -2, 1], [0, -4, 0], 3, None), - # vtype continuous - ([2, 1, 3], [2, 3, 1], [1, 4, 1], [0, 2, 0], 3, ["C", "C", "C"]), - # vtype integer - ([2, 1, 3], [2, 3, 1], [1, 4, 1], [0, 2, 0], 3, ["I", "I", "I"]), - # above MAXABSBOUND - ([2, 1, 3], [2, 3, 1], [1, 4, 1001], [0, 2, 1000], 3, None), - # no variables to shift - ([2, 1, 3], [2, 3, 1], [1, 2, 1], [0, 0, 0], 3, None), -] - -INSTANCE_IDS = [ - "small-integer", - "small-integer-flipped", - "vtype continuous", - "vtype integer", - "above MAXABSBOUND", - "no variables to shift", -] - -# presolver option combinations; parametrise doNotAggr, maxshift, and -# flipping and integer flags -PRESOLVER_OPTIONS = [ - (True, None, True, True), - (False, 0, True, True), - (False, None, True, True), - (False, None, True, False), - (False, None, False, True), - (False, None, False, False), -] - -# pre-define the amount of variables that should be aggregated for each -# presolver option -EXPECTED_VALUE = [ - # small integer knapsack - (0, 0, 1, 1, 1, 1), - # small integer knapsack (flipped) - (0, 0, 1, 1, 1, 1), - # vtype continuous - (0, 0, 1, 1, 1, 1), - # vtype integer - (0, 0, 1, 1, 1, 1), - # above MAXABSBOUND - (0, 0, 1, 1, 1, 1), - # no variables to shift - (0, 0, 0, 0, 0, 0), -] - -# build explicit (instance, options, expected_value) test cases -TEST_CASES = [] -TEST_IDS = [] -for inst_idx, inst in enumerate(INSTANCES): - for opt_idx, opt in enumerate(PRESOLVER_OPTIONS): - expected_for_inst = EXPECTED_VALUE[inst_idx][opt_idx] - TEST_CASES.append((inst, opt, expected_for_inst)) - TEST_IDS.append(f"{INSTANCE_IDS[inst_idx]}") - -@pytest.fixture -def instance(request): - # get human-readable instance id that comes from `ids=INSTANCE_IDS` - instance_name = None - if ( - hasattr(request, "node") - and hasattr(request.node, "callspec") - and request.node.callspec is not None - ): - instance_name = request.node.callspec.id[:-1] - else: - # fallback when callspec/id is not available - instance_name = "instance-" + str(hash(request.param))[:8] - - sizes, values, ubs, lbs, capacity, vtypes = request.param - model, vars_list = knapsack( - instance_name, sizes, values, ubs, lbs, capacity, vtypes - ) - - # return a tuple so tests can inspect variables easily - try: - yield ( - model, - vars_list, - { - "sizes": sizes, - "values": values, - "ubs": ubs, - "lbs": lbs, - "capacity": capacity, - "vtypes": vtypes, - "instance_name": instance_name, - }, - ) - finally: - # cleanup - try: - model.freeProb() - except Exception: - pass - - -@pytest.mark.parametrize( - "instance, options, expected_value", - TEST_CASES, - ids=TEST_IDS, - indirect=["instance"], -) -def test_shiftbound(instance, options, expected_value): - model, vars_list, meta = instance - doNotAggr, maxshift, flipping, integer = options - - # silence solver output - model.hideOutput() - - # isolate test: disable many automatic presolvers/propagators - model.setSeparating(SCIP_PARAMSETTING.OFF) - model.setHeuristics(SCIP_PARAMSETTING.OFF) - model.disablePropagation() - for key in ( - "presolving/boundshift/maxrounds", - "presolving/domcol/maxrounds", - "presolving/dualsparsify/maxrounds", - "presolving/implics/maxrounds", - "presolving/inttobinary/maxrounds", - "presolving/milp/maxrounds", - "presolving/sparsify/maxrounds", - "presolving/trivial/maxrounds", - "propagating/dualfix/maxprerounds", - "propagating/probing/maxprerounds", - "propagating/symmetry/maxprerounds", - "constraints/linear/maxprerounds", - ): - try: - model.setParam(key, 0) - except Exception: - # parameter might not exist on older/newer SCIP builds; ignore - pass - - if isinstance(doNotAggr, bool): - try: - model.setParam("presolving/donotaggr", doNotAggr) - except Exception: - # parameter might not exist on older/newer SCIP builds; ignore - pass - - # Register and apply custom boundshift presolver - if not (isinstance(maxshift, float) or isinstance(maxshift, int)): - maxshift = float("inf") - presolver = ShiftboundPresolver( - maxshift=maxshift, flipping=flipping, integer=integer - ) - model.includePresol( - presolver, - "shiftbound", - "converts variables with domain [a,b] to variables with domain [0,b-a]", - priority=7900000, - maxrounds=1, - timing=SCIP_PRESOLTIMING.FAST, - ) - # set presolver calls to one (maxrounds=1) to keep tests deterministic - - # run presolve on instance - model.presolve() - if logging.getLogger(__name__).isEnabledFor(logging.DEBUG): - model.printStatistics() - model.printProblem() - model.printProblem(trans=True) - - # count shifted variables created by the presolver (names ending with - # "_shift") - shifted_names = [] - for v in model.getVars(transformed=True): - name = None - if hasattr(v, "name"): - name = v.name - else: - try: - name = v.getName() - except Exception: - name = None - if name.endswith("_shift"): - shifted_names.append(name) - - shifted_count = len(shifted_names) - - assert shifted_count == expected_value, ( - f"expected {expected_value} shifted variables for test " - f'"{meta.get("instance_name")}" ' - f"with options {options}, got {shifted_count}" - ) \ No newline at end of file From 0565bcb6e83df7182b669d8ecc3ab5c8a44bc606 Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 18:48:22 +0200 Subject: [PATCH 11/15] Refactor TODO comment in test_isActive to clarify missing test cases for fixed and aggregated variables --- tests/test_vars.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_vars.py b/tests/test_vars.py index 05b18e8ea..236426dd1 100644 --- a/tests/test_vars.py +++ b/tests/test_vars.py @@ -119,4 +119,6 @@ def test_isActive(): assert x.isActive() m.freeProb() - # TODO lacks tests for cases when returned false due to fixed (probably during probing) or aggregated + # TODO lacks tests for cases when returned false due to + # - fixed (probably during probing) + # - aggregated From 2f02bfae7ccbe062e113d9e4a9b39c259b7ef115 Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 18:53:40 +0200 Subject: [PATCH 12/15] Update CHANGELOG to include new features: isActive(), aggregateVars(), and example shiftbound.py --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index addf21992..a2970e0f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ - Added enableDebugSol() and disableDebugSol() for controlling the debug solution mechanism if DEBUGSOL=true - Added getVarPseudocostScore() and getVarPseudocost() - Added getNBranchings() and getNBranchingsCurrentRun() +- Added isActive() which wraps SCIPvarIsActive() and test +- Added aggregateVars() and tests +- Added example shiftbound.py ### Fixed - Raised an error when an expression is used when a variable is required - Fixed some compile warnings From b3a8d0a16360220223543a18416b0a05a50644e9 Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Fri, 3 Oct 2025 19:22:15 +0200 Subject: [PATCH 13/15] Add tutorial for writing a custom presolver using PySCIPOpt --- docs/tutorials/presolver.rst | 227 +++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/tutorials/presolver.rst diff --git a/docs/tutorials/presolver.rst b/docs/tutorials/presolver.rst new file mode 100644 index 000000000..aab9f319e --- /dev/null +++ b/docs/tutorials/presolver.rst @@ -0,0 +1,227 @@ +########### +Presolvers +########### + +For the following let us assume that a Model object is available, which is created as follows: + +.. code-block:: python + + from pyscipopt import Model, Presol, SCIP_RESULT, SCIP_PRESOLTIMING + + scip = Model() + +.. contents:: Contents +---------------------- + + +What is Presolving? +=================== + +Presolving simplifies a problem before the actual search starts. Typical +transformations include: + +- tightening bounds, +- removing redundant variables/constraints, +- aggregating variables, +- detecting infeasibility early. + +This can reduce numerical issues and simplify constraints and objective +expressions without changing the solution space. + + +The Presol Plugin Interface (Python) +==================================== + +A presolver in PySCIPOpt is a subclass of ``pyscipopt.Presol`` that implements the method: + +- ``presolexec(self, nrounds, presoltiming)`` + +and is registered on a ``pyscipopt.Model`` via +the class method ``pyscipopt.Model.includePresol``. + +Here is a high-level flow: + +1. Subclass ``MyPresolver`` and capture any parameters in ``__init__``. +2. Implement ``presolexec``: inspect variables, compute transformations, call SCIP aggregation APIs, and return a result code. +3. Register your presolver using ``includePresol`` with a priority, maximal rounds, and timing. +4. Solve the model, e.g. by calling ``presolve`` or ``optimize``. + + +A Minimal Skeleton +------------------ + +.. code-block:: python + + from pyscipopt import Presol, SCIP_RESULT + + class MyPresolver(Presol): + def __init__(self, someparam=123): + self.someparam = someparam + + def presolexec(self, nrounds, presoltiming): + scip = self.model + + # ... inspect model, change bounds, aggregate variables, etc. ... + + return {"result": SCIP_RESULT.SUCCESS} # or DIDNOTFIND, DIDNOTRUN, CUTOFF + + +Example: Writing a Custom Presolver +=================================== + +This tutorial shows how to write a presolver entirely in Python using +PySCIPOpt's ``Presol`` plugin interface. We will implement a small +presolver that shifts variable bounds from ``[a, b]`` to ``[0, b - a]`` +and optionally flips signs to reduce constant offsets. + +For educational purposes, we keep our example as close as possible to SCIP's implementation, which can be found `here `__. However, one may implement Boundshift differently as SCIP's logic does not translate perfectly to Python. To avoid any confusion with the already implemented version of Boundshift, we will call our custom presolver *Shiftbound*. + +A complete working example can be found in the directory: + +- ``examples/finished/shiftbound.py`` + + +Implementing Shiftbound +----------------------- + +Below we walk through the important parts to illustrate design decisions to translate the Boundshift presolver to PySCIPOpt. + +We want to provide parameters to control the presolver's behaviour: + +- ``maxshift``: maximum length of interval ``b - a`` we are willing to shift, +- ``flipping``: allow sign flips for better numerics, +- ``integer``: only shift integer-ranged variables if true. + +We will put these parameters into the ``__init__`` method to help us initialise the attributes of the presolver class. Then, in ``presolexec``, we implement the algorithm our custom presolver must follow. + +.. code-block:: python + + import math + from pyscipopt import SCIP_RESULT, Presol + + class ShiftboundPresolver(Presol): + def __init__(self, maxshift=float("inf"), flipping=True, integer=True): + self.maxshift = maxshift + self.flipping = flipping + self.integer = integer + + def presolexec(self, nrounds, presoltiming): + scip = self.model + + # Utility replacements for a few SCIP helpers which are not exposed to PySCIPOpt + # Emulate SCIP's absolute real value + def REALABS(x): return math.fabs(x) + + # Emulate SCIP's "is integral" using the model's epsilon value + def SCIPisIntegral(val): + return val - math.floor(val + scip.epsilon()) <= scip.epsilon() + + # Emulate adjusted bound rounding for integral variables + def SCIPadjustedVarBound(var, val): + if val < 0 and -val >= scip.infinity(): + return -scip.infinity() + if val > 0 and val >= scip.infinity(): + return scip.infinity() + if var.vtype() != "CONTINUOUS": + return scip.feasCeil(val) + if REALABS(val) <= scip.epsilon(): + return 0.0 + return val + + # Respect global presolve switches (here, if aggregation disabled) + if scip.getParam("presolving/donotaggr"): + return {"result": SCIP_RESULT.DIDNOTRUN} + + # We want to operate on non-binary active variables only + scipvars = scip.getVars() + nbin = scip.getNBinVars() + vars = scipvars[nbin:] # SCIP orders by type: binaries first + + result = SCIP_RESULT.DIDNOTFIND + + for var in reversed(vars): + if var.vtype() == "BINARY": + continue + if not var.isActive(): + continue + + lb = var.getLbGlobal() + ub = var.getUbGlobal() + + # For integral types: round to feasible integers to avoid noise + if var.vtype() != "CONTINUOUS": + assert SCIPisIntegral(lb) + assert SCIPisIntegral(ub) + lb = SCIPadjustedVarBound(var, lb) + ub = SCIPadjustedVarBound(var, ub) + + # Is the variable already fixed? + if scip.isEQ(lb, ub): + continue + + # If demanded by the parameters, restrict to integral-length intervals + if self.integer and not SCIPisIntegral(ub - lb): + continue + + # Only shift "reasonable" finite bounds + MAXABSBOUND = 1000.0 + shiftable = all(( + not scip.isEQ(lb, 0.0), + scip.isLT(ub, scip.infinity()), + scip.isGT(lb, -scip.infinity()), + scip.isLT(ub - lb, self.maxshift), + scip.isLE(REALABS(lb), MAXABSBOUND), + scip.isLE(REALABS(ub), MAXABSBOUND), + )) + if not shiftable: + continue + + # Create a new variable y with bounds [0, ub-lb], and same type + newvar = scip.addVar( + name=f"{var.name}_shift", + vtype=var.vtype(), + lb=0.0, + ub=(ub - lb), + obj=0.0, + ) + + # Aggregate old variable with new variable: + # x = y + lb (no flip), or + # x = -y + ub (flip), whichever yields smaller |offset| + if self.flipping and abs(ub) < abs(lb): + infeasible, redundant, aggregated = scip.aggregateVars(var, newvar, 1.0, 1.0, ub) + else: + infeasible, redundant, aggregated = scip.aggregateVars(var, newvar, 1.0, -1.0, lb) + + # Has the problem become infeasible? + if infeasible: + return {"result": SCIP_RESULT.CUTOFF} + + # Aggregation succeeded; SCIP marks x as redundant and keeps y for further search + assert redundant + assert aggregated + result = SCIP_RESULT.SUCCESS + + return {"result": result} + +Registering the Presolver +------------------------- + +After having initialised our ``model``, we instantiate an object based on our ``ShiftboundPresolver`` including the parameters we wish our presolver's behaviour to be set to. +Lastly, we register the custom presolver by including ``presolver``, followed by a name and a description, as well as specifying its priority, maximum rounds to be called (where ``-1`` specifies no limit), and timing mode. + +.. code-block:: python + + from pyscipopt import Model, SCIP_PRESOLTIMING, SCIP_PARAMSETTING + + model = Model() + + presolver = ShiftboundPresolver(maxshift=float("inf"), flipping=True, integer=True) + model.includePresol( + presolver, + "shiftbound", + "converts variables with domain [a,b] to variables with domain [0,b-a]", + priority=7900000, + maxrounds=-1, + timing=SCIP_PRESOLTIMING.FAST, + ) From 5ce2777bc7572978534debd7973fe9a25fa2fe30 Mon Sep 17 00:00:00 2001 From: fvz185 <> Date: Sat, 4 Oct 2025 16:04:15 +0200 Subject: [PATCH 14/15] Add tutorial for presolver plugin to CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2970e0f1..1257d32b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Added isActive() which wraps SCIPvarIsActive() and test - Added aggregateVars() and tests - Added example shiftbound.py +- Added a tutorial in ./docs on the presolver plugin ### Fixed - Raised an error when an expression is used when a variable is required - Fixed some compile warnings From 3b2ac0ccee37c449814f70b6889b7823c16d74f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dion=C3=ADsio?= <57299939+Joao-Dionisio@users.noreply.github.com> Date: Sun, 5 Oct 2025 01:16:09 +0100 Subject: [PATCH 15/15] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/finished/shiftbound.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/finished/shiftbound.py b/examples/finished/shiftbound.py index 34cc0cd71..7a29e13fa 100644 --- a/examples/finished/shiftbound.py +++ b/examples/finished/shiftbound.py @@ -111,7 +111,7 @@ def SCIPadjustedVarBound(var, val): # aggregation that might trigger assertions when updating bounds of # aggregated variables (floating-point rounding errors). # check if variable is integer - if var.vtype != "CONTINUOUS": + if var.vtype() != "CONTINUOUS": # assert if bounds are integral assert SCIPisIntegral(lb) assert SCIPisIntegral(ub) @@ -254,7 +254,6 @@ def knapsack( instance_name, sizes, values, upper_bounds, lower_bounds, capacity ) - model = Model() # isolate test: disable many automatic presolvers/propagators model.setSeparating(SCIP_PARAMSETTING.OFF)