From e5524392a9c99208448fe863952586e545776927 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Mon, 18 Mar 2024 15:14:12 +0100 Subject: [PATCH 01/66] Add first draft of SCIP persistent solving --- pyomo/solvers/plugins/solvers/__init__.py | 2 + pyomo/solvers/plugins/solvers/scip_direct.py | 838 ++++++++++++++++++ .../plugins/solvers/scip_persistent.py | 185 ++++ pyomo/solvers/tests/checks/test_SCIPDirect.py | 335 +++++++ .../tests/checks/test_SCIPPersistent.py | 318 +++++++ pyomo/solvers/tests/solvers.py | 21 + 6 files changed, 1699 insertions(+) create mode 100644 pyomo/solvers/plugins/solvers/scip_direct.py create mode 100644 pyomo/solvers/plugins/solvers/scip_persistent.py create mode 100644 pyomo/solvers/tests/checks/test_SCIPDirect.py create mode 100644 pyomo/solvers/tests/checks/test_SCIPPersistent.py diff --git a/pyomo/solvers/plugins/solvers/__init__.py b/pyomo/solvers/plugins/solvers/__init__.py index 9b2507d876c..e8f4e00e31a 100644 --- a/pyomo/solvers/plugins/solvers/__init__.py +++ b/pyomo/solvers/plugins/solvers/__init__.py @@ -30,3 +30,5 @@ import pyomo.solvers.plugins.solvers.mosek_persistent import pyomo.solvers.plugins.solvers.xpress_direct import pyomo.solvers.plugins.solvers.xpress_persistent +import pyomo.solvers.plugins.solvers.scip_direct +import pyomo.solvers.plugins.solvers.scip_persistent diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py new file mode 100644 index 00000000000..0aafb596007 --- /dev/null +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -0,0 +1,838 @@ +# ___________________________________________________________________________ +# +# 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 re +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 +from pyomo.core.base.suffix import Suffix + + +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 = str(self._scip.Model().version()) + self._version_major = self._version.split(".")[0] + except ImportError: + self._python_api_exists = False + except Exception as e: + print("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 + + # 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() + + # Supress 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("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() + + 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) + + # TODO: Introduce handling on non-linear expressions + 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"DivisonExpression 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) + try: + self._solver_model = self._scip.Model() + except Exception: + e = sys.exc_info()[1] + msg = ( + "Unable to create SCIP model. " + "Have you installed PySCIPOpt correctly?\n\n\t" + + "Error message: {0}".format(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( + "Encountered a fixed variable (%s) inside " + "an active objective or constraint " + "expression on model %s, 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." + % (var.name, self._pyomo_model.name) + ) + + 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( + "Lower bound of constraint {0} is not constant.".format(con) + ) + if con.has_ub(): + if not is_fixed(con.upper): + raise ValueError( + "Upper bound of constraint {0} is not constant.".format(con) + ) + + if con.equality: + scip_cons = self._solver_model.addCons( + scip_expr == value(con.lower), name=conname + ) + elif con.has_lb() and con.has_ub(): + scip_cons = self._solver_model.addCons( + value(con.lower) <= (scip_expr <= value(con.upper)), name=conname + ) + elif con.has_lb(): + scip_cons = self._solver_model.addCons( + value(con.lower) <= scip_expr, name=conname + ) + elif con.has_ub(): + scip_cons = self._solver_model.addCons( + scip_expr <= value(con.upper), name=conname + ) + else: + raise ValueError( + "Constraint does not have a lower " + "or an upper bound: {0} \n".format(con) + ) + + 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 + :param var: pyomo.core.base.var.Var + :return: B, I, or C + """ + if var.is_binary(): + vtype = "B" + elif var.is_integer(): + vtype = "I" + elif var.is_continuous(): + vtype = "C" + else: + raise ValueError( + "Variable domain type is not recognized for {0}".format(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("Objective sense is not recognized: {0}".format(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 + + self._needs_updated = True + + def _postsolve(self): + # the only suffixes that we extract from SCIP are + # constraint duals, constraint slacks, and variable + # reduced-costs. scan through the solver suffix list + # and throw an exception if the user has specified + # any others. + extract_duals = False + extract_slacks = False + extract_reduced_costs = False + for suffix in self._suffixes: + flag = False + if re.match(suffix, "dual"): + extract_duals = True + flag = True + if re.match(suffix, "slack"): + extract_slacks = True + flag = True + if re.match(suffix, "rc"): + extract_reduced_costs = True + flag = True + if not flag: + raise RuntimeError( + "***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]) + + if n_bin_vars + n_int_vars > 0: + if extract_reduced_costs: + logger.warning("Cannot get reduced costs for MIP.") + if extract_duals: + logger.warning("Cannot get duals for MIP.") + extract_reduced_costs = False + extract_duals = False + + self.results = SolverResults() + soln = Solution() + + self.results.solver.name = f"SCIP{self._version}" + self.results.solver.wallclock_time = scip.getSolvingTime() + + 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 = ( + "Unhandled SCIP status (" + str(status) + ")" + ) + self.results.solver.termination_condition = TerminationCondition.error + soln.status = SolutionStatus.error + + self.results.problem.name = scip.getProbName() + + if scip.getObjectiveSense() == "minimize": + self.results.problem.sense = minimize + elif scip.getObjectiveSense() == "maximize": + self.results.problem.sense = maximize + else: + raise RuntimeError( + f"Unrecognized SCIP objective sense: {scip.getObjectiveSense()}" + ) + + 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 + + # TODO: Should these values be of the transformed or the original problem? + self.results.problem.number_of_constraints = scip.getNConss() + # self.results.problem.number_of_nonzeros = None + self.results.problem.number_of_variables = scip.getNVars() + 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 + soln_constraints = soln.constraint + scip_sol = scip.getBestSol() + + 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} + + if extract_reduced_costs: + vals = [scip.getVarRedcost(scip_var) for scip_var in scip_vars] + for scip_var, val, name in zip(scip_vars, vals, scip_var_names): + pyomo_var = self._solver_var_to_pyomo_var_map[name] + if self._referenced_variables[pyomo_var] > 0: + soln_variables[name]["Rc"] = val + + if extract_duals or extract_slacks: + scip_cons = scip.getConss() + con_names = [cons.name for cons in scip_cons] + assert set(self._solver_con_to_pyomo_con_map.keys()) == set( + con_names + ) + for name in con_names: + soln_constraints[name] = {} + + if extract_duals: + vals = [scip.getDualSolVal(con) for con in scip_cons] + for val, name in zip(vals, con_names): + soln_constraints[name]["Dual"] = val + + if extract_slacks: + vals = [scip.getSlack(con, scip_sol) for con in scip_cons] + for val, name in zip(vals, con_names): + soln_constraints[name]["Slack"] = val + + elif self._load_solutions: + if scip.getNSols() > 0: + self.load_vars() + + if extract_reduced_costs: + self._load_rc() + + if extract_duals: + self._load_duals() + + if extract_slacks: + self._load_slacks() + + 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): + 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) + self._solver_model.trySol(scip_sol, free=True) + + 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): + if not hasattr(self._pyomo_model, "rc"): + self._pyomo_model.rc = Suffix(direction=Suffix.IMPORT) + var_map = self._pyomo_var_to_solver_var_expr_map + ref_vars = self._referenced_variables + rc = self._pyomo_model.rc + 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.getVarRedcost(scip_var) for scip_var in scip_vars_to_load + ] + + for var, val in zip(vars_to_load, vals): + if ref_vars[var] > 0: + rc[var] = val + + def _load_duals(self, cons_to_load=None): + if not hasattr(self._pyomo_model, "dual"): + self._pyomo_model.dual = Suffix(direction=Suffix.IMPORT) + con_map = self._pyomo_con_to_solver_con_map + reverse_con_map = self._solver_con_to_pyomo_con_map + dual = self._pyomo_model.dual + scip_cons = self._solver_model.getConss() + + if cons_to_load is None: + con_names = [con.name for con in scip_cons] + vals = [self._solver_model.getDualSolVal(con) for con in scip_cons] + else: + con_names = set([con_map[pyomo_con] for pyomo_con in cons_to_load]) + scip_cons_to_load = [con for con in scip_cons if con.name in con_names] + vals = [self._solver_model.getDualSolVal(con) for con in scip_cons_to_load] + + for i, con_name in enumerate(con_names): + pyomo_con = reverse_con_map[con_name] + dual[pyomo_con] = vals[i] + + def _load_slacks(self, cons_to_load=None): + if not hasattr(self._pyomo_model, "slack"): + self._pyomo_model.slack = Suffix(direction=Suffix.IMPORT) + con_map = self._pyomo_con_to_solver_con_map + reverse_con_map = self._solver_con_to_pyomo_con_map + slack = self._pyomo_model.slack + scip_cons = self._solver_model.getConss() + scip_sol = self._solver_model.getBestSol() + + if cons_to_load is None: + con_names = [con.name for con in scip_cons] + vals = [self._solver_model.getSlack(con, scip_sol) for con in scip_cons] + else: + con_names = set([con_map[pyomo_con] for pyomo_con in cons_to_load]) + scip_cons_to_load = [con for con in scip_cons if con.name in con_names] + vals = [ + self._solver_model.getSlack(con, scip_sol) for con in scip_cons_to_load + ] + + for i, con_name in enumerate(con_names): + pyomo_con = reverse_con_map[con_name] + slack[pyomo_con] = vals[i] + + 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..408aa84633f --- /dev/null +++ b/pyomo/solvers/plugins/solvers/scip_persistent.py @@ -0,0 +1,185 @@ +# ___________________________________________________________________________ +# +# 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) + + 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) + + 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) + + 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( + "The Var provided to compile_var needs to be added first: {0}".format( + 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., and 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. + 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. + """ + 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): + 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..ee37f5ddcc8 --- /dev/null +++ b/pyomo/solvers/tests/checks/test_SCIPDirect.py @@ -0,0 +1,335 @@ +# ___________________________________________________________________________ +# +# 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 + + +class SCIPDirectTests(unittest.TestCase): + def setUp(self): + self.stderr = sys.stderr + sys.stderr = None + + def tearDown(self): + sys.stderr = self.stderr + + @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") + 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 + ) + + @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") + 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, + ), + ) + + @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") + 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) + + @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") + def test_get_duals_lp(self): + with SolverFactory("scip_direct", solver_io="python") as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeReals) + model.Y = Var(within=NonNegativeReals) + + 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) + + results = opt.solve(model, suffixes=["dual"], load_solutions=False) + + model.dual = Suffix(direction=Suffix.IMPORT) + model.solutions.load_from(results) + + self.assertAlmostEqual(model.dual[model.C1], 0.4) + self.assertAlmostEqual(model.dual[model.C2], 0.2) + + @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") + 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 + ) + + @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") + 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, + ), + ) + + @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") + 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..3ad944de8d1 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -376,6 +376,27 @@ 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=["slack", "dual", "rc"], + ) + # # CONOPT # From 6a14f108636dc9afb4e854b2fb27512aeb719ad0 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Mon, 18 Mar 2024 17:20:01 +0100 Subject: [PATCH 02/66] Add SCIPPersistent to docs --- doc/OnlineDocs/library_reference/solvers/index.rst | 1 + .../library_reference/solvers/scip_persistent.rst | 7 +++++++ 2 files changed, 8 insertions(+) create mode 100644 doc/OnlineDocs/library_reference/solvers/scip_persistent.rst diff --git a/doc/OnlineDocs/library_reference/solvers/index.rst b/doc/OnlineDocs/library_reference/solvers/index.rst index 400032df076..628f9cfdab0 100644 --- a/doc/OnlineDocs/library_reference/solvers/index.rst +++ b/doc/OnlineDocs/library_reference/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/library_reference/solvers/scip_persistent.rst b/doc/OnlineDocs/library_reference/solvers/scip_persistent.rst new file mode 100644 index 00000000000..63ed55b74e3 --- /dev/null +++ b/doc/OnlineDocs/library_reference/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 From c1079090567bbe95b290402a3918c936a0ded576 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Tue, 19 Mar 2024 11:48:19 +0100 Subject: [PATCH 03/66] Add SCIp to Github action scripts --- .github/workflows/test_branches.yml | 6 ++++++ .github/workflows/test_pr_and_main.yml | 6 ++++++ pyomo/solvers/plugins/solvers/scip_persistent.py | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/.github/workflows/test_branches.yml b/.github/workflows/test_branches.yml index 55f903a37f9..89e789db5ba 100644 --- a/.github/workflows/test_branches.yml +++ b/.github/workflows/test_branches.yml @@ -268,6 +268,12 @@ jobs: || echo "WARNING: Gurobi is not available" python -m pip install --cache-dir cache/pip xpress \ || echo "WARNING: Xpress Community Edition is not available" + if [[ ${{matrix.python}} == pypy* ]]; then + echo "skipping SCIP for pypy" + else + python -m pip install --cache-dir cache/pip pyscipopt==5.0.0 \ + || echo "WARNING: SCIP is not available" + fi if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index 76ec6de951a..a6cf6ef7eec 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -298,6 +298,12 @@ jobs: || echo "WARNING: Gurobi is not available" python -m pip install --cache-dir cache/pip xpress \ || echo "WARNING: Xpress Community Edition is not available" + if [[ ${{matrix.python}} == pypy* ]]; then + echo "skipping SCIP for pypy" + else + python -m pip install --cache-dir cache/pip pyscipopt==5.0.0 \ + || echo "WARNING: SCIP is not available" + fi if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else diff --git a/pyomo/solvers/plugins/solvers/scip_persistent.py b/pyomo/solvers/plugins/solvers/scip_persistent.py index 408aa84633f..e28c91073ab 100644 --- a/pyomo/solvers/plugins/solvers/scip_persistent.py +++ b/pyomo/solvers/plugins/solvers/scip_persistent.py @@ -182,4 +182,9 @@ def _add_column(self, var, obj_coef, constraints, coefficients): 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() From e00ded8e33e823b8a9146facff85166291e17d71 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Tue, 19 Mar 2024 14:55:52 +0100 Subject: [PATCH 04/66] Remove 5.0.0 specific version. Add conda to workflow --- .github/workflows/test_branches.yml | 4 ++-- .github/workflows/test_pr_and_main.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test_branches.yml b/.github/workflows/test_branches.yml index 89e789db5ba..1d61aaf2d77 100644 --- a/.github/workflows/test_branches.yml +++ b/.github/workflows/test_branches.yml @@ -271,7 +271,7 @@ jobs: if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping SCIP for pypy" else - python -m pip install --cache-dir cache/pip pyscipopt==5.0.0 \ + python -m pip install --cache-dir cache/pip pyscipopt \ || echo "WARNING: SCIP is not available" fi if [[ ${{matrix.python}} == pypy* ]]; then @@ -347,7 +347,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=10.0.3' xpress cyipopt pymumps scip; do + for PKG in 'cplex>=12.10' docplex 'gurobi=10.0.3' 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 a6cf6ef7eec..89fd90c41d0 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -301,7 +301,7 @@ jobs: if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping SCIP for pypy" else - python -m pip install --cache-dir cache/pip pyscipopt==5.0.0 \ + python -m pip install --cache-dir cache/pip pyscipopt \ || echo "WARNING: SCIP is not available" fi if [[ ${{matrix.python}} == pypy* ]]; then @@ -376,7 +376,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=10.0.3' xpress cyipopt pymumps scip; do + for PKG in 'cplex>=12.10' docplex 'gurobi=10.0.3' xpress cyipopt pymumps scip pyscipopt; do echo "" echo "*** Install $PKG ***" # conda can literally take an hour to determine that a From a0b625060217e04aa126dc9f2cb9a410e0968078 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Tue, 19 Mar 2024 15:14:54 +0100 Subject: [PATCH 05/66] Standardise string formatting to fstring --- pyomo/solvers/plugins/solvers/scip_direct.py | 35 +++++++------------ .../plugins/solvers/scip_persistent.py | 8 ++--- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 0aafb596007..e93e5579f26 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -74,7 +74,7 @@ def _init(self): except ImportError: self._python_api_exists = False except Exception as e: - print("Import of pyscipopt failed - SCIP message=" + str(e) + "\n") + print(f"Import of pyscipopt failed - SCIP message={str(e)}\n") self._python_api_exists = False # Note: Undefined capabilities default to None @@ -104,7 +104,7 @@ def _apply_solver(self): if self._keepfiles: # Only save log file when the user wants to keep it. self._solver_model.setLogfile(self._log_file) - print("Solver log file: " + self._log_file) + print(f"Solver log file: {self._log_file}") # Set user specified parameters for key, option in self.options.items(): @@ -257,14 +257,14 @@ def __exit__(self, t, v, traceback): 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. " - "Have you installed PySCIPOpt correctly?\n\n\t" - + "Error message: {0}".format(e) + f"Have you installed PySCIPOpt correctly?\n\n\t Error message: {e}" ) raise Exception(msg) @@ -275,14 +275,13 @@ def _set_instance(self, model, kwds={}): if var.fixed: if not self._output_fixed_variable_bounds: raise ValueError( - "Encountered a fixed variable (%s) inside " + f"Encountered a fixed variable {var.name} inside " "an active objective or constraint " - "expression on model %s, which is usually " + 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." - % (var.name, self._pyomo_model.name) ) def _add_block(self, block): @@ -308,14 +307,10 @@ def _add_constraint(self, con): if con.has_lb(): if not is_fixed(con.lower): - raise ValueError( - "Lower bound of constraint {0} is not constant.".format(con) - ) + raise ValueError(f"Lower bound of constraint {con} is not constant.") if con.has_ub(): if not is_fixed(con.upper): - raise ValueError( - "Upper bound of constraint {0} is not constant.".format(con) - ) + raise ValueError(f"Upper bound of constraint {con} is not constant.") if con.equality: scip_cons = self._solver_model.addCons( @@ -335,8 +330,7 @@ def _add_constraint(self, con): ) else: raise ValueError( - "Constraint does not have a lower " - "or an upper bound: {0} \n".format(con) + f"Constraint does not have a lower or an upper bound: {con} \n" ) for var in referenced_vars: @@ -398,9 +392,7 @@ def _scip_vtype_from_var(self, var): elif var.is_continuous(): vtype = "C" else: - raise ValueError( - "Variable domain type is not recognized for {0}".format(var.domain) - ) + raise ValueError(f"Variable domain type is not recognized for {var.domain}") return vtype def _set_objective(self, obj): @@ -418,7 +410,7 @@ def _set_objective(self, obj): elif obj.sense == maximize: sense = "maximize" else: - raise ValueError("Objective sense is not recognized: {0}".format(obj.sense)) + 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 @@ -455,8 +447,7 @@ def _postsolve(self): flag = True if not flag: raise RuntimeError( - "***The scip_direct solver plugin cannot extract solution suffix=" - + suffix + f"***The scip_direct solver plugin cannot extract solution suffix={suffix}" ) scip = self._solver_model @@ -593,7 +584,7 @@ def _postsolve(self): else: self.results.solver.status = SolverStatus.error self.results.solver.termination_message = ( - "Unhandled SCIP status (" + str(status) + ")" + f"Unhandled SCIP status ({str(status)})" ) self.results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.error diff --git a/pyomo/solvers/plugins/solvers/scip_persistent.py b/pyomo/solvers/plugins/solvers/scip_persistent.py index e28c91073ab..abb85b8dbca 100644 --- a/pyomo/solvers/plugins/solvers/scip_persistent.py +++ b/pyomo/solvers/plugins/solvers/scip_persistent.py @@ -84,9 +84,7 @@ def update_var(self, var): # return if var not in self._pyomo_var_to_solver_var_map: raise ValueError( - "The Var provided to compile_var needs to be added first: {0}".format( - var - ) + 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) @@ -182,9 +180,9 @@ def _add_column(self, var, obj_coef, constraints, coefficients): 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 + """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. """ + constraints will raise an error unless this function is called.""" self._solver_model.freeTransform() From d0816eb008bae43eff5bff54390873c98e5b7a1b Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Tue, 19 Mar 2024 15:17:29 +0100 Subject: [PATCH 06/66] Add parameter link to docstring --- pyomo/solvers/plugins/solvers/scip_persistent.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyomo/solvers/plugins/solvers/scip_persistent.py b/pyomo/solvers/plugins/solvers/scip_persistent.py index abb85b8dbca..49fe224e72a 100644 --- a/pyomo/solvers/plugins/solvers/scip_persistent.py +++ b/pyomo/solvers/plugins/solvers/scip_persistent.py @@ -116,6 +116,7 @@ def set_scip_param(self, param, val): 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. """ @@ -129,6 +130,7 @@ def get_scip_param(self, param): ---------- 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) From 0e11f112161b6947ddf30d6530d6167ef174cf44 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Tue, 19 Mar 2024 15:20:07 +0100 Subject: [PATCH 07/66] Remove redundant second objective sense check --- pyomo/solvers/plugins/solvers/scip_direct.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index e93e5579f26..c6285ff53cb 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -591,15 +591,6 @@ def _postsolve(self): self.results.problem.name = scip.getProbName() - if scip.getObjectiveSense() == "minimize": - self.results.problem.sense = minimize - elif scip.getObjectiveSense() == "maximize": - self.results.problem.sense = maximize - else: - raise RuntimeError( - f"Unrecognized SCIP objective sense: {scip.getObjectiveSense()}" - ) - self.results.problem.upper_bound = None self.results.problem.lower_bound = None if scip.getNSols() > 0: From 068ec99277321743b611c5323be3858a5b505ce8 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Tue, 19 Mar 2024 15:35:54 +0100 Subject: [PATCH 08/66] Clean up _post_solve with a helper function for status handling --- pyomo/solvers/plugins/solvers/scip_direct.py | 109 +++++++++++-------- 1 file changed, 63 insertions(+), 46 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index c6285ff53cb..9074d40870f 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -382,8 +382,16 @@ def _add_sos_constraint(self, con): def _scip_vtype_from_var(self, var): """ This function takes a pyomo variable and returns the appropriate SCIP variable type - :param var: pyomo.core.base.var.Var - :return: B, I, or C + + 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" @@ -425,52 +433,12 @@ def _set_objective(self, obj): self._needs_updated = True - def _postsolve(self): - # the only suffixes that we extract from SCIP are - # constraint duals, constraint slacks, and variable - # reduced-costs. scan through the solver suffix list - # and throw an exception if the user has specified - # any others. - extract_duals = False - extract_slacks = False - extract_reduced_costs = False - for suffix in self._suffixes: - flag = False - if re.match(suffix, "dual"): - extract_duals = True - flag = True - if re.match(suffix, "slack"): - extract_slacks = True - flag = True - if re.match(suffix, "rc"): - extract_reduced_costs = True - flag = True - if not flag: - raise RuntimeError( - f"***The scip_direct solver plugin cannot extract solution suffix={suffix}" - ) - - scip = self._solver_model + def _get_solver_solution_status(self, scip, soln): + """ """ + # Get the status of the SCIP Model currently 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]) - - if n_bin_vars + n_int_vars > 0: - if extract_reduced_costs: - logger.warning("Cannot get reduced costs for MIP.") - if extract_duals: - logger.warning("Cannot get duals for MIP.") - extract_reduced_costs = False - extract_duals = False - - self.results = SolverResults() - soln = Solution() - - self.results.solver.name = f"SCIP{self._version}" - self.results.solver.wallclock_time = scip.getSolvingTime() + # 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 = ( @@ -588,6 +556,55 @@ def _postsolve(self): ) self.results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.error + return soln + + def _postsolve(self): + # the only suffixes that we extract from SCIP are + # constraint duals, constraint slacks, and variable + # reduced-costs. scan through the solver suffix list + # and throw an exception if the user has specified + # any others. + extract_duals = False + extract_slacks = False + extract_reduced_costs = False + for suffix in self._suffixes: + flag = False + if re.match(suffix, "dual"): + extract_duals = True + flag = True + if re.match(suffix, "slack"): + extract_slacks = True + flag = True + if re.match(suffix, "rc"): + extract_reduced_costs = True + flag = True + if not flag: + 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]) + + if n_bin_vars + n_int_vars > 0: + if extract_reduced_costs: + logger.warning("Cannot get reduced costs for MIP.") + if extract_duals: + logger.warning("Cannot get duals for MIP.") + extract_reduced_costs = False + extract_duals = False + + 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() From 63af6d8ce13a28b50a59c3d9a027d57db64d8ca6 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Tue, 19 Mar 2024 15:38:45 +0100 Subject: [PATCH 09/66] Remove individual skip_test option --- pyomo/solvers/tests/checks/test_SCIPDirect.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pyomo/solvers/tests/checks/test_SCIPDirect.py b/pyomo/solvers/tests/checks/test_SCIPDirect.py index ee37f5ddcc8..cc9e114fed1 100644 --- a/pyomo/solvers/tests/checks/test_SCIPDirect.py +++ b/pyomo/solvers/tests/checks/test_SCIPDirect.py @@ -37,6 +37,7 @@ 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 @@ -45,7 +46,6 @@ def setUp(self): def tearDown(self): sys.stderr = self.stderr - @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") def test_infeasible_lp(self): with SolverFactory("scip_direct", solver_io="python") as opt: model = ConcreteModel() @@ -60,7 +60,6 @@ def test_infeasible_lp(self): results.solver.termination_condition, TerminationCondition.infeasible ) - @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") def test_unbounded_lp(self): with SolverFactory("scip_direct", solver_io="python") as opt: model = ConcreteModel() @@ -77,7 +76,6 @@ def test_unbounded_lp(self): ), ) - @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") def test_optimal_lp(self): with SolverFactory("scip_direct", solver_io="python") as opt: model = ConcreteModel() @@ -88,7 +86,6 @@ def test_optimal_lp(self): self.assertEqual(results.solution.status, SolutionStatus.optimal) - @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") def test_get_duals_lp(self): with SolverFactory("scip_direct", solver_io="python") as opt: model = ConcreteModel() @@ -108,7 +105,6 @@ def test_get_duals_lp(self): self.assertAlmostEqual(model.dual[model.C1], 0.4) self.assertAlmostEqual(model.dual[model.C2], 0.2) - @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") def test_infeasible_mip(self): with SolverFactory("scip_direct", solver_io="python") as opt: model = ConcreteModel() @@ -123,7 +119,6 @@ def test_infeasible_mip(self): results.solver.termination_condition, TerminationCondition.infeasible ) - @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") def test_unbounded_mip(self): with SolverFactory("scip_direct", solver_io="python") as opt: model = AbstractModel() @@ -141,7 +136,6 @@ def test_unbounded_mip(self): ), ) - @unittest.skipIf(not scip_available, "The SCIP python bindings are not available") def test_optimal_mip(self): with SolverFactory("scip_direct", solver_io="python") as opt: model = ConcreteModel() From 4075d9ad8ed1200c3d06e74ca5d3a678dd4e7239 Mon Sep 17 00:00:00 2001 From: Mark Turner <64978342+Opt-Mucca@users.noreply.github.com> Date: Tue, 19 Mar 2024 15:48:02 +0100 Subject: [PATCH 10/66] Update pyomo/solvers/plugins/solvers/scip_persistent.py Co-authored-by: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> --- pyomo/solvers/plugins/solvers/scip_persistent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/solvers/plugins/solvers/scip_persistent.py b/pyomo/solvers/plugins/solvers/scip_persistent.py index 49fe224e72a..572a1b638e0 100644 --- a/pyomo/solvers/plugins/solvers/scip_persistent.py +++ b/pyomo/solvers/plugins/solvers/scip_persistent.py @@ -96,7 +96,7 @@ def update_var(self, var): def write(self, filename, filetype=""): """ - Write the model to a file (e.g., and lp file). + Write the model to a file (e.g., an lp file). Parameters ---------- From f55fcc5aaa329049a6ac521738537fb8498f1a36 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Tue, 19 Mar 2024 15:52:10 +0100 Subject: [PATCH 11/66] Update from the black command --- pyomo/solvers/tests/solvers.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pyomo/solvers/tests/solvers.py b/pyomo/solvers/tests/solvers.py index 3ad944de8d1..1a5c1671f19 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -381,13 +381,7 @@ def test_solver_cases(*args): # _scip_persistent_capabilities = set( - [ - "linear", - "integer", - "quadratic_constraint", - "sos1", - "sos2", - ] + ["linear", "integer", "quadratic_constraint", "sos1", "sos2"] ) _test_solver_cases["scip_persistent", "python"] = initialize( From 91eae7b573624fe35e5fb579debde19efbed740a Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Tue, 19 Mar 2024 16:37:13 +0100 Subject: [PATCH 12/66] Fix typos --- pyomo/solvers/plugins/solvers/scip_direct.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 9074d40870f..5ba3395d1d2 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -94,7 +94,7 @@ def _init(self): def _apply_solver(self): StaleFlagManager.mark_all_as_stale() - # Supress solver output if requested + # Suppress solver output if requested if self._tee: self._solver_model.hideOutput(quiet=False) else: @@ -179,7 +179,7 @@ def get_nl_expr_recursively(pyomo_expr): elif isinstance(pyomo_expr, DivisionExpression): if len(scip_expr_list) != 2: raise ValueError( - f"DivisonExpression has {len(scip_expr_list)} many terms instead of two!" + 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): From 5ee2007a05921ef90a5ef9b31e76a877c9899007 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Tue, 19 Mar 2024 17:28:07 +0100 Subject: [PATCH 13/66] Replace trySol via more safe checkSol --- pyomo/solvers/plugins/solvers/scip_direct.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 5ba3395d1d2..25c668a0a06 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -729,7 +729,14 @@ def _warm_start(self): 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) - self._solver_model.trySol(scip_sol, free=True) + feasible = self._solver_model.checkSol(scip_sol) + if feasible: + self._solver_model.addSol(scip_sol) + del scip_sol + else: + logger.warning("Warm start solution was not accepted by SCIP") + self._solver_model.freeSol(scip_sol) + del scip_sol def _load_vars(self, vars_to_load=None): var_map = self._pyomo_var_to_solver_var_expr_map From f6ff0923ba9dbbc2bfd04990846c1446ca9e9ed8 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Wed, 20 Mar 2024 10:01:01 +0100 Subject: [PATCH 14/66] Adds support for partial solution loading --- pyomo/solvers/plugins/solvers/scip_direct.py | 24 +++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 25c668a0a06..0d4ad722459 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -725,18 +725,30 @@ def warm_start_capable(self): return True def _warm_start(self): - scip_sol = self._solver_model.createSol() + 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) - feasible = self._solver_model.checkSol(scip_sol) - if feasible: + if partial_sol: self._solver_model.addSol(scip_sol) del scip_sol else: - logger.warning("Warm start solution was not accepted by SCIP") - self._solver_model.freeSol(scip_sol) - del scip_sol + feasible = self._solver_model.checkSol(scip_sol) + if feasible: + self._solver_model.addSol(scip_sol) + del scip_sol + else: + logger.warning("Warm start solution was not accepted by SCIP") + self._solver_model.freeSol(scip_sol) + del scip_sol def _load_vars(self, vars_to_load=None): var_map = self._pyomo_var_to_solver_var_expr_map From e7ac980a6e58a9b1035bafc38cf3ee55420900b8 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Thu, 21 Mar 2024 09:58:24 +0100 Subject: [PATCH 15/66] Add error handling for setting non-linear objective --- pyomo/solvers/plugins/solvers/scip_direct.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 0d4ad722459..456b370eff1 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -134,6 +134,15 @@ def _apply_solver(self): 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: From 2540f650df319bcca59cf26d0bc524a7fab7de8c Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Fri, 19 Apr 2024 18:44:29 +0200 Subject: [PATCH 16/66] Remove dual and rc loading for SCIP. Fix bug of ranged rows --- pyomo/solvers/plugins/solvers/scip_direct.py | 151 ++++++------------ .../plugins/solvers/scip_persistent.py | 21 ++- pyomo/solvers/tests/solvers.py | 2 +- 3 files changed, 73 insertions(+), 101 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 456b370eff1..04440b59f9b 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -86,10 +86,12 @@ def _init(self): 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() + self._pyomo_con_to_solver_expr_map = dict() def _apply_solver(self): StaleFlagManager.mark_all_as_stale() @@ -239,6 +241,7 @@ def _scip_lb_ub_from_var(self, var): ub = value(var.ub) else: ub = self._solver_model.infinity() + return lb, ub def _add_var(self, var): @@ -327,7 +330,10 @@ def _add_constraint(self, con): ) elif con.has_lb() and con.has_ub(): scip_cons = self._solver_model.addCons( - value(con.lower) <= (scip_expr <= value(con.upper)), name=conname + value(con.lower) <= scip_expr, name=conname + ) + self._solver_model.chgRhs( + scip_cons, value(con.upper) - value(con.body.constant) ) elif con.has_lb(): scip_cons = self._solver_model.addCons( @@ -346,6 +352,7 @@ def _add_constraint(self, con): 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_expr_map[con] = scip_expr self._pyomo_con_to_solver_con_map[con] = scip_cons.name self._solver_con_to_pyomo_con_map[conname] = con @@ -440,8 +447,6 @@ def _set_objective(self, obj): self._objective = obj self._vars_referenced_by_obj = referenced_vars - self._needs_updated = True - def _get_solver_solution_status(self, scip, soln): """ """ # Get the status of the SCIP Model currently @@ -569,24 +574,17 @@ def _get_solver_solution_status(self, scip, soln): def _postsolve(self): # the only suffixes that we extract from SCIP are - # constraint duals, constraint slacks, and variable - # reduced-costs. scan through the solver suffix list + # constraint slacks. constraint duals and variable + # reduced-costs were removed as in SCIP they contain + # too many caveats. scan through the solver suffix list # and throw an exception if the user has specified # any others. - extract_duals = False extract_slacks = False - extract_reduced_costs = False for suffix in self._suffixes: flag = False - if re.match(suffix, "dual"): - extract_duals = True - flag = True if re.match(suffix, "slack"): extract_slacks = True flag = True - if re.match(suffix, "rc"): - extract_reduced_costs = True - flag = True if not flag: raise RuntimeError( f"***The scip_direct solver plugin cannot extract solution suffix={suffix}" @@ -599,14 +597,6 @@ def _postsolve(self): 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]) - if n_bin_vars + n_int_vars > 0: - if extract_reduced_costs: - logger.warning("Cannot get reduced costs for MIP.") - if extract_duals: - logger.warning("Cannot get duals for MIP.") - extract_reduced_costs = False - extract_duals = False - self.results = SolverResults() soln = Solution() @@ -667,6 +657,7 @@ def _postsolve(self): 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 soln_constraints = soln.constraint @@ -683,42 +674,35 @@ def _postsolve(self): if self._referenced_variables[pyomo_var] > 0: soln_variables[name] = {"Value": val} - if extract_reduced_costs: - vals = [scip.getVarRedcost(scip_var) for scip_var in scip_vars] - for scip_var, val, name in zip(scip_vars, vals, scip_var_names): - pyomo_var = self._solver_var_to_pyomo_var_map[name] - if self._referenced_variables[pyomo_var] > 0: - soln_variables[name]["Rc"] = val - - if extract_duals or extract_slacks: - scip_cons = scip.getConss() - con_names = [cons.name for cons in scip_cons] - assert set(self._solver_con_to_pyomo_con_map.keys()) == set( - con_names - ) - for name in con_names: - soln_constraints[name] = {} - - if extract_duals: - vals = [scip.getDualSolVal(con) for con in scip_cons] - for val, name in zip(vals, con_names): - soln_constraints[name]["Dual"] = val - if extract_slacks: - vals = [scip.getSlack(con, scip_sol) for con in scip_cons] - for val, name in zip(vals, con_names): - soln_constraints[name]["Slack"] = val + scip_cons = list(self._pyomo_con_to_solver_con_expr_map.values()) + con_names = [cons.name for cons in scip_cons] + if set(self._solver_con_to_pyomo_con_map.keys()) != set(con_names): + raise AssertionError( + f"{set(self._solver_con_to_pyomo_con_map.keys())}, {set(con_names)}" + ) + for cons in scip_cons: + if cons.getConshdlrName() in ["linear", "nonlinear"]: + soln_constraints[cons.name] = {} + pyomo_con = self._solver_con_to_pyomo_con_map[cons.name] + scip_expr = self._pyomo_con_to_solver_expr_map[pyomo_con] + activity = scip_sol[scip_expr] + if pyomo_con.has_lb(): + lhs = value(pyomo_con.lower) + else: + lhs = -1e20 + if pyomo_con.has_ub(): + rhs = value(pyomo_con.upper) + else: + rhs = 1e20 + soln_constraints[cons.name]["Slack"] = min( + activity - lhs, rhs - activity + ) elif self._load_solutions: if scip.getNSols() > 0: self.load_vars() - if extract_reduced_costs: - self._load_rc() - - if extract_duals: - self._load_duals() - if extract_slacks: self._load_slacks() @@ -773,65 +757,36 @@ def _load_vars(self, vars_to_load=None): var.set_value(val, skip_validation=True) def _load_rc(self, vars_to_load=None): - if not hasattr(self._pyomo_model, "rc"): - self._pyomo_model.rc = Suffix(direction=Suffix.IMPORT) - var_map = self._pyomo_var_to_solver_var_expr_map - ref_vars = self._referenced_variables - rc = self._pyomo_model.rc - 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.getVarRedcost(scip_var) for scip_var in scip_vars_to_load - ] - - for var, val in zip(vars_to_load, vals): - if ref_vars[var] > 0: - rc[var] = val + raise NotImplementedError( + "SCIP via Pyomo does not support reduced cost loading." + ) def _load_duals(self, cons_to_load=None): - if not hasattr(self._pyomo_model, "dual"): - self._pyomo_model.dual = Suffix(direction=Suffix.IMPORT) - con_map = self._pyomo_con_to_solver_con_map - reverse_con_map = self._solver_con_to_pyomo_con_map - dual = self._pyomo_model.dual - scip_cons = self._solver_model.getConss() - - if cons_to_load is None: - con_names = [con.name for con in scip_cons] - vals = [self._solver_model.getDualSolVal(con) for con in scip_cons] - else: - con_names = set([con_map[pyomo_con] for pyomo_con in cons_to_load]) - scip_cons_to_load = [con for con in scip_cons if con.name in con_names] - vals = [self._solver_model.getDualSolVal(con) for con in scip_cons_to_load] - - for i, con_name in enumerate(con_names): - pyomo_con = reverse_con_map[con_name] - dual[pyomo_con] = vals[i] + raise NotImplementedError( + "SCIP via Pyomo does not support dual solution loading" + ) def _load_slacks(self, cons_to_load=None): if not hasattr(self._pyomo_model, "slack"): self._pyomo_model.slack = Suffix(direction=Suffix.IMPORT) - con_map = self._pyomo_con_to_solver_con_map - reverse_con_map = self._solver_con_to_pyomo_con_map slack = self._pyomo_model.slack - scip_cons = self._solver_model.getConss() scip_sol = self._solver_model.getBestSol() if cons_to_load is None: - con_names = [con.name for con in scip_cons] - vals = [self._solver_model.getSlack(con, scip_sol) for con in scip_cons] + scip_cons = list(self._pyomo_con_to_solver_con_expr_map.values()) else: - con_names = set([con_map[pyomo_con] for pyomo_con in cons_to_load]) - scip_cons_to_load = [con for con in scip_cons if con.name in con_names] - vals = [ - self._solver_model.getSlack(con, scip_sol) for con in scip_cons_to_load + scip_cons = [ + self._pyomo_con_to_solver_con_expr_map[pyomo_cons] + for pyomo_cons in cons_to_load ] - - for i, con_name in enumerate(con_names): - pyomo_con = reverse_con_map[con_name] - slack[pyomo_con] = vals[i] + for cons in scip_cons: + if cons.getConshdlrName() in ["linear", "nonlinear"]: + pyomo_con = self._solver_con_to_pyomo_con_map[cons.name] + scip_expr = self._pyomo_con_to_solver_expr_map[pyomo_con] + activity = scip_sol[scip_expr] + rhs = self._solver_model.getRhs(cons) + lhs = self._solver_model.getLhs(cons) + slack[pyomo_con] = min(activity - lhs, rhs - activity) def load_duals(self, cons_to_load=None): """ diff --git a/pyomo/solvers/plugins/solvers/scip_persistent.py b/pyomo/solvers/plugins/solvers/scip_persistent.py index 572a1b638e0..880380ced1f 100644 --- a/pyomo/solvers/plugins/solvers/scip_persistent.py +++ b/pyomo/solvers/plugins/solvers/scip_persistent.py @@ -8,7 +8,6 @@ # 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 @@ -50,16 +49,34 @@ 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) - + for var in self._vars_reference_by_con[con]: + self._references_vars[var] -= 1 + del self._vars_reference_by_con[con] + del self._pyomo_con_to_solver_con_map[con] + del self._pyomo_con_to_solver_con_expr_map[con] + del self._pyomo_con_to_solver_expr_map[con] + del self._solver_con_to_pyomo_con_map[solver_conname] + + 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) + for var in self._vars_reference_by_con[con]: + self._references_vars[var] -= 1 + del self._vars_reference_by_con[con] + del self._pyomo_con_to_solver_con_map[con] + del self._pyomo_con_to_solver_con_expr_map[con] + del self._solver_con_to_pyomo_con_map[solver_conname] 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] + del self._pyomo_var_to_solver_var_map[var] + del self._solver_var_to_pyomo_var_map[scip_var.name] + del self._referenced_variables[var] def _warm_start(self): SCIPDirect._warm_start(self) diff --git a/pyomo/solvers/tests/solvers.py b/pyomo/solvers/tests/solvers.py index 1a5c1671f19..b66c1ca5af5 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -388,7 +388,7 @@ def test_solver_cases(*args): name="scip_persistent", io="python", capabilities=_scip_persistent_capabilities, - import_suffixes=["slack", "dual", "rc"], + import_suffixes=["slack"], ) # From 9e5d9442ea0900d36629a3f0677eb6c6ce8d7f19 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Fri, 19 Apr 2024 18:48:34 +0200 Subject: [PATCH 17/66] Add safe con.body.constant check --- pyomo/solvers/plugins/solvers/scip_direct.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 04440b59f9b..1b5e81db302 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -332,9 +332,10 @@ def _add_constraint(self, con): scip_cons = self._solver_model.addCons( value(con.lower) <= scip_expr, name=conname ) - self._solver_model.chgRhs( - scip_cons, value(con.upper) - value(con.body.constant) - ) + rhs = value(con.upper) + if hasattr(con.body, "constant"): + rhs -= value(con.body.constant) + self._solver_model.chgRhs(scip_cons, rhs) elif con.has_lb(): scip_cons = self._solver_model.addCons( value(con.lower) <= scip_expr, name=conname From f90dfade88dafd2d150409efd0a216c34578c89d Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Mon, 29 Apr 2024 10:33:26 +0200 Subject: [PATCH 18/66] Remove slack loading for SCIP --- pyomo/solvers/plugins/solvers/scip_direct.py | 72 +++---------------- .../plugins/solvers/scip_persistent.py | 15 ---- 2 files changed, 8 insertions(+), 79 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 1b5e81db302..57cfc213f3d 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -91,7 +91,6 @@ def _init(self): # 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() - self._pyomo_con_to_solver_expr_map = dict() def _apply_solver(self): StaleFlagManager.mark_all_as_stale() @@ -353,7 +352,6 @@ def _add_constraint(self, con): 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_expr_map[con] = scip_expr self._pyomo_con_to_solver_con_map[con] = scip_cons.name self._solver_con_to_pyomo_con_map[conname] = con @@ -574,22 +572,17 @@ def _get_solver_solution_status(self, scip, soln): return soln def _postsolve(self): - # the only suffixes that we extract from SCIP are - # constraint slacks. constraint duals and variable + # Constraint duals and variable # reduced-costs were removed as in SCIP they contain - # too many caveats. scan through the solver suffix list + # 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. - extract_slacks = False for suffix in self._suffixes: - flag = False - if re.match(suffix, "slack"): - extract_slacks = True - flag = True - if not flag: - raise RuntimeError( - f"***The scip_direct solver plugin cannot extract solution suffix={suffix}" - ) + raise RuntimeError( + f"***The scip_direct solver plugin cannot extract solution suffix={suffix}" + ) scip = self._solver_model status = scip.getStatus() @@ -661,8 +654,6 @@ def _postsolve(self): if scip.getNSols() > 0: soln_variables = soln.variable - soln_constraints = soln.constraint - scip_sol = scip.getBestSol() scip_vars = scip.getVars() scip_var_names = [scip_var.name for scip_var in scip_vars] @@ -675,38 +666,10 @@ def _postsolve(self): if self._referenced_variables[pyomo_var] > 0: soln_variables[name] = {"Value": val} - if extract_slacks: - scip_cons = list(self._pyomo_con_to_solver_con_expr_map.values()) - con_names = [cons.name for cons in scip_cons] - if set(self._solver_con_to_pyomo_con_map.keys()) != set(con_names): - raise AssertionError( - f"{set(self._solver_con_to_pyomo_con_map.keys())}, {set(con_names)}" - ) - for cons in scip_cons: - if cons.getConshdlrName() in ["linear", "nonlinear"]: - soln_constraints[cons.name] = {} - pyomo_con = self._solver_con_to_pyomo_con_map[cons.name] - scip_expr = self._pyomo_con_to_solver_expr_map[pyomo_con] - activity = scip_sol[scip_expr] - if pyomo_con.has_lb(): - lhs = value(pyomo_con.lower) - else: - lhs = -1e20 - if pyomo_con.has_ub(): - rhs = value(pyomo_con.upper) - else: - rhs = 1e20 - soln_constraints[cons.name]["Slack"] = min( - activity - lhs, rhs - activity - ) - elif self._load_solutions: if scip.getNSols() > 0: self.load_vars() - if extract_slacks: - self._load_slacks() - self.results.solution.insert(soln) # finally, clean any temporary files registered with the temp file @@ -768,26 +731,7 @@ def _load_duals(self, cons_to_load=None): ) def _load_slacks(self, cons_to_load=None): - if not hasattr(self._pyomo_model, "slack"): - self._pyomo_model.slack = Suffix(direction=Suffix.IMPORT) - slack = self._pyomo_model.slack - scip_sol = self._solver_model.getBestSol() - - if cons_to_load is None: - scip_cons = list(self._pyomo_con_to_solver_con_expr_map.values()) - else: - scip_cons = [ - self._pyomo_con_to_solver_con_expr_map[pyomo_cons] - for pyomo_cons in cons_to_load - ] - for cons in scip_cons: - if cons.getConshdlrName() in ["linear", "nonlinear"]: - pyomo_con = self._solver_con_to_pyomo_con_map[cons.name] - scip_expr = self._pyomo_con_to_solver_expr_map[pyomo_con] - activity = scip_sol[scip_expr] - rhs = self._solver_model.getRhs(cons) - lhs = self._solver_model.getLhs(cons) - slack[pyomo_con] = min(activity - lhs, rhs - activity) + raise NotImplementedError("SCIP via Pyomo does not support slack loading") def load_duals(self, cons_to_load=None): """ diff --git a/pyomo/solvers/plugins/solvers/scip_persistent.py b/pyomo/solvers/plugins/solvers/scip_persistent.py index 880380ced1f..e3fe9e37b5d 100644 --- a/pyomo/solvers/plugins/solvers/scip_persistent.py +++ b/pyomo/solvers/plugins/solvers/scip_persistent.py @@ -49,34 +49,19 @@ 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) - for var in self._vars_reference_by_con[con]: - self._references_vars[var] -= 1 - del self._vars_reference_by_con[con] - del self._pyomo_con_to_solver_con_map[con] del self._pyomo_con_to_solver_con_expr_map[con] - del self._pyomo_con_to_solver_expr_map[con] - del self._solver_con_to_pyomo_con_map[solver_conname] - 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) - for var in self._vars_reference_by_con[con]: - self._references_vars[var] -= 1 - del self._vars_reference_by_con[con] - del self._pyomo_con_to_solver_con_map[con] del self._pyomo_con_to_solver_con_expr_map[con] - del self._solver_con_to_pyomo_con_map[solver_conname] 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] - del self._pyomo_var_to_solver_var_map[var] - del self._solver_var_to_pyomo_var_map[scip_var.name] - del self._referenced_variables[var] def _warm_start(self): SCIPDirect._warm_start(self) From f703d1f71128a95d509aa9ea0b08d12de2dcb41a Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Mon, 29 Apr 2024 10:38:10 +0200 Subject: [PATCH 19/66] Remove dual loading test for SCIP --- pyomo/solvers/tests/checks/test_SCIPDirect.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/pyomo/solvers/tests/checks/test_SCIPDirect.py b/pyomo/solvers/tests/checks/test_SCIPDirect.py index cc9e114fed1..5863a54bdcb 100644 --- a/pyomo/solvers/tests/checks/test_SCIPDirect.py +++ b/pyomo/solvers/tests/checks/test_SCIPDirect.py @@ -86,25 +86,6 @@ def test_optimal_lp(self): self.assertEqual(results.solution.status, SolutionStatus.optimal) - def test_get_duals_lp(self): - with SolverFactory("scip_direct", solver_io="python") as opt: - model = ConcreteModel() - model.X = Var(within=NonNegativeReals) - model.Y = Var(within=NonNegativeReals) - - 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) - - results = opt.solve(model, suffixes=["dual"], load_solutions=False) - - model.dual = Suffix(direction=Suffix.IMPORT) - model.solutions.load_from(results) - - self.assertAlmostEqual(model.dual[model.C1], 0.4) - self.assertAlmostEqual(model.dual[model.C2], 0.2) - def test_infeasible_mip(self): with SolverFactory("scip_direct", solver_io="python") as opt: model = ConcreteModel() From 5c02d32009990b8054440f0a6049bdf934247a79 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Mon, 29 Apr 2024 10:42:00 +0200 Subject: [PATCH 20/66] Remove slack for suffix in tests --- pyomo/solvers/plugins/solvers/scip_direct.py | 2 -- pyomo/solvers/tests/solvers.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 57cfc213f3d..a965e66362e 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -10,7 +10,6 @@ # ___________________________________________________________________________ import logging -import re import sys from pyomo.common.collections import ComponentSet, ComponentMap, Bunch @@ -36,7 +35,6 @@ from pyomo.opt.results.solution import Solution, SolutionStatus from pyomo.opt.results.solver import TerminationCondition, SolverStatus from pyomo.opt.base import SolverFactory -from pyomo.core.base.suffix import Suffix logger = logging.getLogger("pyomo.solvers") diff --git a/pyomo/solvers/tests/solvers.py b/pyomo/solvers/tests/solvers.py index b66c1ca5af5..ba1530c67cc 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -388,7 +388,7 @@ def test_solver_cases(*args): name="scip_persistent", io="python", capabilities=_scip_persistent_capabilities, - import_suffixes=["slack"], + import_suffixes=[], ) # From 8ebcf88365267e28a5b820eedce12a0d1bf5473c Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Mon, 29 Apr 2024 11:13:28 +0200 Subject: [PATCH 21/66] Remove TODO for nonlinear handling --- pyomo/solvers/plugins/solvers/scip_direct.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index a965e66362e..9061deac6ad 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -161,7 +161,6 @@ def _get_expr_from_pyomo_repn(self, repn, max_degree=None): referenced_vars.add(x) referenced_vars.add(y) - # TODO: Introduce handling on non-linear expressions if repn.nonlinear_expr is not None: def get_nl_expr_recursively(pyomo_expr): From 30d8cc62d5903e9e8dce4f0cabe79f22a5aba495 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Thu, 13 Jun 2024 14:48:43 +0200 Subject: [PATCH 22/66] Skip LP_trivial_constraints for SCIP persistent --- pyomo/solvers/plugins/solvers/scip_direct.py | 6 ++++-- pyomo/solvers/tests/testcases.py | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 9061deac6ad..39c3a4fd996 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -67,8 +67,10 @@ def _init(self): self._scip = pyscipopt self._python_api_exists = True - self._version = str(self._scip.Model().version()) - self._version_major = self._version.split(".")[0] + 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: 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 # From 30e5e65bfd063b049f950b9f74c0d238187077ca Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Thu, 13 Jun 2024 16:12:24 +0200 Subject: [PATCH 23/66] Add transformation for add_cons with non float/int rhs e.g. np.int --- pyomo/solvers/plugins/solvers/scip_direct.py | 21 ++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 39c3a4fd996..7c26670c2b4 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -318,29 +318,38 @@ def _add_constraint(self, con): 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 not isinstance(con_lower, (float, int)): + 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 not isinstance(con_upper, (float, int)): + con_upper = float(con_upper) if con.equality: scip_cons = self._solver_model.addCons( - scip_expr == value(con.lower), name=conname + scip_expr == con_lower, name=conname ) elif con.has_lb() and con.has_ub(): scip_cons = self._solver_model.addCons( - value(con.lower) <= scip_expr, name=conname + con_lower <= scip_expr, name=conname ) - rhs = value(con.upper) + rhs = con_upper if hasattr(con.body, "constant"): - rhs -= value(con.body.constant) + con_constant = value(con.body.constant) + if not isinstance(con_constant, (float, int)): + con_body = float(con_constant) + rhs -= con_constant self._solver_model.chgRhs(scip_cons, rhs) elif con.has_lb(): scip_cons = self._solver_model.addCons( - value(con.lower) <= scip_expr, name=conname + con_lower <= scip_expr, name=conname ) elif con.has_ub(): scip_cons = self._solver_model.addCons( - scip_expr <= value(con.upper), name=conname + scip_expr <= con_upper, name=conname ) else: raise ValueError( From 9104a921c55f9cd170bd1a7e93e1628869de2360 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Thu, 13 Jun 2024 16:35:41 +0200 Subject: [PATCH 24/66] Add warning if type is converted. Tidy up logic --- pyomo/solvers/plugins/solvers/scip_direct.py | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 7c26670c2b4..6ce98d80e27 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -319,38 +319,38 @@ def _add_constraint(self, con): if not is_fixed(con.lower): raise ValueError(f"Lower bound of constraint {con} is not constant.") con_lower = value(con.lower) - if not isinstance(con_lower, (float, int)): + 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 not isinstance(con_upper, (float, int)): + 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 - ) + 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 - ) + 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_body = float(con_constant) + 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 - ) + 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 - ) + 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" From f3f2d7c0334afbea37e9d8d24d60227f333ee4c1 Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Wed, 20 Nov 2024 17:13:03 +0100 Subject: [PATCH 25/66] Fix num. vars and cons from transformed. Silent warm start fail --- pyomo/solvers/plugins/solvers/scip_direct.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 6ce98d80e27..89dd25b86ee 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -641,10 +641,9 @@ def _postsolve(self): except TypeError: soln.gap = None - # TODO: Should these values be of the transformed or the original problem? - self.results.problem.number_of_constraints = scip.getNConss() + 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() + 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 @@ -704,16 +703,13 @@ def _warm_start(self): scip_sol[scip_var] = value(pyomo_var) if partial_sol: self._solver_model.addSol(scip_sol) - del scip_sol else: - feasible = self._solver_model.checkSol(scip_sol) + feasible = self._solver_model.checkSol(scip_sol, printreason=not self._tee) if feasible: self._solver_model.addSol(scip_sol) - del scip_sol else: logger.warning("Warm start solution was not accepted by SCIP") self._solver_model.freeSol(scip_sol) - del scip_sol def _load_vars(self, vars_to_load=None): var_map = self._pyomo_var_to_solver_var_expr_map From 7b18354386df3954cf9fc59e5f0ec9b587e316ed Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Wed, 19 Feb 2025 16:38:06 +0100 Subject: [PATCH 26/66] Add minor changes --- pyomo/solvers/plugins/solvers/scip_direct.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 89dd25b86ee..314fce40da5 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -322,19 +322,13 @@ def _add_constraint(self, con): 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." + f"Converting to float as SCIP fails otherwise." ) 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) @@ -642,7 +636,6 @@ def _postsolve(self): 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 From 27e3d108662f554966819c4ea5db52df1ca38ffc Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Fri, 21 Mar 2025 11:26:23 +0100 Subject: [PATCH 27/66] Change copyright 2024 to 2025 --- pyomo/solvers/plugins/solvers/scip_direct.py | 2 +- pyomo/solvers/plugins/solvers/scip_persistent.py | 2 +- pyomo/solvers/tests/checks/test_SCIPDirect.py | 2 +- pyomo/solvers/tests/checks/test_SCIPPersistent.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/solvers/plugins/solvers/scip_direct.py index 314fce40da5..c862d9047c1 100644 --- a/pyomo/solvers/plugins/solvers/scip_direct.py +++ b/pyomo/solvers/plugins/solvers/scip_direct.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # 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 diff --git a/pyomo/solvers/plugins/solvers/scip_persistent.py b/pyomo/solvers/plugins/solvers/scip_persistent.py index e3fe9e37b5d..bc64edc28a8 100644 --- a/pyomo/solvers/plugins/solvers/scip_persistent.py +++ b/pyomo/solvers/plugins/solvers/scip_persistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # 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 diff --git a/pyomo/solvers/tests/checks/test_SCIPDirect.py b/pyomo/solvers/tests/checks/test_SCIPDirect.py index 5863a54bdcb..186de0eaf58 100644 --- a/pyomo/solvers/tests/checks/test_SCIPDirect.py +++ b/pyomo/solvers/tests/checks/test_SCIPDirect.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # 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 diff --git a/pyomo/solvers/tests/checks/test_SCIPPersistent.py b/pyomo/solvers/tests/checks/test_SCIPPersistent.py index 0cf1aab65f6..61cf7385352 100644 --- a/pyomo/solvers/tests/checks/test_SCIPPersistent.py +++ b/pyomo/solvers/tests/checks/test_SCIPPersistent.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # 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 From 9e7cd0dc9f4679a7b19b4706b2ffcf963bb7dc3f Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 14 Aug 2025 07:15:39 -0600 Subject: [PATCH 28/66] moving scip to contrib solvers --- .../solvers => contrib/solver/solvers/scip}/scip_direct.py | 0 .../solvers => contrib/solver/solvers/scip}/scip_persistent.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename pyomo/{solvers/plugins/solvers => contrib/solver/solvers/scip}/scip_direct.py (100%) rename pyomo/{solvers/plugins/solvers => contrib/solver/solvers/scip}/scip_persistent.py (100%) diff --git a/pyomo/solvers/plugins/solvers/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py similarity index 100% rename from pyomo/solvers/plugins/solvers/scip_direct.py rename to pyomo/contrib/solver/solvers/scip/scip_direct.py diff --git a/pyomo/solvers/plugins/solvers/scip_persistent.py b/pyomo/contrib/solver/solvers/scip/scip_persistent.py similarity index 100% rename from pyomo/solvers/plugins/solvers/scip_persistent.py rename to pyomo/contrib/solver/solvers/scip/scip_persistent.py From bf204bb2b6d21644fa5dd8f0d83521075541cb9f Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 14 Aug 2025 07:15:57 -0600 Subject: [PATCH 29/66] moving scip to contrib solvers --- pyomo/contrib/solver/solvers/scip/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 pyomo/contrib/solver/solvers/scip/__init__.py diff --git a/pyomo/contrib/solver/solvers/scip/__init__.py b/pyomo/contrib/solver/solvers/scip/__init__.py new file mode 100644 index 00000000000..e69de29bb2d From dabf031e34490ed6dcf00e25eaba68abafc7a022 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 14 Aug 2025 23:36:24 -0600 Subject: [PATCH 30/66] porting scip interface --- .../contrib/solver/common/solution_loader.py | 20 +- .../solvers/gurobi/gurobi_direct_base.py | 1 - .../solver/solvers/scip/scip_direct.py | 1228 ++++++++--------- 3 files changed, 604 insertions(+), 645 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 666ea66e1e9..f8723b6e0f4 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -18,6 +18,10 @@ from pyomo.core.staleflag import StaleFlagManager from pyomo.core.base.suffix import Suffix from .util import NoSolutionError +import logging + + +logger = logging.getLogger(__name__) def load_import_suffixes( @@ -33,11 +37,19 @@ def load_import_suffixes( elif suffix.local_name == 'rc': rc_suffix = suffix if dual_suffix is not None: - for k, v in solution_loader.get_duals(solution_id=solution_id).items(): - dual_suffix[k] = v + duals = solution_loader.get_duals(solution_id=solution_id) + if duals is NotImplemented: + logger.warning(f'Cannot load duals into suffix') + else: + for k, v in duals.items(): + dual_suffix[k] = v if rc_suffix is not None: - for k, v in solution_loader.get_reduced_costs(solution_id=solution_id).items(): - rc_suffix[k] = v + rc = solution_loader.get_reduced_costs(solution_id=solution_id) + if rc is NotImplemented: + logger.warning(f'cannot load duals into suffix') + else: + for k, v in rc.items(): + rc_suffix[k] = v class SolutionLoaderBase: diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index ce77c31c6f7..8989fc5047a 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -362,7 +362,6 @@ def solve(self, model, **kwds) -> Results: # hack to work around legacy solver wrapper __setattr__ # otherwise, this would just be self.config = orig_config object.__setattr__(self, 'config', orig_config) - self.config = orig_config res.solver_log = ostreams[0].getvalue() end_timestamp = datetime.datetime.now(datetime.timezone.utc) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index c862d9047c1..b8d4d14a6c1 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -9,392 +9,578 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import datetime +import io import logging -import sys - -from pyomo.common.collections import ComponentSet, ComponentMap, Bunch -from pyomo.common.tempfiles import TempfileManager -from pyomo.core import Var +from typing import Tuple, List, Optional, Sequence, Mapping, Dict + +from pyomo.common.collections import ComponentMap +from pyomo.common.numeric_types import native_numeric_types +from pyomo.common.errors import InfeasibleConstraintException, ApplicationError +from pyomo.common.timing import HierarchicalTimer +from pyomo.core.base.block import BlockData +from pyomo.core.base.var import VarData, ScalarVar +from pyomo.core.base.param import ParamData, ScalarParam +from pyomo.core.base.constraint import Constraint, ConstraintData +from pyomo.core.base.sos import SOSConstraint, SOSConstraintData +from pyomo.core.kernel.objective import minimize, maximize from pyomo.core.expr.numeric_expr import ( - SumExpression, - ProductExpression, - UnaryFunctionExpression, + NegationExpression, PowExpression, + ProductExpression, + MonomialTermExpression, DivisionExpression, + SumExpression, + LinearExpression, + UnaryFunctionExpression, + NPV_NegationExpression, + NPV_PowExpression, + NPV_ProductExpression, + NPV_DivisionExpression, + NPV_SumExpression, + NPV_UnaryFunctionExpression, ) -from pyomo.core.expr.numvalue import is_fixed -from pyomo.core.expr.numvalue import value +from pyomo.core.base.expression import ExpressionData, ScalarExpression +from pyomo.core.expr.relational_expr import EqualityExpression, InequalityExpression, RangedExpression 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.expr.visitor import StreamBasedExpressionVisitor +from pyomo.common.dependencies import attempt_import +from pyomo.contrib.solver.common.base import SolverBase, Availability +from pyomo.contrib.solver.common.config import BranchAndBoundConfig +from pyomo.contrib.solver.common.util import ( + NoFeasibleSolutionError, + NoOptimalSolutionError, ) -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 +from pyomo.contrib.solver.common.util import get_objective +from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader +from pyomo.contrib.solver.common.results import ( + Results, + SolutionStatus, + TerminationCondition, +) +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) +from pyomo.common.config import ConfigValue +from pyomo.common.tee import capture_output, TeeStream -logger = logging.getLogger("pyomo.solvers") +logger = logging.getLogger(__name__) -class DegreeError(ValueError): - pass +scip, scip_available = attempt_import('pyscipyopt') -def _is_numeric(x): - try: - float(x) - except ValueError: - return False - return True +class ScipConfig(BranchAndBoundConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + BranchAndBoundConfig.__init__( + self, + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + self.use_mipstart: bool = self.declare( + 'use_mipstart', + ConfigValue( + default=False, + domain=bool, + description="If True, the current values of the integer variables " + "will be passed to Scip.", + ), + ) -@SolverFactory.register("scip_direct", doc="Direct python interface to SCIP") -class SCIPDirect(DirectSolver): +def _handle_var(node, data, opt): + if id(node) not in opt._pyomo_var_to_solver_var_map: + scip_var = opt._add_var(node) + else: + scip_var = opt._pyomo_var_to_solver_var_map[id(node)] + return scip_var - def __init__(self, **kwds): - kwds["type"] = "scipdirect" - DirectSolver.__init__(self, **kwds) - self._init() - self._solver_model = None - def _init(self): - try: - import pyscipopt +def _handle_param(node, data, opt): + if not node.mutable: + return node.value + if id(node) not in opt._pyomo_param_to_solver_param_map: + scip_param = opt._add_param(node) + else: + scip_param = opt._pyomo_param_to_solver_param_map[id(node)] + return scip_param - 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}") +def _handle_float(node, data, opt): + return float(node) - # 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." - ) +def _handle_negation(node, data, opt): + return -data[0] - 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) - ) +def _handle_pow(node, data, opt): + return data[0] ** data[1] - 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) +def _handle_product(node, data, opt): + assert len(data) == 2 + return data[0] * data[1] + + +def _handle_division(node, data, opt): + return data[0] / data[1] + + +def _handle_sum(node, data, opt): + return sum(data) + + +def _handle_exp(node, data, opt): + return scip.exp(data[0]) + + +def _handle_log(node, data, opt): + return scip.log(data[0]) + + +def _handle_sin(node, data, opt): + return scip.sin(data[0]) + + +def _handle_cos(node, data, opt): + return scip.cos(data[0]) + + +def _handle_sqrt(node, data, opt): + return scip.sqrt(data[0]) + + +def _handle_abs(node, data, opt): + return abs(data[0]) + - return new_expr, referenced_vars +def _handle_tan(node, data, opt): + return scip.sin(data[0]) / scip.cos(data[0]) - 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) + +_unary_map = { + 'exp': _handle_exp, + 'log': _handle_log, + 'sin': _handle_sin, + 'cos': _handle_cos, + 'sqrt': _handle_sqrt, + 'abs': _handle_abs, + 'tan': _handle_tan, +} + + +def _handle_unary(node, data, opt): + if node.getname() in _unary_map: + return _unary_map[node.getname()](node, data, opt) + else: + raise NotImplementedError(f'unable to handle unary expression: {str(node)}') + + +def _handle_equality(node, data, opt): + return data[0] == data[1] + + +def _handle_ranged(node, data, opt): + return data[0] <= (data[1] <= data[2]) + + +def _handle_inequality(node, data, opt): + return data[0] <= data[1] + + +def _handle_named_expression(node, data, opt): + return data[0] + + +_operator_map = { + NegationExpression: _handle_negation, + PowExpression: _handle_pow, + ProductExpression: _handle_product, + MonomialTermExpression: _handle_product, + DivisionExpression: _handle_division, + SumExpression: _handle_sum, + LinearExpression: _handle_sum, + UnaryFunctionExpression: _handle_unary, + NPV_NegationExpression: _handle_negation, + NPV_PowExpression: _handle_pow, + NPV_ProductExpression: _handle_product, + NPV_DivisionExpression: _handle_division, + NPV_SumExpression: _handle_sum, + NPV_UnaryFunctionExpression: _handle_unary, + EqualityExpression: _handle_equality, + RangedExpression: _handle_ranged, + InequalityExpression: _handle_inequality, + ScalarExpression: _handle_named_expression, + ExpressionData: _handle_named_expression, + VarData: _handle_var, + ScalarVar: _handle_var, + ParamData: _handle_param, + ScalarParam: _handle_param, + float: _handle_float, + int: _handle_float, +} + + +class _PyomoToScipVisitor(StreamBasedExpressionVisitor): + def __init__(self, solver, **kwds): + super().__init__(**kwds) + self.solver = solver + + def exitNode(self, node, data): + nt = type(node) + if nt in _operator_map: + return _operator_map[nt](node, data, self.solver) + elif nt in native_numeric_types: + _operator_map[nt] = _handle_float + return _handle_float(node, data, self.solver) else: - repn = generate_standard_repn(expr, quadratic=False) + raise NotImplementedError(f'unrecognized expression type: {nt}') + + +logger = logging.getLogger("pyomo.solvers") + + +class ScipDirectSolutionLoader(SolutionLoaderBase): + def __init__( + self, + solver_model, + var_id_map, + var_map, + con_map, + pyomo_model, + opt, + ) -> None: + super().__init__() + self._solver_model = solver_model + self._vars = var_id_map + self._var_map = var_map + self._con_map = con_map + self._pyomo_model = pyomo_model + # make sure the scip model does not get freed until the solution loader is garbage collected + self._opt = opt + + def get_number_of_solutions(self) -> int: + return self._solver_model.getNSols() + + def get_solution_ids(self) -> List: + return list(range(self.get_number_of_solutions())) + + def load_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> None: + for v, val in self.get_vars(vars_to_load=vars_to_load, solution_id=solution_id).items(): + v.value = val + + def get_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> Mapping[VarData, float]: + if vars_to_load is None: + vars_to_load = list(self._vars.values()) + if solution_id is None: + solution_id = 0 + sol = self._solver_model.getSols()[solution_id] + res = ComponentMap() + for v in vars_to_load: + sv = self._var_map[id(v)] + res[v] = sol[sv] + return res + + def get_reduced_costs( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> Mapping[VarData, float]: + return NotImplemented + + def get_duals( + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None + ) -> Dict[ConstraintData, float]: + return NotImplemented + + def load_import_suffixes(self, solution_id=None): + load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) + + +class SCIPDirect(SolverBase): - scip_expr, referenced_vars = self._get_expr_from_pyomo_repn(repn, max_degree) + _available = None + _tc_map = None + _minimum_version = (5, 5, 0) # this is probably conservative - return scip_expr, referenced_vars + CONFIG = ScipConfig() + + def __init__(self, **kwds): + super().__init__(**kwds) + self._solver_model = None + self._vars = {} # var id to var + self._params = {} # param id to param + self._pyomo_var_to_solver_var_map = {} # var id to scip var + self._pyomo_con_to_solver_con_map = {} + self._pyomo_param_to_solver_param_map = {} # param id to scip var with equal bounds + self._pyomo_sos_to_solver_sos_map = {} + self._expr_visitor = _PyomoToScipVisitor(self) + self._objective = None # pyomo objective + self._obj_var = None # a scip variable because the objective cannot be nonlinear + self._obj_con = None # a scip constraint (obj_var >= obj_expr) + + def _clear(self): + self._solver_model = None + self._vars = {} + self._params = {} + self._pyomo_var_to_solver_var_map = {} + self._pyomo_con_to_solver_con_map = {} + self._pyomo_param_to_solver_param_map = {} + self._pyomo_sos_to_solver_sos_map = {} + self._objective = None + self._obj_var = None + self._obj_con = None + + def available(self) -> Availability: + if self._available is not None: + return self._available + + if not scip_available: + SCIPDirect._available = Availability.NotFound + elif self.version() < self._minimum_version: + SCIPDirect._available = Availability.BadVersion + else: + SCIPDirect._available = Availability.FullLicense + + return self._available + + def version(self) -> Tuple: + return tuple(int(i) for i in scip.__version__) + + def solve(self, model: BlockData, **kwargs) -> Results: + start_timestamp = datetime.datetime.now(datetime.timezone.utc) + orig_config = self.config + if not self.available(): + raise ApplicationError( + f'{self.name} is not available: {self.available()}' + ) + try: + config = self.config(value=kwds, preserve_implicit=True) + + # hack to work around legacy solver wrapper __setattr__ + # otherwise, this would just be self.config = config + object.__setattr__(self, 'config', config) + + StaleFlagManager.mark_all_as_stale() + + if config.timer is None: + config.timer = HierarchicalTimer() + timer = config.timer + + ostreams = [io.StringIO()] + config.tee + + scip_model, solution_loader, has_obj = self._create_solver_model(model) + + scip_model.hideOutput(quiet=False) + if config.threads is not None: + scip_model.setParam('lp/threads', config.threads) + if config.time_limit is not None: + scip_model.setParam('limits/time', config.time_limit) + if config.rel_gap is not None: + scip_model.setParam('limits/gap', config.rel_gap) + if config.abs_gap is not None: + scip_model.setParam('limits/absgap', config.abs_gap) + + if config.use_mipstart: + self._mipstart() + + for key, option in config.solver_options.items(): + scip_model.setParam(key, option) + + timer.start('optimize') + with capture_output(TeeStream(*ostreams), capture_fd=False): + scip_model.optimize() + timer.stop('optimize') + + results = self._postsolve(scip_model, solution_loader, has_obj) + except InfeasibleConstraintException: + results = self._get_infeasible_results() + finally: + # hack to work around legacy solver wrapper __setattr__ + # otherwise, this would just be self.config = orig_config + object.__setattr__(self, 'config', orig_config) + + results.solver_log = ostreams[0].getvalue() + end_timestamp = datetime.datetime.now(datetime.timezone.utc) + results.timing_info.start_timestamp = start_timestamp + results.timing_info.wall_time = (end_timestamp - start_timestamp).total_seconds() + results.timing_info.timer = timer + return results + + def _get_tc_map(self): + if SCIPDirect._tc_map is None: + tc = TerminationCondition + SCIPDirect._tc_map = { + "unknown": tc.unknown, + "userinterrupt": tc.interrupted, + "nodelimit": tc.iterationLimit, + "totalnodelimit": tc.iterationLimit, + "stallnodelimit": tc.iterationLimit, + "timelimit": tc.maxTimeLimit, + "memlimit": tc.unknown, + "gaplimit": tc.convergenceCriteriaSatisfied, # TODO: check this + "primallimit": tc.objectiveLimit, + "duallimit": tc.objectiveLimit, + "sollimit": tc.unknown, + "bestsollimit": tc.unknown, + "restartlimit": tc.unknown, + "optimal": tc.convergenceCriteriaSatisfied, + "infeasible": tc.provenInfeasible, + "unbounded": tc.unbounded, + "inforunbd": tc.infeasibleOrUnbounded, + "terminate": tc.unknown, + } + return SCIPDirect._tc_map + + def _get_infeasible_results(self): + res = Results() + res.solution_loader = NoSolutionSolutionLoader() + res.solution_status = SolutionStatus.noSolution + res.termination_condition = TerminationCondition.provenInfeasible + res.incumbent_objective = None + res.objective_bound = None + res.iteration_count = None + res.timing_info.scip_time = None + res.solver_config = self.config + res.solver_name = self.name + res.solver_version = self.version() + if self.config.raise_exception_on_nonoptimal_result: + raise NoOptimalSolutionError() + if self.config.load_solutions: + raise NoFeasibleSolutionError() + return res 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, ub = var.bounds() + + if lb is None: lb = -self._solver_model.infinity() - if var.has_ub(): - ub = value(var.ub) - else: + if ub is None: 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): + scip_var = self._solver_model.addVar(lb=lb, ub=ub, vtype=vtype) + + self._vars[id(var)] = var + self._pyomo_var_to_solver_var_map[id(var)] = scip_var + return scip_var + + def _add_param(self, p): + vtype = "C" + lb = ub = p.value + scip_var = self._solver_model.addVar(lb=lb, ub=ub, vtype=vtype) + self._params[id(p)] = p + self._pyomo_param_to_solver_param_map[id(p)] = scip_var + return scip_var + + def __del__(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}" + def _add_constraints(self, cons: List[ConstraintData]): + for con in cons: + self._add_constraint(con) + + def _add_sos_constraints(self, cons: List[SOSConstraintData]): + for on in cons: + self._add_sos_constraint(con) + + def _create_solver_model(self, model): + timer = self.config.timer + timer.start('create scip model') + self._clear() + self._solver_model = scip.Model() + timer.start('collect constraints') + cons = list( + model.component_data_objects( + Constraint, descend_into=True, active=True ) - 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 SCIP fails otherwise." - ) - 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 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" + ) + timer.stop('collect constraints') + timer.start('translate constraints') + self._add_constraints(cons) + timer.stop('translate constraints') + timer.start('sos') + sos = list( + model.component_data_objects( + SOSConstraint, descend_into=True, active=True ) + ) + self._add_sos_constraints(sos) + timer.stop('sos') + timer.start('get objective') + obj = get_objective(model) + timer.stop('get objective') + timer.start('translate objective') + self._set_objective(obj) + timer.stop('translate objective') + has_obj = obj is not None + solution_loader = ScipDirectSolutionLoader( + solver_model=self._solver_model, + var_id_map=self._vars, + var_map=self._pyomo_var_to_solver_var_map, + con_map=self._pyomo_con_to_solver_con_map, + pyomo_model=model, + opt=self, + ) + timer.stop('create scip model') + return self._solver_model, solution_loader, has_obj - 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_constraint(self, con): + scip_expr = self._expr_visitor.walk_expression(con.expr) + scip_con = self._solver_model.addCons(scip_expr) + self._pyomo_con_to_solver_con_map[con] = scip_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") + raise ValueError(f"{self.name} 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 + for v, w in con.get_items(): + vid = id(v) + if vid not in self._pyomo_var_to_solver_var_map: + self._add_var(v) + scip_vars.append(self._pyomo_var_to_solver_var_map[vid]) weights.append(w) if level == 1: scip_cons = self._solver_model.addConsSOS1( - scip_vars, weights=weights, name=conname + scip_vars, weights=weights ) else: scip_cons = self._solver_model.addConsSOS2( - scip_vars, weights=weights, name=conname + scip_vars, weights=weights ) - 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 + self._pyomo_con_to_solver_con_map[con] = scip_cons def _scip_vtype_from_var(self, var): """ @@ -421,342 +607,104 @@ def _scip_vtype_from_var(self, var): return vtype def _set_objective(self, obj): + if self._obj_var is None: + self._obj_var = self._solver_model.addVar( + lb=-self._solver_model.infinity(), + ub=self._solver_model.infinity(), + vtype="C" + ) + 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 + self._solver_model.delCons(self._obj_con) - if obj.active is False: - raise ValueError("Cannot add inactive objective to solver.") + if obj is None: + scip_expr = 0 + else: + scip_expr = self._expr_visitor.walk_expression(obj.expr) if obj.sense == minimize: sense = "minimize" + self._obj_con = self._solver_model.addCons(self._obj_var >= scip_expr) elif obj.sense == maximize: sense = "maximize" + self._obj_con = self._solver_model.addCons(self._obj_var <= scip_expr) 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._solver_model.setObjective(self._obj_var, 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 + def _postsolve( + self, + scip_model, + solution_loader: ScipDirectSolutionLoader, + has_obj + ): + + results = Results() + results.solution_loader = solution_loader + results.timing_info.scip_time = scip_model.getSolvingTime() + results.termination_condition = self._get_tc_map().get(scip_model.getStatus(), TerminationCondition.unknown) + + if solution_loader.get_number_of_solutions() > 0: + if results.termination_condition == TerminationCondition.convergenceCriteriaSatisfied: + results.solution_status = SolutionStatus.optimal + else: + results.solution_status = SolutionStatus.feasible 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() + results.solution_status = SolutionStatus.noSolution + + if ( + results.termination_condition + != TerminationCondition.convergenceCriteriaSatisfied + and self.config.raise_exception_on_nonoptimal_result + ): + raise NoOptimalSolutionError() + + if has_obj: + try: + if scip_model.getObjVal() < scip_model.infinity(): + results.incumbent_objective = scip_model.getObjVal() else: - self.results.problem.lower_bound = -scip.infinity() - + results.incumbent_objective = None + except: + results.incumbent_objective = None 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_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) + results.objective_bound = scip_model.getDualbound() + if results.objective_bound <= -scip_model.infinity(): + results.objective_bound = -math.inf + if results.objective_bound >= scip_model.infinity(): + results.objective_bound = math.inf + except: + if self._objective.sense == minimize: + results.objective_bound = -math.inf + else: + results.objective_bound = math.inf 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." - ) + results.incumbent_objective = None + results.objective_bound = None - 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) + self.config.timer.start('load solution') + if self.config.load_solutions: + if solution_loader.get_number_of_solutions() > 0: + solution_loader.load_solution() + else: + raise NoFeasibleSolutionError() + self.config.timer.stop('load solution') + + results.iteration_count = scip_model.getNNodes() + results.solver_config = self.config + results.solver_name = self.name + results.solver_version = self.version() + + return results + + def _mipstart(self): + # TODO: it is also possible to specify continuous variables, but + # I think we should have a differnt option for that + sol = self._solver_model.createPartialSol() + for vid, scip_var in self._pyomo_var_to_solver_var_map.items(): + pyomo_var = self._vars[vid] + if pyomo_var.is_integer(): + sol[scip_var] = pyomo_var.value + self._solver_model.addSol(sol) From 96017680cd9329626ae18bb2bdf7993f97940f74 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 14 Aug 2025 23:47:44 -0600 Subject: [PATCH 31/66] porting scip interface --- pyomo/contrib/solver/solvers/scip/scip_direct.py | 8 ++++---- pyomo/solvers/plugins/solvers/__init__.py | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index b8d4d14a6c1..5ea3391eecd 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -69,7 +69,7 @@ logger = logging.getLogger(__name__) -scip, scip_available = attempt_import('pyscipyopt') +scip, scip_available = attempt_import('pyscipopt') class ScipConfig(BranchAndBoundConfig): @@ -360,9 +360,9 @@ def available(self) -> Availability: return self._available def version(self) -> Tuple: - return tuple(int(i) for i in scip.__version__) + return tuple(int(i) for i in scip.__version__.split('.')) - def solve(self, model: BlockData, **kwargs) -> Results: + def solve(self, model: BlockData, **kwds) -> Results: start_timestamp = datetime.datetime.now(datetime.timezone.utc) orig_config = self.config if not self.available(): @@ -470,7 +470,7 @@ def _scip_lb_ub_from_var(self, var): val = var.value return val, val - lb, ub = var.bounds() + lb, ub = var.bounds if lb is None: lb = -self._solver_model.infinity() diff --git a/pyomo/solvers/plugins/solvers/__init__.py b/pyomo/solvers/plugins/solvers/__init__.py index 55baaab9de8..cf10af15186 100644 --- a/pyomo/solvers/plugins/solvers/__init__.py +++ b/pyomo/solvers/plugins/solvers/__init__.py @@ -31,8 +31,6 @@ mosek_persistent, xpress_direct, xpress_persistent, - scip_direct, - scip_persistent, SAS, KNITROAMPL, ) From 9d2f22ab5ba29157c7765c82b0fd0e67d34a8e22 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 14 Aug 2025 23:55:03 -0600 Subject: [PATCH 32/66] porting scip interface --- pyomo/contrib/solver/plugins.py | 6 ++++++ pyomo/contrib/solver/tests/solvers/test_solvers.py | 10 +++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index fed739232ad..a4d6f5f9004 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -15,6 +15,7 @@ from .solvers.gurobi.gurobi_direct import GurobiDirect from .solvers.gurobi.gurobi_persistent import GurobiDirectQuadratic, GurobiPersistent from .solvers.highs import Highs +from .solvers.scip.scip_direct import SCIPDirect def load(): @@ -39,3 +40,8 @@ def load(): SolverFactory.register( name='highs', legacy_name='highs_v2', doc='Persistent interface to HiGHS' )(Highs) + SolverFactory.register( + name='scip_direct', + legacy_name='scip_direct_v2', + doc='Direct interface pyscipopt', + ) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 189b0373780..f4988ca5c8b 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -35,6 +35,7 @@ GurobiDirectQuadratic, GurobiPersistent, ) +from pyomo.contrib.solver.solvers.scip.scip_direct import SCIPDirect from pyomo.contrib.solver.common.util import ( NoSolutionError, NoFeasibleSolutionError, @@ -60,23 +61,30 @@ ('gurobi_direct_quadratic', GurobiDirectQuadratic), ('ipopt', Ipopt), ('highs', Highs), + ('scip_direct', SCIPDirect), ] mip_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct', GurobiDirect), ('gurobi_direct_quadratic', GurobiDirectQuadratic), ('highs', Highs), + ('scip_direct', SCIPDirect), +] +nlp_solvers = [ + ('ipopt', Ipopt), + ('scip_direct', SCIPDirect), ] -nlp_solvers = [('ipopt', Ipopt)] qcp_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct_quadratic', GurobiDirectQuadratic), ('ipopt', Ipopt), + ('scip_direct', SCIPDirect), ] qp_solvers = qcp_solvers + [("highs", Highs)] miqcqp_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct_quadratic', GurobiDirectQuadratic), + ('scip_direct', SCIPDirect), ] nl_solvers = [('ipopt', Ipopt)] nl_solvers_set = {i[0] for i in nl_solvers} From b4837295c57ee4005643a19f783f25dff3530be9 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Fri, 15 Aug 2025 00:49:53 -0600 Subject: [PATCH 33/66] bugs and tests --- .../solver/solvers/scip/scip_direct.py | 25 ++- .../solver/tests/solvers/test_solvers.py | 180 ++++++++++-------- 2 files changed, 120 insertions(+), 85 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 5ea3391eecd..1ff470223bd 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -12,6 +12,7 @@ import datetime import io import logging +import math from typing import Tuple, List, Optional, Sequence, Mapping, Dict from pyomo.common.collections import ComponentMap @@ -40,6 +41,7 @@ NPV_SumExpression, NPV_UnaryFunctionExpression, ) +from pyomo.gdp.disjunct import AutoLinkedBinaryVar from pyomo.core.base.expression import ExpressionData, ScalarExpression from pyomo.core.expr.relational_expr import EqualityExpression, InequalityExpression, RangedExpression from pyomo.core.staleflag import StaleFlagManager @@ -50,6 +52,7 @@ from pyomo.contrib.solver.common.util import ( NoFeasibleSolutionError, NoOptimalSolutionError, + NoSolutionError, ) from pyomo.contrib.solver.common.util import get_objective from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader @@ -109,6 +112,8 @@ def _handle_var(node, data, opt): def _handle_param(node, data, opt): + if not opt.is_persistent(): + return node.value if not node.mutable: return node.value if id(node) not in opt._pyomo_param_to_solver_param_map: @@ -231,6 +236,7 @@ def _handle_named_expression(node, data, opt): ScalarParam: _handle_param, float: _handle_float, int: _handle_float, + AutoLinkedBinaryVar: _handle_var, } @@ -287,6 +293,8 @@ def load_vars( def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: + if self.get_number_of_solutions() == 0: + raise NoSolutionError() if vars_to_load is None: vars_to_load = list(self._vars.values()) if solution_id is None: @@ -403,7 +411,7 @@ def solve(self, model: BlockData, **kwds) -> Results: scip_model.setParam(key, option) timer.start('optimize') - with capture_output(TeeStream(*ostreams), capture_fd=False): + with capture_output(TeeStream(*ostreams), capture_fd=True): scip_model.optimize() timer.stop('optimize') @@ -619,17 +627,20 @@ def _set_objective(self, obj): if obj is None: scip_expr = 0 + sense = "minimize" else: scip_expr = self._expr_visitor.walk_expression(obj.expr) + if obj.sense == minimize: + sense = "minimize" + elif obj.sense == maximize: + sense = "maximize" + else: + raise ValueError(f"Objective sense is not recognized: {obj.sense}") - if obj.sense == minimize: - sense = "minimize" + if sense == "minimize": self._obj_con = self._solver_model.addCons(self._obj_var >= scip_expr) - elif obj.sense == maximize: - sense = "maximize" - self._obj_con = self._solver_model.addCons(self._obj_var <= scip_expr) else: - raise ValueError(f"Objective sense is not recognized: {obj.sense}") + self._obj_con = self._solver_model.addCons(self._obj_var <= scip_expr) self._solver_model.setObjective(self._obj_var, sense=sense) self._objective = obj diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index f4988ca5c8b..1b6f122219c 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -88,6 +88,13 @@ ] nl_solvers = [('ipopt', Ipopt)] nl_solvers_set = {i[0] for i in nl_solvers} +dual_solvers = [ + ('gurobi_persistent', GurobiPersistent), + ('gurobi_direct', GurobiDirect), + ('gurobi_direct_quadratic', GurobiDirectQuadratic), + ('ipopt', Ipopt), + ('highs', Highs), +] def _load_tests(solver_list): @@ -114,7 +121,7 @@ def test_all_solvers_list(): class TestDualSignConvention(unittest.TestCase): - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -166,7 +173,7 @@ def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bo self.assertAlmostEqual(duals[m.c1], 0) self.assertAlmostEqual(duals[m.c2], -1) - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_inequality( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -228,7 +235,7 @@ def test_inequality( self.assertAlmostEqual(duals[m.c1], 0.5) self.assertAlmostEqual(duals[m.c2], 0.5) - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_bounds(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -283,7 +290,7 @@ def test_bounds(self, name: str, opt_class: Type[SolverBase], use_presolve: bool rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], -1) - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_range(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -335,7 +342,7 @@ def test_range(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) self.assertAlmostEqual(duals[m.c1], -0.5) self.assertAlmostEqual(duals[m.c2], -0.5) - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_equality_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -389,7 +396,7 @@ def test_equality_max( self.assertAlmostEqual(duals[m.c1], 0) self.assertAlmostEqual(duals[m.c2], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_inequality_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -451,7 +458,7 @@ def test_inequality_max( self.assertAlmostEqual(duals[m.c1], -0.5) self.assertAlmostEqual(duals[m.c2], -0.5) - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_bounds_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -508,7 +515,7 @@ def test_bounds_max( rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_range_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -664,16 +671,18 @@ def test_range_constraint( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, -1) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c], 1) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c], 1) m.obj.sense = pyo.maximize res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 1) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c], 1) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_reduced_costs( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -702,7 +711,7 @@ def test_reduced_costs( self.assertAlmostEqual(rc[m.x], -3) self.assertAlmostEqual(rc[m.y], -4) - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_reduced_costs2( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -768,9 +777,10 @@ def test_param_changes( else: bound = res.objective_bound self.assertTrue(bound <= m.y.value) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) @parameterized.expand(input=_load_tests(all_solvers)) def test_immutable_param( @@ -815,9 +825,10 @@ def test_immutable_param( else: bound = res.objective_bound self.assertTrue(bound <= m.y.value) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) @parameterized.expand(input=_load_tests(all_solvers)) def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): @@ -831,6 +842,8 @@ def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bo check_duals = False else: opt.config.writer_config.linear_presolve = False + if (name, opt_class) not in dual_solvers: + check_duals = False m = pyo.ConcreteModel() m.x = pyo.Var() m.y = pyo.Var() @@ -922,6 +935,8 @@ def test_no_objective( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False + if (name, opt_class) not in dual_solvers: + check_duals = False m = pyo.ConcreteModel() m.x = pyo.Var() m.y = pyo.Var() @@ -983,9 +998,10 @@ def test_add_remove_cons( else: bound = res.objective_bound self.assertTrue(bound <= m.y.value) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) m.c3 = pyo.Constraint(expr=m.y >= a3 * m.x + b3) res = opt.solve(m) @@ -994,10 +1010,11 @@ def test_add_remove_cons( self.assertAlmostEqual(m.y.value, a1 * (b3 - b1) / (a1 - a3) + b1) self.assertAlmostEqual(res.incumbent_objective, m.y.value) self.assertTrue(res.objective_bound is None or res.objective_bound <= m.y.value) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a3 - a1))) - self.assertAlmostEqual(duals[m.c2], 0) - self.assertAlmostEqual(duals[m.c3], a1 / (a3 - a1)) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a3 - a1))) + self.assertAlmostEqual(duals[m.c2], 0) + self.assertAlmostEqual(duals[m.c3], a1 / (a3 - a1)) del m.c3 res = opt.solve(m) @@ -1006,9 +1023,10 @@ def test_add_remove_cons( self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) self.assertAlmostEqual(res.incumbent_objective, m.y.value) self.assertTrue(res.objective_bound is None or res.objective_bound <= m.y.value) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) @parameterized.expand(input=_load_tests(all_solvers)) def test_results_infeasible( @@ -1057,14 +1075,15 @@ def test_results_infeasible( NoSolutionError, '.*does not currently have a valid solution.*' ): res.solution_loader.load_vars() - with self.assertRaisesRegex( - NoDualsError, '.*does not currently have valid duals.*' - ): - res.solution_loader.get_duals() - with self.assertRaisesRegex( - NoReducedCostsError, '.*does not currently have valid reduced costs.*' - ): - res.solution_loader.get_reduced_costs() + if (name, opt_class) in dual_solvers: + with self.assertRaisesRegex( + NoDualsError, '.*does not currently have valid duals.*' + ): + res.solution_loader.get_duals() + with self.assertRaisesRegex( + NoReducedCostsError, '.*does not currently have valid reduced costs.*' + ): + res.solution_loader.get_reduced_costs() @parameterized.expand(input=_load_tests(all_solvers)) def test_trivial_constraints( @@ -1118,7 +1137,7 @@ def test_trivial_constraints( self.assertIn(res.termination_condition, acceptable_termination_conditions) self.assertIsNone(res.incumbent_objective) - @parameterized.expand(input=_load_tests(all_solvers)) + @parameterized.expand(input=_load_tests(dual_solvers)) def test_duals(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -1167,13 +1186,13 @@ def test_mutable_quadratic_coefficient( m.c = pyo.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 0.41024548525899274, 4) - self.assertAlmostEqual(m.y.value, 0.34781038127030117, 4) + self.assertAlmostEqual(m.x.value, 0.41024548525899274, 3) + self.assertAlmostEqual(m.y.value, 0.34781038127030117, 3) m.a.value = 2 m.b.value = -0.5 res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 0.10256137418973625, 4) - self.assertAlmostEqual(m.y.value, 0.0869525991355825, 4) + self.assertAlmostEqual(m.x.value, 0.10256137418973625, 3) + self.assertAlmostEqual(m.y.value, 0.0869525991355825, 3) @parameterized.expand(input=_load_tests(qcp_solvers)) def test_mutable_quadratic_objective_qcp( @@ -1198,14 +1217,14 @@ def test_mutable_quadratic_objective_qcp( m.ccon = pyo.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 0.2719178742733325, 4) - self.assertAlmostEqual(m.y.value, 0.5301035741688002, 4) + self.assertAlmostEqual(m.x.value, 0.2719178742733325, 3) + self.assertAlmostEqual(m.y.value, 0.5301035741688002, 3) m.c.value = 3.5 m.d.value = -1 res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 0.6962249634573562, 4) - self.assertAlmostEqual(m.y.value, 0.09227926676152151, 4) + self.assertAlmostEqual(m.x.value, 0.6962249634573562, 3) + self.assertAlmostEqual(m.y.value, 0.09227926676152151, 3) @parameterized.expand(input=_load_tests(qp_solvers)) def test_mutable_quadratic_objective_qp( @@ -1412,7 +1431,7 @@ def test_fixed_vars_4( else: opt.config.writer_config.linear_presolve = False m = pyo.ConcreteModel() - m.x = pyo.Var() + m.x = pyo.Var(bounds=(0, None)) m.y = pyo.Var() m.obj = pyo.Objective(expr=m.x**2 + m.y**2) m.c1 = pyo.Constraint(expr=m.x == 2 / m.y) @@ -1421,8 +1440,8 @@ def test_fixed_vars_4( self.assertAlmostEqual(m.x.value, 2) m.y.unfix() res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 2**0.5) - self.assertAlmostEqual(m.y.value, 2**0.5) + self.assertAlmostEqual(m.x.value, 2**0.5, 3) + self.assertAlmostEqual(m.y.value, 2**0.5, 3) @parameterized.expand(input=_load_tests(all_solvers)) def test_mutable_param_with_range( @@ -1506,9 +1525,10 @@ def test_mutable_param_with_range( res.objective_bound is None or res.objective_bound <= m.y.value + 1e-12 ) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) - self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) + self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) else: self.assertAlmostEqual(m.x.value, (c2 - c1) / (a1 - a2), 6) self.assertAlmostEqual(m.y.value, a1 * (c2 - c1) / (a1 - a2) + c1, 6) @@ -1517,9 +1537,10 @@ def test_mutable_param_with_range( res.objective_bound is None or res.objective_bound >= m.y.value - 1e-12 ) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) - self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) + self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) @parameterized.expand(input=_load_tests(all_solvers)) def test_add_and_remove_vars( @@ -1590,8 +1611,8 @@ def test_exp(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): m.obj = pyo.Objective(expr=m.x**2 + m.y**2) m.c1 = pyo.Constraint(expr=m.y >= pyo.exp(m.x)) res = opt.solve(m) - self.assertAlmostEqual(m.x.value, -0.42630274815985264) - self.assertAlmostEqual(m.y.value, 0.6529186341994245) + self.assertAlmostEqual(m.x.value, -0.42630274815985264, 4) + self.assertAlmostEqual(m.y.value, 0.6529186341994245, 4) @parameterized.expand(input=_load_tests(nlp_solvers)) def test_log(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): @@ -1609,8 +1630,8 @@ def test_log(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): m.obj = pyo.Objective(expr=m.x**2 + m.y**2) m.c1 = pyo.Constraint(expr=m.y <= pyo.log(m.x)) res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 0.6529186341994245) - self.assertAlmostEqual(m.y.value, -0.42630274815985264) + self.assertAlmostEqual(m.x.value, 0.6529186341994245, 3) + self.assertAlmostEqual(m.y.value, -0.42630274815985264, 3) @parameterized.expand(input=_load_tests(all_solvers)) def test_with_numpy( @@ -1720,24 +1741,25 @@ def test_solution_loader( self.assertNotIn(m.x, primals) self.assertIn(m.y, primals) self.assertAlmostEqual(primals[m.y], 1) - reduced_costs = res.solution_loader.get_reduced_costs() - self.assertIn(m.x, reduced_costs) - self.assertIn(m.y, reduced_costs) - self.assertAlmostEqual(reduced_costs[m.x], 1) - self.assertAlmostEqual(reduced_costs[m.y], 0) - reduced_costs = res.solution_loader.get_reduced_costs([m.y]) - self.assertNotIn(m.x, reduced_costs) - self.assertIn(m.y, reduced_costs) - self.assertAlmostEqual(reduced_costs[m.y], 0) - duals = res.solution_loader.get_duals() - self.assertIn(m.c1, duals) - self.assertIn(m.c2, duals) - self.assertAlmostEqual(duals[m.c1], 1) - self.assertAlmostEqual(duals[m.c2], 0) - duals = res.solution_loader.get_duals([m.c1]) - self.assertNotIn(m.c2, duals) - self.assertIn(m.c1, duals) - self.assertAlmostEqual(duals[m.c1], 1) + if (name, opt_class) in dual_solvers: + reduced_costs = res.solution_loader.get_reduced_costs() + self.assertIn(m.x, reduced_costs) + self.assertIn(m.y, reduced_costs) + self.assertAlmostEqual(reduced_costs[m.x], 1) + self.assertAlmostEqual(reduced_costs[m.y], 0) + reduced_costs = res.solution_loader.get_reduced_costs([m.y]) + self.assertNotIn(m.x, reduced_costs) + self.assertIn(m.y, reduced_costs) + self.assertAlmostEqual(reduced_costs[m.y], 0) + duals = res.solution_loader.get_duals() + self.assertIn(m.c1, duals) + self.assertIn(m.c2, duals) + self.assertAlmostEqual(duals[m.c1], 1) + self.assertAlmostEqual(duals[m.c2], 0) + duals = res.solution_loader.get_duals([m.c1]) + self.assertNotIn(m.c2, duals) + self.assertIn(m.c1, duals) + self.assertAlmostEqual(duals[m.c1], 1) @parameterized.expand(input=_load_tests(all_solvers)) def test_time_limit( @@ -2219,6 +2241,8 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False + if (name, opt_class) not in dual_solvers: + check_duals = False m = pyo.ConcreteModel() m.x = pyo.Var() From 37a31a7fc7b7fe8cb14179e4dc18df4dbdd8bc87 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Fri, 15 Aug 2025 01:50:18 -0600 Subject: [PATCH 34/66] scip direct --- pyomo/contrib/solver/plugins.py | 2 +- .../contrib/solver/solvers/scip/scip_direct.py | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index a4d6f5f9004..81e3677b19e 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -44,4 +44,4 @@ def load(): name='scip_direct', legacy_name='scip_direct_v2', doc='Direct interface pyscipopt', - ) + )(SCIPDirect) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 1ff470223bd..3926dd25a1c 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -16,6 +16,7 @@ from typing import Tuple, List, Optional, Sequence, Mapping, Dict from pyomo.common.collections import ComponentMap +from pyomo.core.expr.numvalue import is_constant from pyomo.common.numeric_types import native_numeric_types from pyomo.common.errors import InfeasibleConstraintException, ApplicationError from pyomo.common.timing import HierarchicalTimer @@ -67,6 +68,8 @@ ) from pyomo.common.config import ConfigValue from pyomo.common.tee import capture_output, TeeStream +from pyomo.core.base.units_container import _PyomoUnit +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr logger = logging.getLogger(__name__) @@ -132,7 +135,15 @@ def _handle_negation(node, data, opt): def _handle_pow(node, data, opt): - return data[0] ** data[1] + x, y = data # x ** y = exp(log(x**y)) = exp(y*log(x)) + if is_constant(node.args[1]): + return x**y + else: + xlb, xub = compute_bounds_on_expr(node.args[0]) + if xlb > 0: + return scip.exp(y*scip.log(x)) + else: + return x**y # scip will probably raise an error here def _handle_product(node, data, opt): @@ -210,6 +221,10 @@ def _handle_named_expression(node, data, opt): return data[0] +def _handle_unit(node, data, opt): + return node.value + + _operator_map = { NegationExpression: _handle_negation, PowExpression: _handle_pow, @@ -237,6 +252,7 @@ def _handle_named_expression(node, data, opt): float: _handle_float, int: _handle_float, AutoLinkedBinaryVar: _handle_var, + _PyomoUnit: _handle_unit, } From 0b84dcc77e826022c705ae95d187dc29e0b24e1f Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 18 Aug 2025 04:57:27 -0600 Subject: [PATCH 35/66] more expression types for scip --- .../solver/solvers/scip/scip_direct.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 3926dd25a1c..d72ce47ef02 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -42,6 +42,7 @@ NPV_SumExpression, NPV_UnaryFunctionExpression, ) +from pyomo.core.expr.numvalue import NumericConstant from pyomo.gdp.disjunct import AutoLinkedBinaryVar from pyomo.core.base.expression import ExpressionData, ScalarExpression from pyomo.core.expr.relational_expr import EqualityExpression, InequalityExpression, RangedExpression @@ -126,6 +127,10 @@ def _handle_param(node, data, opt): return scip_param +def _handle_constant(node, data, opt): + return node.value + + def _handle_float(node, data, opt): return float(node) @@ -167,6 +172,10 @@ def _handle_log(node, data, opt): return scip.log(data[0]) +def _handle_log10(node, data, opt): + return scip.log(data[0]) / math.log(10) + + def _handle_sin(node, data, opt): return scip.sin(data[0]) @@ -187,6 +196,12 @@ def _handle_tan(node, data, opt): return scip.sin(data[0]) / scip.cos(data[0]) +def _handle_tanh(node, data, opt): + x = data[0] + _exp = scip.exp + return (_exp(x) - _exp(-x)) / (_exp(x) + _exp(-x)) + + _unary_map = { 'exp': _handle_exp, 'log': _handle_log, @@ -195,6 +210,8 @@ def _handle_tan(node, data, opt): 'sqrt': _handle_sqrt, 'abs': _handle_abs, 'tan': _handle_tan, + 'log10': _handle_log10, + 'tanh': _handle_tanh, } @@ -253,6 +270,7 @@ def _handle_unit(node, data, opt): int: _handle_float, AutoLinkedBinaryVar: _handle_var, _PyomoUnit: _handle_unit, + NumericConstant: _handle_constant, } @@ -427,7 +445,7 @@ def solve(self, model: BlockData, **kwds) -> Results: scip_model.setParam(key, option) timer.start('optimize') - with capture_output(TeeStream(*ostreams), capture_fd=True): + with capture_output(TeeStream(*ostreams), capture_fd=False): scip_model.optimize() timer.stop('optimize') @@ -690,7 +708,7 @@ def _postsolve( if has_obj: try: - if scip_model.getObjVal() < scip_model.infinity(): + if scip_model.getNSols() > 0 and scip_model.getObjVal() < scip_model.infinity(): results.incumbent_objective = scip_model.getObjVal() else: results.incumbent_objective = None From 3180462477261e7c1eaa63e349b35abf080c6f05 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Wed, 20 Aug 2025 09:08:52 -0600 Subject: [PATCH 36/66] capture_fd for scip --- pyomo/contrib/solver/solvers/scip/scip_direct.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index d72ce47ef02..8deca600b40 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -445,7 +445,7 @@ def solve(self, model: BlockData, **kwds) -> Results: scip_model.setParam(key, option) timer.start('optimize') - with capture_output(TeeStream(*ostreams), capture_fd=False): + with capture_output(TeeStream(*ostreams), capture_fd=True): scip_model.optimize() timer.stop('optimize') From 72912e0d9e5397a3403061aff3daf32a4e9b281c Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Fri, 22 Aug 2025 08:49:40 -0600 Subject: [PATCH 37/66] working on persistent interface to scip --- .../solver/solvers/scip/scip_direct.py | 161 +++++++++++++++++- 1 file changed, 153 insertions(+), 8 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 8deca600b40..344b1741552 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -9,6 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from __future__ import annotations import datetime import io import logging @@ -49,7 +50,7 @@ from pyomo.core.staleflag import StaleFlagManager from pyomo.core.expr.visitor import StreamBasedExpressionVisitor from pyomo.common.dependencies import attempt_import -from pyomo.contrib.solver.common.base import SolverBase, Availability +from pyomo.contrib.solver.common.base import SolverBase, Availability, PersistentSolverBase from pyomo.contrib.solver.common.config import BranchAndBoundConfig from pyomo.contrib.solver.common.util import ( NoFeasibleSolutionError, @@ -71,6 +72,7 @@ from pyomo.common.tee import capture_output, TeeStream from pyomo.core.base.units_container import _PyomoUnit from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr +from pyomo.contrib.observer.model_observer import Observer, ModelChangeDetector logger = logging.getLogger(__name__) @@ -354,7 +356,72 @@ def load_import_suffixes(self, solution_id=None): load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) -class SCIPDirect(SolverBase): +class ScipPersistentSolutionLoader(ScipDirectSolutionLoader): + def __init__( + self, + solver_model, + var_id_map, + var_map, + con_map, + pyomo_model, + opt, + ) -> None: + super().__init__( + solver_model, + var_id_map, + var_map, + con_map, + pyomo_model, + opt, + ) + self._valid = False + + def invalidate(self): + self._valid = False + + def _assert_solution_still_valid(self): + if not self._valid: + raise RuntimeError('The results in the solver are no longer valid.') + + def load_vars( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> None: + self._assert_solution_still_valid() + return super().load_vars(vars_to_load, solution_id) + + def get_vars( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> Mapping[VarData, float]: + self._assert_solution_still_valid() + return super().get_vars(vars_to_load, solution_id) + + def get_duals( + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + ) -> Dict[ConstraintData, float]: + self._assert_solution_still_valid() + return super().get_duals(cons_to_load) + + def get_reduced_costs( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> Mapping[VarData, float]: + self._assert_solution_still_valid() + return super().get_reduced_costs(vars_to_load) + + def get_number_of_solutions(self) -> int: + self._assert_solution_still_valid() + return super().get_number_of_solutions() + + def get_solution_ids(self) -> List: + self._assert_solution_still_valid() + return super().get_solution_ids() + + def load_import_suffixes(self, solution_id=None): + self._assert_solution_still_valid() + super().load_import_suffixes(solution_id) + + + +class ScipDirect(SolverBase): _available = None _tc_map = None @@ -393,11 +460,11 @@ def available(self) -> Availability: return self._available if not scip_available: - SCIPDirect._available = Availability.NotFound + ScipDirect._available = Availability.NotFound elif self.version() < self._minimum_version: - SCIPDirect._available = Availability.BadVersion + ScipDirect._available = Availability.BadVersion else: - SCIPDirect._available = Availability.FullLicense + ScipDirect._available = Availability.FullLicense return self._available @@ -465,9 +532,9 @@ def solve(self, model: BlockData, **kwds) -> Results: return results def _get_tc_map(self): - if SCIPDirect._tc_map is None: + if ScipDirect._tc_map is None: tc = TerminationCondition - SCIPDirect._tc_map = { + ScipDirect._tc_map = { "unknown": tc.unknown, "userinterrupt": tc.interrupted, "nodelimit": tc.iterationLimit, @@ -487,7 +554,7 @@ def _get_tc_map(self): "inforunbd": tc.infeasibleOrUnbounded, "terminate": tc.unknown, } - return SCIPDirect._tc_map + return ScipDirect._tc_map def _get_infeasible_results(self): res = Results() @@ -753,3 +820,81 @@ def _mipstart(self): if pyomo_var.is_integer(): sol[scip_var] = pyomo_var.value self._solver_model.addSol(sol) + + +class _SCIPObserver(Observer): + def __init__(self, opt: ScipPersistent) -> None: + self.opt = opt + + def add_variables(self, variables: List[VarData]): + self.opt._add_variables(variables) + + def add_parameters(self, params: List[ParamData]): + pass + + def add_constraints(self, cons: List[ConstraintData]): + self.opt._add_constraints(cons) + + def add_sos_constraints(self, cons: List[SOSConstraintData]): + self.opt._add_sos_constraints(cons) + + def set_objective(self, obj: ObjectiveData | None): + self.opt._set_objective(obj) + + def remove_constraints(self, cons: List[ConstraintData]): + self.opt._remove_constraints(cons) + + def remove_sos_constraints(self, cons: List[SOSConstraintData]): + self.opt._remove_sos_constraints(cons) + + def remove_variables(self, variables: List[VarData]): + self.opt._remove_variables(variables) + + def remove_parameters(self, params: List[ParamData]): + pass + + def update_variables(self, variables: List[VarData]): + self.opt._update_variables(variables) + + def update_parameters(self, params: List[ParamData]): + self.opt._update_parameters(params) + + +class ScipPersistent(ScipDirect, PersistentSolverBase): + _minimum_version = (5, 5, 0) # this is probably conservative + + CONFIG = ScipConfig() + + def __init__(self, **kwds): + super().__init__(**kwds) + self._pyomo_model = None + self._objective = None + self._observer = _SCIPObserver(self) + self._change_detector = ModelChangeDetector(observers=[self._observer]) + + @property + def auto_updates(self): + return self._change_detector.config + + def _clear(self): + super()._clear() + self._pyomo_model = None + self._objective = None + + def _create_solver_model(self, model): + if model is self._pyomo_model: + self.update() + else: + self.set_instance(model=model) + + solution_loader = ScipPersistentSolutionLoader( + solver_model=self._solver_model, + var_id_map=self._vars, + var_map=self._pyomo_var_to_solver_var_map, + con_map=self._pyomo_con_to_solver_con_map, + pyomo_model=model, + opt=self, + ) + + has_obj = self._objective is not None: + return self._solver_model, solution_loader, has_obj \ No newline at end of file From cfa1e9108f6edd05d238056e6f071122a3164d39 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 1 Sep 2025 17:10:19 -0600 Subject: [PATCH 38/66] minor fixes --- pyomo/contrib/solver/plugins.py | 4 ++-- pyomo/contrib/solver/solvers/scip/scip_direct.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 81e3677b19e..895b6387725 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -15,7 +15,7 @@ from .solvers.gurobi.gurobi_direct import GurobiDirect from .solvers.gurobi.gurobi_persistent import GurobiDirectQuadratic, GurobiPersistent from .solvers.highs import Highs -from .solvers.scip.scip_direct import SCIPDirect +from .solvers.scip.scip_direct import ScipDirect def load(): @@ -44,4 +44,4 @@ def load(): name='scip_direct', legacy_name='scip_direct_v2', doc='Direct interface pyscipopt', - )(SCIPDirect) + )(ScipDirect) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 344b1741552..1032affd597 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -896,5 +896,5 @@ def _create_solver_model(self, model): opt=self, ) - has_obj = self._objective is not None: + has_obj = self._objective is not None return self._solver_model, solution_loader, has_obj \ No newline at end of file From e6331dfa67f60224a075f0fb1a8c4bc57baf43df Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sun, 5 Oct 2025 21:28:15 -0600 Subject: [PATCH 39/66] persistent interface to scip --- .../solver/solvers/scip/scip_direct.py | 223 ++++++++++++++++-- 1 file changed, 204 insertions(+), 19 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 1032affd597..cf0c71606f8 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -25,6 +25,7 @@ from pyomo.core.base.var import VarData, ScalarVar from pyomo.core.base.param import ParamData, ScalarParam from pyomo.core.base.constraint import Constraint, ConstraintData +from pyomo.core.base.objective import ObjectiveData from pyomo.core.base.sos import SOSConstraint, SOSConstraintData from pyomo.core.kernel.objective import minimize, maximize from pyomo.core.expr.numeric_expr import ( @@ -72,7 +73,7 @@ from pyomo.common.tee import capture_output, TeeStream from pyomo.core.base.units_container import _PyomoUnit from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr -from pyomo.contrib.observer.model_observer import Observer, ModelChangeDetector +from pyomo.contrib.observer.model_observer import Observer, ModelChangeDetector, AutoUpdateConfig logger = logging.getLogger(__name__) @@ -420,7 +421,6 @@ def load_import_suffixes(self, solution_id=None): super().load_import_suffixes(solution_id) - class ScipDirect(SolverBase): _available = None @@ -822,7 +822,27 @@ def _mipstart(self): self._solver_model.addSol(sol) -class _SCIPObserver(Observer): +class ScipPersistentConfig(ScipConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + ScipConfig.__init__( + self, + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + self.auto_updates: bool = self.declare('auto_updates', AutoUpdateConfig()) + + +class _ScipObserver(Observer): def __init__(self, opt: ScipPersistent) -> None: self.opt = opt @@ -838,8 +858,11 @@ def add_constraints(self, cons: List[ConstraintData]): def add_sos_constraints(self, cons: List[SOSConstraintData]): self.opt._add_sos_constraints(cons) - def set_objective(self, obj: ObjectiveData | None): - self.opt._set_objective(obj) + def add_objectives(self, objs: List[ObjectiveData]): + self.opt._add_objectives(objs) + + def remove_objectives(self, objs: List[ObjectiveData]): + self.opt._remove_objectives(objs) def remove_constraints(self, cons: List[ConstraintData]): self.opt._remove_constraints(cons) @@ -862,39 +885,201 @@ def update_parameters(self, params: List[ParamData]): class ScipPersistent(ScipDirect, PersistentSolverBase): _minimum_version = (5, 5, 0) # this is probably conservative - - CONFIG = ScipConfig() + CONFIG = ScipPersistentConfig() def __init__(self, **kwds): super().__init__(**kwds) self._pyomo_model = None - self._objective = None - self._observer = _SCIPObserver(self) - self._change_detector = ModelChangeDetector(observers=[self._observer]) - - @property - def auto_updates(self): - return self._change_detector.config + self._observer = None + self._change_detector = None + self._last_results_object: Optional[Results] = None def _clear(self): super()._clear() self._pyomo_model = None self._objective = None + self._observer = None + self._change_detector = None - def _create_solver_model(self, model): - if model is self._pyomo_model: + def _create_solver_model(self, pyomo_model): + if pyomo_model is self._pyomo_model: self.update() else: - self.set_instance(model=model) + self.set_instance(pyomo_model=pyomo_model) solution_loader = ScipPersistentSolutionLoader( solver_model=self._solver_model, var_id_map=self._vars, var_map=self._pyomo_var_to_solver_var_map, con_map=self._pyomo_con_to_solver_con_map, - pyomo_model=model, + pyomo_model=pyomo_model, opt=self, ) has_obj = self._objective is not None - return self._solver_model, solution_loader, has_obj \ No newline at end of file + return self._solver_model, solution_loader, has_obj + + def solve(self, model, **kwds) -> Results: + res = super().solve(model, **kwds) + return res + + def update(self): + if self.config.timer is None: + timer = HierarchicalTimer() + else: + timer = self.config.timer + if self._pyomo_model is None: + raise RuntimeError('must call set_instance or solve before update') + timer.start('update') + self._change_detector.update(timer=timer) + timer.stop('update') + + def set_instance(self, pyomo_model): + if self.config.timer is None: + timer = HierarchicalTimer() + else: + timer = self.config.timer + self._clear() + self._pyomo_model = pyomo_model + self._solver_model = scip.Model() + self._observer = _ScipObserver(self) + timer.start('set_instance') + self._change_detector = ModelChangeDetector( + model=self._pyomo_model, + observers=[self._observer], + **dict(self.config.auto_updates), + ) + self._change_detector.config = self.config.auto_updates + timer.stop('set_instance') + + def _invalidate_last_results(self): + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + + def _add_variables(self, variables: List[VarData]): + self._invalidate_last_results() + for v in variables: + self._add_var(v) + + def _add_constraints(self, cons: List[ConstraintData]): + self._invalidate_last_results() + super()._add_constraints(cons) + + def _add_sos_constraints(self, cons: List[SOSConstraintData]): + self._invalidate_last_results() + return super()._add_sos_constraints(cons) + + def _add_objectives(self, objs: List[ObjectiveData]): + if len(objs) > 1: + raise NotImplementedError( + 'the persistent interface to gurobi currently ' + f'only supports single-objective problems; got {len(objs)}: ' + f'{[str(i) for i in objs]}' + ) + + if len(objs) == 0: + return + + obj = objs[0] + + if self._objective is not None: + raise NotImplementedError( + 'the persistent interface to gurobi currently ' + 'only supports single-objective problems; tried to add ' + f'an objective ({str(obj)}), but there is already an ' + f'active objective ({str(self._objective)})' + ) + + self._invalidate_last_results() + self._set_objective(obj) + + def _remove_objectives(self, objs: List[ObjectiveData]): + for obj in objs: + if obj is not self._objective: + raise RuntimeError( + 'tried to remove an objective that has not been added: ' + f'{str(obj)}' + ) + else: + self._invalidate_last_results() + self._set_objective(None) + + def _remove_constraints(self, cons: List[ConstraintData]): + for con in cons: + scip_con = self._pyomo_con_to_solver_con_map.pop(con) + self._solver_model.delCons(scip_con) + + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): + for con in cons: + scip_con = self._pyomo_con_to_solver_con_map.pop(con) + self._solver_model.delCons(scip_con) + + def _remove_variables(self, variables: List[VarData]): + for v in variables: + vid = id(v) + scip_var = self._pyomo_var_to_solver_var_map.pop(vid) + self._solver_model.delVar(scip_var) + self._vars.pop(vid) + + def _update_variables(self, variables: List[VarData]): + for v in variables: + vid = id(v) + scip_var = self._pyomo_var_to_solver_var_map[vid] + vtype = self._scip_vtype_from_var(v) + lb, ub = self._scip_lb_ub_from_var(v) + self._solver_model.chgVarLb(scip_var, lb) + self._solver_model.chgVarUb(scip_var, ub) + self._solver_model.chgVarType(scip_var, vtype) + + def _update_parameters(self, params: List[ParamData]): + for p in params: + pid = id(p) + scip_var = self._pyomo_param_to_solver_param_map[pid] + lb = ub = p.value + self._solver_model.chgVarLb(scip_var, lb) + self._solver_model.chgVarUb(scip_var, ub) + + def add_variables(self, variables): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.add_variables(variables) + + def add_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.add_constraints(cons) + + def add_sos_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.add_sos_constraints(cons) + + def set_objective(self, obj: ObjectiveData): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.add_objectives([obj]) + + def remove_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.remove_constraints(cons) + + def remove_sos_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.remove_sos_constraints(cons) + + def remove_variables(self, variables): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.remove_variables(variables) + + def update_variables(self, variables): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.update_variables(variables) + + def update_parameters(self, params): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.update_parameters(params) From 98e2c9a9904b688eb4cb735474bbfd0ea092370f Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sun, 5 Oct 2025 21:40:47 -0600 Subject: [PATCH 40/66] update docs --- .../reference/topical/solvers/scip_persistent.rst | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 doc/OnlineDocs/reference/topical/solvers/scip_persistent.rst diff --git a/doc/OnlineDocs/reference/topical/solvers/scip_persistent.rst b/doc/OnlineDocs/reference/topical/solvers/scip_persistent.rst deleted file mode 100644 index 63ed55b74e3..00000000000 --- a/doc/OnlineDocs/reference/topical/solvers/scip_persistent.rst +++ /dev/null @@ -1,7 +0,0 @@ -SCIPPersistent -================ - -.. autoclass:: pyomo.solvers.plugins.solvers.scip_persistent.SCIPPersistent - :members: - :inherited-members: - :show-inheritance: \ No newline at end of file From f0be4ffd5a9cfed6b51d8aa22453358426d873cd Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sun, 5 Oct 2025 21:41:07 -0600 Subject: [PATCH 41/66] update docs --- doc/OnlineDocs/reference/topical/solvers/index.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/OnlineDocs/reference/topical/solvers/index.rst b/doc/OnlineDocs/reference/topical/solvers/index.rst index 628f9cfdab0..400032df076 100644 --- a/doc/OnlineDocs/reference/topical/solvers/index.rst +++ b/doc/OnlineDocs/reference/topical/solvers/index.rst @@ -9,4 +9,3 @@ Solver Interfaces gurobi_direct.rst gurobi_persistent.rst xpress_persistent.rst - scip_persistent.rst From 7ec95a8f4f2c4a9e678cc61aa97c71be20411171 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sun, 5 Oct 2025 21:46:55 -0600 Subject: [PATCH 42/66] persistent interface to scip --- pyomo/contrib/solver/plugins.py | 7 ++++++- .../solver/tests/solvers/test_solvers.py | 17 +++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 2c7bab3bf03..4ac74ecf560 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -15,7 +15,7 @@ from .solvers.gurobi.gurobi_direct import GurobiDirect from .solvers.gurobi.gurobi_persistent import GurobiDirectQuadratic, GurobiPersistent from .solvers.highs import Highs -from .solvers.scip.scip_direct import ScipDirect +from .solvers.scip.scip_direct import ScipDirect, ScipPersistent def load(): @@ -45,3 +45,8 @@ def load(): legacy_name='scip_direct_v2', doc='Direct interface pyscipopt', )(ScipDirect) + SolverFactory.register( + name='scip_persistent', + legacy_name='scip_persistent_v2', + doc='Persistent interface pyscipopt', + )(ScipPersistent) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index b49d80baa37..6bd7d01e679 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -34,7 +34,7 @@ SolutionStatus, Results, ) -from pyomo.contrib.solver.solvers.scip.scip_direct import SCIPDirect +from pyomo.contrib.solver.solvers.scip.scip_direct import ScipDirect, ScipPersistent from pyomo.contrib.solver.common.util import ( NoDualsError, NoOptimalSolutionError, @@ -60,30 +60,35 @@ ('gurobi_direct_quadratic', GurobiDirectQuadratic), ('ipopt', Ipopt), ('highs', Highs), - ('scip_direct', SCIPDirect), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ] mip_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct', GurobiDirect), ('gurobi_direct_quadratic', GurobiDirectQuadratic), ('highs', Highs), - ('scip_direct', SCIPDirect), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ] nlp_solvers = [ ('ipopt', Ipopt), - ('scip_direct', SCIPDirect), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ] qcp_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct_quadratic', GurobiDirectQuadratic), ('ipopt', Ipopt), - ('scip_direct', SCIPDirect), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ] qp_solvers = qcp_solvers + [("highs", Highs)] miqcqp_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct_quadratic', GurobiDirectQuadratic), - ('scip_direct', SCIPDirect), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ] nl_solvers = [('ipopt', Ipopt)] nl_solvers_set = {i[0] for i in nl_solvers} From 75903d6cbbfbcd19f96bb709535e6b2a12b0086c Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 6 Oct 2025 08:44:16 -0600 Subject: [PATCH 43/66] persistent interface to scip --- pyomo/contrib/observer/model_observer.py | 9 ++ .../solver/solvers/scip/scip_direct.py | 135 +++++++++++++----- 2 files changed, 112 insertions(+), 32 deletions(-) diff --git a/pyomo/contrib/observer/model_observer.py b/pyomo/contrib/observer/model_observer.py index 2da340aab4f..77356ac1b57 100644 --- a/pyomo/contrib/observer/model_observer.py +++ b/pyomo/contrib/observer/model_observer.py @@ -1202,3 +1202,12 @@ def update(self, timer: Optional[HierarchicalTimer] = None, **kwds): finally: if is_gc_enabled: gc.enable() + + def get_variables_impacted_by_param(self, p: ParamData): + return [self._vars[vid][0] for vid in self._referenced_params[id(p)][3]] + + def get_constraints_impacted_by_param(self, p: ParamData): + return list(self._referenced_params[id(p)][0]) + + def get_constraints_impacted_by_var(self, v: VarData): + return list(self._referenced_variables[id(v)][0]) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index cf0c71606f8..99de1d80125 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -110,7 +110,7 @@ def __init__( ) -def _handle_var(node, data, opt): +def _handle_var(node, data, opt, visitor): if id(node) not in opt._pyomo_var_to_solver_var_map: scip_var = opt._add_var(node) else: @@ -118,7 +118,13 @@ def _handle_var(node, data, opt): return scip_var -def _handle_param(node, data, opt): +def _handle_param(node, data, opt, visitor): + # for the persistent interface, we create scip variables in place + # of parameters. However, this makes things complicated for range + # constraints because scip does not allow variables in the + # lower and upper parts of range constraints + if visitor.in_range: + return node.value if not opt.is_persistent(): return node.value if not node.mutable: @@ -130,19 +136,19 @@ def _handle_param(node, data, opt): return scip_param -def _handle_constant(node, data, opt): +def _handle_constant(node, data, opt, visitor): return node.value -def _handle_float(node, data, opt): +def _handle_float(node, data, opt, visitor): return float(node) -def _handle_negation(node, data, opt): +def _handle_negation(node, data, opt, visitor): return -data[0] -def _handle_pow(node, data, opt): +def _handle_pow(node, data, opt, visitor): x, y = data # x ** y = exp(log(x**y)) = exp(y*log(x)) if is_constant(node.args[1]): return x**y @@ -154,52 +160,52 @@ def _handle_pow(node, data, opt): return x**y # scip will probably raise an error here -def _handle_product(node, data, opt): +def _handle_product(node, data, opt, visitor): assert len(data) == 2 return data[0] * data[1] -def _handle_division(node, data, opt): +def _handle_division(node, data, opt, visitor): return data[0] / data[1] -def _handle_sum(node, data, opt): +def _handle_sum(node, data, opt, visitor): return sum(data) -def _handle_exp(node, data, opt): +def _handle_exp(node, data, opt, visitor): return scip.exp(data[0]) -def _handle_log(node, data, opt): +def _handle_log(node, data, opt, visitor): return scip.log(data[0]) -def _handle_log10(node, data, opt): +def _handle_log10(node, data, opt, visitor): return scip.log(data[0]) / math.log(10) -def _handle_sin(node, data, opt): +def _handle_sin(node, data, opt, visitor): return scip.sin(data[0]) -def _handle_cos(node, data, opt): +def _handle_cos(node, data, opt, visitor): return scip.cos(data[0]) -def _handle_sqrt(node, data, opt): +def _handle_sqrt(node, data, opt, visitor): return scip.sqrt(data[0]) -def _handle_abs(node, data, opt): +def _handle_abs(node, data, opt, visitor): return abs(data[0]) -def _handle_tan(node, data, opt): +def _handle_tan(node, data, opt, visitor): return scip.sin(data[0]) / scip.cos(data[0]) -def _handle_tanh(node, data, opt): +def _handle_tanh(node, data, opt, visitor): x = data[0] _exp = scip.exp return (_exp(x) - _exp(-x)) / (_exp(x) + _exp(-x)) @@ -218,30 +224,32 @@ def _handle_tanh(node, data, opt): } -def _handle_unary(node, data, opt): +def _handle_unary(node, data, opt, visitor): if node.getname() in _unary_map: - return _unary_map[node.getname()](node, data, opt) + return _unary_map[node.getname()](node, data, opt, visitor) else: raise NotImplementedError(f'unable to handle unary expression: {str(node)}') -def _handle_equality(node, data, opt): +def _handle_equality(node, data, opt, visitor): return data[0] == data[1] -def _handle_ranged(node, data, opt): +def _handle_ranged(node, data, opt, visitor): + # note that the lower and upper parts of the + # range constraint cannot have variables return data[0] <= (data[1] <= data[2]) -def _handle_inequality(node, data, opt): +def _handle_inequality(node, data, opt, visitor): return data[0] <= data[1] -def _handle_named_expression(node, data, opt): +def _handle_named_expression(node, data, opt, visitor): return data[0] -def _handle_unit(node, data, opt): +def _handle_unit(node, data, opt, visitor): return node.value @@ -281,16 +289,26 @@ class _PyomoToScipVisitor(StreamBasedExpressionVisitor): def __init__(self, solver, **kwds): super().__init__(**kwds) self.solver = solver + self.in_range = False + + def initializeWalker(self, expr): + self.in_range = False + return True, None def exitNode(self, node, data): nt = type(node) if nt in _operator_map: - return _operator_map[nt](node, data, self.solver) + return _operator_map[nt](node, data, self.solver, self) elif nt in native_numeric_types: _operator_map[nt] = _handle_float - return _handle_float(node, data, self.solver) + return _handle_float(node, data, self.solver, self) else: raise NotImplementedError(f'unrecognized expression type: {nt}') + + def enterNode(self, node): + if type(node) is RangedExpression: + self.in_range = True + return None, [] logger = logging.getLogger("pyomo.solvers") @@ -375,7 +393,7 @@ def __init__( pyomo_model, opt, ) - self._valid = False + self._valid = True def invalidate(self): self._valid = False @@ -513,6 +531,7 @@ def solve(self, model: BlockData, **kwds) -> Results: timer.start('optimize') with capture_output(TeeStream(*ostreams), capture_fd=True): + # scip_model.writeProblem(filename='foo.lp') scip_model.optimize() timer.stop('optimize') @@ -723,7 +742,7 @@ def _set_objective(self, obj): vtype="C" ) - if self._objective is not None: + if self._obj_con is not None: self._solver_model.delCons(self._obj_con) if obj is None: @@ -850,7 +869,7 @@ def add_variables(self, variables: List[VarData]): self.opt._add_variables(variables) def add_parameters(self, params: List[ParamData]): - pass + self.opt._add_parameters(params) def add_constraints(self, cons: List[ConstraintData]): self.opt._add_constraints(cons) @@ -874,7 +893,7 @@ def remove_variables(self, variables: List[VarData]): self.opt._remove_variables(variables) def remove_parameters(self, params: List[ParamData]): - pass + self.opt._remove_parameters(params) def update_variables(self, variables: List[VarData]): self.opt._update_variables(variables) @@ -893,13 +912,22 @@ def __init__(self, **kwds): self._observer = None self._change_detector = None self._last_results_object: Optional[Results] = None - + self._needs_reopt = False + self._range_constraints = set() + def _clear(self): super()._clear() self._pyomo_model = None self._objective = None self._observer = None self._change_detector = None + self._needs_reopt = False + + def _check_reopt(self): + if self._needs_reopt: + # self._solver_model.freeReoptSolve() # when is it safe to use this one??? + self._solver_model.freeTransform() + self._needs_reopt = False def _create_solver_model(self, pyomo_model): if pyomo_model is self._pyomo_model: @@ -921,6 +949,7 @@ def _create_solver_model(self, pyomo_model): def solve(self, model, **kwds) -> Results: res = super().solve(model, **kwds) + self._needs_reopt = True return res def update(self): @@ -957,19 +986,32 @@ def _invalidate_last_results(self): self._last_results_object.solution_loader.invalidate() def _add_variables(self, variables: List[VarData]): + self._check_reopt() self._invalidate_last_results() for v in variables: self._add_var(v) + def _add_parameters(self, params: List[ParamData]): + self._check_reopt() + self._invalidate_last_results() + for p in params: + self._add_param(p) + def _add_constraints(self, cons: List[ConstraintData]): + self._check_reopt() self._invalidate_last_results() + for con in cons: + if type(con.expr) is RangedExpression: + self._range_constraints.add(con) super()._add_constraints(cons) def _add_sos_constraints(self, cons: List[SOSConstraintData]): + self._check_reopt() self._invalidate_last_results() return super()._add_sos_constraints(cons) def _add_objectives(self, objs: List[ObjectiveData]): + self._check_reopt() if len(objs) > 1: raise NotImplementedError( 'the persistent interface to gurobi currently ' @@ -994,6 +1036,7 @@ def _add_objectives(self, objs: List[ObjectiveData]): self._set_objective(obj) def _remove_objectives(self, objs: List[ObjectiveData]): + self._check_reopt() for obj in objs: if obj is not self._objective: raise RuntimeError( @@ -1005,23 +1048,41 @@ def _remove_objectives(self, objs: List[ObjectiveData]): self._set_objective(None) def _remove_constraints(self, cons: List[ConstraintData]): + self._check_reopt() + self._invalidate_last_results() for con in cons: scip_con = self._pyomo_con_to_solver_con_map.pop(con) self._solver_model.delCons(scip_con) + self._range_constraints.discard(con) def _remove_sos_constraints(self, cons: List[SOSConstraintData]): + self._check_reopt() + self._invalidate_last_results() for con in cons: scip_con = self._pyomo_con_to_solver_con_map.pop(con) self._solver_model.delCons(scip_con) def _remove_variables(self, variables: List[VarData]): + self._check_reopt() + self._invalidate_last_results() for v in variables: vid = id(v) scip_var = self._pyomo_var_to_solver_var_map.pop(vid) self._solver_model.delVar(scip_var) self._vars.pop(vid) + def _remove_parameters(self, params: List[ParamData]): + self._check_reopt() + self._invalidate_last_results() + for p in params: + pid = id(p) + scip_var = self._pyomo_param_to_solver_param_map.pop(pid) + self._solver_model.delVar(scip_var) + self._params.pop(pid) + def _update_variables(self, variables: List[VarData]): + self._check_reopt() + self._invalidate_last_results() for v in variables: vid = id(v) scip_var = self._pyomo_var_to_solver_var_map[vid] @@ -1032,12 +1093,22 @@ def _update_variables(self, variables: List[VarData]): self._solver_model.chgVarType(scip_var, vtype) def _update_parameters(self, params: List[ParamData]): + self._check_reopt() + self._invalidate_last_results() for p in params: pid = id(p) scip_var = self._pyomo_param_to_solver_param_map[pid] lb = ub = p.value self._solver_model.chgVarLb(scip_var, lb) self._solver_model.chgVarUb(scip_var, ub) + impacted_vars = self._change_detector.get_variables_impacted_by_param(p) + if impacted_vars: + self._update_variables(impacted_vars) + impacted_cons = self._change_detector.get_constraints_impacted_by_param(p) + for con in impacted_cons: + if con in self._range_constraints: + self._remove_constraints([con]) + self._add_constraints([con]) def add_variables(self, variables): if self._change_detector is None: From 0051024e7a44ab7d4c3df43c96890c49669e3b69 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 6 Oct 2025 10:46:14 -0600 Subject: [PATCH 44/66] updating tests --- .../solver/tests/solvers/test_solvers.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 6bd7d01e679..3665de4521a 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -2381,7 +2381,8 @@ def test_param_updates(self, name: str, opt_class: Type[SolverBase]): m.obj = pyo.Objective(expr=m.y) m.c1 = pyo.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) m.c2 = pyo.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) - m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) + if (name, opt_class) in dual_solvers: + m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -2393,8 +2394,9 @@ def test_param_updates(self, name: str, opt_class: Type[SolverBase]): pyo.assert_optimal_termination(res) self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) - self.assertAlmostEqual(m.dual[m.c1], (1 + a1 / (a2 - a1))) - self.assertAlmostEqual(m.dual[m.c2], a1 / (a2 - a1)) + if (name, opt_class) in dual_solvers: + self.assertAlmostEqual(m.dual[m.c1], (1 + a1 / (a2 - a1))) + self.assertAlmostEqual(m.dual[m.c2], a1 / (a2 - a1)) @parameterized.expand(input=all_solvers) def test_load_solutions(self, name: str, opt_class: Type[SolverBase]): @@ -2405,11 +2407,14 @@ def test_load_solutions(self, name: str, opt_class: Type[SolverBase]): m.x = pyo.Var() m.obj = pyo.Objective(expr=m.x) m.c = pyo.Constraint(expr=(-1, m.x, 1)) - m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) + if (name, opt_class) in dual_solvers: + m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) res = opt.solve(m, load_solutions=False) pyo.assert_optimal_termination(res) self.assertIsNone(m.x.value) - self.assertNotIn(m.c, m.dual) + if (name, opt_class) in dual_solvers: + self.assertNotIn(m.c, m.dual) m.solutions.load_from(res) self.assertAlmostEqual(m.x.value, -1) - self.assertAlmostEqual(m.dual[m.c], 1) + if (name, opt_class) in dual_solvers: + self.assertAlmostEqual(m.dual[m.c], 1) From b037b9c356cd9caac3bf04924bece2c247962f91 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 6 Oct 2025 10:57:00 -0600 Subject: [PATCH 45/66] forgot to delete/revert some files --- .../solver/solvers/scip/scip_persistent.py | 192 ----------- pyomo/solvers/tests/checks/test_SCIPDirect.py | 310 ----------------- .../tests/checks/test_SCIPPersistent.py | 318 ------------------ pyomo/solvers/tests/solvers.py | 15 - pyomo/solvers/tests/testcases.py | 9 - 5 files changed, 844 deletions(-) delete mode 100644 pyomo/contrib/solver/solvers/scip/scip_persistent.py delete mode 100644 pyomo/solvers/tests/checks/test_SCIPDirect.py delete mode 100644 pyomo/solvers/tests/checks/test_SCIPPersistent.py diff --git a/pyomo/contrib/solver/solvers/scip/scip_persistent.py b/pyomo/contrib/solver/solvers/scip/scip_persistent.py deleted file mode 100644 index bc64edc28a8..00000000000 --- a/pyomo/contrib/solver/solvers/scip/scip_persistent.py +++ /dev/null @@ -1,192 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2025 -# 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 deleted file mode 100644 index 186de0eaf58..00000000000 --- a/pyomo/solvers/tests/checks/test_SCIPDirect.py +++ /dev/null @@ -1,310 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2025 -# 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 deleted file mode 100644 index 61cf7385352..00000000000 --- a/pyomo/solvers/tests/checks/test_SCIPPersistent.py +++ /dev/null @@ -1,318 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2025 -# 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 e9967cd1ce2..e5058e8894b 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -369,21 +369,6 @@ 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 c1725bedee7..696936ddf05 100644 --- a/pyomo/solvers/tests/testcases.py +++ b/pyomo/solvers/tests/testcases.py @@ -248,15 +248,6 @@ "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 # From c200e2e996920c0eccb2e12d3b74376847e3b448 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 6 Oct 2025 15:09:16 -0600 Subject: [PATCH 46/66] run black --- pyomo/contrib/observer/model_observer.py | 2 +- pyomo/contrib/solver/plugins.py | 8 +- .../solver/solvers/scip/scip_direct.py | 138 +++++++++--------- .../solver/tests/solvers/test_solvers.py | 3 +- 4 files changed, 75 insertions(+), 76 deletions(-) diff --git a/pyomo/contrib/observer/model_observer.py b/pyomo/contrib/observer/model_observer.py index 77356ac1b57..9bb9c917fc5 100644 --- a/pyomo/contrib/observer/model_observer.py +++ b/pyomo/contrib/observer/model_observer.py @@ -1208,6 +1208,6 @@ def get_variables_impacted_by_param(self, p: ParamData): def get_constraints_impacted_by_param(self, p: ParamData): return list(self._referenced_params[id(p)][0]) - + def get_constraints_impacted_by_var(self, v: VarData): return list(self._referenced_variables[id(v)][0]) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 4ac74ecf560..ff24148dd73 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -41,12 +41,12 @@ def load(): name='highs', legacy_name='highs_v2', doc='Persistent interface to HiGHS' )(Highs) SolverFactory.register( - name='scip_direct', - legacy_name='scip_direct_v2', + name='scip_direct', + legacy_name='scip_direct_v2', doc='Direct interface pyscipopt', )(ScipDirect) SolverFactory.register( - name='scip_persistent', - legacy_name='scip_persistent_v2', + name='scip_persistent', + legacy_name='scip_persistent_v2', doc='Persistent interface pyscipopt', )(ScipPersistent) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 99de1d80125..7e39d6e8595 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -47,11 +47,19 @@ from pyomo.core.expr.numvalue import NumericConstant from pyomo.gdp.disjunct import AutoLinkedBinaryVar from pyomo.core.base.expression import ExpressionData, ScalarExpression -from pyomo.core.expr.relational_expr import EqualityExpression, InequalityExpression, RangedExpression +from pyomo.core.expr.relational_expr import ( + EqualityExpression, + InequalityExpression, + RangedExpression, +) from pyomo.core.staleflag import StaleFlagManager from pyomo.core.expr.visitor import StreamBasedExpressionVisitor from pyomo.common.dependencies import attempt_import -from pyomo.contrib.solver.common.base import SolverBase, Availability, PersistentSolverBase +from pyomo.contrib.solver.common.base import ( + SolverBase, + Availability, + PersistentSolverBase, +) from pyomo.contrib.solver.common.config import BranchAndBoundConfig from pyomo.contrib.solver.common.util import ( NoFeasibleSolutionError, @@ -73,7 +81,11 @@ from pyomo.common.tee import capture_output, TeeStream from pyomo.core.base.units_container import _PyomoUnit from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr -from pyomo.contrib.observer.model_observer import Observer, ModelChangeDetector, AutoUpdateConfig +from pyomo.contrib.observer.model_observer import ( + Observer, + ModelChangeDetector, + AutoUpdateConfig, +) logger = logging.getLogger(__name__) @@ -120,8 +132,8 @@ def _handle_var(node, data, opt, visitor): def _handle_param(node, data, opt, visitor): # for the persistent interface, we create scip variables in place - # of parameters. However, this makes things complicated for range - # constraints because scip does not allow variables in the + # of parameters. However, this makes things complicated for range + # constraints because scip does not allow variables in the # lower and upper parts of range constraints if visitor.in_range: return node.value @@ -155,7 +167,7 @@ def _handle_pow(node, data, opt, visitor): else: xlb, xub = compute_bounds_on_expr(node.args[0]) if xlb > 0: - return scip.exp(y*scip.log(x)) + return scip.exp(y * scip.log(x)) else: return x**y # scip will probably raise an error here @@ -236,7 +248,7 @@ def _handle_equality(node, data, opt, visitor): def _handle_ranged(node, data, opt, visitor): - # note that the lower and upper parts of the + # note that the lower and upper parts of the # range constraint cannot have variables return data[0] <= (data[1] <= data[2]) @@ -304,7 +316,7 @@ def exitNode(self, node, data): return _handle_float(node, data, self.solver, self) else: raise NotImplementedError(f'unrecognized expression type: {nt}') - + def enterNode(self, node): if type(node) is RangedExpression: self.in_range = True @@ -316,13 +328,7 @@ def enterNode(self, node): class ScipDirectSolutionLoader(SolutionLoaderBase): def __init__( - self, - solver_model, - var_id_map, - var_map, - con_map, - pyomo_model, - opt, + self, solver_model, var_id_map, var_map, con_map, pyomo_model, opt ) -> None: super().__init__() self._solver_model = solver_model @@ -342,7 +348,9 @@ def get_solution_ids(self) -> List: def load_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: - for v, val in self.get_vars(vars_to_load=vars_to_load, solution_id=solution_id).items(): + for v, val in self.get_vars( + vars_to_load=vars_to_load, solution_id=solution_id + ).items(): v.value = val def get_vars( @@ -377,22 +385,9 @@ def load_import_suffixes(self, solution_id=None): class ScipPersistentSolutionLoader(ScipDirectSolutionLoader): def __init__( - self, - solver_model, - var_id_map, - var_map, - con_map, - pyomo_model, - opt, + self, solver_model, var_id_map, var_map, con_map, pyomo_model, opt ) -> None: - super().__init__( - solver_model, - var_id_map, - var_map, - con_map, - pyomo_model, - opt, - ) + super().__init__(solver_model, var_id_map, var_map, con_map, pyomo_model, opt) self._valid = True def invalidate(self): @@ -401,7 +396,7 @@ def invalidate(self): def _assert_solution_still_valid(self): if not self._valid: raise RuntimeError('The results in the solver are no longer valid.') - + def load_vars( self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> None: @@ -454,11 +449,15 @@ def __init__(self, **kwds): self._params = {} # param id to param self._pyomo_var_to_solver_var_map = {} # var id to scip var self._pyomo_con_to_solver_con_map = {} - self._pyomo_param_to_solver_param_map = {} # param id to scip var with equal bounds + self._pyomo_param_to_solver_param_map = ( + {} + ) # param id to scip var with equal bounds self._pyomo_sos_to_solver_sos_map = {} self._expr_visitor = _PyomoToScipVisitor(self) self._objective = None # pyomo objective - self._obj_var = None # a scip variable because the objective cannot be nonlinear + self._obj_var = ( + None # a scip variable because the objective cannot be nonlinear + ) self._obj_con = None # a scip constraint (obj_var >= obj_expr) def _clear(self): @@ -476,7 +475,7 @@ def _clear(self): def available(self) -> Availability: if self._available is not None: return self._available - + if not scip_available: ScipDirect._available = Availability.NotFound elif self.version() < self._minimum_version: @@ -485,7 +484,7 @@ def available(self) -> Availability: ScipDirect._available = Availability.FullLicense return self._available - + def version(self) -> Tuple: return tuple(int(i) for i in scip.__version__.split('.')) @@ -493,9 +492,7 @@ def solve(self, model: BlockData, **kwds) -> Results: start_timestamp = datetime.datetime.now(datetime.timezone.utc) orig_config = self.config if not self.available(): - raise ApplicationError( - f'{self.name} is not available: {self.available()}' - ) + raise ApplicationError(f'{self.name} is not available: {self.available()}') try: config = self.config(value=kwds, preserve_implicit=True) @@ -546,7 +543,9 @@ def solve(self, model: BlockData, **kwds) -> Results: results.solver_log = ostreams[0].getvalue() end_timestamp = datetime.datetime.now(datetime.timezone.utc) results.timing_info.start_timestamp = start_timestamp - results.timing_info.wall_time = (end_timestamp - start_timestamp).total_seconds() + results.timing_info.wall_time = ( + end_timestamp - start_timestamp + ).total_seconds() results.timing_info.timer = timer return results @@ -616,7 +615,7 @@ def _add_var(self, var): self._vars[id(var)] = var self._pyomo_var_to_solver_var_map[id(var)] = scip_var return scip_var - + def _add_param(self, p): vtype = "C" lb = ub = p.value @@ -646,9 +645,7 @@ def _create_solver_model(self, model): self._solver_model = scip.Model() timer.start('collect constraints') cons = list( - model.component_data_objects( - Constraint, descend_into=True, active=True - ) + model.component_data_objects(Constraint, descend_into=True, active=True) ) timer.stop('collect constraints') timer.start('translate constraints') @@ -656,9 +653,7 @@ def _create_solver_model(self, model): timer.stop('translate constraints') timer.start('sos') sos = list( - model.component_data_objects( - SOSConstraint, descend_into=True, active=True - ) + model.component_data_objects(SOSConstraint, descend_into=True, active=True) ) self._add_sos_constraints(sos) timer.stop('sos') @@ -688,7 +683,9 @@ def _add_constraint(self, con): def _add_sos_constraint(self, con): level = con.level if level not in [1, 2]: - raise ValueError(f"{self.name} does not support SOS level {level} constraints") + raise ValueError( + f"{self.name} does not support SOS level {level} constraints" + ) scip_vars = [] weights = [] @@ -701,13 +698,9 @@ def _add_sos_constraint(self, con): weights.append(w) if level == 1: - scip_cons = self._solver_model.addConsSOS1( - scip_vars, weights=weights - ) + scip_cons = self._solver_model.addConsSOS1(scip_vars, weights=weights) else: - scip_cons = self._solver_model.addConsSOS2( - scip_vars, weights=weights - ) + scip_cons = self._solver_model.addConsSOS2(scip_vars, weights=weights) self._pyomo_con_to_solver_con_map[con] = scip_cons def _scip_vtype_from_var(self, var): @@ -737,9 +730,9 @@ def _scip_vtype_from_var(self, var): def _set_objective(self, obj): if self._obj_var is None: self._obj_var = self._solver_model.addVar( - lb=-self._solver_model.infinity(), - ub=self._solver_model.infinity(), - vtype="C" + lb=-self._solver_model.infinity(), + ub=self._solver_model.infinity(), + vtype="C", ) if self._obj_con is not None: @@ -766,19 +759,21 @@ def _set_objective(self, obj): self._objective = obj def _postsolve( - self, - scip_model, - solution_loader: ScipDirectSolutionLoader, - has_obj + self, scip_model, solution_loader: ScipDirectSolutionLoader, has_obj ): results = Results() results.solution_loader = solution_loader - results.timing_info.scip_time = scip_model.getSolvingTime() - results.termination_condition = self._get_tc_map().get(scip_model.getStatus(), TerminationCondition.unknown) - + results.timing_info.scip_time = scip_model.getSolvingTime() + results.termination_condition = self._get_tc_map().get( + scip_model.getStatus(), TerminationCondition.unknown + ) + if solution_loader.get_number_of_solutions() > 0: - if results.termination_condition == TerminationCondition.convergenceCriteriaSatisfied: + if ( + results.termination_condition + == TerminationCondition.convergenceCriteriaSatisfied + ): results.solution_status = SolutionStatus.optimal else: results.solution_status = SolutionStatus.feasible @@ -786,15 +781,18 @@ def _postsolve( results.solution_status = SolutionStatus.noSolution if ( - results.termination_condition + results.termination_condition != TerminationCondition.convergenceCriteriaSatisfied and self.config.raise_exception_on_nonoptimal_result ): raise NoOptimalSolutionError() - + if has_obj: try: - if scip_model.getNSols() > 0 and scip_model.getObjVal() < scip_model.infinity(): + if ( + scip_model.getNSols() > 0 + and scip_model.getObjVal() < scip_model.infinity() + ): results.incumbent_objective = scip_model.getObjVal() else: results.incumbent_objective = None @@ -831,7 +829,7 @@ def _postsolve( return results def _mipstart(self): - # TODO: it is also possible to specify continuous variables, but + # TODO: it is also possible to specify continuous variables, but # I think we should have a differnt option for that sol = self._solver_model.createPartialSol() for vid, scip_var in self._pyomo_var_to_solver_var_map.items(): @@ -1009,7 +1007,7 @@ def _add_sos_constraints(self, cons: List[SOSConstraintData]): self._check_reopt() self._invalidate_last_results() return super()._add_sos_constraints(cons) - + def _add_objectives(self, objs: List[ObjectiveData]): self._check_reopt() if len(objs) > 1: diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 3665de4521a..e6686266028 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -1152,7 +1152,8 @@ def test_results_infeasible( ): res.solution_loader.get_duals() with self.assertRaisesRegex( - NoReducedCostsError, '.*does not currently have valid reduced costs.*' + NoReducedCostsError, + '.*does not currently have valid reduced costs.*', ): res.solution_loader.get_reduced_costs() From 960c531bba4c9bc9ea65cb652c699c5a131a8382 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 6 Oct 2025 17:06:23 -0600 Subject: [PATCH 47/66] typos --- pyomo/contrib/solver/common/solution_loader.py | 2 +- pyomo/contrib/solver/solvers/scip/scip_direct.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index f8723b6e0f4..6be23b63c77 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -63,7 +63,7 @@ def get_solution_ids(self) -> List[Any]: """ If there are multiple solutions available, this will return a list of the solution ids which can then be used with other - methods like `load_soltuion`. If only one solution is + methods like `load_solution`. If only one solution is available, this will return [None]. If no solutions are available, this will return None diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 7e39d6e8595..05f39b0cb16 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -830,7 +830,7 @@ def _postsolve( def _mipstart(self): # TODO: it is also possible to specify continuous variables, but - # I think we should have a differnt option for that + # I think we should have a different option for that sol = self._solver_model.createPartialSol() for vid, scip_var in self._pyomo_var_to_solver_var_map.items(): pyomo_var = self._vars[vid] From 879ed3aea700887204873d1dd42b2063d1327f85 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 18 Dec 2025 11:21:10 -0700 Subject: [PATCH 48/66] update scip interface to use observer --- pyomo/contrib/observer/model_observer.py | 3 + .../solver/solvers/scip/scip_direct.py | 273 +++++++++--------- 2 files changed, 145 insertions(+), 131 deletions(-) diff --git a/pyomo/contrib/observer/model_observer.py b/pyomo/contrib/observer/model_observer.py index b1eda200a9a..9a4e8f27563 100644 --- a/pyomo/contrib/observer/model_observer.py +++ b/pyomo/contrib/observer/model_observer.py @@ -741,6 +741,9 @@ def remove_objectives(self, objs: Collection[ObjectiveData]): def _check_for_unknown_active_components(self): for ctype in self._model.collect_ctypes(active=True, descend_into=True): + if not issubclass(ctype, ActiveComponent): + # strangly, this is needed to skip things like Param + continue if ctype in self._known_active_ctypes: continue if ctype is Suffix: diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 05f39b0cb16..2d820e92028 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -85,6 +85,7 @@ Observer, ModelChangeDetector, AutoUpdateConfig, + Reason, ) @@ -111,8 +112,8 @@ def __init__( implicit_domain=implicit_domain, visibility=visibility, ) - self.use_mipstart: bool = self.declare( - 'use_mipstart', + self.warmstart_discrete_vars: bool = self.declare( + 'warmstart_discrete_vars', ConfigValue( default=False, domain=bool, @@ -328,11 +329,10 @@ def enterNode(self, node): class ScipDirectSolutionLoader(SolutionLoaderBase): def __init__( - self, solver_model, var_id_map, var_map, con_map, pyomo_model, opt + self, solver_model, var_map, con_map, pyomo_model, opt ) -> None: super().__init__() self._solver_model = solver_model - self._vars = var_id_map self._var_map = var_map self._con_map = con_map self._pyomo_model = pyomo_model @@ -359,13 +359,13 @@ def get_vars( if self.get_number_of_solutions() == 0: raise NoSolutionError() if vars_to_load is None: - vars_to_load = list(self._vars.values()) + vars_to_load = list(self._var_map.keys()) if solution_id is None: solution_id = 0 sol = self._solver_model.getSols()[solution_id] res = ComponentMap() for v in vars_to_load: - sv = self._var_map[id(v)] + sv = self._var_map[v] res[v] = sol[sv] return res @@ -385,9 +385,9 @@ def load_import_suffixes(self, solution_id=None): class ScipPersistentSolutionLoader(ScipDirectSolutionLoader): def __init__( - self, solver_model, var_id_map, var_map, con_map, pyomo_model, opt + self, solver_model, var_map, con_map, pyomo_model, opt ) -> None: - super().__init__(solver_model, var_id_map, var_map, con_map, pyomo_model, opt) + super().__init__(solver_model, var_map, con_map, pyomo_model, opt) self._valid = True def invalidate(self): @@ -445,13 +445,11 @@ class ScipDirect(SolverBase): def __init__(self, **kwds): super().__init__(**kwds) self._solver_model = None - self._vars = {} # var id to var - self._params = {} # param id to param - self._pyomo_var_to_solver_var_map = {} # var id to scip var + self._pyomo_var_to_solver_var_map = ComponentMap() self._pyomo_con_to_solver_con_map = {} self._pyomo_param_to_solver_param_map = ( - {} - ) # param id to scip var with equal bounds + ComponentMap() + ) # param to scip var with equal bounds self._pyomo_sos_to_solver_sos_map = {} self._expr_visitor = _PyomoToScipVisitor(self) self._objective = None # pyomo objective @@ -462,11 +460,9 @@ def __init__(self, **kwds): def _clear(self): self._solver_model = None - self._vars = {} - self._params = {} - self._pyomo_var_to_solver_var_map = {} + self._pyomo_var_to_solver_var_map = ComponentMap() self._pyomo_con_to_solver_con_map = {} - self._pyomo_param_to_solver_param_map = {} + self._pyomo_param_to_solver_param_map = ComponentMap() self._pyomo_sos_to_solver_sos_map = {} self._objective = None self._obj_var = None @@ -490,16 +486,9 @@ def version(self) -> Tuple: def solve(self, model: BlockData, **kwds) -> Results: start_timestamp = datetime.datetime.now(datetime.timezone.utc) - orig_config = self.config - if not self.available(): - raise ApplicationError(f'{self.name} is not available: {self.available()}') try: config = self.config(value=kwds, preserve_implicit=True) - # hack to work around legacy solver wrapper __setattr__ - # otherwise, this would just be self.config = config - object.__setattr__(self, 'config', config) - StaleFlagManager.mark_all_as_stale() if config.timer is None: @@ -508,7 +497,7 @@ def solve(self, model: BlockData, **kwds) -> Results: ostreams = [io.StringIO()] + config.tee - scip_model, solution_loader, has_obj = self._create_solver_model(model) + scip_model, solution_loader, has_obj = self._create_solver_model(model, config) scip_model.hideOutput(quiet=False) if config.threads is not None: @@ -520,7 +509,7 @@ def solve(self, model: BlockData, **kwds) -> Results: if config.abs_gap is not None: scip_model.setParam('limits/absgap', config.abs_gap) - if config.use_mipstart: + if config.warmstart_discrete_vars: self._mipstart() for key, option in config.solver_options.items(): @@ -532,13 +521,10 @@ def solve(self, model: BlockData, **kwds) -> Results: scip_model.optimize() timer.stop('optimize') - results = self._postsolve(scip_model, solution_loader, has_obj) + results = self._populate_results(scip_model, solution_loader, has_obj, config) except InfeasibleConstraintException: + # is it possible to hit this? results = self._get_infeasible_results() - finally: - # hack to work around legacy solver wrapper __setattr__ - # otherwise, this would just be self.config = orig_config - object.__setattr__(self, 'config', orig_config) results.solver_log = ostreams[0].getvalue() end_timestamp = datetime.datetime.now(datetime.timezone.utc) @@ -612,16 +598,14 @@ def _add_var(self, var): scip_var = self._solver_model.addVar(lb=lb, ub=ub, vtype=vtype) - self._vars[id(var)] = var - self._pyomo_var_to_solver_var_map[id(var)] = scip_var + self._pyomo_var_to_solver_var_map[var] = scip_var return scip_var def _add_param(self, p): vtype = "C" lb = ub = p.value scip_var = self._solver_model.addVar(lb=lb, ub=ub, vtype=vtype) - self._params[id(p)] = p - self._pyomo_param_to_solver_param_map[id(p)] = scip_var + self._pyomo_param_to_solver_param_map[p] = scip_var return scip_var def __del__(self): @@ -638,8 +622,8 @@ def _add_sos_constraints(self, cons: List[SOSConstraintData]): for on in cons: self._add_sos_constraint(con) - def _create_solver_model(self, model): - timer = self.config.timer + def _create_solver_model(self, model, config): + timer = config.timer timer.start('create scip model') self._clear() self._solver_model = scip.Model() @@ -666,7 +650,6 @@ def _create_solver_model(self, model): has_obj = obj is not None solution_loader = ScipDirectSolutionLoader( solver_model=self._solver_model, - var_id_map=self._vars, var_map=self._pyomo_var_to_solver_var_map, con_map=self._pyomo_con_to_solver_con_map, pyomo_model=model, @@ -758,8 +741,8 @@ def _set_objective(self, obj): self._solver_model.setObjective(self._obj_var, sense=sense) self._objective = obj - def _postsolve( - self, scip_model, solution_loader: ScipDirectSolutionLoader, has_obj + def _populate_results( + self, scip_model, solution_loader: ScipDirectSolutionLoader, has_obj, config ): results = Results() @@ -783,7 +766,7 @@ def _postsolve( if ( results.termination_condition != TerminationCondition.convergenceCriteriaSatisfied - and self.config.raise_exception_on_nonoptimal_result + and config.raise_exception_on_nonoptimal_result ): raise NoOptimalSolutionError() @@ -813,16 +796,16 @@ def _postsolve( results.incumbent_objective = None results.objective_bound = None - self.config.timer.start('load solution') - if self.config.load_solutions: + config.timer.start('load solution') + if config.load_solutions: if solution_loader.get_number_of_solutions() > 0: solution_loader.load_solution() else: raise NoFeasibleSolutionError() - self.config.timer.stop('load solution') + config.timer.stop('load solution') - results.iteration_count = scip_model.getNNodes() - results.solver_config = self.config + results.extra_info['NNodes'] = scip_model.getNNodes() + results.solver_config = config results.solver_name = self.name results.solver_version = self.version() @@ -832,8 +815,7 @@ def _mipstart(self): # TODO: it is also possible to specify continuous variables, but # I think we should have a different option for that sol = self._solver_model.createPartialSol() - for vid, scip_var in self._pyomo_var_to_solver_var_map.items(): - pyomo_var = self._vars[vid] + for pyomo_var, scip_var in self._pyomo_var_to_solver_var_map.items(): if pyomo_var.is_integer(): sol[scip_var] = pyomo_var.value self._solver_model.addSol(sol) @@ -859,55 +841,13 @@ def __init__( self.auto_updates: bool = self.declare('auto_updates', AutoUpdateConfig()) -class _ScipObserver(Observer): - def __init__(self, opt: ScipPersistent) -> None: - self.opt = opt - - def add_variables(self, variables: List[VarData]): - self.opt._add_variables(variables) - - def add_parameters(self, params: List[ParamData]): - self.opt._add_parameters(params) - - def add_constraints(self, cons: List[ConstraintData]): - self.opt._add_constraints(cons) - - def add_sos_constraints(self, cons: List[SOSConstraintData]): - self.opt._add_sos_constraints(cons) - - def add_objectives(self, objs: List[ObjectiveData]): - self.opt._add_objectives(objs) - - def remove_objectives(self, objs: List[ObjectiveData]): - self.opt._remove_objectives(objs) - - def remove_constraints(self, cons: List[ConstraintData]): - self.opt._remove_constraints(cons) - - def remove_sos_constraints(self, cons: List[SOSConstraintData]): - self.opt._remove_sos_constraints(cons) - - def remove_variables(self, variables: List[VarData]): - self.opt._remove_variables(variables) - - def remove_parameters(self, params: List[ParamData]): - self.opt._remove_parameters(params) - - def update_variables(self, variables: List[VarData]): - self.opt._update_variables(variables) - - def update_parameters(self, params: List[ParamData]): - self.opt._update_parameters(params) - - -class ScipPersistent(ScipDirect, PersistentSolverBase): +class ScipPersistent(ScipDirect, PersistentSolverBase, Observer): _minimum_version = (5, 5, 0) # this is probably conservative CONFIG = ScipPersistentConfig() def __init__(self, **kwds): super().__init__(**kwds) self._pyomo_model = None - self._observer = None self._change_detector = None self._last_results_object: Optional[Results] = None self._needs_reopt = False @@ -916,10 +856,9 @@ def __init__(self, **kwds): def _clear(self): super()._clear() self._pyomo_model = None - self._objective = None - self._observer = None self._change_detector = None self._needs_reopt = False + self._range_constraints = set() def _check_reopt(self): if self._needs_reopt: @@ -927,15 +866,14 @@ def _check_reopt(self): self._solver_model.freeTransform() self._needs_reopt = False - def _create_solver_model(self, pyomo_model): + def _create_solver_model(self, pyomo_model, config): if pyomo_model is self._pyomo_model: - self.update() + self.update(**config) else: - self.set_instance(pyomo_model=pyomo_model) + self.set_instance(pyomo_model, **config) solution_loader = ScipPersistentSolutionLoader( solver_model=self._solver_model, - var_id_map=self._vars, var_map=self._pyomo_var_to_solver_var_map, con_map=self._pyomo_con_to_solver_con_map, pyomo_model=pyomo_model, @@ -950,39 +888,128 @@ def solve(self, model, **kwds) -> Results: self._needs_reopt = True return res - def update(self): - if self.config.timer is None: + def update(self, **kwds): + config = self.config(value=kwds, preserve_implicit=True) + if config.timer is None: timer = HierarchicalTimer() else: - timer = self.config.timer + timer = config.timer if self._pyomo_model is None: raise RuntimeError('must call set_instance or solve before update') timer.start('update') - self._change_detector.update(timer=timer) + self._change_detector.update(timer=timer, **config.auto_updates) timer.stop('update') - def set_instance(self, pyomo_model): - if self.config.timer is None: + def set_instance(self, pyomo_model, **kwds): + config = self.config(value=kwds, preserve_implicit=True) + if config.timer is None: timer = HierarchicalTimer() else: - timer = self.config.timer + timer = config.timer self._clear() self._pyomo_model = pyomo_model self._solver_model = scip.Model() - self._observer = _ScipObserver(self) timer.start('set_instance') self._change_detector = ModelChangeDetector( model=self._pyomo_model, - observers=[self._observer], - **dict(self.config.auto_updates), + observers=[self], + **config.auto_updates, ) - self._change_detector.config = self.config.auto_updates timer.stop('set_instance') def _invalidate_last_results(self): if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() + def _update_variables(self, variables: Mapping[VarData, Reason]): + new_vars = [] + old_vars = [] + mod_vars = [] + for v, reason in variables.items(): + if reason & Reason.added: + new_vars.append(v) + elif reason & Reason.removed: + old_vars.append(v) + else: + mod_vars.append(v) + + if new_vars: + self._add_variables(new_vars) + if old_vars: + self._remove_variables(old_vars) + if mod_vars: + self._update_vars_for_real(mod_vars) + + def _update_parameters(self, params: Mapping[ParamData, Reason]): + new_params = [] + old_params = [] + mod_params = [] + for p, reason in params.items(): + if reason & Reason.added: + new_params.append(p) + elif reason & Reason.removed: + old_params.append(p) + else: + mod_params.append(p) + + if new_params: + self._add_parameters(new_params) + if old_params: + self._remove_parameters(old_params) + if mod_params: + self._update_params_for_real(mod_params) + + def _update_constraints(self, cons: Mapping[ConstraintData, Reason]): + new_cons = [] + old_cons = [] + for c, reason in cons.items(): + if reason & Reason.added: + new_cons.append(c) + elif reason & Reason.removed: + old_cons.append(c) + elif reason & Reason.expr: + old_cons.append(c) + new_cons.append(c) + + if old_cons: + self._remove_constraints(old_cons) + if new_cons: + self._add_constraints(new_cons) + + def _update_sos_constraints(self, cons: Mapping[SOSConstraintData, Reason]): + new_cons = [] + old_cons = [] + for c, reason in cons.items(): + if reason & Reason.added: + new_cons.append(c) + elif reason & Reason.removed: + old_cons.append(c) + elif reason & Reason.sos_items: + old_cons.append(c) + new_cons.append(c) + + if old_cons: + self._remove_sos_constraints(old_cons) + if new_cons: + self._add_sos_constraints(new_cons) + + def _update_objectives(self, objs: Mapping[ObjectiveData, Reason]): + new_objs = [] + old_objs = [] + for obj, reason in objs.items(): + if reason & Reason.added: + new_objs.append(obj) + elif reason & Reason.removed: + old_objs.append(obj) + elif reason & (Reason.expr | Reason.sense): + old_objs.append(obj) + new_objs.append(obj) + + if old_objs: + self._remove_objectives(old_objs) + if new_objs: + self._add_objectives(new_objs) + def _add_variables(self, variables: List[VarData]): self._check_reopt() self._invalidate_last_results() @@ -1024,7 +1051,7 @@ def _add_objectives(self, objs: List[ObjectiveData]): if self._objective is not None: raise NotImplementedError( - 'the persistent interface to gurobi currently ' + 'the persistent interface to scip currently ' 'only supports single-objective problems; tried to add ' f'an objective ({str(obj)}), but there is already an ' f'active objective ({str(self._objective)})' @@ -1064,38 +1091,32 @@ def _remove_variables(self, variables: List[VarData]): self._check_reopt() self._invalidate_last_results() for v in variables: - vid = id(v) - scip_var = self._pyomo_var_to_solver_var_map.pop(vid) + scip_var = self._pyomo_var_to_solver_var_map.pop(v) self._solver_model.delVar(scip_var) - self._vars.pop(vid) def _remove_parameters(self, params: List[ParamData]): self._check_reopt() self._invalidate_last_results() for p in params: - pid = id(p) - scip_var = self._pyomo_param_to_solver_param_map.pop(pid) + scip_var = self._pyomo_param_to_solver_param_map.pop(p) self._solver_model.delVar(scip_var) - self._params.pop(pid) - def _update_variables(self, variables: List[VarData]): + def _update_vars_for_real(self, variables: List[VarData]): self._check_reopt() self._invalidate_last_results() for v in variables: - vid = id(v) - scip_var = self._pyomo_var_to_solver_var_map[vid] + scip_var = self._pyomo_var_to_solver_var_map[v] vtype = self._scip_vtype_from_var(v) lb, ub = self._scip_lb_ub_from_var(v) self._solver_model.chgVarLb(scip_var, lb) self._solver_model.chgVarUb(scip_var, ub) self._solver_model.chgVarType(scip_var, vtype) - def _update_parameters(self, params: List[ParamData]): + def _update_params_for_real(self, params: List[ParamData]): self._check_reopt() self._invalidate_last_results() for p in params: - pid = id(p) - scip_var = self._pyomo_param_to_solver_param_map[pid] + scip_var = self._pyomo_param_to_solver_param_map[p] lb = ub = p.value self._solver_model.chgVarLb(scip_var, lb) self._solver_model.chgVarUb(scip_var, ub) @@ -1108,11 +1129,6 @@ def _update_parameters(self, params: List[ParamData]): self._remove_constraints([con]) self._add_constraints([con]) - def add_variables(self, variables): - if self._change_detector is None: - raise RuntimeError('call set_instance first') - self._change_detector.add_variables(variables) - def add_constraints(self, cons): if self._change_detector is None: raise RuntimeError('call set_instance first') @@ -1138,11 +1154,6 @@ def remove_sos_constraints(self, cons): raise RuntimeError('call set_instance first') self._change_detector.remove_sos_constraints(cons) - def remove_variables(self, variables): - if self._change_detector is None: - raise RuntimeError('call set_instance first') - self._change_detector.remove_variables(variables) - def update_variables(self, variables): if self._change_detector is None: raise RuntimeError('call set_instance first') From 3bfa5cbd9e5ba3856e8b4c3d73a26d79c0e623c2 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 8 Jan 2026 14:40:23 -0700 Subject: [PATCH 49/66] run black --- .../solver/solvers/scip/scip_direct.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 2d820e92028..b5cb1a6946a 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -328,9 +328,7 @@ def enterNode(self, node): class ScipDirectSolutionLoader(SolutionLoaderBase): - def __init__( - self, solver_model, var_map, con_map, pyomo_model, opt - ) -> None: + def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: super().__init__() self._solver_model = solver_model self._var_map = var_map @@ -384,9 +382,7 @@ def load_import_suffixes(self, solution_id=None): class ScipPersistentSolutionLoader(ScipDirectSolutionLoader): - def __init__( - self, solver_model, var_map, con_map, pyomo_model, opt - ) -> None: + def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: super().__init__(solver_model, var_map, con_map, pyomo_model, opt) self._valid = True @@ -497,7 +493,9 @@ def solve(self, model: BlockData, **kwds) -> Results: ostreams = [io.StringIO()] + config.tee - scip_model, solution_loader, has_obj = self._create_solver_model(model, config) + scip_model, solution_loader, has_obj = self._create_solver_model( + model, config + ) scip_model.hideOutput(quiet=False) if config.threads is not None: @@ -521,7 +519,9 @@ def solve(self, model: BlockData, **kwds) -> Results: scip_model.optimize() timer.stop('optimize') - results = self._populate_results(scip_model, solution_loader, has_obj, config) + results = self._populate_results( + scip_model, solution_loader, has_obj, config + ) except InfeasibleConstraintException: # is it possible to hit this? results = self._get_infeasible_results() @@ -911,9 +911,7 @@ def set_instance(self, pyomo_model, **kwds): self._solver_model = scip.Model() timer.start('set_instance') self._change_detector = ModelChangeDetector( - model=self._pyomo_model, - observers=[self], - **config.auto_updates, + model=self._pyomo_model, observers=[self], **config.auto_updates ) timer.stop('set_instance') @@ -951,7 +949,7 @@ def _update_parameters(self, params: Mapping[ParamData, Reason]): old_params.append(p) else: mod_params.append(p) - + if new_params: self._add_parameters(new_params) if old_params: From aec0e65de1414ca33e4b8c71372d6c30198248c1 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 23 Mar 2026 09:12:25 -0600 Subject: [PATCH 50/66] merge trivial_constraints into scip_port --- pyomo/contrib/solver/common/solution_loader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 776aa8f258d..d0f14d403fa 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -35,12 +35,14 @@ def load_import_suffixes( elif suffix.local_name == 'rc': rc_suffix = suffix if dual_suffix is not None: + dual_suffix.clear() duals = solution_loader.get_duals(solution_id=solution_id) if duals is NotImplemented: logger.warning(f'Cannot load duals into suffix') else: dual_suffix.update(duals) if rc_suffix is not None: + rc_suffix.clear() rc = solution_loader.get_reduced_costs(solution_id=solution_id) if rc is NotImplemented: logger.warning(f'cannot load duals into suffix') From 1ae089076ec33a49a6c04aef3c6e59cd092d98ad Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 23 Mar 2026 11:39:41 -0600 Subject: [PATCH 51/66] bug --- pyomo/contrib/solver/solvers/scip/scip_direct.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index b5cb1a6946a..022878f918d 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -140,7 +140,7 @@ def _handle_param(node, data, opt, visitor): return node.value if not opt.is_persistent(): return node.value - if not node.mutable: + if node.is_constant(): return node.value if id(node) not in opt._pyomo_param_to_solver_param_map: scip_param = opt._add_param(node) From 8c6e488d3f4eeaba6f5ef656749b3c9719ae7815 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Wed, 15 Apr 2026 06:52:28 -0600 Subject: [PATCH 52/66] run black --- pyomo/contrib/solver/common/solution_loader.py | 1 - pyomo/contrib/solver/solvers/scip/scip_direct.py | 1 - 2 files changed, 2 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index d0f14d403fa..d9c5469f7e2 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -18,7 +18,6 @@ from .util import NoSolutionError import logging - logger = logging.getLogger(__name__) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 022878f918d..c43b243b9fd 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -88,7 +88,6 @@ Reason, ) - logger = logging.getLogger(__name__) From 3646b41fc1231285fb2a211b6c8d5f1c99b023ea Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Wed, 15 Apr 2026 07:10:33 -0600 Subject: [PATCH 53/66] fix typos --- pyomo/contrib/observer/model_observer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/observer/model_observer.py b/pyomo/contrib/observer/model_observer.py index 51f236b8ad0..c9814704b42 100644 --- a/pyomo/contrib/observer/model_observer.py +++ b/pyomo/contrib/observer/model_observer.py @@ -739,7 +739,7 @@ def remove_objectives(self, objs: Collection[ObjectiveData]): def _check_for_unknown_active_components(self): for ctype in self._model.collect_ctypes(active=True, descend_into=True): if not issubclass(ctype, ActiveComponent): - # strangly, this is needed to skip things like Param + # strangely, this is needed to skip things like Param continue if ctype in self._known_active_ctypes: continue From ed194d491e242552a19d10eff640d999df8b4117 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 5 May 2026 08:20:53 -0600 Subject: [PATCH 54/66] update scip solution loader --- .../solver/solvers/scip/scip_direct.py | 74 +++++++------------ 1 file changed, 25 insertions(+), 49 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index c43b243b9fd..7a31d09a2f2 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -67,16 +67,15 @@ NoSolutionError, ) from pyomo.contrib.solver.common.util import get_objective -from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader +from pyomo.contrib.solver.common.solution_loader import ( + NoSolutionSolutionLoader, + SolutionLoader, +) from pyomo.contrib.solver.common.results import ( Results, SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.solution_loader import ( - SolutionLoaderBase, - load_import_suffixes, -) from pyomo.common.config import ConfigValue from pyomo.common.tee import capture_output, TeeStream from pyomo.core.base.units_container import _PyomoUnit @@ -326,7 +325,7 @@ def enterNode(self, node): logger = logging.getLogger("pyomo.solvers") -class ScipDirectSolutionLoader(SolutionLoaderBase): +class ScipDirectSolutionLoader(SolutionLoader): def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: super().__init__() self._solver_model = solver_model @@ -335,6 +334,14 @@ def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: self._pyomo_model = pyomo_model # make sure the scip model does not get freed until the solution loader is garbage collected self._opt = opt + self._active_solution_id = 0 + + def _set_solution_id(self, solution_id: int) -> int: + if solution_id is None: + solution_id = 0 + previous_id = self._active_solution_id + self._active_solution_id = solution_id + return previous_id def get_number_of_solutions(self) -> int: return self._solver_model.getNSols() @@ -342,43 +349,20 @@ def get_number_of_solutions(self) -> int: def get_solution_ids(self) -> List: return list(range(self.get_number_of_solutions())) - def load_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None - ) -> None: - for v, val in self.get_vars( - vars_to_load=vars_to_load, solution_id=solution_id - ).items(): - v.value = val - def get_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: if self.get_number_of_solutions() == 0: raise NoSolutionError() if vars_to_load is None: vars_to_load = list(self._var_map.keys()) - if solution_id is None: - solution_id = 0 - sol = self._solver_model.getSols()[solution_id] + sol = self._solver_model.getSols()[self._active_solution_id] res = ComponentMap() for v in vars_to_load: sv = self._var_map[v] res[v] = sol[sv] return res - def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None - ) -> Mapping[VarData, float]: - return NotImplemented - - def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None - ) -> Dict[ConstraintData, float]: - return NotImplemented - - def load_import_suffixes(self, solution_id=None): - load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) - class ScipPersistentSolutionLoader(ScipDirectSolutionLoader): def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: @@ -393,28 +377,16 @@ def _assert_solution_still_valid(self): raise RuntimeError('The results in the solver are no longer valid.') def load_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> None: self._assert_solution_still_valid() - return super().load_vars(vars_to_load, solution_id) + return super().load_vars(vars_to_load) def get_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() - return super().get_vars(vars_to_load, solution_id) - - def get_duals( - self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None - ) -> Dict[ConstraintData, float]: - self._assert_solution_still_valid() - return super().get_duals(cons_to_load) - - def get_reduced_costs( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None - ) -> Mapping[VarData, float]: - self._assert_solution_still_valid() - return super().get_reduced_costs(vars_to_load) + return super().get_vars(vars_to_load) def get_number_of_solutions(self) -> int: self._assert_solution_still_valid() @@ -424,9 +396,13 @@ def get_solution_ids(self) -> List: self._assert_solution_still_valid() return super().get_solution_ids() - def load_import_suffixes(self, solution_id=None): + def load_import_suffixes(self): + self._assert_solution_still_valid() + super().load_import_suffixes() + + def _set_solution_id(self, solution_id: int) -> int: self._assert_solution_still_valid() - super().load_import_suffixes(solution_id) + return super()._set_solution_id(solution_id) class ScipDirect(SolverBase): From 2a6ae690167fdcef722bcd5022c5584cf03bc360 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 5 May 2026 08:48:11 -0600 Subject: [PATCH 55/66] update scip persistent interface --- pyomo/contrib/solver/solvers/scip/scip_direct.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 7a31d09a2f2..42c782e694b 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -1095,7 +1095,9 @@ def _update_params_for_real(self, params: List[ParamData]): self._solver_model.chgVarUb(scip_var, ub) impacted_vars = self._change_detector.get_variables_impacted_by_param(p) if impacted_vars: - self._update_variables(impacted_vars) + # Convert list to ComponentMap with Reason.bounds for impacted variables + impacted_vars_mapping = ComponentMap((v, Reason.bounds) for v in impacted_vars) + self._update_variables(impacted_vars_mapping) impacted_cons = self._change_detector.get_constraints_impacted_by_param(p) for con in impacted_cons: if con in self._range_constraints: From b8640c69e6f162d0c713bdd447529a2daea250ac Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 5 May 2026 08:50:30 -0600 Subject: [PATCH 56/66] run black --- pyomo/contrib/solver/solvers/scip/scip_direct.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 42c782e694b..4a996b43b20 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -376,9 +376,7 @@ def _assert_solution_still_valid(self): if not self._valid: raise RuntimeError('The results in the solver are no longer valid.') - def load_vars( - self, vars_to_load: Sequence[VarData] | None = None - ) -> None: + def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: self._assert_solution_still_valid() return super().load_vars(vars_to_load) @@ -1096,7 +1094,9 @@ def _update_params_for_real(self, params: List[ParamData]): impacted_vars = self._change_detector.get_variables_impacted_by_param(p) if impacted_vars: # Convert list to ComponentMap with Reason.bounds for impacted variables - impacted_vars_mapping = ComponentMap((v, Reason.bounds) for v in impacted_vars) + impacted_vars_mapping = ComponentMap( + (v, Reason.bounds) for v in impacted_vars + ) self._update_variables(impacted_vars_mapping) impacted_cons = self._change_detector.get_constraints_impacted_by_param(p) for con in impacted_cons: From 885565c77ca6ff81fdd97651286512dd0f2cdbd0 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 11 May 2026 08:47:53 -0600 Subject: [PATCH 57/66] remove comment --- pyomo/contrib/observer/model_observer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyomo/contrib/observer/model_observer.py b/pyomo/contrib/observer/model_observer.py index c9814704b42..d28fde01cb0 100644 --- a/pyomo/contrib/observer/model_observer.py +++ b/pyomo/contrib/observer/model_observer.py @@ -739,7 +739,6 @@ def remove_objectives(self, objs: Collection[ObjectiveData]): def _check_for_unknown_active_components(self): for ctype in self._model.collect_ctypes(active=True, descend_into=True): if not issubclass(ctype, ActiveComponent): - # strangely, this is needed to skip things like Param continue if ctype in self._known_active_ctypes: continue From dc764daaab3f29114d9827b549b02976ef56f526 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 12 May 2026 09:09:04 -0600 Subject: [PATCH 58/66] try removing scip __del__ to prevent segfaults? --- pyomo/contrib/solver/solvers/scip/scip_direct.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index 4a996b43b20..f1c2de9828a 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -581,11 +581,12 @@ def _add_param(self, p): self._pyomo_param_to_solver_param_map[p] = scip_var return scip_var - def __del__(self): - """Frees SCIP resources used by this solver instance.""" - if self._solver_model is not None: - self._solver_model.freeProb() - self._solver_model = None + # causing segfaults? + # def __del__(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 _add_constraints(self, cons: List[ConstraintData]): for con in cons: From b11a3b734b35ffd6be5b351731ec805806e6d6b7 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Fri, 15 May 2026 14:40:15 -0600 Subject: [PATCH 59/66] Reorganize the scip code to split direct/persistent and add unit-style tests --- pyomo/contrib/solver/plugins.py | 3 +- pyomo/contrib/solver/solvers/scip/__init__.py | 10 + pyomo/contrib/solver/solvers/scip/base.py | 323 ++++++++ .../solver/solvers/scip/scip_direct.py | 776 +----------------- .../solver/solvers/scip/scip_persistent.py | 418 ++++++++++ .../solver/tests/solvers/test_scip_direct.py | 309 +++++++ .../tests/solvers/test_scip_persistent.py | 291 +++++++ .../solver/tests/solvers/test_solvers.py | 3 +- 8 files changed, 1387 insertions(+), 746 deletions(-) create mode 100644 pyomo/contrib/solver/solvers/scip/base.py create mode 100644 pyomo/contrib/solver/solvers/scip/scip_persistent.py create mode 100644 pyomo/contrib/solver/tests/solvers/test_scip_direct.py create mode 100644 pyomo/contrib/solver/tests/solvers/test_scip_persistent.py diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 3d499a39dfd..e6a72f5ec5e 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -14,7 +14,8 @@ from .solvers.gurobi.gurobi_persistent import GurobiPersistent from .solvers.gurobi.gurobi_direct_minlp import GurobiDirectMINLP from .solvers.highs import Highs -from .solvers.scip.scip_direct import ScipDirect, ScipPersistent +from .solvers.scip.scip_direct import ScipDirect +from .solvers.scip.scip_persistent import ScipPersistent from .solvers.gams import GAMS from .solvers.knitro.direct import KnitroDirectSolver diff --git a/pyomo/contrib/solver/solvers/scip/__init__.py b/pyomo/contrib/solver/solvers/scip/__init__.py index e69de29bb2d..6eb9ea8b81d 100644 --- a/pyomo/contrib/solver/solvers/scip/__init__.py +++ b/pyomo/contrib/solver/solvers/scip/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/solver/solvers/scip/base.py b/pyomo/contrib/solver/solvers/scip/base.py new file mode 100644 index 00000000000..73bf69e1528 --- /dev/null +++ b/pyomo/contrib/solver/solvers/scip/base.py @@ -0,0 +1,323 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 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 __future__ import annotations + +import logging +import math +from typing import List, Optional, Sequence, Mapping + +from pyomo.common.collections import ComponentMap +from pyomo.common.numeric_types import native_numeric_types +from pyomo.common.config import ConfigValue +from pyomo.common.dependencies import attempt_import +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr +from pyomo.contrib.solver.common.config import BranchAndBoundConfig +from pyomo.contrib.solver.common.solution_loader import SolutionLoader +from pyomo.contrib.solver.common.util import NoSolutionError +from pyomo.core.base.expression import ExpressionData, ScalarExpression +from pyomo.core.base.param import ParamData, ScalarParam +from pyomo.core.base.units_container import _PyomoUnit +from pyomo.core.base.var import VarData, ScalarVar +from pyomo.core.expr.numvalue import is_constant, NumericConstant +from pyomo.core.expr.numeric_expr import ( + NegationExpression, + PowExpression, + ProductExpression, + MonomialTermExpression, + DivisionExpression, + SumExpression, + LinearExpression, + UnaryFunctionExpression, + NPV_NegationExpression, + NPV_PowExpression, + NPV_ProductExpression, + NPV_DivisionExpression, + NPV_SumExpression, + NPV_UnaryFunctionExpression, +) +from pyomo.core.expr.relational_expr import ( + EqualityExpression, + InequalityExpression, + RangedExpression, +) +from pyomo.core.expr.visitor import StreamBasedExpressionVisitor +from pyomo.gdp.disjunct import AutoLinkedBinaryVar + +logger = logging.getLogger(__name__) + +scip, scip_available = attempt_import('pyscipopt') + + +class ScipConfig(BranchAndBoundConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + BranchAndBoundConfig.__init__( + self, + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + self.warmstart_discrete_vars: bool = self.declare( + 'warmstart_discrete_vars', + ConfigValue( + default=False, + domain=bool, + description="If True, the current values of the integer variables " + "will be passed to Scip.", + ), + ) + + +def _handle_var(node, data, opt, visitor): + if node not in opt._pyomo_var_to_solver_var_map: + scip_var = opt._add_var(node) + else: + scip_var = opt._pyomo_var_to_solver_var_map[node] + return scip_var + + +def _handle_param(node, data, opt, visitor): + # for the persistent interface, we create scip variables in place + # of parameters. However, this makes things complicated for range + # constraints because scip does not allow variables in the + # lower and upper parts of range constraints + if visitor.in_range: + return node.value + if not opt.is_persistent(): + return node.value + if node.is_constant(): + return node.value + if node not in opt._pyomo_param_to_solver_param_map: + scip_param = opt._add_param(node) + else: + scip_param = opt._pyomo_param_to_solver_param_map[node] + return scip_param + + +def _handle_constant(node, data, opt, visitor): + return node.value + + +def _handle_float(node, data, opt, visitor): + return float(node) + + +def _handle_negation(node, data, opt, visitor): + return -data[0] + + +def _handle_pow(node, data, opt, visitor): + x, y = data # x ** y = exp(log(x**y)) = exp(y*log(x)) + if is_constant(node.args[1]): + return x**y + else: + xlb, xub = compute_bounds_on_expr(node.args[0]) + if xlb > 0: + return scip.exp(y * scip.log(x)) + else: + return x**y # scip will probably raise an error here + + +def _handle_product(node, data, opt, visitor): + assert len(data) == 2 + return data[0] * data[1] + + +def _handle_division(node, data, opt, visitor): + return data[0] / data[1] + + +def _handle_sum(node, data, opt, visitor): + return sum(data) + + +def _handle_exp(node, data, opt, visitor): + return scip.exp(data[0]) + + +def _handle_log(node, data, opt, visitor): + return scip.log(data[0]) + + +def _handle_log10(node, data, opt, visitor): + return scip.log(data[0]) / math.log(10) + + +def _handle_sin(node, data, opt, visitor): + return scip.sin(data[0]) + + +def _handle_cos(node, data, opt, visitor): + return scip.cos(data[0]) + + +def _handle_sqrt(node, data, opt, visitor): + return scip.sqrt(data[0]) + + +def _handle_abs(node, data, opt, visitor): + return abs(data[0]) + + +def _handle_tan(node, data, opt, visitor): + return scip.sin(data[0]) / scip.cos(data[0]) + + +def _handle_tanh(node, data, opt, visitor): + x = data[0] + _exp = scip.exp + return (_exp(x) - _exp(-x)) / (_exp(x) + _exp(-x)) + + +_unary_map = { + 'exp': _handle_exp, + 'log': _handle_log, + 'sin': _handle_sin, + 'cos': _handle_cos, + 'sqrt': _handle_sqrt, + 'abs': _handle_abs, + 'tan': _handle_tan, + 'log10': _handle_log10, + 'tanh': _handle_tanh, +} + + +def _handle_unary(node, data, opt, visitor): + if node.getname() in _unary_map: + return _unary_map[node.getname()](node, data, opt, visitor) + else: + raise NotImplementedError(f'unable to handle unary expression: {str(node)}') + + +def _handle_equality(node, data, opt, visitor): + return data[0] == data[1] + + +def _handle_ranged(node, data, opt, visitor): + # note that the lower and upper parts of the + # range constraint cannot have variables + return data[0] <= (data[1] <= data[2]) + + +def _handle_inequality(node, data, opt, visitor): + return data[0] <= data[1] + + +def _handle_named_expression(node, data, opt, visitor): + return data[0] + + +def _handle_unit(node, data, opt, visitor): + return node.value + + +_operator_map = { + NegationExpression: _handle_negation, + PowExpression: _handle_pow, + ProductExpression: _handle_product, + MonomialTermExpression: _handle_product, + DivisionExpression: _handle_division, + SumExpression: _handle_sum, + LinearExpression: _handle_sum, + UnaryFunctionExpression: _handle_unary, + NPV_NegationExpression: _handle_negation, + NPV_PowExpression: _handle_pow, + NPV_ProductExpression: _handle_product, + NPV_DivisionExpression: _handle_division, + NPV_SumExpression: _handle_sum, + NPV_UnaryFunctionExpression: _handle_unary, + EqualityExpression: _handle_equality, + RangedExpression: _handle_ranged, + InequalityExpression: _handle_inequality, + ScalarExpression: _handle_named_expression, + ExpressionData: _handle_named_expression, + VarData: _handle_var, + ScalarVar: _handle_var, + ParamData: _handle_param, + ScalarParam: _handle_param, + float: _handle_float, + int: _handle_float, + AutoLinkedBinaryVar: _handle_var, + _PyomoUnit: _handle_unit, + NumericConstant: _handle_constant, +} + + +class _PyomoToScipVisitor(StreamBasedExpressionVisitor): + def __init__(self, solver, **kwds): + super().__init__(**kwds) + self.solver = solver + self.in_range = False + + def initializeWalker(self, expr): + self.in_range = False + return True, None + + def exitNode(self, node, data): + nt = type(node) + if nt in _operator_map: + return _operator_map[nt](node, data, self.solver, self) + elif nt in native_numeric_types: + _operator_map[nt] = _handle_float + return _handle_float(node, data, self.solver, self) + else: + raise NotImplementedError(f'unrecognized expression type: {nt}') + + def enterNode(self, node): + if type(node) is RangedExpression: + self.in_range = True + return None, [] + + +class ScipSolutionLoader(SolutionLoader): + def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: + super().__init__() + self._solver_model = solver_model + self._var_map = var_map + self._con_map = con_map + self._pyomo_model = pyomo_model + # make sure the scip model does not get freed until the solution loader is garbage collected + self._opt = opt + self._active_solution_id = 0 + + def _set_solution_id(self, solution_id: int) -> int: + if solution_id is None: + solution_id = 0 + previous_id = self._active_solution_id + self._active_solution_id = solution_id + return previous_id + + def get_number_of_solutions(self) -> int: + return self._solver_model.getNSols() + + def get_solution_ids(self) -> List: + return list(range(self.get_number_of_solutions())) + + def get_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + if self.get_number_of_solutions() == 0: + raise NoSolutionError() + if vars_to_load is None: + vars_to_load = list(self._var_map.keys()) + sol = self._solver_model.getSols()[self._active_solution_id] + res = ComponentMap() + for v in vars_to_load: + sv = self._var_map[v] + res[v] = sol[sv] + return res diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index f1c2de9828a..b9592b672ae 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -1,406 +1,53 @@ -# ___________________________________________________________________________ +# ____________________________________________________________________________________ # -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2025 -# 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. -# ___________________________________________________________________________ +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 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 __future__ import annotations + import datetime import io import logging import math -from typing import Tuple, List, Optional, Sequence, Mapping, Dict +from typing import Tuple, List from pyomo.common.collections import ComponentMap -from pyomo.core.expr.numvalue import is_constant -from pyomo.common.numeric_types import native_numeric_types -from pyomo.common.errors import InfeasibleConstraintException, ApplicationError +from pyomo.common.errors import InfeasibleConstraintException from pyomo.common.timing import HierarchicalTimer +from pyomo.common.tee import capture_output, TeeStream + from pyomo.core.base.block import BlockData -from pyomo.core.base.var import VarData, ScalarVar -from pyomo.core.base.param import ParamData, ScalarParam from pyomo.core.base.constraint import Constraint, ConstraintData -from pyomo.core.base.objective import ObjectiveData from pyomo.core.base.sos import SOSConstraint, SOSConstraintData from pyomo.core.kernel.objective import minimize, maximize -from pyomo.core.expr.numeric_expr import ( - NegationExpression, - PowExpression, - ProductExpression, - MonomialTermExpression, - DivisionExpression, - SumExpression, - LinearExpression, - UnaryFunctionExpression, - NPV_NegationExpression, - NPV_PowExpression, - NPV_ProductExpression, - NPV_DivisionExpression, - NPV_SumExpression, - NPV_UnaryFunctionExpression, -) -from pyomo.core.expr.numvalue import NumericConstant -from pyomo.gdp.disjunct import AutoLinkedBinaryVar -from pyomo.core.base.expression import ExpressionData, ScalarExpression -from pyomo.core.expr.relational_expr import ( - EqualityExpression, - InequalityExpression, - RangedExpression, -) from pyomo.core.staleflag import StaleFlagManager -from pyomo.core.expr.visitor import StreamBasedExpressionVisitor -from pyomo.common.dependencies import attempt_import -from pyomo.contrib.solver.common.base import ( - SolverBase, - Availability, - PersistentSolverBase, -) -from pyomo.contrib.solver.common.config import BranchAndBoundConfig -from pyomo.contrib.solver.common.util import ( - NoFeasibleSolutionError, - NoOptimalSolutionError, - NoSolutionError, -) -from pyomo.contrib.solver.common.util import get_objective -from pyomo.contrib.solver.common.solution_loader import ( - NoSolutionSolutionLoader, - SolutionLoader, -) + +from pyomo.contrib.solver.common.base import SolverBase, Availability +from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader from pyomo.contrib.solver.common.results import ( Results, SolutionStatus, TerminationCondition, ) -from pyomo.common.config import ConfigValue -from pyomo.common.tee import capture_output, TeeStream -from pyomo.core.base.units_container import _PyomoUnit -from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr -from pyomo.contrib.observer.model_observer import ( - Observer, - ModelChangeDetector, - AutoUpdateConfig, - Reason, +from pyomo.contrib.solver.common.util import ( + NoFeasibleSolutionError, + NoOptimalSolutionError, + get_objective, ) -logger = logging.getLogger(__name__) - - -scip, scip_available = attempt_import('pyscipopt') - - -class ScipConfig(BranchAndBoundConfig): - def __init__( - self, - description=None, - doc=None, - implicit=False, - implicit_domain=None, - visibility=0, - ): - BranchAndBoundConfig.__init__( - self, - description=description, - doc=doc, - implicit=implicit, - implicit_domain=implicit_domain, - visibility=visibility, - ) - self.warmstart_discrete_vars: bool = self.declare( - 'warmstart_discrete_vars', - ConfigValue( - default=False, - domain=bool, - description="If True, the current values of the integer variables " - "will be passed to Scip.", - ), - ) - - -def _handle_var(node, data, opt, visitor): - if id(node) not in opt._pyomo_var_to_solver_var_map: - scip_var = opt._add_var(node) - else: - scip_var = opt._pyomo_var_to_solver_var_map[id(node)] - return scip_var - - -def _handle_param(node, data, opt, visitor): - # for the persistent interface, we create scip variables in place - # of parameters. However, this makes things complicated for range - # constraints because scip does not allow variables in the - # lower and upper parts of range constraints - if visitor.in_range: - return node.value - if not opt.is_persistent(): - return node.value - if node.is_constant(): - return node.value - if id(node) not in opt._pyomo_param_to_solver_param_map: - scip_param = opt._add_param(node) - else: - scip_param = opt._pyomo_param_to_solver_param_map[id(node)] - return scip_param - - -def _handle_constant(node, data, opt, visitor): - return node.value - - -def _handle_float(node, data, opt, visitor): - return float(node) - - -def _handle_negation(node, data, opt, visitor): - return -data[0] - - -def _handle_pow(node, data, opt, visitor): - x, y = data # x ** y = exp(log(x**y)) = exp(y*log(x)) - if is_constant(node.args[1]): - return x**y - else: - xlb, xub = compute_bounds_on_expr(node.args[0]) - if xlb > 0: - return scip.exp(y * scip.log(x)) - else: - return x**y # scip will probably raise an error here - - -def _handle_product(node, data, opt, visitor): - assert len(data) == 2 - return data[0] * data[1] - - -def _handle_division(node, data, opt, visitor): - return data[0] / data[1] - - -def _handle_sum(node, data, opt, visitor): - return sum(data) - - -def _handle_exp(node, data, opt, visitor): - return scip.exp(data[0]) - - -def _handle_log(node, data, opt, visitor): - return scip.log(data[0]) - - -def _handle_log10(node, data, opt, visitor): - return scip.log(data[0]) / math.log(10) - - -def _handle_sin(node, data, opt, visitor): - return scip.sin(data[0]) - - -def _handle_cos(node, data, opt, visitor): - return scip.cos(data[0]) - - -def _handle_sqrt(node, data, opt, visitor): - return scip.sqrt(data[0]) - - -def _handle_abs(node, data, opt, visitor): - return abs(data[0]) - - -def _handle_tan(node, data, opt, visitor): - return scip.sin(data[0]) / scip.cos(data[0]) - - -def _handle_tanh(node, data, opt, visitor): - x = data[0] - _exp = scip.exp - return (_exp(x) - _exp(-x)) / (_exp(x) + _exp(-x)) - - -_unary_map = { - 'exp': _handle_exp, - 'log': _handle_log, - 'sin': _handle_sin, - 'cos': _handle_cos, - 'sqrt': _handle_sqrt, - 'abs': _handle_abs, - 'tan': _handle_tan, - 'log10': _handle_log10, - 'tanh': _handle_tanh, -} - - -def _handle_unary(node, data, opt, visitor): - if node.getname() in _unary_map: - return _unary_map[node.getname()](node, data, opt, visitor) - else: - raise NotImplementedError(f'unable to handle unary expression: {str(node)}') - - -def _handle_equality(node, data, opt, visitor): - return data[0] == data[1] - - -def _handle_ranged(node, data, opt, visitor): - # note that the lower and upper parts of the - # range constraint cannot have variables - return data[0] <= (data[1] <= data[2]) - - -def _handle_inequality(node, data, opt, visitor): - return data[0] <= data[1] - - -def _handle_named_expression(node, data, opt, visitor): - return data[0] - - -def _handle_unit(node, data, opt, visitor): - return node.value - - -_operator_map = { - NegationExpression: _handle_negation, - PowExpression: _handle_pow, - ProductExpression: _handle_product, - MonomialTermExpression: _handle_product, - DivisionExpression: _handle_division, - SumExpression: _handle_sum, - LinearExpression: _handle_sum, - UnaryFunctionExpression: _handle_unary, - NPV_NegationExpression: _handle_negation, - NPV_PowExpression: _handle_pow, - NPV_ProductExpression: _handle_product, - NPV_DivisionExpression: _handle_division, - NPV_SumExpression: _handle_sum, - NPV_UnaryFunctionExpression: _handle_unary, - EqualityExpression: _handle_equality, - RangedExpression: _handle_ranged, - InequalityExpression: _handle_inequality, - ScalarExpression: _handle_named_expression, - ExpressionData: _handle_named_expression, - VarData: _handle_var, - ScalarVar: _handle_var, - ParamData: _handle_param, - ScalarParam: _handle_param, - float: _handle_float, - int: _handle_float, - AutoLinkedBinaryVar: _handle_var, - _PyomoUnit: _handle_unit, - NumericConstant: _handle_constant, -} - - -class _PyomoToScipVisitor(StreamBasedExpressionVisitor): - def __init__(self, solver, **kwds): - super().__init__(**kwds) - self.solver = solver - self.in_range = False - - def initializeWalker(self, expr): - self.in_range = False - return True, None - - def exitNode(self, node, data): - nt = type(node) - if nt in _operator_map: - return _operator_map[nt](node, data, self.solver, self) - elif nt in native_numeric_types: - _operator_map[nt] = _handle_float - return _handle_float(node, data, self.solver, self) - else: - raise NotImplementedError(f'unrecognized expression type: {nt}') - - def enterNode(self, node): - if type(node) is RangedExpression: - self.in_range = True - return None, [] - - -logger = logging.getLogger("pyomo.solvers") - - -class ScipDirectSolutionLoader(SolutionLoader): - def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: - super().__init__() - self._solver_model = solver_model - self._var_map = var_map - self._con_map = con_map - self._pyomo_model = pyomo_model - # make sure the scip model does not get freed until the solution loader is garbage collected - self._opt = opt - self._active_solution_id = 0 - - def _set_solution_id(self, solution_id: int) -> int: - if solution_id is None: - solution_id = 0 - previous_id = self._active_solution_id - self._active_solution_id = solution_id - return previous_id - - def get_number_of_solutions(self) -> int: - return self._solver_model.getNSols() - - def get_solution_ids(self) -> List: - return list(range(self.get_number_of_solutions())) - - def get_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None - ) -> Mapping[VarData, float]: - if self.get_number_of_solutions() == 0: - raise NoSolutionError() - if vars_to_load is None: - vars_to_load = list(self._var_map.keys()) - sol = self._solver_model.getSols()[self._active_solution_id] - res = ComponentMap() - for v in vars_to_load: - sv = self._var_map[v] - res[v] = sol[sv] - return res - - -class ScipPersistentSolutionLoader(ScipDirectSolutionLoader): - def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: - super().__init__(solver_model, var_map, con_map, pyomo_model, opt) - self._valid = True - - def invalidate(self): - self._valid = False - - def _assert_solution_still_valid(self): - if not self._valid: - raise RuntimeError('The results in the solver are no longer valid.') - - def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: - self._assert_solution_still_valid() - return super().load_vars(vars_to_load) - - def get_vars( - self, vars_to_load: Sequence[VarData] | None = None - ) -> Mapping[VarData, float]: - self._assert_solution_still_valid() - return super().get_vars(vars_to_load) - - def get_number_of_solutions(self) -> int: - self._assert_solution_still_valid() - return super().get_number_of_solutions() - - def get_solution_ids(self) -> List: - self._assert_solution_still_valid() - return super().get_solution_ids() - - def load_import_suffixes(self): - self._assert_solution_still_valid() - super().load_import_suffixes() +from pyomo.contrib.solver.solvers.scip.base import ( + scip, + scip_available, + ScipConfig, + _PyomoToScipVisitor, + ScipSolutionLoader, +) - def _set_solution_id(self, solution_id: int) -> int: - self._assert_solution_still_valid() - return super()._set_solution_id(solution_id) +logger = logging.getLogger(__name__) class ScipDirect(SolverBase): @@ -488,7 +135,6 @@ def solve(self, model: BlockData, **kwds) -> Results: timer.start('optimize') with capture_output(TeeStream(*ostreams), capture_fd=True): - # scip_model.writeProblem(filename='foo.lp') scip_model.optimize() timer.stop('optimize') @@ -496,7 +142,6 @@ def solve(self, model: BlockData, **kwds) -> Results: scip_model, solution_loader, has_obj, config ) except InfeasibleConstraintException: - # is it possible to hit this? results = self._get_infeasible_results() results.solver_log = ostreams[0].getvalue() @@ -581,19 +226,12 @@ def _add_param(self, p): self._pyomo_param_to_solver_param_map[p] = scip_var return scip_var - # causing segfaults? - # def __del__(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 _add_constraints(self, cons: List[ConstraintData]): for con in cons: self._add_constraint(con) def _add_sos_constraints(self, cons: List[SOSConstraintData]): - for on in cons: + for con in cons: self._add_sos_constraint(con) def _create_solver_model(self, model, config): @@ -622,7 +260,7 @@ def _create_solver_model(self, model, config): self._set_objective(obj) timer.stop('translate objective') has_obj = obj is not None - solution_loader = ScipDirectSolutionLoader( + solution_loader = ScipSolutionLoader( solver_model=self._solver_model, var_map=self._pyomo_var_to_solver_var_map, con_map=self._pyomo_con_to_solver_con_map, @@ -648,10 +286,9 @@ def _add_sos_constraint(self, con): weights = [] for v, w in con.get_items(): - vid = id(v) - if vid not in self._pyomo_var_to_solver_var_map: + if v not in self._pyomo_var_to_solver_var_map: self._add_var(v) - scip_vars.append(self._pyomo_var_to_solver_var_map[vid]) + scip_vars.append(self._pyomo_var_to_solver_var_map[v]) weights.append(w) if level == 1: @@ -715,10 +352,7 @@ def _set_objective(self, obj): self._solver_model.setObjective(self._obj_var, sense=sense) self._objective = obj - def _populate_results( - self, scip_model, solution_loader: ScipDirectSolutionLoader, has_obj, config - ): - + def _populate_results(self, scip_model, solution_loader, has_obj, config): results = Results() results.solution_loader = solution_loader results.timing_info.scip_time = scip_model.getSolvingTime() @@ -793,349 +427,3 @@ def _mipstart(self): if pyomo_var.is_integer(): sol[scip_var] = pyomo_var.value self._solver_model.addSol(sol) - - -class ScipPersistentConfig(ScipConfig): - def __init__( - self, - description=None, - doc=None, - implicit=False, - implicit_domain=None, - visibility=0, - ): - ScipConfig.__init__( - self, - description=description, - doc=doc, - implicit=implicit, - implicit_domain=implicit_domain, - visibility=visibility, - ) - self.auto_updates: bool = self.declare('auto_updates', AutoUpdateConfig()) - - -class ScipPersistent(ScipDirect, PersistentSolverBase, Observer): - _minimum_version = (5, 5, 0) # this is probably conservative - CONFIG = ScipPersistentConfig() - - def __init__(self, **kwds): - super().__init__(**kwds) - self._pyomo_model = None - self._change_detector = None - self._last_results_object: Optional[Results] = None - self._needs_reopt = False - self._range_constraints = set() - - def _clear(self): - super()._clear() - self._pyomo_model = None - self._change_detector = None - self._needs_reopt = False - self._range_constraints = set() - - def _check_reopt(self): - if self._needs_reopt: - # self._solver_model.freeReoptSolve() # when is it safe to use this one??? - self._solver_model.freeTransform() - self._needs_reopt = False - - def _create_solver_model(self, pyomo_model, config): - if pyomo_model is self._pyomo_model: - self.update(**config) - else: - self.set_instance(pyomo_model, **config) - - solution_loader = ScipPersistentSolutionLoader( - solver_model=self._solver_model, - var_map=self._pyomo_var_to_solver_var_map, - con_map=self._pyomo_con_to_solver_con_map, - pyomo_model=pyomo_model, - opt=self, - ) - - has_obj = self._objective is not None - return self._solver_model, solution_loader, has_obj - - def solve(self, model, **kwds) -> Results: - res = super().solve(model, **kwds) - self._needs_reopt = True - return res - - def update(self, **kwds): - config = self.config(value=kwds, preserve_implicit=True) - if config.timer is None: - timer = HierarchicalTimer() - else: - timer = config.timer - if self._pyomo_model is None: - raise RuntimeError('must call set_instance or solve before update') - timer.start('update') - self._change_detector.update(timer=timer, **config.auto_updates) - timer.stop('update') - - def set_instance(self, pyomo_model, **kwds): - config = self.config(value=kwds, preserve_implicit=True) - if config.timer is None: - timer = HierarchicalTimer() - else: - timer = config.timer - self._clear() - self._pyomo_model = pyomo_model - self._solver_model = scip.Model() - timer.start('set_instance') - self._change_detector = ModelChangeDetector( - model=self._pyomo_model, observers=[self], **config.auto_updates - ) - timer.stop('set_instance') - - def _invalidate_last_results(self): - if self._last_results_object is not None: - self._last_results_object.solution_loader.invalidate() - - def _update_variables(self, variables: Mapping[VarData, Reason]): - new_vars = [] - old_vars = [] - mod_vars = [] - for v, reason in variables.items(): - if reason & Reason.added: - new_vars.append(v) - elif reason & Reason.removed: - old_vars.append(v) - else: - mod_vars.append(v) - - if new_vars: - self._add_variables(new_vars) - if old_vars: - self._remove_variables(old_vars) - if mod_vars: - self._update_vars_for_real(mod_vars) - - def _update_parameters(self, params: Mapping[ParamData, Reason]): - new_params = [] - old_params = [] - mod_params = [] - for p, reason in params.items(): - if reason & Reason.added: - new_params.append(p) - elif reason & Reason.removed: - old_params.append(p) - else: - mod_params.append(p) - - if new_params: - self._add_parameters(new_params) - if old_params: - self._remove_parameters(old_params) - if mod_params: - self._update_params_for_real(mod_params) - - def _update_constraints(self, cons: Mapping[ConstraintData, Reason]): - new_cons = [] - old_cons = [] - for c, reason in cons.items(): - if reason & Reason.added: - new_cons.append(c) - elif reason & Reason.removed: - old_cons.append(c) - elif reason & Reason.expr: - old_cons.append(c) - new_cons.append(c) - - if old_cons: - self._remove_constraints(old_cons) - if new_cons: - self._add_constraints(new_cons) - - def _update_sos_constraints(self, cons: Mapping[SOSConstraintData, Reason]): - new_cons = [] - old_cons = [] - for c, reason in cons.items(): - if reason & Reason.added: - new_cons.append(c) - elif reason & Reason.removed: - old_cons.append(c) - elif reason & Reason.sos_items: - old_cons.append(c) - new_cons.append(c) - - if old_cons: - self._remove_sos_constraints(old_cons) - if new_cons: - self._add_sos_constraints(new_cons) - - def _update_objectives(self, objs: Mapping[ObjectiveData, Reason]): - new_objs = [] - old_objs = [] - for obj, reason in objs.items(): - if reason & Reason.added: - new_objs.append(obj) - elif reason & Reason.removed: - old_objs.append(obj) - elif reason & (Reason.expr | Reason.sense): - old_objs.append(obj) - new_objs.append(obj) - - if old_objs: - self._remove_objectives(old_objs) - if new_objs: - self._add_objectives(new_objs) - - def _add_variables(self, variables: List[VarData]): - self._check_reopt() - self._invalidate_last_results() - for v in variables: - self._add_var(v) - - def _add_parameters(self, params: List[ParamData]): - self._check_reopt() - self._invalidate_last_results() - for p in params: - self._add_param(p) - - def _add_constraints(self, cons: List[ConstraintData]): - self._check_reopt() - self._invalidate_last_results() - for con in cons: - if type(con.expr) is RangedExpression: - self._range_constraints.add(con) - super()._add_constraints(cons) - - def _add_sos_constraints(self, cons: List[SOSConstraintData]): - self._check_reopt() - self._invalidate_last_results() - return super()._add_sos_constraints(cons) - - def _add_objectives(self, objs: List[ObjectiveData]): - self._check_reopt() - if len(objs) > 1: - raise NotImplementedError( - 'the persistent interface to gurobi currently ' - f'only supports single-objective problems; got {len(objs)}: ' - f'{[str(i) for i in objs]}' - ) - - if len(objs) == 0: - return - - obj = objs[0] - - if self._objective is not None: - raise NotImplementedError( - 'the persistent interface to scip currently ' - 'only supports single-objective problems; tried to add ' - f'an objective ({str(obj)}), but there is already an ' - f'active objective ({str(self._objective)})' - ) - - self._invalidate_last_results() - self._set_objective(obj) - - def _remove_objectives(self, objs: List[ObjectiveData]): - self._check_reopt() - for obj in objs: - if obj is not self._objective: - raise RuntimeError( - 'tried to remove an objective that has not been added: ' - f'{str(obj)}' - ) - else: - self._invalidate_last_results() - self._set_objective(None) - - def _remove_constraints(self, cons: List[ConstraintData]): - self._check_reopt() - self._invalidate_last_results() - for con in cons: - scip_con = self._pyomo_con_to_solver_con_map.pop(con) - self._solver_model.delCons(scip_con) - self._range_constraints.discard(con) - - def _remove_sos_constraints(self, cons: List[SOSConstraintData]): - self._check_reopt() - self._invalidate_last_results() - for con in cons: - scip_con = self._pyomo_con_to_solver_con_map.pop(con) - self._solver_model.delCons(scip_con) - - def _remove_variables(self, variables: List[VarData]): - self._check_reopt() - self._invalidate_last_results() - for v in variables: - scip_var = self._pyomo_var_to_solver_var_map.pop(v) - self._solver_model.delVar(scip_var) - - def _remove_parameters(self, params: List[ParamData]): - self._check_reopt() - self._invalidate_last_results() - for p in params: - scip_var = self._pyomo_param_to_solver_param_map.pop(p) - self._solver_model.delVar(scip_var) - - def _update_vars_for_real(self, variables: List[VarData]): - self._check_reopt() - self._invalidate_last_results() - for v in variables: - scip_var = self._pyomo_var_to_solver_var_map[v] - vtype = self._scip_vtype_from_var(v) - lb, ub = self._scip_lb_ub_from_var(v) - self._solver_model.chgVarLb(scip_var, lb) - self._solver_model.chgVarUb(scip_var, ub) - self._solver_model.chgVarType(scip_var, vtype) - - def _update_params_for_real(self, params: List[ParamData]): - self._check_reopt() - self._invalidate_last_results() - for p in params: - scip_var = self._pyomo_param_to_solver_param_map[p] - lb = ub = p.value - self._solver_model.chgVarLb(scip_var, lb) - self._solver_model.chgVarUb(scip_var, ub) - impacted_vars = self._change_detector.get_variables_impacted_by_param(p) - if impacted_vars: - # Convert list to ComponentMap with Reason.bounds for impacted variables - impacted_vars_mapping = ComponentMap( - (v, Reason.bounds) for v in impacted_vars - ) - self._update_variables(impacted_vars_mapping) - impacted_cons = self._change_detector.get_constraints_impacted_by_param(p) - for con in impacted_cons: - if con in self._range_constraints: - self._remove_constraints([con]) - self._add_constraints([con]) - - def add_constraints(self, cons): - if self._change_detector is None: - raise RuntimeError('call set_instance first') - self._change_detector.add_constraints(cons) - - def add_sos_constraints(self, cons): - if self._change_detector is None: - raise RuntimeError('call set_instance first') - self._change_detector.add_sos_constraints(cons) - - def set_objective(self, obj: ObjectiveData): - if self._change_detector is None: - raise RuntimeError('call set_instance first') - self._change_detector.add_objectives([obj]) - - def remove_constraints(self, cons): - if self._change_detector is None: - raise RuntimeError('call set_instance first') - self._change_detector.remove_constraints(cons) - - def remove_sos_constraints(self, cons): - if self._change_detector is None: - raise RuntimeError('call set_instance first') - self._change_detector.remove_sos_constraints(cons) - - def update_variables(self, variables): - if self._change_detector is None: - raise RuntimeError('call set_instance first') - self._change_detector.update_variables(variables) - - def update_parameters(self, params): - if self._change_detector is None: - raise RuntimeError('call set_instance first') - self._change_detector.update_parameters(params) diff --git a/pyomo/contrib/solver/solvers/scip/scip_persistent.py b/pyomo/contrib/solver/solvers/scip/scip_persistent.py new file mode 100644 index 00000000000..663d26f1675 --- /dev/null +++ b/pyomo/contrib/solver/solvers/scip/scip_persistent.py @@ -0,0 +1,418 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 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 __future__ import annotations + +from typing import List, Mapping, Optional + +from pyomo.common.collections import ComponentMap +from pyomo.common.timing import HierarchicalTimer + +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.objective import ObjectiveData +from pyomo.core.base.param import ParamData +from pyomo.core.base.sos import SOSConstraintData +from pyomo.core.base.var import VarData + +from pyomo.contrib.observer.model_observer import ( + Observer, + ModelChangeDetector, + AutoUpdateConfig, + Reason, +) +from pyomo.contrib.solver.common.base import PersistentSolverBase +from pyomo.contrib.solver.common.results import Results + +import pyomo.contrib.solver.solvers.scip.base as scip_base +import pyomo.contrib.solver.solvers.scip.scip_direct as scip_direct + + +class ScipPersistentSolutionLoader(scip_base.ScipSolutionLoader): + def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: + super().__init__(solver_model, var_map, con_map, pyomo_model, opt) + self._valid = True + + def invalidate(self): + self._valid = False + + def _assert_solution_still_valid(self): + if not self._valid: + raise RuntimeError('The results in the solver are no longer valid.') + + def load_vars(self, vars_to_load: List[VarData] | None = None) -> None: + self._assert_solution_still_valid() + return super().load_vars(vars_to_load) + + def get_vars( + self, vars_to_load: List[VarData] | None = None + ) -> Mapping[VarData, float]: + self._assert_solution_still_valid() + return super().get_vars(vars_to_load) + + def get_number_of_solutions(self) -> int: + self._assert_solution_still_valid() + return super().get_number_of_solutions() + + def get_solution_ids(self) -> List: + self._assert_solution_still_valid() + return super().get_solution_ids() + + def load_import_suffixes(self): + self._assert_solution_still_valid() + super().load_import_suffixes() + + def _set_solution_id(self, solution_id: int) -> int: + self._assert_solution_still_valid() + return super()._set_solution_id(solution_id) + + +class ScipPersistentConfig(scip_base.ScipConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + scip_base.ScipConfig.__init__( + self, + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + self.auto_updates: bool = self.declare('auto_updates', AutoUpdateConfig()) + + +class ScipPersistent(scip_direct.ScipDirect, PersistentSolverBase, Observer): + _minimum_version = (5, 5, 0) # this is probably conservative + CONFIG = ScipPersistentConfig() + + def __init__(self, **kwds): + super().__init__(**kwds) + self._pyomo_model = None + self._change_detector = None + self._last_results_object: Optional[Results] = None + self._needs_reopt = False + self._range_constraints = set() + + def _clear(self): + super()._clear() + self._pyomo_model = None + self._change_detector = None + self._needs_reopt = False + self._range_constraints = set() + + def _check_reopt(self): + if self._needs_reopt: + # self._solver_model.freeReoptSolve() # when is it safe to use this one??? + self._solver_model.freeTransform() + self._needs_reopt = False + + def _create_solver_model(self, pyomo_model, config): + if pyomo_model is self._pyomo_model: + self.update(**config) + else: + self.set_instance(pyomo_model, **config) + + solution_loader = ScipPersistentSolutionLoader( + solver_model=self._solver_model, + var_map=self._pyomo_var_to_solver_var_map, + con_map=self._pyomo_con_to_solver_con_map, + pyomo_model=pyomo_model, + opt=self, + ) + + has_obj = self._objective is not None + return self._solver_model, solution_loader, has_obj + + def solve(self, model, **kwds) -> Results: + res = super().solve(model, **kwds) + self._last_results_object = res + self._needs_reopt = True + return res + + def update(self, **kwds): + config = self.config(value=kwds, preserve_implicit=True) + if config.timer is None: + timer = HierarchicalTimer() + else: + timer = config.timer + if self._pyomo_model is None: + raise RuntimeError('must call set_instance or solve before update') + timer.start('update') + self._change_detector.update(timer=timer, **config.auto_updates) + timer.stop('update') + + def set_instance(self, pyomo_model, **kwds): + config = self.config(value=kwds, preserve_implicit=True) + if config.timer is None: + timer = HierarchicalTimer() + else: + timer = config.timer + self._clear() + self._pyomo_model = pyomo_model + self._solver_model = scip_base.scip.Model() + timer.start('set_instance') + self._change_detector = ModelChangeDetector( + model=self._pyomo_model, observers=[self], **config.auto_updates + ) + timer.stop('set_instance') + + def _invalidate_last_results(self): + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + + def _update_variables(self, variables: Mapping[VarData, Reason]): + new_vars = [] + old_vars = [] + mod_vars = [] + for v, reason in variables.items(): + if reason & Reason.added: + new_vars.append(v) + elif reason & Reason.removed: + old_vars.append(v) + else: + mod_vars.append(v) + + if new_vars: + self._add_variables(new_vars) + if old_vars: + self._remove_variables(old_vars) + if mod_vars: + self._update_vars_for_real(mod_vars) + + def _update_parameters(self, params: Mapping[ParamData, Reason]): + new_params = [] + old_params = [] + mod_params = [] + for p, reason in params.items(): + if reason & Reason.added: + new_params.append(p) + elif reason & Reason.removed: + old_params.append(p) + else: + mod_params.append(p) + + if new_params: + self._add_parameters(new_params) + if old_params: + self._remove_parameters(old_params) + if mod_params: + self._update_params_for_real(mod_params) + + def _update_constraints(self, cons: Mapping[ConstraintData, Reason]): + new_cons = [] + old_cons = [] + for c, reason in cons.items(): + if reason & Reason.added: + new_cons.append(c) + elif reason & Reason.removed: + old_cons.append(c) + elif reason & Reason.expr: + old_cons.append(c) + new_cons.append(c) + + if old_cons: + self._remove_constraints(old_cons) + if new_cons: + self._add_constraints(new_cons) + + def _update_sos_constraints(self, cons: Mapping[SOSConstraintData, Reason]): + new_cons = [] + old_cons = [] + for c, reason in cons.items(): + if reason & Reason.added: + new_cons.append(c) + elif reason & Reason.removed: + old_cons.append(c) + elif reason & Reason.sos_items: + old_cons.append(c) + new_cons.append(c) + + if old_cons: + self._remove_sos_constraints(old_cons) + if new_cons: + self._add_sos_constraints(new_cons) + + def _update_objectives(self, objs: Mapping[ObjectiveData, Reason]): + new_objs = [] + old_objs = [] + for obj, reason in objs.items(): + if reason & Reason.added: + new_objs.append(obj) + elif reason & Reason.removed: + old_objs.append(obj) + elif reason & (Reason.expr | Reason.sense): + old_objs.append(obj) + new_objs.append(obj) + + if old_objs: + self._remove_objectives(old_objs) + if new_objs: + self._add_objectives(new_objs) + + def _add_variables(self, variables: List[VarData]): + self._check_reopt() + self._invalidate_last_results() + for v in variables: + self._add_var(v) + + def _add_parameters(self, params: List[ParamData]): + self._check_reopt() + self._invalidate_last_results() + for p in params: + self._add_param(p) + + def _add_constraints(self, cons: List[ConstraintData]): + self._check_reopt() + self._invalidate_last_results() + for con in cons: + if type(con.expr) is scip_base.RangedExpression: + self._range_constraints.add(con) + super()._add_constraints(cons) + + def _add_sos_constraints(self, cons: List[SOSConstraintData]): + self._check_reopt() + self._invalidate_last_results() + return super()._add_sos_constraints(cons) + + def _add_objectives(self, objs: List[ObjectiveData]): + self._check_reopt() + if len(objs) > 1: + raise NotImplementedError( + 'the persistent interface to scip currently ' + f'only supports single-objective problems; got {len(objs)}: ' + f'{[str(i) for i in objs]}' + ) + + if len(objs) == 0: + return + + obj = objs[0] + + if self._objective is not None: + raise NotImplementedError( + 'the persistent interface to scip currently ' + 'only supports single-objective problems; tried to add ' + f'an objective ({str(obj)}), but there is already an ' + f'active objective ({str(self._objective)})' + ) + + self._invalidate_last_results() + self._set_objective(obj) + + def _remove_objectives(self, objs: List[ObjectiveData]): + self._check_reopt() + for obj in objs: + if obj is not self._objective: + raise RuntimeError( + 'tried to remove an objective that has not been added: ' + f'{str(obj)}' + ) + else: + self._invalidate_last_results() + self._set_objective(None) + + def _remove_constraints(self, cons: List[ConstraintData]): + self._check_reopt() + self._invalidate_last_results() + for con in cons: + scip_con = self._pyomo_con_to_solver_con_map.pop(con) + self._solver_model.delCons(scip_con) + self._range_constraints.discard(con) + + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): + self._check_reopt() + self._invalidate_last_results() + for con in cons: + scip_con = self._pyomo_con_to_solver_con_map.pop(con) + self._solver_model.delCons(scip_con) + + def _remove_variables(self, variables: List[VarData]): + self._check_reopt() + self._invalidate_last_results() + for v in variables: + scip_var = self._pyomo_var_to_solver_var_map.pop(v) + self._solver_model.delVar(scip_var) + + def _remove_parameters(self, params: List[ParamData]): + self._check_reopt() + self._invalidate_last_results() + for p in params: + scip_var = self._pyomo_param_to_solver_param_map.pop(p) + self._solver_model.delVar(scip_var) + + def _update_vars_for_real(self, variables: List[VarData]): + self._check_reopt() + self._invalidate_last_results() + for v in variables: + scip_var = self._pyomo_var_to_solver_var_map[v] + vtype = self._scip_vtype_from_var(v) + lb, ub = self._scip_lb_ub_from_var(v) + self._solver_model.chgVarLb(scip_var, lb) + self._solver_model.chgVarUb(scip_var, ub) + self._solver_model.chgVarType(scip_var, vtype) + + def _update_params_for_real(self, params: List[ParamData]): + self._check_reopt() + self._invalidate_last_results() + for p in params: + scip_var = self._pyomo_param_to_solver_param_map[p] + lb = ub = p.value + self._solver_model.chgVarLb(scip_var, lb) + self._solver_model.chgVarUb(scip_var, ub) + impacted_vars = self._change_detector.get_variables_impacted_by_param(p) + if impacted_vars: + impacted_vars_mapping = ComponentMap( + (v, Reason.bounds) for v in impacted_vars + ) + self._update_variables(impacted_vars_mapping) + impacted_cons = self._change_detector.get_constraints_impacted_by_param(p) + for con in impacted_cons: + if con in self._range_constraints: + self._remove_constraints([con]) + self._add_constraints([con]) + + def add_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.add_constraints(cons) + + def add_sos_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.add_sos_constraints(cons) + + def set_objective(self, obj: ObjectiveData): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.add_objectives([obj]) + + def remove_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.remove_constraints(cons) + + def remove_sos_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.remove_sos_constraints(cons) + + def update_variables(self, variables): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.update_variables(variables) + + def update_parameters(self, params): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.update_parameters(params) diff --git a/pyomo/contrib/solver/tests/solvers/test_scip_direct.py b/pyomo/contrib/solver/tests/solvers/test_scip_direct.py new file mode 100644 index 00000000000..9eb330b1183 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_scip_direct.py @@ -0,0 +1,309 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 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 datetime + +import pyomo.common.unittest as unittest +import pyomo.environ as pyo + +from pyomo.common.config import ConfigDict +from pyomo.common.timing import HierarchicalTimer +from pyomo.contrib.solver.common.base import Availability +from pyomo.contrib.solver.common.results import SolutionStatus, TerminationCondition +from pyomo.contrib.solver.common.util import ( + NoFeasibleSolutionError, + NoOptimalSolutionError, + NoSolutionError, +) +from pyomo.contrib.solver.solvers.scip.base import ScipConfig +from pyomo.contrib.solver.solvers.scip.scip_direct import ScipDirect + +scip_available = ScipDirect().available() + + +@unittest.pytest.mark.solver("scip_direct") +class TestScipDirectConfig(unittest.TestCase): + def test_default_instantiation(self): + config = ScipConfig() + self.assertIsNone(config._description) + self.assertEqual(config._visibility, 0) + self.assertFalse(config.tee) + self.assertTrue(config.load_solutions) + self.assertTrue(config.raise_exception_on_nonoptimal_result) + self.assertFalse(config.symbolic_solver_labels) + self.assertIsNone(config.timer) + self.assertIsNone(config.threads) + self.assertIsNone(config.time_limit) + self.assertIsNone(config.rel_gap) + self.assertIsNone(config.abs_gap) + self.assertFalse(config.warmstart_discrete_vars) + self.assertIsInstance(config.solver_options, ConfigDict) + + def test_custom_instantiation(self): + config = ScipConfig(description="A description") + config.tee = True + config.warmstart_discrete_vars = True + self.assertTrue(config.tee) + self.assertEqual(config._description, "A description") + self.assertTrue(config.warmstart_discrete_vars) + + +@unittest.pytest.mark.solver("scip_direct") +class TestScipDirectInterface(unittest.TestCase): + def test_class_member_list(self): + opt = ScipDirect() + expected_list = [ + 'CONFIG', + 'available', + 'config', + 'api_version', + 'is_persistent', + 'name', + 'solve', + 'version', + ] + method_list = [method for method in dir(opt) if not method.startswith('_')] + self.assertEqual(sorted(expected_list), sorted(method_list)) + + def test_default_instantiation(self): + opt = ScipDirect() + self.assertFalse(opt.is_persistent()) + self.assertEqual(opt.name, 'scip_direct') + self.assertEqual(opt.CONFIG, opt.config) + self.assertIn( + opt.available(), + {Availability.NotFound, Availability.BadVersion, Availability.FullLicense}, + ) + + def test_context_manager(self): + with ScipDirect() as opt: + self.assertFalse(opt.is_persistent()) + self.assertEqual(opt.name, 'scip_direct') + self.assertEqual(opt.CONFIG, opt.config) + + def test_version(self): + opt = ScipDirect() + if opt.available() == Availability.FullLicense: + ver = opt.version() + self.assertIsInstance(ver, tuple) + self.assertGreaterEqual(len(ver), 3) + self.assertTrue(all(isinstance(_, int) for _ in ver)) + + def test_get_tc_map(self): + opt = ScipDirect() + tc_map = opt._get_tc_map() + self.assertEqual( + tc_map["optimal"], TerminationCondition.convergenceCriteriaSatisfied + ) + self.assertEqual(tc_map["timelimit"], TerminationCondition.maxTimeLimit) + self.assertEqual(tc_map["infeasible"], TerminationCondition.provenInfeasible) + self.assertEqual(tc_map["unbounded"], TerminationCondition.unbounded) + self.assertEqual( + tc_map["inforunbd"], TerminationCondition.infeasibleOrUnbounded + ) + + def test_scip_vtype_from_var(self): + m = pyo.ConcreteModel() + m.b = pyo.Var(within=pyo.Binary) + m.i = pyo.Var(within=pyo.Integers) + m.c = pyo.Var(within=pyo.Reals) + + opt = ScipDirect() + self.assertEqual(opt._scip_vtype_from_var(m.b), "B") + self.assertEqual(opt._scip_vtype_from_var(m.i), "I") + self.assertEqual(opt._scip_vtype_from_var(m.c), "C") + + +@unittest.skipIf(not scip_available, "SCIP is not available") +@unittest.pytest.mark.solver("scip_direct") +class TestScipDirect(unittest.TestCase): + def create_lp_model(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, None), initialize=0) + m.y = pyo.Var(bounds=(0, None), initialize=0) + m.obj = pyo.Objective(expr=m.x + 2 * m.y) + m.c = pyo.Constraint(expr=m.x + m.y >= 1) + return m + + def create_feasible_model_no_objective(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, None), initialize=0) + m.c = pyo.Constraint(expr=m.x >= 1) + return m + + def create_infeasible_model(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 1), initialize=0) + m.obj = pyo.Objective(expr=m.x) + m.c = pyo.Constraint(expr=m.x >= 2) + return m + + def create_sos_model(self, level): + m = pyo.ConcreteModel() + m.I = pyo.RangeSet(3) + m.x = pyo.Var(m.I, bounds=(0, 1)) + m.obj = pyo.Objective(expr=sum(i * m.x[i] for i in m.I)) + m.c = pyo.Constraint(expr=sum(m.x[i] for i in m.I) == 1) + m.sos = pyo.SOSConstraint(var=m.x, sos=level) + return m + + def test_solve(self): + m = self.create_lp_model() + opt = ScipDirect() + res = opt.solve(m) + + self.assertEqual( + res.termination_condition, TerminationCondition.convergenceCriteriaSatisfied + ) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 0) + self.assertAlmostEqual(res.incumbent_objective, 1) + self.assertIsNotNone(res.objective_bound) + self.assertEqual(res.solver_name, 'scip_direct') + self.assertIsInstance(res.solver_version, tuple) + self.assertIsNotNone(res.solver_log) + self.assertIsNotNone(res.timing_info.scip_time) + self.assertEqual(res.timing_info.start_timestamp.tzinfo, datetime.timezone.utc) + self.assertGreaterEqual(res.timing_info.wall_time, 0) + self.assertIn('NNodes', res.extra_info) + + def test_solve_load_solutions_false(self): + m = self.create_lp_model() + opt = ScipDirect() + res = opt.solve(m, load_solutions=False) + + self.assertEqual( + res.termination_condition, TerminationCondition.convergenceCriteriaSatisfied + ) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 0) + + self.assertEqual(res.solution_loader.get_number_of_solutions(), 1) + self.assertEqual(res.solution_loader.get_solution_ids(), [0]) + + vals = res.solution_loader.get_vars() + self.assertAlmostEqual(vals[m.x], 1) + self.assertAlmostEqual(vals[m.y], 0) + + res.solution_loader.load_vars() + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 0) + + def test_no_objective(self): + m = self.create_feasible_model_no_objective() + opt = ScipDirect() + res = opt.solve(m) + + self.assertEqual( + res.termination_condition, TerminationCondition.convergenceCriteriaSatisfied + ) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertIsNone(res.incumbent_objective) + self.assertIsNone(res.objective_bound) + + def test_infeasible_no_exception(self): + m = self.create_infeasible_model() + opt = ScipDirect() + res = opt.solve( + m, load_solutions=False, raise_exception_on_nonoptimal_result=False + ) + + self.assertEqual( + res.termination_condition, TerminationCondition.provenInfeasible + ) + self.assertEqual(res.solution_status, SolutionStatus.noSolution) + self.assertIsNone(res.incumbent_objective) + self.assertEqual(res.solution_loader.get_number_of_solutions(), 0) + with self.assertRaises(NoSolutionError): + res.solution_loader.get_vars() + + def test_infeasible_raises_no_optimal_solution_error(self): + m = self.create_infeasible_model() + opt = ScipDirect() + with self.assertRaises(NoOptimalSolutionError): + opt.solve(m, load_solutions=False) + + def test_infeasible_raises_no_feasible_solution_error(self): + m = self.create_infeasible_model() + opt = ScipDirect() + with self.assertRaises(NoFeasibleSolutionError): + opt.solve( + m, load_solutions=True, raise_exception_on_nonoptimal_result=False + ) + + def test_timer(self): + m = self.create_lp_model() + timer = HierarchicalTimer() + opt = ScipDirect() + res = opt.solve(m, timer=timer) + self.assertIs(res.timing_info.timer, timer) + + def test_fixed_var(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10), initialize=0) + m.y = pyo.Var(bounds=(-10, 10), initialize=0) + m.x.fix(2) + m.obj = pyo.Objective(expr=m.y) + m.c = pyo.Constraint(expr=m.y >= m.x + 1) + + opt = ScipDirect() + res = opt.solve(m) + + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 2) + self.assertAlmostEqual(m.y.value, 3) + + def test_sos1(self): + m = self.create_sos_model(level=1) + opt = ScipDirect() + res = opt.solve(m) + + self.assertEqual( + res.termination_condition, TerminationCondition.convergenceCriteriaSatisfied + ) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + + def test_sos2(self): + m = self.create_sos_model(level=2) + opt = ScipDirect() + res = opt.solve(m) + + self.assertEqual( + res.termination_condition, TerminationCondition.convergenceCriteriaSatisfied + ) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + + def test_bad_sos_level(self): + m = self.create_sos_model(level=1) + m.del_component(m.sos) + m.sos = pyo.SOSConstraint(var=m.x, sos=3) + + opt = ScipDirect() + with self.assertRaisesRegex(ValueError, "does not support SOS level 3"): + opt.solve(m) + + def test_warmstart_discrete_vars(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(within=pyo.Binary, initialize=1) + m.y = pyo.Var(within=pyo.Binary, initialize=0) + m.obj = pyo.Objective(expr=m.x + 2 * m.y, sense=pyo.maximize) + m.c = pyo.Constraint(expr=m.x + m.y <= 1) + + opt = ScipDirect() + res = opt.solve(m, warmstart_discrete_vars=True) + + self.assertEqual( + res.termination_condition, TerminationCondition.convergenceCriteriaSatisfied + ) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) diff --git a/pyomo/contrib/solver/tests/solvers/test_scip_persistent.py b/pyomo/contrib/solver/tests/solvers/test_scip_persistent.py new file mode 100644 index 00000000000..aa17d5c3495 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_scip_persistent.py @@ -0,0 +1,291 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 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.common.unittest as unittest +import pyomo.environ as pyo + +from pyomo.common.timing import HierarchicalTimer +from pyomo.contrib.solver.common.base import Availability +from pyomo.contrib.solver.common.results import SolutionStatus, TerminationCondition +from pyomo.contrib.solver.common.util import ( + NoFeasibleSolutionError, + NoOptimalSolutionError, +) +from pyomo.contrib.solver.solvers.scip.scip_persistent import ( + ScipPersistent, + ScipPersistentConfig, +) + +scip_available = ScipPersistent().available() + + +@unittest.pytest.mark.solver("scip_persistent") +class TestScipPersistentConfig(unittest.TestCase): + def test_default_instantiation(self): + config = ScipPersistentConfig() + self.assertIsNone(config._description) + self.assertEqual(config._visibility, 0) + self.assertFalse(config.tee) + self.assertTrue(config.load_solutions) + self.assertTrue(config.raise_exception_on_nonoptimal_result) + self.assertFalse(config.symbolic_solver_labels) + self.assertIsNone(config.timer) + self.assertIsNone(config.threads) + self.assertIsNone(config.time_limit) + self.assertIsNone(config.rel_gap) + self.assertIsNone(config.abs_gap) + self.assertFalse(config.warmstart_discrete_vars) + self.assertTrue(hasattr(config, 'auto_updates')) + + def test_custom_instantiation(self): + config = ScipPersistentConfig(description="A description") + config.tee = True + config.warmstart_discrete_vars = True + self.assertTrue(config.tee) + self.assertEqual(config._description, "A description") + self.assertTrue(config.warmstart_discrete_vars) + + +@unittest.pytest.mark.solver("scip_persistent") +class TestScipPersistentInterface(unittest.TestCase): + def test_default_instantiation(self): + opt = ScipPersistent() + self.assertTrue(opt.is_persistent()) + self.assertEqual(opt.name, 'scip_persistent') + self.assertEqual(opt.CONFIG, opt.config) + self.assertIn( + opt.available(), + {Availability.NotFound, Availability.BadVersion, Availability.FullLicense}, + ) + + def test_context_manager(self): + with ScipPersistent() as opt: + self.assertTrue(opt.is_persistent()) + self.assertEqual(opt.name, 'scip_persistent') + self.assertEqual(opt.CONFIG, opt.config) + + def test_update_before_set_instance_raises(self): + opt = ScipPersistent() + with self.assertRaisesRegex( + RuntimeError, 'must call set_instance or solve before update' + ): + opt.update() + + def test_add_constraints_before_set_instance_raises(self): + opt = ScipPersistent() + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.c = pyo.Constraint(expr=m.x >= 1) + with self.assertRaisesRegex(RuntimeError, 'call set_instance first'): + opt.add_constraints([m.c]) + + +@unittest.skipIf(not scip_available, "SCIP is not available") +@unittest.pytest.mark.solver("scip_persistent") +class TestScipPersistent(unittest.TestCase): + def create_lp_model(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, None), initialize=0) + m.y = pyo.Var(bounds=(0, None), initialize=0) + m.obj = pyo.Objective(expr=m.x + 2 * m.y) + m.c = pyo.Constraint(expr=m.x + m.y >= 1) + return m + + def create_range_model(self): + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.xl = pyo.Param(initialize=-1, mutable=True) + m.xu = pyo.Param(initialize=1, mutable=True) + m.c = pyo.Constraint(expr=pyo.inequality(m.xl, m.x, m.xu)) + m.obj = pyo.Objective(expr=m.x) + return m + + def test_set_instance_and_solve(self): + m = self.create_lp_model() + opt = ScipPersistent() + opt.set_instance(m) + res = opt.solve(m) + + self.assertEqual( + res.termination_condition, TerminationCondition.convergenceCriteriaSatisfied + ) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 0) + + def test_solve_twice_same_instance(self): + m = self.create_lp_model() + opt = ScipPersistent() + + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 0) + + m.c.set_value(m.x + m.y >= 2) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 2) + self.assertAlmostEqual(m.y.value, 0) + + def test_load_solutions_false(self): + m = self.create_lp_model() + opt = ScipPersistent() + res = opt.solve(m, load_solutions=False) + + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 0) + + vals = res.solution_loader.get_vars() + self.assertAlmostEqual(vals[m.x], 1) + self.assertAlmostEqual(vals[m.y], 0) + + res.solution_loader.load_vars() + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 0) + + def test_solution_loader_invalidated_after_update(self): + m = self.create_lp_model() + opt = ScipPersistent() + res = opt.solve(m, load_solutions=False) + + m.c.set_value(m.x + m.y >= 2) + opt.update() + + with self.assertRaisesRegex( + RuntimeError, 'The results in the solver are no longer valid.' + ): + res.solution_loader.get_vars() + + def test_range_constraint_mutable_params(self): + m = self.create_range_model() + opt = ScipPersistent() + opt.set_instance(m) + + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(m.x.value, -1) + + m.xl.value = -3 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -3) + + del m.obj + m.obj = pyo.Objective(expr=m.x, sense=pyo.maximize) + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 1) + + m.xu.value = 3 + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 3) + + def test_add_remove_constraints(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10)) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= 2 * m.x + 1) + + opt = ScipPersistent() + opt.set_instance(m) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -10) + self.assertAlmostEqual(m.y.value, -19) + + m.c2 = pyo.Constraint(expr=m.y >= -m.x + 1) + opt.add_constraints([m.c2]) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + + opt.remove_constraints([m.c2]) + m.del_component(m.c2) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -10) + self.assertAlmostEqual(m.y.value, -19) + + def test_update_variables_manual(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-1, 1)) + m.obj = pyo.Objective(expr=m.x) + + opt = ScipPersistent() + opt.config.auto_updates.update_vars = False + opt.set_instance(m) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -1) + + m.x.setlb(-3) + opt.update_variables([m.x]) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, -3) + + def test_update_parameters_manual(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-10, 10)) + m.p = pyo.Param(initialize=1, mutable=True) + m.obj = pyo.Objective(expr=m.x) + m.c = pyo.Constraint(expr=m.x >= m.p) + + opt = ScipPersistent() + opt.config.auto_updates.update_parameters = False + opt.set_instance(m) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 1) + + m.p.value = 3 + opt.update_parameters([m.p]) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 3) + + def test_timer(self): + m = self.create_lp_model() + timer = HierarchicalTimer() + opt = ScipPersistent() + res = opt.solve(m, timer=timer) + self.assertIs(res.timing_info.timer, timer) + + def test_infeasible_no_exception(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 1)) + m.obj = pyo.Objective(expr=m.x) + m.c = pyo.Constraint(expr=m.x >= 2) + + opt = ScipPersistent() + res = opt.solve( + m, load_solutions=False, raise_exception_on_nonoptimal_result=False + ) + + self.assertEqual( + res.termination_condition, TerminationCondition.provenInfeasible + ) + self.assertEqual(res.solution_status, SolutionStatus.noSolution) + + def test_infeasible_raises(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 1)) + m.obj = pyo.Objective(expr=m.x) + m.c = pyo.Constraint(expr=m.x >= 2) + + opt = ScipPersistent() + with self.assertRaises(NoOptimalSolutionError): + opt.solve(m, load_solutions=False) + + with self.assertRaises(NoFeasibleSolutionError): + opt.solve( + m, load_solutions=True, raise_exception_on_nonoptimal_result=False + ) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index e6bd71e268a..4432ed4b40c 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -25,7 +25,8 @@ SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.solvers.scip.scip_direct import ScipDirect, ScipPersistent +from pyomo.contrib.solver.solvers.scip.scip_direct import ScipDirect +from pyomo.contrib.solver.solvers.scip.scip_persistent import ScipPersistent from pyomo.contrib.solver.common.util import ( NoDualsError, NoReducedCostsError, From cb53be35012c82c15a7d1a5d5f088252d3a4302f Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Fri, 15 May 2026 14:45:20 -0600 Subject: [PATCH 60/66] Wrong copyright --- pyomo/contrib/solver/solvers/scip/__init__.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/__init__.py b/pyomo/contrib/solver/solvers/scip/__init__.py index 6eb9ea8b81d..231b44987f6 100644 --- a/pyomo/contrib/solver/solvers/scip/__init__.py +++ b/pyomo/contrib/solver/solvers/scip/__init__.py @@ -1,10 +1,8 @@ -# ___________________________________________________________________________ +# ____________________________________________________________________________________ # -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2025 -# 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. -# ___________________________________________________________________________ +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 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 993bcc9a6711f87396e8801efb408142e56911bc Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Fri, 15 May 2026 15:01:07 -0600 Subject: [PATCH 61/66] Update online docs --- .../explanation/experimental/solvers.rst | 12 +++++++--- doc/OnlineDocs/getting_started/solvers.rst | 7 +++++- pyomo/contrib/solver/plugins.py | 22 +++++++++---------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/doc/OnlineDocs/explanation/experimental/solvers.rst b/doc/OnlineDocs/explanation/experimental/solvers.rst index c04a7d870b8..5701e37d188 100644 --- a/doc/OnlineDocs/explanation/experimental/solvers.rst +++ b/doc/OnlineDocs/explanation/experimental/solvers.rst @@ -45,6 +45,9 @@ with existing interfaces). * - Ipopt - ``ipopt`` - ``ipopt_v2`` + * - GAMS + - ``gams`` + - ``gams_v2`` * - Gurobi (persistent) - ``gurobi_persistent`` - ``gurobi_persistent_v2`` @@ -57,9 +60,12 @@ with existing interfaces). * - KNITRO - ``knitro_direct`` - ``knitro_direct`` - * - GAMS - - ``gams`` - - ``gams_v2`` + * - SCIP (direct) + - ``scip_direct`` + - ``scip_direct`` + * - SCIP (persistent) + - ``scip_persistent`` + - ``scip_persistent`` Using the new interfaces through the legacy interface ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/OnlineDocs/getting_started/solvers.rst b/doc/OnlineDocs/getting_started/solvers.rst index ba04217b8f0..8183905c093 100644 --- a/doc/OnlineDocs/getting_started/solvers.rst +++ b/doc/OnlineDocs/getting_started/solvers.rst @@ -76,11 +76,16 @@ the license requirements for their desired solver. - ``conda install ‑c conda‑forge pymumps`` - `License `__ `Docs `__ - * - SCIP + * - SCIP (Command-line) - N/A - ``conda install ‑c conda‑forge scip`` - `License `__ `Docs `__ + * - SCIP (Python) + - ``pip install pyscipopt`` + - ``conda install ‑c conda‑forge pyscipopt`` + - `License `__ + `Docs `__ * - XPRESS - ``pip install xpress`` - ``conda install ‑c fico‑xpress xpress`` diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index e6a72f5ec5e..4fa18dc9694 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -24,6 +24,9 @@ def load(): SolverFactory.register( name="ipopt", legacy_name="ipopt_v2", doc="The IPOPT NLP solver" )(Ipopt, LegacyIpoptSolver) + SolverFactory.register(name='gams', legacy_name='gams_v2', doc='Interface to GAMS')( + GAMS + ) SolverFactory.register( name="gurobi_persistent", legacy_name="gurobi_persistent_v2", @@ -42,21 +45,16 @@ def load(): SolverFactory.register( name="highs", legacy_name="highs", doc="Persistent interface to HiGHS" )(Highs) - SolverFactory.register(name='gams', legacy_name='gams_v2', doc='Interface to GAMS')( - GAMS - ) SolverFactory.register( - name='scip_direct', - legacy_name='scip_direct_v2', - doc='Direct interface pyscipopt', + name="knitro_direct", + legacy_name="knitro_direct", + doc="Direct interface to KNITRO solver", + )(KnitroDirectSolver) + SolverFactory.register( + name='scip_direct', legacy_name='scip_direct', doc='Direct interface pyscipopt' )(ScipDirect) SolverFactory.register( name='scip_persistent', - legacy_name='scip_persistent_v2', + legacy_name='scip_persistent', doc='Persistent interface pyscipopt', )(ScipPersistent) - SolverFactory.register( - name="knitro_direct", - legacy_name="knitro_direct", - doc="Direct interface to KNITRO solver", - )(KnitroDirectSolver) From dff6dc5b5289987a9dd0a1b11c1bec9f97bf14ee Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 18 May 2026 06:18:41 -0600 Subject: [PATCH 62/66] remove unreachable code --- .../solver/solvers/scip/scip_direct.py | 87 +++++++------------ 1 file changed, 33 insertions(+), 54 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index b9592b672ae..b9e0a6c064f 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -102,47 +102,44 @@ def version(self) -> Tuple: def solve(self, model: BlockData, **kwds) -> Results: start_timestamp = datetime.datetime.now(datetime.timezone.utc) - try: - config = self.config(value=kwds, preserve_implicit=True) + config = self.config(value=kwds, preserve_implicit=True) - StaleFlagManager.mark_all_as_stale() + StaleFlagManager.mark_all_as_stale() - if config.timer is None: - config.timer = HierarchicalTimer() - timer = config.timer + if config.timer is None: + config.timer = HierarchicalTimer() + timer = config.timer - ostreams = [io.StringIO()] + config.tee + ostreams = [io.StringIO()] + config.tee - scip_model, solution_loader, has_obj = self._create_solver_model( - model, config - ) + scip_model, solution_loader, has_obj = self._create_solver_model( + model, config + ) - scip_model.hideOutput(quiet=False) - if config.threads is not None: - scip_model.setParam('lp/threads', config.threads) - if config.time_limit is not None: - scip_model.setParam('limits/time', config.time_limit) - if config.rel_gap is not None: - scip_model.setParam('limits/gap', config.rel_gap) - if config.abs_gap is not None: - scip_model.setParam('limits/absgap', config.abs_gap) - - if config.warmstart_discrete_vars: - self._mipstart() - - for key, option in config.solver_options.items(): - scip_model.setParam(key, option) - - timer.start('optimize') - with capture_output(TeeStream(*ostreams), capture_fd=True): - scip_model.optimize() - timer.stop('optimize') - - results = self._populate_results( - scip_model, solution_loader, has_obj, config - ) - except InfeasibleConstraintException: - results = self._get_infeasible_results() + scip_model.hideOutput(quiet=False) + if config.threads is not None: + scip_model.setParam('lp/threads', config.threads) + if config.time_limit is not None: + scip_model.setParam('limits/time', config.time_limit) + if config.rel_gap is not None: + scip_model.setParam('limits/gap', config.rel_gap) + if config.abs_gap is not None: + scip_model.setParam('limits/absgap', config.abs_gap) + + if config.warmstart_discrete_vars: + self._mipstart() + + for key, option in config.solver_options.items(): + scip_model.setParam(key, option) + + timer.start('optimize') + with capture_output(TeeStream(*ostreams), capture_fd=True): + scip_model.optimize() + timer.stop('optimize') + + results = self._populate_results( + scip_model, solution_loader, has_obj, config + ) results.solver_log = ostreams[0].getvalue() end_timestamp = datetime.datetime.now(datetime.timezone.utc) @@ -178,24 +175,6 @@ def _get_tc_map(self): } return ScipDirect._tc_map - def _get_infeasible_results(self): - res = Results() - res.solution_loader = NoSolutionSolutionLoader() - res.solution_status = SolutionStatus.noSolution - res.termination_condition = TerminationCondition.provenInfeasible - res.incumbent_objective = None - res.objective_bound = None - res.iteration_count = None - res.timing_info.scip_time = None - res.solver_config = self.config - res.solver_name = self.name - res.solver_version = self.version() - if self.config.raise_exception_on_nonoptimal_result: - raise NoOptimalSolutionError() - if self.config.load_solutions: - raise NoFeasibleSolutionError() - return res - def _scip_lb_ub_from_var(self, var): if var.is_fixed(): val = var.value From fa7e4281b957051c9e2a5acc0d68f98b3e80aa08 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 18 May 2026 06:46:25 -0600 Subject: [PATCH 63/66] contrib.solvers: tests for sos constraints --- .../solver/tests/solvers/test_solvers.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 4432ed4b40c..587498a21b7 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -122,6 +122,11 @@ def param_as_standalone_func(cls, p, func, name): ('ipopt', Ipopt), ('highs', Highs), ] +sos_solvers = [ + ('gurobi_persistent', GurobiPersistent), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), +] def _load_tests(solver_list): @@ -2414,6 +2419,43 @@ def test_external_function( res = opt.solve(model) self.assertAlmostEqual(pyo.value(model.o), 0.885603194411, 7) + @mark_parameterized.expand(input=_load_tests(sos_solvers)) + def test_sos( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + + m = pyo.ConcreteModel() + m.a = pyo.Set(initialize=[0, 1, 2, 3]) + m.x = pyo.Var(m.a, within=pyo.Binary) + m.obj = pyo.Objective(expr=sum((i+1) * m.x[i] for i in range(4)), sense=pyo.maximize) + m.c = pyo.SOSConstraint(var=m.x, sos=1) + + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(res.incumbent_objective, 4) + for i in range(3): + self.assertAlmostEqual(m.x[i].value, 0) + self.assertAlmostEqual(m.x[3].value, 1) + + del m.c + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(res.incumbent_objective, 10) + for i in range(4): + self.assertAlmostEqual(m.x[i].value, 1) + + m.c = pyo.SOSConstraint(var=m.x, sos=2) + res = opt.solve(m) + self.assertEqual(res.solution_status, SolutionStatus.optimal) + self.assertAlmostEqual(res.incumbent_objective, 7) + for i in range(2): + self.assertAlmostEqual(m.x[i].value, 0) + self.assertAlmostEqual(m.x[2].value, 1) + self.assertAlmostEqual(m.x[3].value, 1) + class TestLegacySolverInterface(unittest.TestCase): @mark_parameterized.expand(input=all_solvers) From f7762e658e7da574f098753214cea8ac4489a4a1 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 18 May 2026 06:47:26 -0600 Subject: [PATCH 64/66] run black --- pyomo/contrib/solver/solvers/scip/scip_direct.py | 8 ++------ pyomo/contrib/solver/tests/solvers/test_solvers.py | 8 ++++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py index b9e0a6c064f..58281218b8e 100644 --- a/pyomo/contrib/solver/solvers/scip/scip_direct.py +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -112,9 +112,7 @@ def solve(self, model: BlockData, **kwds) -> Results: ostreams = [io.StringIO()] + config.tee - scip_model, solution_loader, has_obj = self._create_solver_model( - model, config - ) + scip_model, solution_loader, has_obj = self._create_solver_model(model, config) scip_model.hideOutput(quiet=False) if config.threads is not None: @@ -137,9 +135,7 @@ def solve(self, model: BlockData, **kwds) -> Results: scip_model.optimize() timer.stop('optimize') - results = self._populate_results( - scip_model, solution_loader, has_obj, config - ) + results = self._populate_results(scip_model, solution_loader, has_obj, config) results.solver_log = ostreams[0].getvalue() end_timestamp = datetime.datetime.now(datetime.timezone.utc) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 587498a21b7..723aa39433c 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -2420,9 +2420,7 @@ def test_external_function( self.assertAlmostEqual(pyo.value(model.o), 0.885603194411, 7) @mark_parameterized.expand(input=_load_tests(sos_solvers)) - def test_sos( - self, name: str, opt_class: Type[SolverBase], use_presolve: bool - ): + def test_sos(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): raise unittest.SkipTest(f'Solver {opt.name} not available.') @@ -2430,7 +2428,9 @@ def test_sos( m = pyo.ConcreteModel() m.a = pyo.Set(initialize=[0, 1, 2, 3]) m.x = pyo.Var(m.a, within=pyo.Binary) - m.obj = pyo.Objective(expr=sum((i+1) * m.x[i] for i in range(4)), sense=pyo.maximize) + m.obj = pyo.Objective( + expr=sum((i + 1) * m.x[i] for i in range(4)), sense=pyo.maximize + ) m.c = pyo.SOSConstraint(var=m.x, sos=1) res = opt.solve(m) From b150427b1e4a4c7efeefc80b6cbd22f564b8b801 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 18 May 2026 07:36:23 -0600 Subject: [PATCH 65/66] more tests for scip --- .../solver/tests/solvers/test_scip_direct.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pyomo/contrib/solver/tests/solvers/test_scip_direct.py b/pyomo/contrib/solver/tests/solvers/test_scip_direct.py index 9eb330b1183..05497b33264 100644 --- a/pyomo/contrib/solver/tests/solvers/test_scip_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_scip_direct.py @@ -23,6 +23,7 @@ ) from pyomo.contrib.solver.solvers.scip.base import ScipConfig from pyomo.contrib.solver.solvers.scip.scip_direct import ScipDirect +from pyomo.contrib.solver.tests.solvers.test_gurobi_persistent import create_pmedian_model scip_available = ScipDirect().available() @@ -307,3 +308,44 @@ def test_warmstart_discrete_vars(self): self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 1) + + def test_multiple_solutions(self): + m = create_pmedian_model() + + # The solutions found by scip may change from version to version. + # Let's warmstart scip with a suboptimal solution to ensure we + # have at least 2 solutions. + + init_sol = {1, 2, 3} + for k, y in m.y.items(): + if k in init_sol: + y.value = 1 + else: + y.value = 0 + + opt = ScipDirect() + opt.config.warmstart_discrete_vars = True + opt.config.solver_options['limits/maxsol'] = 100000 + opt.config.solver_options['heuristics/completesol/maxunknownrate'] = 1.0 + res = opt.solve(m, load_solutions=True) + num_solutions = res.solution_loader.get_number_of_solutions() + self.assertGreaterEqual(num_solutions, 2) + + # the best solution + self.assertAlmostEqual(pyo.value(m.obj.expr), 6.431184939357673) + sol = {3, 6, 9} + for k, v in m.y.items(): + if k in sol: + self.assertAlmostEqual(v.value, 1) + else: + self.assertAlmostEqual(v.value, 0) + + # the worst solution that we used to warmstart + res.solution_loader.solution(num_solutions - 1).load_vars() + self.assertAlmostEqual(pyo.value(m.obj.expr), 7.607295680844689) + sol = {1, 2, 3} + for k, v in m.y.items(): + if k in sol: + self.assertAlmostEqual(v.value, 1) + else: + self.assertAlmostEqual(v.value, 0) From 10d9a5926a48116859969b552e71b41aa8fa3d58 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 18 May 2026 07:36:46 -0600 Subject: [PATCH 66/66] run black --- pyomo/contrib/solver/tests/solvers/test_scip_direct.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_scip_direct.py b/pyomo/contrib/solver/tests/solvers/test_scip_direct.py index 05497b33264..221039db764 100644 --- a/pyomo/contrib/solver/tests/solvers/test_scip_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_scip_direct.py @@ -23,7 +23,9 @@ ) from pyomo.contrib.solver.solvers.scip.base import ScipConfig from pyomo.contrib.solver.solvers.scip.scip_direct import ScipDirect -from pyomo.contrib.solver.tests.solvers.test_gurobi_persistent import create_pmedian_model +from pyomo.contrib.solver.tests.solvers.test_gurobi_persistent import ( + create_pmedian_model, +) scip_available = ScipDirect().available()