diff --git a/.github/workflows/test_branches.yml b/.github/workflows/test_branches.yml index 4b25fd858e7..0efc65bba56 100644 --- a/.github/workflows/test_branches.yml +++ b/.github/workflows/test_branches.yml @@ -292,6 +292,12 @@ jobs: || echo "WARNING: Xpress Community Edition is not available" python -m pip install --cache-dir cache/pip maingopy \ || echo "WARNING: MAiNGO is not available" + if [[ ${{matrix.python}} == pypy* ]]; then + echo "skipping SCIP for pypy" + else + python -m pip install --cache-dir cache/pip pyscipopt \ + || echo "WARNING: SCIP is not available" + fi if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else @@ -373,7 +379,7 @@ jobs: if test -z "${{matrix.slim}}"; then PYVER=$(echo "py${{matrix.python}}" | sed 's/\.//g') echo "Installing for $PYVER" - for PKG in 'cplex>=12.10' docplex gurobi xpress cyipopt pymumps scip; do + for PKG in 'cplex>=12.10' docplex gurobi xpress cyipopt pymumps scip pyscipopt; do echo "" echo "*** Install $PKG ***" # conda can literally take an hour to determine that a diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index 19174ea42c8..8f05e1f635e 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -324,6 +324,12 @@ jobs: || echo "WARNING: Xpress Community Edition is not available" python -m pip install --cache-dir cache/pip maingopy \ || echo "WARNING: MAiNGO is not available" + if [[ ${{matrix.python}} == pypy* ]]; then + echo "skipping SCIP for pypy" + else + python -m pip install --cache-dir cache/pip pyscipopt \ + || echo "WARNING: SCIP is not available" + fi if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else @@ -405,7 +411,7 @@ jobs: if test -z "${{matrix.slim}}"; then PYVER=$(echo "py${{matrix.python}}" | sed 's/\.//g') echo "Installing for $PYVER" - for PKG in 'cplex>=12.10' docplex gurobi xpress cyipopt pymumps scip; do + for PKG in 'cplex>=12.10' docplex gurobi xpress cyipopt pymumps scip pyscipopt; do echo "" echo "*** Install $PKG ***" # conda can literally take an hour to determine that a diff --git a/doc/OnlineDocs/reference/topical/solvers/index.rst b/doc/OnlineDocs/reference/topical/solvers/index.rst index 400032df076..628f9cfdab0 100644 --- a/doc/OnlineDocs/reference/topical/solvers/index.rst +++ b/doc/OnlineDocs/reference/topical/solvers/index.rst @@ -9,3 +9,4 @@ Solver Interfaces gurobi_direct.rst gurobi_persistent.rst xpress_persistent.rst + scip_persistent.rst diff --git a/doc/OnlineDocs/reference/topical/solvers/scip_persistent.rst b/doc/OnlineDocs/reference/topical/solvers/scip_persistent.rst new file mode 100644 index 00000000000..63ed55b74e3 --- /dev/null +++ b/doc/OnlineDocs/reference/topical/solvers/scip_persistent.rst @@ -0,0 +1,7 @@ +SCIPPersistent +================ + +.. autoclass:: pyomo.solvers.plugins.solvers.scip_persistent.SCIPPersistent + :members: + :inherited-members: + :show-inheritance: \ No newline at end of file diff --git a/pyomo/solvers/plugins/solvers/__init__.py b/pyomo/solvers/plugins/solvers/__init__.py index 61f92180abc..db3de8a0f18 100644 --- a/pyomo/solvers/plugins/solvers/__init__.py +++ b/pyomo/solvers/plugins/solvers/__init__.py @@ -31,5 +31,7 @@ mosek_persistent, xpress_direct, xpress_persistent, + scip_direct, + scip_persistent, SAS, ) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py new file mode 100644 index 00000000000..89dd25b86ee --- /dev/null +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -0,0 +1,769 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging +import sys + +from pyomo.common.collections import ComponentSet, ComponentMap, Bunch +from pyomo.common.tempfiles import TempfileManager +from pyomo.core import Var +from pyomo.core.expr.numeric_expr import ( + SumExpression, + ProductExpression, + UnaryFunctionExpression, + PowExpression, + DivisionExpression, +) +from pyomo.core.expr.numvalue import is_fixed +from pyomo.core.expr.numvalue import value +from pyomo.core.staleflag import StaleFlagManager +from pyomo.repn import generate_standard_repn +from pyomo.solvers.plugins.solvers.direct_solver import DirectSolver +from pyomo.solvers.plugins.solvers.direct_or_persistent_solver import ( + DirectOrPersistentSolver, +) +from pyomo.core.kernel.objective import minimize, maximize +from pyomo.opt.results.results_ import SolverResults +from pyomo.opt.results.solution import Solution, SolutionStatus +from pyomo.opt.results.solver import TerminationCondition, SolverStatus +from pyomo.opt.base import SolverFactory + + +logger = logging.getLogger("pyomo.solvers") + + +class DegreeError(ValueError): + pass + + +def _is_numeric(x): + try: + float(x) + except ValueError: + return False + return True + + +@SolverFactory.register("scip_direct", doc="Direct python interface to SCIP") +class SCIPDirect(DirectSolver): + + def __init__(self, **kwds): + kwds["type"] = "scipdirect" + DirectSolver.__init__(self, **kwds) + self._init() + self._solver_model = None + + def _init(self): + try: + import pyscipopt + + self._scip = pyscipopt + self._python_api_exists = True + self._version = tuple( + int(k) for k in str(self._scip.Model().version()).split(".") + ) + self._version_major = self._version[0] + except ImportError: + self._python_api_exists = False + except Exception as e: + print(f"Import of pyscipopt failed - SCIP message={str(e)}\n") + self._python_api_exists = False + + # Note: Undefined capabilities default to None + self._max_constraint_degree = None + self._max_obj_degree = 1 + self._capabilities.linear = True + self._capabilities.quadratic_objective = False + self._capabilities.quadratic_constraint = True + self._capabilities.integer = True + self._capabilities.sos1 = True + self._capabilities.sos2 = True + self._skip_trivial_constraints = True + + # Dictionary used exclusively for SCIP, as we want the constraint expressions + self._pyomo_var_to_solver_var_expr_map = ComponentMap() + self._pyomo_con_to_solver_con_expr_map = dict() + + def _apply_solver(self): + StaleFlagManager.mark_all_as_stale() + + # Suppress solver output if requested + if self._tee: + self._solver_model.hideOutput(quiet=False) + else: + self._solver_model.hideOutput(quiet=True) + + # Redirect solver output to a logfile if requested + if self._keepfiles: + # Only save log file when the user wants to keep it. + self._solver_model.setLogfile(self._log_file) + print(f"Solver log file: {self._log_file}") + + # Set user specified parameters + for key, option in self.options.items(): + try: + key_type = type(self._solver_model.getParam(key)) + except KeyError: + raise ValueError(f"Key {key} is an invalid parameter for SCIP") + + if key_type == str: + self._solver_model.setParam(key, option) + else: + if not _is_numeric(option): + raise ValueError( + f"Value {option} for parameter {key} is not a string and can't be converted to float" + ) + self._solver_model.setParam(key, float(option)) + + self._solver_model.optimize() + + # TODO: Check if this is even needed, or if it is sufficient to close the open file + # if self._keepfiles: + # self._solver_model.setLogfile(None) + + # FIXME: can we get a return code indicating if SCIP had a significant failure? + return Bunch(rc=None, log=None) + + def _get_expr_from_pyomo_repn(self, repn, max_degree=None): + referenced_vars = ComponentSet() + + degree = repn.polynomial_degree() + if (max_degree is not None) and (degree > max_degree): + raise DegreeError( + "While SCIP supports general non-linear constraints, the objective must be linear. " + "Please reformulate the objective by introducing a new variable. " + "For min problems: min z s.t z >= f(x). For max problems: max z s.t z <= f(x). " + "f(x) is the original non-linear objective." + ) + + new_expr = repn.constant + + if len(repn.linear_vars) > 0: + referenced_vars.update(repn.linear_vars) + new_expr += sum( + repn.linear_coefs[i] * self._pyomo_var_to_solver_var_expr_map[var] + for i, var in enumerate(repn.linear_vars) + ) + + for i, v in enumerate(repn.quadratic_vars): + x, y = v + new_expr += ( + repn.quadratic_coefs[i] + * self._pyomo_var_to_solver_var_expr_map[x] + * self._pyomo_var_to_solver_var_expr_map[y] + ) + referenced_vars.add(x) + referenced_vars.add(y) + + if repn.nonlinear_expr is not None: + + def get_nl_expr_recursively(pyomo_expr): + if not hasattr(pyomo_expr, "args"): + if not isinstance(pyomo_expr, Var): + return float(pyomo_expr) + else: + referenced_vars.add(pyomo_expr) + return self._pyomo_var_to_solver_var_expr_map[pyomo_expr] + scip_expr_list = [0 for i in range(pyomo_expr.nargs())] + for i in range(pyomo_expr.nargs()): + scip_expr_list[i] = get_nl_expr_recursively(pyomo_expr.args[i]) + if isinstance(pyomo_expr, PowExpression): + if len(scip_expr_list) != 2: + raise ValueError( + f"PowExpression has {len(scip_expr_list)} many terms instead of two!" + ) + return scip_expr_list[0] ** (scip_expr_list[1]) + elif isinstance(pyomo_expr, ProductExpression): + return self._scip.quickprod(scip_expr_list) + elif isinstance(pyomo_expr, SumExpression): + return self._scip.quicksum(scip_expr_list) + elif isinstance(pyomo_expr, DivisionExpression): + if len(scip_expr_list) != 2: + raise ValueError( + f"DivisionExpression has {len(scip_expr_list)} many terms instead of two!" + ) + return scip_expr_list[0] / scip_expr_list[1] + elif isinstance(pyomo_expr, UnaryFunctionExpression): + if len(scip_expr_list) != 1: + raise ValueError( + f"UnaryExpression has {len(scip_expr_list)} many terms instead of one!" + ) + if pyomo_expr.name == "sin": + return self._scip.sin(scip_expr_list[0]) + elif pyomo_expr.name == "cos": + return self._scip.cos(scip_expr_list[0]) + elif pyomo_expr.name == "exp": + return self._scip.exp(scip_expr_list[0]) + elif pyomo_expr.name == "log": + return self._scip.log(scip_expr_list[0]) + else: + raise NotImplementedError( + f"PySCIPOpt through Pyomo does not support the unary function {pyomo_expr.name}" + ) + else: + raise NotImplementedError( + f"PySCIPOpt through Pyomo does not yet support expression type {type(pyomo_expr)}" + ) + + new_expr += get_nl_expr_recursively(repn.nonlinear_expr) + + return new_expr, referenced_vars + + def _get_expr_from_pyomo_expr(self, expr, max_degree=None): + if max_degree is None or max_degree >= 2: + repn = generate_standard_repn(expr, quadratic=True) + else: + repn = generate_standard_repn(expr, quadratic=False) + + scip_expr, referenced_vars = self._get_expr_from_pyomo_repn(repn, max_degree) + + return scip_expr, referenced_vars + + def _scip_lb_ub_from_var(self, var): + if var.is_fixed(): + val = var.value + return val, val + if var.has_lb(): + lb = value(var.lb) + else: + lb = -self._solver_model.infinity() + if var.has_ub(): + ub = value(var.ub) + else: + ub = self._solver_model.infinity() + + return lb, ub + + def _add_var(self, var): + varname = self._symbol_map.getSymbol(var, self._labeler) + vtype = self._scip_vtype_from_var(var) + lb, ub = self._scip_lb_ub_from_var(var) + + scip_var = self._solver_model.addVar(lb=lb, ub=ub, vtype=vtype, name=varname) + + self._pyomo_var_to_solver_var_expr_map[var] = scip_var + self._pyomo_var_to_solver_var_map[var] = scip_var.name + self._solver_var_to_pyomo_var_map[varname] = var + self._referenced_variables[var] = 0 + + def close(self): + """Frees SCIP resources used by this solver instance.""" + + if self._solver_model is not None: + self._solver_model.freeProb() + self._solver_model = None + + def __exit__(self, t, v, traceback): + super().__exit__(t, v, traceback) + self.close() + + def _set_instance(self, model, kwds={}): + DirectOrPersistentSolver._set_instance(self, model, kwds) + self.available() + try: + self._solver_model = self._scip.Model() + except Exception: + e = sys.exc_info()[1] + msg = ( + "Unable to create SCIP model. " + f"Have you installed PySCIPOpt correctly?\n\n\t Error message: {e}" + ) + raise Exception(msg) + + self._add_block(model) + + for var, n_ref in self._referenced_variables.items(): + if n_ref != 0: + if var.fixed: + if not self._output_fixed_variable_bounds: + raise ValueError( + f"Encountered a fixed variable {var.name} inside " + "an active objective or constraint " + f"expression on model {self._pyomo_model.name}, which is usually " + "indicative of a preprocessing error. Use " + "the IO-option 'output_fixed_variable_bounds=True' " + "to suppress this error and fix the variable " + "by overwriting its bounds in the SCIP instance." + ) + + def _add_block(self, block): + DirectOrPersistentSolver._add_block(self, block) + + def _add_constraint(self, con): + if not con.active: + return None + + if is_fixed(con.body) and self._skip_trivial_constraints: + return None + + conname = self._symbol_map.getSymbol(con, self._labeler) + + if con._linear_canonical_form: + scip_expr, referenced_vars = self._get_expr_from_pyomo_repn( + con.canonical_form(), self._max_constraint_degree + ) + else: + scip_expr, referenced_vars = self._get_expr_from_pyomo_expr( + con.body, self._max_constraint_degree + ) + + if con.has_lb(): + if not is_fixed(con.lower): + raise ValueError(f"Lower bound of constraint {con} is not constant.") + con_lower = value(con.lower) + if type(con_lower) != float and type(con_lower) != int: + logger.warning( + f"Constraint {conname} has LHS type {type(value(con.lower))}. " + f"Converting to float as type is not allowed for SCIP." + ) + con_lower = float(con_lower) + if con.has_ub(): + if not is_fixed(con.upper): + raise ValueError(f"Upper bound of constraint {con} is not constant.") + con_upper = value(con.upper) + if type(con_upper) != float and type(con_upper) != int: + logger.warning( + f"Constraint {conname} has RHS type {type(value(con.upper))}. " + f"Converting to float as type is not allowed for SCIP." + ) + con_upper = float(con_upper) + + if con.equality: + scip_cons = self._solver_model.addCons(scip_expr == con_lower, name=conname) + elif con.has_lb() and con.has_ub(): + scip_cons = self._solver_model.addCons(con_lower <= scip_expr, name=conname) + rhs = con_upper + if hasattr(con.body, "constant"): + con_constant = value(con.body.constant) + if not isinstance(con_constant, (float, int)): + con_constant = float(con_constant) + rhs -= con_constant + self._solver_model.chgRhs(scip_cons, rhs) + elif con.has_lb(): + scip_cons = self._solver_model.addCons(con_lower <= scip_expr, name=conname) + elif con.has_ub(): + scip_cons = self._solver_model.addCons(scip_expr <= con_upper, name=conname) + else: + raise ValueError( + f"Constraint does not have a lower or an upper bound: {con} \n" + ) + + for var in referenced_vars: + self._referenced_variables[var] += 1 + self._vars_referenced_by_con[con] = referenced_vars + self._pyomo_con_to_solver_con_expr_map[con] = scip_cons + self._pyomo_con_to_solver_con_map[con] = scip_cons.name + self._solver_con_to_pyomo_con_map[conname] = con + + def _add_sos_constraint(self, con): + if not con.active: + return None + + conname = self._symbol_map.getSymbol(con, self._labeler) + level = con.level + if level not in [1, 2]: + raise ValueError(f"Solver does not support SOS level {level} constraints") + + scip_vars = [] + weights = [] + + self._vars_referenced_by_con[con] = ComponentSet() + + if hasattr(con, "get_items"): + # aml sos constraint + sos_items = list(con.get_items()) + else: + # kernel sos constraint + sos_items = list(con.items()) + + for v, w in sos_items: + self._vars_referenced_by_con[con].add(v) + scip_vars.append(self._pyomo_var_to_solver_var_expr_map[v]) + self._referenced_variables[v] += 1 + weights.append(w) + + if level == 1: + scip_cons = self._solver_model.addConsSOS1( + scip_vars, weights=weights, name=conname + ) + else: + scip_cons = self._solver_model.addConsSOS2( + scip_vars, weights=weights, name=conname + ) + self._pyomo_con_to_solver_con_expr_map[con] = scip_cons + self._pyomo_con_to_solver_con_map[con] = scip_cons.name + self._solver_con_to_pyomo_con_map[conname] = con + + def _scip_vtype_from_var(self, var): + """ + This function takes a pyomo variable and returns the appropriate SCIP variable type + + Parameters + ---------- + var: pyomo.core.base.var.Var + The pyomo variable that we want to retrieve the SCIP vtype of + + Returns + ------- + vtype: str + B for Binary, I for Integer, or C for Continuous + """ + if var.is_binary(): + vtype = "B" + elif var.is_integer(): + vtype = "I" + elif var.is_continuous(): + vtype = "C" + else: + raise ValueError(f"Variable domain type is not recognized for {var.domain}") + return vtype + + def _set_objective(self, obj): + if self._objective is not None: + for var in self._vars_referenced_by_obj: + self._referenced_variables[var] -= 1 + self._vars_referenced_by_obj = ComponentSet() + self._objective = None + + if obj.active is False: + raise ValueError("Cannot add inactive objective to solver.") + + if obj.sense == minimize: + sense = "minimize" + elif obj.sense == maximize: + sense = "maximize" + else: + raise ValueError(f"Objective sense is not recognized: {obj.sense}") + + scip_expr, referenced_vars = self._get_expr_from_pyomo_expr( + obj.expr, self._max_obj_degree + ) + + for var in referenced_vars: + self._referenced_variables[var] += 1 + + self._solver_model.setObjective(scip_expr, sense=sense) + self._objective = obj + self._vars_referenced_by_obj = referenced_vars + + def _get_solver_solution_status(self, scip, soln): + """ """ + # Get the status of the SCIP Model currently + status = scip.getStatus() + + # Go through each potential case and update appropriately + if scip.getStage() == 1: # SCIP Model is created but not yet optimized + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_message = ( + "Model is loaded, but no solution information is available." + ) + self.results.solver.termination_condition = TerminationCondition.error + soln.status = SolutionStatus.unknown + elif status == "optimal": # optimal + self.results.solver.status = SolverStatus.ok + self.results.solver.termination_message = ( + "Model was solved to optimality (subject to tolerances), " + "and an optimal solution is available." + ) + self.results.solver.termination_condition = TerminationCondition.optimal + soln.status = SolutionStatus.optimal + elif status == "infeasible": + self.results.solver.status = SolverStatus.warning + self.results.solver.termination_message = ( + "Model was proven to be infeasible" + ) + self.results.solver.termination_condition = TerminationCondition.infeasible + soln.status = SolutionStatus.infeasible + elif status == "inforunbd": + self.results.solver.status = SolverStatus.warning + self.results.solver.termination_message = ( + "Problem proven to be infeasible or unbounded." + ) + self.results.solver.termination_condition = ( + TerminationCondition.infeasibleOrUnbounded + ) + soln.status = SolutionStatus.unsure + elif status == "unbounded": + self.results.solver.status = SolverStatus.warning + self.results.solver.termination_message = ( + "Model was proven to be unbounded." + ) + self.results.solver.termination_condition = TerminationCondition.unbounded + soln.status = SolutionStatus.unbounded + elif status == "gaplimit": + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_message = ( + "Optimization terminated because the gap dropped below " + "the value specified in the " + "limits/gap parameter." + ) + self.results.solver.termination_condition = TerminationCondition.unknown + soln.status = SolutionStatus.stoppedByLimit + elif status == "stallnodelimit": + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_message = ( + "Optimization terminated because the stalling node limit " + "exceeded the value specified in the " + "limits/stallnodes parameter." + ) + self.results.solver.termination_condition = TerminationCondition.unknown + soln.status = SolutionStatus.stoppedByLimit + elif status == "restartlimit": + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_message = ( + "Optimization terminated because the total number of restarts " + "exceeded the value specified in the " + "limits/restarts parameter." + ) + self.results.solver.termination_condition = TerminationCondition.unknown + soln.status = SolutionStatus.stoppedByLimit + elif status == "nodelimit" or status == "totalnodelimit": + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_message = ( + "Optimization terminated because the number of " + "branch-and-cut nodes explored exceeded the limits specified " + "in the limits/nodes or limits/totalnodes parameter" + ) + self.results.solver.termination_condition = ( + TerminationCondition.maxEvaluations + ) + soln.status = SolutionStatus.stoppedByLimit + elif status == "timelimit": + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_message = ( + "Optimization terminated because the time expended exceeded " + "the value specified in the limits/time parameter." + ) + self.results.solver.termination_condition = ( + TerminationCondition.maxTimeLimit + ) + soln.status = SolutionStatus.stoppedByLimit + elif status == "sollimit" or status == "bestsollimit": + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_message = ( + "Optimization terminated because the number of solutions found " + "reached the value specified in the limits/solutions or" + "limits/bestsol parameter." + ) + self.results.solver.termination_condition = TerminationCondition.unknown + soln.status = SolutionStatus.stoppedByLimit + elif status == "memlimit": + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_message = ( + "Optimization terminated because the memory used exceeded " + "the value specified in the limits/memory parameter." + ) + self.results.solver.termination_condition = TerminationCondition.unknown + soln.status = SolutionStatus.stoppedByLimit + elif status == "userinterrupt": + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_message = ( + "Optimization was terminated by the user." + ) + self.results.solver.termination_condition = TerminationCondition.error + soln.status = SolutionStatus.error + else: + self.results.solver.status = SolverStatus.error + self.results.solver.termination_message = ( + f"Unhandled SCIP status ({str(status)})" + ) + self.results.solver.termination_condition = TerminationCondition.error + soln.status = SolutionStatus.error + return soln + + def _postsolve(self): + # Constraint duals and variable + # reduced-costs were removed as in SCIP they contain + # too many caveats. Slacks were removed as later + # planned interfaces do not intend to support. + # Scan through the solver suffix list + # and throw an exception if the user has specified + # any others. + for suffix in self._suffixes: + raise RuntimeError( + f"***The scip_direct solver plugin cannot extract solution suffix={suffix}" + ) + + scip = self._solver_model + status = scip.getStatus() + scip_vars = scip.getVars() + n_bin_vars = sum([scip_var.vtype() == "BINARY" for scip_var in scip_vars]) + n_int_vars = sum([scip_var.vtype() == "INTEGER" for scip_var in scip_vars]) + n_con_vars = sum([scip_var.vtype() == "CONTINUOUS" for scip_var in scip_vars]) + + self.results = SolverResults() + soln = Solution() + + self.results.solver.name = f"SCIP{self._version}" + self.results.solver.wallclock_time = scip.getSolvingTime() + + soln = self._get_solver_solution_status(scip, soln) + + self.results.problem.name = scip.getProbName() + + self.results.problem.upper_bound = None + self.results.problem.lower_bound = None + if scip.getNSols() > 0: + scip_has_sol = True + else: + scip_has_sol = False + if not scip_has_sol and (status == "inforunbd" or status == "infeasible"): + pass + else: + if n_bin_vars + n_int_vars == 0: + self.results.problem.upper_bound = scip.getObjVal() + self.results.problem.lower_bound = scip.getObjVal() + elif scip.getObjectiveSense() == "minimize": # minimizing + if scip_has_sol: + self.results.problem.upper_bound = scip.getObjVal() + else: + self.results.problem.upper_bound = scip.infinity() + self.results.problem.lower_bound = scip.getDualbound() + else: # maximizing + self.results.problem.upper_bound = scip.getDualbound() + if scip_has_sol: + self.results.problem.lower_bound = scip.getObjVal() + else: + self.results.problem.lower_bound = -scip.infinity() + + try: + soln.gap = ( + self.results.problem.upper_bound - self.results.problem.lower_bound + ) + except TypeError: + soln.gap = None + + self.results.problem.number_of_constraints = scip.getNConss(transformed=False) + # self.results.problem.number_of_nonzeros = None + self.results.problem.number_of_variables = scip.getNVars(transformed=False) + self.results.problem.number_of_binary_variables = n_bin_vars + self.results.problem.number_of_integer_variables = n_int_vars + self.results.problem.number_of_continuous_variables = n_con_vars + self.results.problem.number_of_objectives = 1 + self.results.problem.number_of_solutions = scip.getNSols() + + # if a solve was stopped by a limit, we still need to check to + # see if there is a solution available - this may not always + # be the case, both in LP and MIP contexts. + if self._save_results: + """ + This code in this if statement is only needed for backwards compatibility. It is more efficient to set + _save_results to False and use load_vars, load_duals, etc. + """ + + if scip.getNSols() > 0: + soln_variables = soln.variable + + scip_vars = scip.getVars() + scip_var_names = [scip_var.name for scip_var in scip_vars] + var_names = set(self._solver_var_to_pyomo_var_map.keys()) + assert set(scip_var_names) == var_names + var_vals = [scip.getVal(scip_var) for scip_var in scip_vars] + + for scip_var, val, name in zip(scip_vars, var_vals, scip_var_names): + pyomo_var = self._solver_var_to_pyomo_var_map[name] + if self._referenced_variables[pyomo_var] > 0: + soln_variables[name] = {"Value": val} + + elif self._load_solutions: + if scip.getNSols() > 0: + self.load_vars() + + self.results.solution.insert(soln) + + # finally, clean any temporary files registered with the temp file + # manager, created populated *directly* by this plugin. + TempfileManager.pop(remove=not self._keepfiles) + + return DirectOrPersistentSolver._postsolve(self) + + def warm_start_capable(self): + return True + + def _warm_start(self): + partial_sol = False + for pyomo_var in self._pyomo_var_to_solver_var_expr_map: + if pyomo_var.value is None: + partial_sol = True + break + if partial_sol: + scip_sol = self._solver_model.createPartialSol() + else: + scip_sol = self._solver_model.createSol() + for pyomo_var, scip_var in self._pyomo_var_to_solver_var_expr_map.items(): + if pyomo_var.value is not None: + scip_sol[scip_var] = value(pyomo_var) + if partial_sol: + self._solver_model.addSol(scip_sol) + else: + feasible = self._solver_model.checkSol(scip_sol, printreason=not self._tee) + if feasible: + self._solver_model.addSol(scip_sol) + else: + logger.warning("Warm start solution was not accepted by SCIP") + self._solver_model.freeSol(scip_sol) + + def _load_vars(self, vars_to_load=None): + var_map = self._pyomo_var_to_solver_var_expr_map + ref_vars = self._referenced_variables + if vars_to_load is None: + vars_to_load = var_map.keys() + + scip_vars_to_load = [var_map[pyomo_var] for pyomo_var in vars_to_load] + vals = [self._solver_model.getVal(scip_var) for scip_var in scip_vars_to_load] + + for var, val in zip(vars_to_load, vals): + if ref_vars[var] > 0: + var.set_value(val, skip_validation=True) + + def _load_rc(self, vars_to_load=None): + raise NotImplementedError( + "SCIP via Pyomo does not support reduced cost loading." + ) + + def _load_duals(self, cons_to_load=None): + raise NotImplementedError( + "SCIP via Pyomo does not support dual solution loading" + ) + + def _load_slacks(self, cons_to_load=None): + raise NotImplementedError("SCIP via Pyomo does not support slack loading") + + def load_duals(self, cons_to_load=None): + """ + Load the duals into the 'dual' suffix. The 'dual' suffix must live on the parent model. + + Parameters + ---------- + cons_to_load: list of Constraint + """ + self._load_duals(cons_to_load) + + def load_rc(self, vars_to_load): + """ + Load the reduced costs into the 'rc' suffix. The 'rc' suffix must live on the parent model. + + Parameters + ---------- + vars_to_load: list of Var + """ + self._load_rc(vars_to_load) + + def load_slacks(self, cons_to_load=None): + """ + Load the values of the slack variables into the 'slack' suffix. The 'slack' suffix must live on the parent + model. + + Parameters + ---------- + cons_to_load: list of Constraint + """ + self._load_slacks(cons_to_load) diff --git a/pyomo/solvers/plugins/solvers/scip_persistent.py b/pyomo/solvers/plugins/solvers/scip_persistent.py new file mode 100644 index 00000000000..e3fe9e37b5d --- /dev/null +++ b/pyomo/solvers/plugins/solvers/scip_persistent.py @@ -0,0 +1,192 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +from pyomo.solvers.plugins.solvers.scip_direct import SCIPDirect +from pyomo.solvers.plugins.solvers.persistent_solver import PersistentSolver +from pyomo.opt.base import SolverFactory + + +@SolverFactory.register("scip_persistent", doc="Persistent python interface to SCIP") +class SCIPPersistent(PersistentSolver, SCIPDirect): + """ + A class that provides a persistent interface to SCIP. Direct solver interfaces do not use any file io. + Rather, they interface directly with the python bindings for the specific solver. Persistent solver interfaces + are similar except that they "remember" their model. Thus, persistent solver interfaces allow incremental changes + to the solver model (e.g., the gurobi python model or the cplex python model). Note that users are responsible + for notifying the persistent solver interfaces when changes are made to the corresponding pyomo model. + + Keyword Arguments + ----------------- + model: ConcreteModel + Passing a model to the constructor is equivalent to calling the set_instance method. + type: str + String indicating the class type of the solver instance. + name: str + String representing either the class type of the solver instance or an assigned name. + doc: str + Documentation for the solver + options: dict + Dictionary of solver options + """ + + def __init__(self, **kwds): + kwds["type"] = "scip_persistent" + PersistentSolver.__init__(self, **kwds) + SCIPDirect._init(self) + + self._pyomo_model = kwds.pop("model", None) + if self._pyomo_model is not None: + self.set_instance(self._pyomo_model, **kwds) + + def _remove_constraint(self, solver_conname): + con = self._solver_con_to_pyomo_con_map[solver_conname] + scip_con = self._pyomo_con_to_solver_con_expr_map[con] + self._solver_model.delCons(scip_con) + del self._pyomo_con_to_solver_con_expr_map[con] + + def _remove_sos_constraint(self, solver_sos_conname): + con = self._solver_con_to_pyomo_con_map[solver_sos_conname] + scip_con = self._pyomo_con_to_solver_con_expr_map[con] + self._solver_model.delCons(scip_con) + del self._pyomo_con_to_solver_con_expr_map[con] + + def _remove_var(self, solver_varname): + var = self._solver_var_to_pyomo_var_map[solver_varname] + scip_var = self._pyomo_var_to_solver_var_expr_map[var] + self._solver_model.delVar(scip_var) + del self._pyomo_var_to_solver_var_expr_map[var] + + def _warm_start(self): + SCIPDirect._warm_start(self) + + def update_var(self, var): + """Update a single variable in the solver's model. + + This will update bounds, fix/unfix the variable as needed, and + update the variable type. + + Parameters + ---------- + var: Var (scalar Var or single _VarData) + + """ + # see PR #366 for discussion about handling indexed + # objects and keeping compatibility with the + # pyomo.kernel objects + # if var.is_indexed(): + # for child_var in var.values(): + # self.compile_var(child_var) + # return + if var not in self._pyomo_var_to_solver_var_map: + raise ValueError( + f"The Var provided to compile_var needs to be added first: {var}" + ) + scip_var = self._pyomo_var_to_solver_var_map[var] + vtype = self._scip_vtype_from_var(var) + lb, ub = self._scip_lb_ub_from_var(var) + + self._solver_model.chgVarLb(scip_var, lb) + self._solver_model.chgVarUb(scip_var, ub) + self._solver_model.chgVarType(scip_var, vtype) + + def write(self, filename, filetype=""): + """ + Write the model to a file (e.g., an lp file). + + Parameters + ---------- + filename: str + Name of the file to which the model should be written. + filetype: str + The file type (e.g., lp). + """ + self._solver_model.writeProblem(filename + filetype) + + def set_scip_param(self, param, val): + """ + Set a SCIP parameter. + + Parameters + ---------- + param: str + The SCIP parameter to set. Options include any SCIP parameter. + Please see the SCIP documentation for options. + Link at: https://www.scipopt.org/doc/html/PARAMETERS.php + val: any + The value to set the parameter to. See SCIP documentation for possible values. + """ + self._solver_model.setParam(param, val) + + def get_scip_param(self, param): + """ + Get the value of the SCIP parameter. + + Parameters + ---------- + param: str or int or float + The SCIP parameter to get the value of. See SCIP documentation for possible options. + Link at: https://www.scipopt.org/doc/html/PARAMETERS.php + """ + return self._solver_model.getParam(param) + + def _add_column(self, var, obj_coef, constraints, coefficients): + """Add a column to the solver's model + + This will add the Pyomo variable var to the solver's + model, and put the coefficients on the associated + constraints in the solver model. If the obj_coef is + not zero, it will add obj_coef*var to the objective + of the solver's model. + + Parameters + ---------- + var: Var (scalar Var or single _VarData) + obj_coef: float + constraints: list of solver constraints + coefficients: list of coefficients to put on var in the associated constraint + """ + + # Set-up add var + varname = self._symbol_map.getSymbol(var, self._labeler) + vtype = self._scip_vtype_from_var(var) + lb, ub = self._scip_lb_ub_from_var(var) + + # Add the variable to the model and then to all the constraints + scip_var = self._solver_model.addVar(lb=lb, ub=ub, vtype=vtype, name=varname) + self._pyomo_var_to_solver_var_expr_map[var] = scip_var + self._solver_var_to_pyomo_var_map[varname] = var + self._referenced_variables[var] = len(coefficients) + + # Get the SCIP cons by passing through two dictionaries + pyomo_cons = [self._solver_con_to_pyomo_con_map[con] for con in constraints] + scip_cons = [ + self._pyomo_con_to_solver_con_expr_map[pyomo_con] + for pyomo_con in pyomo_cons + ] + + for i, scip_con in enumerate(scip_cons): + if not scip_con.isLinear(): + raise ValueError( + "_add_column functionality not supported for non-linear constraints" + ) + self._solver_model.addConsCoeff(scip_con, scip_var, coefficients[i]) + con = self._solver_con_to_pyomo_con_map[scip_con.name] + self._vars_referenced_by_con[con].add(var) + + sense = self._solver_model.getObjectiveSense() + self._solver_model.setObjective(obj_coef * scip_var, sense=sense, clear=False) + + def reset(self): + """This function is necessary to call before making any changes to the + SCIP model after optimizing. It frees solution run specific information + that is not automatically done when changes to an already solved model + are made. Making changes to an already optimized model, e.g. adding additional + constraints will raise an error unless this function is called.""" + self._solver_model.freeTransform() diff --git a/pyomo/solvers/tests/checks/test_SCIPDirect.py b/pyomo/solvers/tests/checks/test_SCIPDirect.py new file mode 100644 index 00000000000..5863a54bdcb --- /dev/null +++ b/pyomo/solvers/tests/checks/test_SCIPDirect.py @@ -0,0 +1,310 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import sys + +import pyomo.common.unittest as unittest + +from pyomo.environ import ( + ConcreteModel, + AbstractModel, + Var, + Objective, + Block, + Constraint, + Suffix, + NonNegativeIntegers, + NonNegativeReals, + Integers, + Binary, + value, +) +from pyomo.opt import SolverFactory, TerminationCondition, SolutionStatus + +try: + import pyscipopt + + scip_available = True +except ImportError: + scip_available = False + + +@unittest.skipIf(not scip_available, "The SCIP python bindings are not available") +class SCIPDirectTests(unittest.TestCase): + def setUp(self): + self.stderr = sys.stderr + sys.stderr = None + + def tearDown(self): + sys.stderr = self.stderr + + def test_infeasible_lp(self): + with SolverFactory("scip_direct", solver_io="python") as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeReals) + model.C1 = Constraint(expr=model.X == 1) + model.C2 = Constraint(expr=model.X == 2) + model.O = Objective(expr=model.X) + + results = opt.solve(model) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) + + def test_unbounded_lp(self): + with SolverFactory("scip_direct", solver_io="python") as opt: + model = ConcreteModel() + model.X = Var() + model.O = Objective(expr=model.X) + + results = opt.solve(model) + + self.assertIn( + results.solver.termination_condition, + ( + TerminationCondition.unbounded, + TerminationCondition.infeasibleOrUnbounded, + ), + ) + + def test_optimal_lp(self): + with SolverFactory("scip_direct", solver_io="python") as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeReals) + model.O = Objective(expr=model.X) + + results = opt.solve(model, load_solutions=False) + + self.assertEqual(results.solution.status, SolutionStatus.optimal) + + def test_infeasible_mip(self): + with SolverFactory("scip_direct", solver_io="python") as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeIntegers) + model.C1 = Constraint(expr=model.X == 1) + model.C2 = Constraint(expr=model.X == 2) + model.O = Objective(expr=model.X) + + results = opt.solve(model) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) + + def test_unbounded_mip(self): + with SolverFactory("scip_direct", solver_io="python") as opt: + model = AbstractModel() + model.X = Var(within=Integers) + model.O = Objective(expr=model.X) + + instance = model.create_instance() + results = opt.solve(instance) + + self.assertIn( + results.solver.termination_condition, + ( + TerminationCondition.unbounded, + TerminationCondition.infeasibleOrUnbounded, + ), + ) + + def test_optimal_mip(self): + with SolverFactory("scip_direct", solver_io="python") as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeIntegers) + model.O = Objective(expr=model.X) + + results = opt.solve(model, load_solutions=False) + + self.assertEqual(results.solution.status, SolutionStatus.optimal) + + +@unittest.skipIf(not scip_available, "The SCIP python bindings are not available") +class TestAddVar(unittest.TestCase): + def test_add_single_variable(self): + """Test that the variable is added correctly to `solver_model`.""" + model = ConcreteModel() + + opt = SolverFactory("scip_direct", solver_io="python") + opt._set_instance(model) + + self.assertEqual(opt._solver_model.getNVars(), 0) + + model.X = Var(within=Binary) + + opt._add_var(model.X) + + self.assertEqual(opt._solver_model.getNVars(), 1) + self.assertEqual(opt._solver_model.getVars()[0].vtype(), "BINARY") + + def test_add_block_containing_single_variable(self): + """Test that the variable is added correctly to `solver_model`.""" + model = ConcreteModel() + + opt = SolverFactory("scip_direct", solver_io="python") + opt._set_instance(model) + + self.assertEqual(opt._solver_model.getNVars(), 0) + + model.X = Var(within=Binary) + + opt._add_block(model) + + self.assertEqual(opt._solver_model.getNVars(), 1) + self.assertEqual(opt._solver_model.getVars()[0].vtype(), "BINARY") + + def test_add_block_containing_multiple_variables(self): + """Test that: + - The variable is added correctly to `solver_model` + - Fixed variable bounds are set correctly + """ + model = ConcreteModel() + + opt = SolverFactory("scip_direct", solver_io="python") + opt._set_instance(model) + + self.assertEqual(opt._solver_model.getNVars(), 0) + + model.X1 = Var(within=Binary) + model.X2 = Var(within=NonNegativeReals) + model.X3 = Var(within=NonNegativeIntegers) + + model.X3.fix(5) + + opt._add_block(model) + + self.assertEqual(opt._solver_model.getNVars(), 3) + scip_vars = opt._solver_model.getVars() + vtypes = [scip_var.vtype() for scip_var in scip_vars] + assert "BINARY" in vtypes and "CONTINUOUS" in vtypes and "INTEGER" in vtypes + lbs = [scip_var.getLbGlobal() for scip_var in scip_vars] + ubs = [scip_var.getUbGlobal() for scip_var in scip_vars] + assert 0 in lbs and 5 in lbs + assert ( + 1 in ubs + and 5 in ubs + and any([opt._solver_model.isInfinity(ub) for ub in ubs]) + ) + + +@unittest.skipIf(not scip_available, "The SCIP python bindings are not available") +class TestAddCon(unittest.TestCase): + def test_add_single_constraint(self): + model = ConcreteModel() + model.X = Var(within=Binary) + + opt = SolverFactory("scip_direct", solver_io="python") + opt._set_instance(model) + + self.assertEqual(opt._solver_model.getNConss(), 0) + + model.C = Constraint(expr=model.X == 1) + + opt._add_constraint(model.C) + + self.assertEqual(opt._solver_model.getNConss(), 1) + con = opt._solver_model.getConss()[0] + self.assertEqual(con.isLinear(), 1) + self.assertEqual(opt._solver_model.getRhs(con), 1) + + def test_add_block_containing_single_constraint(self): + model = ConcreteModel() + model.X = Var(within=Binary) + + opt = SolverFactory("scip_direct", solver_io="python") + opt._set_instance(model) + + self.assertEqual(opt._solver_model.getNConss(), 0) + + model.B = Block() + model.B.C = Constraint(expr=model.X == 1) + + opt._add_block(model.B) + + self.assertEqual(opt._solver_model.getNConss(), 1) + con = opt._solver_model.getConss()[0] + self.assertEqual(con.isLinear(), 1) + self.assertEqual(opt._solver_model.getRhs(con), 1) + + def test_add_block_containing_multiple_constraints(self): + model = ConcreteModel() + model.X = Var(within=Binary) + + opt = SolverFactory("scip_direct", solver_io="python") + opt._set_instance(model) + + self.assertEqual(opt._solver_model.getNConss(), 0) + + model.B = Block() + model.B.C1 = Constraint(expr=model.X == 1) + model.B.C2 = Constraint(expr=model.X <= 1) + model.B.C3 = Constraint(expr=model.X >= 1) + + opt._add_block(model.B) + + self.assertEqual(opt._solver_model.getNConss(), 3) + + +@unittest.skipIf(not scip_available, "The SCIP python bindings are not available") +class TestLoadVars(unittest.TestCase): + def setUp(self): + opt = SolverFactory("scip_direct", solver_io="python") + model = ConcreteModel() + model.X = Var(within=NonNegativeReals, initialize=0) + model.Y = Var(within=NonNegativeReals, initialize=0) + + model.C1 = Constraint(expr=2 * model.X + model.Y >= 8) + model.C2 = Constraint(expr=model.X + 3 * model.Y >= 6) + + model.O = Objective(expr=model.X + model.Y) + + opt.solve(model, load_solutions=False, save_results=False) + + self._model = model + self._opt = opt + + def test_all_vars_are_loaded(self): + self.assertTrue(self._model.X.stale) + self.assertTrue(self._model.Y.stale) + self.assertEqual(value(self._model.X), 0) + self.assertEqual(value(self._model.Y), 0) + + self._opt.load_vars() + + self.assertFalse(self._model.X.stale) + self.assertFalse(self._model.Y.stale) + self.assertAlmostEqual(value(self._model.X), 3.6) + self.assertAlmostEqual(value(self._model.Y), 0.8) + + def test_only_specified_vars_are_loaded(self): + self.assertTrue(self._model.X.stale) + self.assertTrue(self._model.Y.stale) + self.assertEqual(value(self._model.X), 0) + self.assertEqual(value(self._model.Y), 0) + + self._opt.load_vars([self._model.X]) + + self.assertFalse(self._model.X.stale) + self.assertTrue(self._model.Y.stale) + self.assertAlmostEqual(value(self._model.X), 3.6) + self.assertEqual(value(self._model.Y), 0) + + self._opt.load_vars([self._model.Y]) + + self.assertFalse(self._model.X.stale) + self.assertFalse(self._model.Y.stale) + self.assertAlmostEqual(value(self._model.X), 3.6) + self.assertAlmostEqual(value(self._model.Y), 0.8) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/solvers/tests/checks/test_SCIPPersistent.py b/pyomo/solvers/tests/checks/test_SCIPPersistent.py new file mode 100644 index 00000000000..0cf1aab65f6 --- /dev/null +++ b/pyomo/solvers/tests/checks/test_SCIPPersistent.py @@ -0,0 +1,318 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.environ +import pyomo.common.unittest as unittest + +from pyomo.core import ( + ConcreteModel, + Var, + Objective, + Constraint, + NonNegativeReals, + NonNegativeIntegers, + Reals, + Binary, + SOSConstraint, + Set, + sin, + cos, + exp, + log, +) +from pyomo.opt import SolverFactory + +try: + import pyscipopt + + scip_available = True +except ImportError: + scip_available = False + + +@unittest.skipIf(not scip_available, "The SCIP python bindings are not available") +class TestQuadraticObjective(unittest.TestCase): + def test_quadratic_objective_linear_surrogate_is_set(self): + m = ConcreteModel() + m.X = Var(bounds=(-2, 2)) + m.Y = Var(bounds=(-2, 2)) + m.Z = Var(within=Reals) + m.O = Objective(expr=m.Z) + m.C1 = Constraint(expr=m.Y >= 2 * m.X - 1) + m.C2 = Constraint(expr=m.Y >= -m.X + 2) + m.C3 = Constraint(expr=m.Z >= m.X**2 + m.Y**2) + opt = SolverFactory("scip_persistent") + opt.set_instance(m) + opt.solve() + + self.assertAlmostEqual(m.X.value, 1, places=3) + self.assertAlmostEqual(m.Y.value, 1, places=3) + + opt.reset() + + opt.remove_constraint(m.C3) + del m.C3 + m.C3 = Constraint(expr=m.Z >= m.X**2) + opt.add_constraint(m.C3) + opt.solve() + self.assertAlmostEqual(m.X.value, 0, places=3) + self.assertAlmostEqual(m.Y.value, 2, places=3) + + def test_add_and_remove_sos(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2, 3]) + m.X = Var(m.I, bounds=(-2, 2)) + + m.C = SOSConstraint(var=m.X, sos=1) + + m.O = Objective(expr=m.X[1] + m.X[2]) + + opt = SolverFactory("scip_persistent") + + opt.set_instance(m) + opt.solve() + + zero_val_var = 0 + for i in range(1, 4): + if -0.001 < m.X[i].value < 0.001: + zero_val_var += 1 + assert zero_val_var == 2 + + opt.reset() + + opt.remove_sos_constraint(m.C) + del m.C + + m.C = SOSConstraint(var=m.X, sos=2) + opt.add_sos_constraint(m.C) + + opt.solve() + + zero_val_var = 0 + for i in range(1, 4): + if -0.001 < m.X[i].value < 0.001: + zero_val_var += 1 + assert zero_val_var == 1 + + def test_get_and_set_param(self): + m = ConcreteModel() + m.X = Var(bounds=(-2, 2)) + m.O = Objective(expr=m.X) + m.C3 = Constraint(expr=m.X <= 2) + opt = SolverFactory("scip_persistent") + opt.set_instance(m) + + opt.set_scip_param("limits/time", 60) + + assert opt.get_scip_param("limits/time") == 60 + + def test_non_linear(self): + + PI = 3.141592653589793238462643 + NWIRES = 11 + DIAMETERS = [ + 0.207, + 0.225, + 0.244, + 0.263, + 0.283, + 0.307, + 0.331, + 0.362, + 0.394, + 0.4375, + 0.500, + ] + PRELOAD = 300.0 + MAXWORKLOAD = 1000.0 + MAXDEFLECT = 6.0 + DEFLECTPRELOAD = 1.25 + MAXFREELEN = 14.0 + MAXCOILDIAM = 3.0 + MAXSHEARSTRESS = 189000.0 + SHEARMOD = 11500000.0 + + m = ConcreteModel() + m.coil = Var(within=NonNegativeReals) + m.wire = Var(within=NonNegativeReals) + m.defl = Var( + bounds=(DEFLECTPRELOAD / (MAXWORKLOAD - PRELOAD), MAXDEFLECT / PRELOAD) + ) + m.ncoils = Var(within=NonNegativeIntegers) + m.const1 = Var(within=NonNegativeReals) + m.const2 = Var(within=NonNegativeReals) + m.volume = Var(within=NonNegativeReals) + m.I = Set(initialize=[i for i in range(NWIRES)]) + m.y = Var(m.I, within=Binary) + + m.O = Objective(expr=m.volume) + + m.c1 = Constraint( + expr=PI / 2 * (m.ncoils + 2) * m.coil * m.wire**2 - m.volume == 0 + ) + + m.c2 = Constraint(expr=m.coil / m.wire - m.const1 == 0) + + m.c3 = Constraint( + expr=(4 * m.const1 - 1) / (4 * m.const1 - 4) + 0.615 / m.const1 - m.const2 + == 0 + ) + + m.c4 = Constraint( + expr=8.0 * MAXWORKLOAD / PI * m.const1 * m.const2 + - MAXSHEARSTRESS * m.wire**2 + <= 0 + ) + + m.c5 = Constraint( + expr=8 / SHEARMOD * m.ncoils * m.const1**3 / m.wire - m.defl == 0 + ) + + m.c6 = Constraint( + expr=MAXWORKLOAD * m.defl + 1.05 * m.ncoils * m.wire + 2.1 * m.wire + <= MAXFREELEN + ) + + m.c7 = Constraint(expr=m.coil + m.wire <= MAXCOILDIAM) + + m.c8 = Constraint( + expr=sum(m.y[i] * DIAMETERS[i] for i in range(NWIRES)) - m.wire == 0 + ) + + m.c9 = Constraint(expr=sum(m.y[i] for i in range(NWIRES)) == 1) + + opt = SolverFactory("scip_persistent") + opt.set_instance(m) + + opt.solve() + + self.assertAlmostEqual(m.volume.value, 1.6924910128, places=2) + + def test_non_linear_unary_expressions(self): + + m = ConcreteModel() + m.X = Var(bounds=(1, 2)) + m.Y = Var(within=Reals) + + m.O = Objective(expr=m.Y) + + m.C = Constraint(expr=exp(m.X) == m.Y) + + opt = SolverFactory("scip_persistent") + opt.set_instance(m) + + opt.solve() + self.assertAlmostEqual(m.X.value, 1, places=3) + self.assertAlmostEqual(m.Y.value, exp(1), places=3) + + opt.reset() + opt.remove_constraint(m.C) + del m.C + + m.C = Constraint(expr=log(m.X) == m.Y) + opt.add_constraint(m.C) + opt.solve() + self.assertAlmostEqual(m.X.value, 1, places=3) + self.assertAlmostEqual(m.Y.value, 0, places=3) + + opt.reset() + opt.remove_constraint(m.C) + del m.C + + m.C = Constraint(expr=sin(m.X) == m.Y) + opt.add_constraint(m.C) + opt.solve() + self.assertAlmostEqual(m.X.value, 1, places=3) + self.assertAlmostEqual(m.Y.value, sin(1), places=3) + + opt.reset() + opt.remove_constraint(m.C) + del m.C + + m.C = Constraint(expr=cos(m.X) == m.Y) + opt.add_constraint(m.C) + opt.solve() + self.assertAlmostEqual(m.X.value, 2, places=3) + self.assertAlmostEqual(m.Y.value, cos(2), places=3) + + def test_add_column(self): + m = ConcreteModel() + m.x = Var(within=NonNegativeReals) + m.c = Constraint(expr=(0, m.x, 1)) + m.obj = Objective(expr=-m.x) + + opt = SolverFactory("scip_persistent") + opt.set_instance(m) + opt.solve() + self.assertAlmostEqual(m.x.value, 1) + + m.y = Var(within=NonNegativeReals) + + opt.reset() + + opt.add_column(m, m.y, -3, [m.c], [2]) + opt.solve() + + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 0.5) + + def test_add_column_exceptions(self): + m = ConcreteModel() + m.x = Var() + m.c = Constraint(expr=(0, m.x, 1)) + m.ci = Constraint([1, 2], rule=lambda m, i: (0, m.x, i + 1)) + m.cd = Constraint(expr=(0, -m.x, 1)) + m.cd.deactivate() + m.obj = Objective(expr=-m.x) + + opt = SolverFactory("scip_persistent") + + # set_instance not called + self.assertRaises(RuntimeError, opt.add_column, m, m.x, 0, [m.c], [1]) + + opt.set_instance(m) + + m2 = ConcreteModel() + m2.y = Var() + m2.c = Constraint(expr=(0, m.x, 1)) + + # different model than attached to opt + self.assertRaises(RuntimeError, opt.add_column, m2, m2.y, 0, [], []) + # pyomo var attached to different model + self.assertRaises(RuntimeError, opt.add_column, m, m2.y, 0, [], []) + + z = Var() + # pyomo var floating + self.assertRaises(RuntimeError, opt.add_column, m, z, -2, [m.c, z], [1]) + + m.y = Var() + # len(coefficients) == len(constraints) + self.assertRaises(RuntimeError, opt.add_column, m, m.y, -2, [m.c], [1, 2]) + self.assertRaises(RuntimeError, opt.add_column, m, m.y, -2, [m.c, z], [1]) + + # add indexed constraint + self.assertRaises(AttributeError, opt.add_column, m, m.y, -2, [m.ci], [1]) + # add something not a _ConstraintData + self.assertRaises(AttributeError, opt.add_column, m, m.y, -2, [m.x], [1]) + + # constraint not on solver model + self.assertRaises(KeyError, opt.add_column, m, m.y, -2, [m2.c], [1]) + + # inactive constraint + self.assertRaises(KeyError, opt.add_column, m, m.y, -2, [m.cd], [1]) + + opt.add_var(m.y) + # var already in solver model + self.assertRaises(RuntimeError, opt.add_column, m, m.y, -2, [m.c], [1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/solvers/tests/solvers.py b/pyomo/solvers/tests/solvers.py index 918a801ae37..ba1530c67cc 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -376,6 +376,21 @@ def test_solver_cases(*args): name='scip', io='nl', capabilities=_scip_capabilities, import_suffixes=[] ) + # + # SCIP PERSISTENT + # + + _scip_persistent_capabilities = set( + ["linear", "integer", "quadratic_constraint", "sos1", "sos2"] + ) + + _test_solver_cases["scip_persistent", "python"] = initialize( + name="scip_persistent", + io="python", + capabilities=_scip_persistent_capabilities, + import_suffixes=[], + ) + # # CONOPT # diff --git a/pyomo/solvers/tests/testcases.py b/pyomo/solvers/tests/testcases.py index 6bef40818d9..f586e22b1e1 100644 --- a/pyomo/solvers/tests/testcases.py +++ b/pyomo/solvers/tests/testcases.py @@ -248,6 +248,15 @@ "inside NL files. A ticket has been filed.", ) +# +# SCIP Persistent +# + +ExpectedFailures["scip_persistent", "python", "LP_trivial_constraints"] = ( + lambda v: v <= _trunk_version, + "SCIP does not allow empty constraints with no variables to be added to the Model.", +) + # # BARON #