diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e203885c..5b78fd91c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,10 @@ - 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 +- 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 diff --git a/docs/tutorials/presolver.rst b/docs/tutorials/presolver.rst new file mode 100644 index 000000000..4b98be0f8 --- /dev/null +++ b/docs/tutorials/presolver.rst @@ -0,0 +1,206 @@ +########### +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. Create 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/presol_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 + + 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 + + # 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 scip.isIntegral(lb) + assert scip.isIntegral(ub) + lb = scip.adjustedVarLb(var, lb) + ub = scip.adjustedVarUb(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 scip.isIntegral(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(abs(lb), MAXABSBOUND), + scip.isLE(abs(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: + # 1.0 * var + 1.0 * newvar = ub (flip), whichever yields smaller |offset|, or + # 1.0 * var + (-1.0) * newvar = lb (no flip) + 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 var as redundant and keeps newvar 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, + ) diff --git a/examples/finished/presol_shiftbound.py b/examples/finished/presol_shiftbound.py new file mode 100644 index 000000000..c82438922 --- /dev/null +++ b/examples/finished/presol_shiftbound.py @@ -0,0 +1,261 @@ +""" +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 using PySCIPOpt. +A simple knapsack problem was chosen to let the presolver plugin +operate on. +""" + +from pyscipopt import ( + Model, + 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 + + # 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 scip.isIntegral(lb) + assert scip.isIntegral(ub) + + # round the bound values for integral variables + lb = scip.adjustedVarLb(var, lb) + ub = scip.adjustedVarUb(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 scip.isIntegral(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(abs(lb), MAXABSBOUND), + scip.isLE(abs(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 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 + ) + 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], + 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, None + ) + + + # 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/sparsify/maxrounds", + "presolving/trivial/maxrounds", + "propagating/dualfix/maxprerounds", + "propagating/probing/maxprerounds", + "propagating/symmetry/maxprerounds", + "constraints/linear/maxprerounds", + ): + 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 diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index b9bffc1d6..48eb60731 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -870,6 +870,18 @@ 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) + SCIP_Real SCIPadjustedVarLb(SCIP* scip, SCIP_VAR* var, SCIP_Real lb) + SCIP_Real SCIPadjustedVarUb(SCIP* scip, SCIP_VAR* var, SCIP_Real ub) + 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 c4e11ebcb..1f6c05191 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -1870,6 +1870,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. @@ -3569,6 +3579,62 @@ cdef class Model: """ return SCIPisFeasIntegral(self._scip, value) + def isIntegral(self, value): + """ + Returns whether value is integral within epsilon tolerance. + + Parameters + ---------- + value : float + value to check + + Returns + ------- + bool + + """ + return SCIPisIntegral(self._scip, value) + + def adjustedVarLb(self, Variable var, lb): + """ + Returns the adjusted (i.e. rounded, if the given variable is of integral type) lower bound value; + does not change the bounds of the variable. + + Parameters + ---------- + var : Variable + variable for which the bound is adjusted + lb : float + lower bound value to adjust + + Returns + ------- + float + adjusted lower bound + + """ + return SCIPadjustedVarLb(self._scip, var.scip_var, lb) + + def adjustedVarUb(self, Variable var, ub): + """ + Returns the adjusted (i.e. rounded, if the given variable is of integral type) upper bound value; + does not change the bounds of the variable. + + Parameters + ---------- + var : Variable + variable for which the bound is adjusted + ub : float + upper bound value to adjust + + Returns + ------- + float + adjusted upper bound + + """ + return SCIPadjustedVarUb(self._scip, var.scip_var, ub) + def isEQ(self, val1, val2): """ Checks, if values are in range of epsilon. @@ -4454,6 +4520,66 @@ cdef class Model: PY_SCIP_CALL(SCIPdelVar(self._scip, var.scip_var, &deleted)) return deleted + def aggregateVars(self, Variable varx, Variable vary, coefx=1.0, coefy=-1.0, rhs=0.0): + """ + Aggregate two variables by adding an aggregation constraint. + + The aggregation is defined by the linear equation: + + coefx * varx + coefy * vary = rhs + + After aggregation, varx becomes a redundant variable and vary remains active. + The aggregation effectively substitutes varx with: (rhs - coefy * vary) / coefx + + This method can only be called during presolving. + + Parameters + ---------- + varx : Variable + variable to be aggregated (will become redundant) + vary : Variable + variable to aggregate with (will remain active) + coefx : float, optional + coefficient for varx in the aggregation equation (default: 1.0) + coefy : float, optional + coefficient for vary in the aggregation equation (default: -1.0) + rhs : float, optional + right-hand side of the aggregation equation (default: 0.0) + + Returns + ------- + infeasible : bool + whether the aggregation is infeasible (e.g., bounds are incompatible) + redundant : bool + whether the aggregation makes varx redundant + aggregated : bool + whether the aggregation was actually performed + + Examples + -------- + To express x = y (i.e., 1*x + (-1)*y = 0): + + infeas, redun, aggr = model.aggregateVars(x, y, 1.0, -1.0, 0.0) + + To express x = 5 - y (i.e., 1*x + 1*y = 5): + + infeas, redun, aggr = model.aggregateVars(x, y, 1.0, 1.0, 5.0) + + """ + cdef SCIP_Bool infeasible + cdef SCIP_Bool redundant + cdef SCIP_Bool aggregated + PY_SCIP_CALL(SCIPaggregateVars(self._scip, + varx.scip_var, + vary.scip_var, + coefx, + coefy, + 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. diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 1e703c006..2d0efd41d 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -844,6 +844,16 @@ class Model: def addVarToRow( self, row: Incomplete, var: Incomplete, value: Incomplete ) -> Incomplete: ... + def adjustedVarLb(self, var: Variable, lb: float) -> float: ... + def adjustedVarUb(self, var: Variable, ub: float) -> float: ... + def aggregateVars( + self, + varx: Variable, + vary: Variable, + coefx: float = ..., + coefy: float = ..., + rhs: float = ..., + ) -> tuple[bool, bool, bool]: ... def allColsInLP(self) -> Incomplete: ... def allowNegSlackExact(self) -> Incomplete: ... def appendVarSOS1(self, cons: Incomplete, var: Incomplete) -> Incomplete: ... @@ -1377,6 +1387,7 @@ class Model: def isGT(self, val1: Incomplete, val2: Incomplete) -> Incomplete: ... def isHugeValue(self, val: Incomplete) -> Incomplete: ... def isInfinity(self, value: Incomplete) -> Incomplete: ... + def isIntegral(self, value: float) -> bool: ... def isLE(self, val1: Incomplete, val2: Incomplete) -> Incomplete: ... def isLPSolBasic(self) -> Incomplete: ... def isLT(self, val1: Incomplete, val2: Incomplete) -> Incomplete: ... diff --git a/tests/test_aggregate_vars.py b/tests/test_aggregate_vars.py new file mode 100644 index 000000000..cd7f634ef --- /dev/null +++ b/tests/test_aggregate_vars.py @@ -0,0 +1,100 @@ +""" +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, coefx, coefy, rhs): + self._args = (varx, vary, coefx, coefy, 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. + + After aggregation x = y, the model has only one active variable (y). + """ + 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 + assert redundant # x should be marked as redundant + + # model should stay consistent and solve optimally + model.optimize() + assert model.getStatus() == "optimal" + + +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 diff --git a/tests/test_vars.py b/tests/test_vars.py index d142413bc..43f71586d 100644 --- a/tests/test_vars.py +++ b/tests/test_vars.py @@ -121,6 +121,17 @@ def test_getNBranchingsCurrentRun(): 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) + # - aggregated + def test_markDoNotAggrVar_and_getStatus(): model = Model() x = model.addVar("x", obj=2, lb=0, ub=10) @@ -152,4 +163,64 @@ def test_markDoNotAggrVar_and_getStatus(): assert model.getTransformedVar(z).getStatus() != "AGGREGATED" assert model.getNVars(True) == 4 - assert x.getStatus() == "ORIGINAL" \ No newline at end of file + assert x.getStatus() == "ORIGINAL" + + +def test_isIntegral(): + """Test that Model.isIntegral correctly identifies integral values.""" + m = Model() + + # Exact integer values should be integral + assert m.isIntegral(5.0) + assert m.isIntegral(0.0) + assert m.isIntegral(-3.0) + + # Values very close to integers (within epsilon) should be integral + eps = m.epsilon() + assert m.isIntegral(5.0 + eps / 2) + assert m.isIntegral(5.0 - eps / 2) + + # Non-integer values should not be integral + assert not m.isIntegral(5.5) + assert not m.isIntegral(0.1) + assert not m.isIntegral(-3.7) + + +def test_adjustedVarLb(): + """Test that Model.adjustedVarLb correctly rounds bounds for integer variables.""" + m = Model() + + # For integer variables, lower bounds should be rounded up (ceiling) + x_int = m.addVar(vtype='I', lb=-10.0, ub=10.0, name="x_int") + # 2.3 should be adjusted to 3.0 for lower bound of integer + assert m.adjustedVarLb(x_int, 2.3) == 3.0 + # -2.3 should be adjusted to -2.0 for lower bound of integer + assert m.adjustedVarLb(x_int, -2.3) == -2.0 + # Exact integer should stay the same + assert m.adjustedVarLb(x_int, 5.0) == 5.0 + + # For continuous variables, values within epsilon of zero should be set to 0 + x_cont = m.addVar(vtype='C', lb=-10.0, ub=10.0, name="x_cont") + eps = m.epsilon() + assert m.adjustedVarLb(x_cont, eps / 2) == 0.0 + # Non-zero values should stay the same + assert m.adjustedVarLb(x_cont, 5.5) == 5.5 + + +def test_adjustedVarUb(): + """Test that Model.adjustedVarUb correctly rounds bounds for integer variables.""" + m = Model() + + # For integer variables, upper bounds should be rounded down (floor) + x_int = m.addVar(vtype='I', lb=-10.0, ub=10.0, name="x_int") + # 2.7 should be adjusted to 2.0 for upper bound of integer + assert m.adjustedVarUb(x_int, 2.7) == 2.0 + # -2.7 should be adjusted to -3.0 for upper bound of integer + assert m.adjustedVarUb(x_int, -2.7) == -3.0 + # Exact integer should stay the same + assert m.adjustedVarUb(x_int, 5.0) == 5.0 + + # For continuous variables, values should generally stay the same + x_cont = m.addVar(vtype='C', lb=-10.0, ub=10.0, name="x_cont") + assert m.adjustedVarUb(x_cont, 5.5) == 5.5 + assert m.adjustedVarUb(x_cont, -3.2) == -3.2 \ No newline at end of file