diff --git a/.github/workflows/test_branches.yml b/.github/workflows/test_branches.yml index 6650a663a1b..0962c85797d 100644 --- a/.github/workflows/test_branches.yml +++ b/.github/workflows/test_branches.yml @@ -328,6 +328,12 @@ jobs: || echo "WARNING: Xpress Community Edition is not available" python -m pip install --cache-dir cache/pip maingopy \ || echo "WARNING: MAiNGO is not available" + if [[ ${{matrix.python}} == pypy* ]]; then + echo "skipping SCIP for pypy" + else + python -m pip install --cache-dir cache/pip pyscipopt \ + || echo "WARNING: SCIP is not available" + fi if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else @@ -415,7 +421,7 @@ jobs: else XPRESS='xpress' fi - for PKG in "$CPLEX" docplex gurobi "$XPRESS" cyipopt pymumps scip; do + for PKG in "$CPLEX" docplex gurobi "$XPRESS" cyipopt pymumps scip pyscipopt; do echo "" echo "*** Install $PKG ***" echo "" diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index eb90c885aed..936383519e6 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -380,6 +380,12 @@ jobs: || echo "WARNING: Xpress Community Edition is not available" python -m pip install --cache-dir cache/pip maingopy \ || echo "WARNING: MAiNGO is not available" + if [[ ${{matrix.python}} == pypy* ]]; then + echo "skipping SCIP for pypy" + else + python -m pip install --cache-dir cache/pip pyscipopt \ + || echo "WARNING: SCIP is not available" + fi if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else @@ -467,7 +473,7 @@ jobs: else XPRESS='xpress' fi - for PKG in "$CPLEX" docplex gurobi "$XPRESS" cyipopt pymumps scip; do + for PKG in "$CPLEX" docplex gurobi "$XPRESS" cyipopt pymumps scip pyscipopt; do echo "" echo "*** Install $PKG ***" echo "" diff --git a/pyomo/contrib/observer/model_observer.py b/pyomo/contrib/observer/model_observer.py index d28fde01cb0..c9814704b42 100644 --- a/pyomo/contrib/observer/model_observer.py +++ b/pyomo/contrib/observer/model_observer.py @@ -739,6 +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): + # strangely, this is needed to skip things like Param continue if ctype in self._known_active_ctypes: continue diff --git a/pyomo/contrib/solver/common/results.py b/pyomo/contrib/solver/common/results.py index 4129386aa5f..b3dcbe78dce 100644 --- a/pyomo/contrib/solver/common/results.py +++ b/pyomo/contrib/solver/common/results.py @@ -21,6 +21,8 @@ ADVANCED_OPTION, DEVELOPER_OPTION, ) +from pyomo.contrib.solver.common.util import NoOptimalSolutionError, NoSolutionError +from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader from pyomo.opt.results.solution import SolutionStatus as LegacySolutionStatus from pyomo.opt.results.solver import ( TerminationCondition as LegacyTerminationCondition, @@ -242,6 +244,24 @@ def display( return super().display(content_filter, indent_spacing, ostream, visibility) +def get_infeasible_results(config, err_msg, solver_name, solver_version): + res = Results() + res.solution_loader = NoSolutionSolutionLoader(err_msg) + res.solution_status = SolutionStatus.noSolution + res.termination_condition = TerminationCondition.provenInfeasible + res.incumbent_objective = None + res.objective_bound = None + res.timing_info.gurobi_time = None + res.solver_config = config + res.solver_name = solver_name + res.solver_version = solver_version + if config.raise_exception_on_nonoptimal_result: + raise NoOptimalSolutionError(err_msg) + if config.load_solutions: + raise NoSolutionError(err_msg) + return res + + # Everything below here preserves backwards compatibility legacy_termination_condition_map = { diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index dc97f91bbec..1f89fcf38b0 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -19,6 +19,10 @@ from pyomo.core.base.var import VarData from pyomo.core.staleflag import StaleFlagManager from pyomo.core.base.suffix import Suffix +from .util import NoSolutionError +import logging + +logger = logging.getLogger(__name__) class SolutionLoader: @@ -331,6 +335,29 @@ def load_import_suffixes(self): return self._loader.load_import_suffixes() +class NoSolutionSolutionLoader(SolutionLoader): + def __init__(self, err_msg: str) -> None: + self.err_msg = err_msg + + def get_number_of_solutions(self) -> int: + return 0 + + def get_vars( + self, vars_to_load: Sequence[VarData] | None = None + ) -> Mapping[VarData, float]: + raise NoSolutionError(self.err_msg) + + def get_duals( + self, cons_to_load: Sequence[ConstraintData] | None = None + ) -> dict[ConstraintData, float]: + raise NoSolutionError(self.err_msg) + + def get_reduced_costs( + self, vars_to_load: Sequence[VarData] | None = None + ) -> Mapping[VarData, float]: + raise NoSolutionError(self.err_msg) + + class PersistentSolutionLoader(SolutionLoader): """ Loader for persistent solvers diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index c16c369abd3..3d499a39dfd 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -14,6 +14,7 @@ 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.gams import GAMS from .solvers.knitro.direct import KnitroDirectSolver @@ -43,6 +44,16 @@ def load(): 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', + )(ScipDirect) + SolverFactory.register( + name='scip_persistent', + legacy_name='scip_persistent_v2', + doc='Persistent interface pyscipopt', + )(ScipPersistent) SolverFactory.register( name="knitro_direct", legacy_name="knitro_direct", diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 0aa24cd9a8f..514af8ec2a8 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -19,6 +19,7 @@ import pathlib from pyomo.common.dependencies import attempt_import +from pyomo.common.errors import InfeasibleConstraintException from pyomo.common.fileutils import Executable, ExecutableData from pyomo.common.config import ( ConfigValue, @@ -37,6 +38,7 @@ Results, SolutionStatus, TerminationCondition, + get_infeasible_results, ) from pyomo.contrib.solver.solvers.gms_sol_reader import GMSSolutionLoader @@ -292,110 +294,122 @@ def solve(self, model, **kwds): output_filename = None with TempfileManager.new_context() as tempfile: - # IMPORTANT - only delete the whole tmpdir if the solver was the one - # that made the directory. Otherwise, just delete the files the solver - # made, if not keepfiles. That way the user can select a directory - # they already have, like the current directory, without having to - # worry about the rest of the contents of that directory being deleted. - if not config.working_dir: - dname = tempfile.mkdtemp() - else: - dname = config.working_dir - if not os.path.exists(dname): - os.mkdir(dname) - basename = os.path.join(dname, model_name) - output_filename = basename + '.gms' - lst_filename = os.path.join(dname, lst) - - timer.start(f'write_gms_file') - with open(output_filename, 'w', newline='\n', encoding='utf-8') as gms_file: - gms_info = GAMSWriter().write( - model, gms_file, config=config.writer_config - ) - # NOTE: omit InfeasibleConstraintException for now - timer.stop(f'write_gms_file') + try: + # IMPORTANT - only delete the whole tmpdir if the solver was the one + # that made the directory. Otherwise, just delete the files the solver + # made, if not keepfiles. That way the user can select a directory + # they already have, like the current directory, without having to + # worry about the rest of the contents of that directory being deleted. + if not config.working_dir: + dname = tempfile.mkdtemp() + else: + dname = config.working_dir + if not os.path.exists(dname): + os.mkdir(dname) + basename = os.path.join(dname, model_name) + output_filename = basename + '.gms' + lst_filename = os.path.join(dname, lst) + + timer.start(f'write_gms_file') + with open( + output_filename, 'w', newline='\n', encoding='utf-8' + ) as gms_file: + gms_info = GAMSWriter().write( + model, gms_file, config=config.writer_config + ) + timer.stop(f'write_gms_file') - if config.writer_config.put_results_format == 'gdx': - results_filename = os.path.join(dname, "GAMS_MODEL_p.gdx") - statresults_filename = os.path.join( - dname, "%s_s.gdx" % (config.writer_config.put_results,) - ) - else: - results_filename = os.path.join( - dname, "%s.dat" % (config.writer_config.put_results,) - ) - statresults_filename = os.path.join( - dname, "%sstat.dat" % (config.writer_config.put_results,) - ) + if config.writer_config.put_results_format == 'gdx': + results_filename = os.path.join(dname, "GAMS_MODEL_p.gdx") + statresults_filename = os.path.join( + dname, "%s_s.gdx" % (config.writer_config.put_results,) + ) + else: + results_filename = os.path.join( + dname, "%s.dat" % (config.writer_config.put_results,) + ) + statresults_filename = os.path.join( + dname, "%sstat.dat" % (config.writer_config.put_results,) + ) - #################################################################### - # Apply solver - #################################################################### - exe_path = config.executable.path() - command = [exe_path, output_filename, "o=" + lst, "curdir=" + dname] + #################################################################### + # Apply solver + #################################################################### + exe_path = config.executable.path() + command = [exe_path, output_filename, "o=" + lst, "curdir=" + dname] - # handled tee and logfile based on the length of list and - # string respectively - command.append(self._log_levels[(bool(config.tee), bool(config.logfile))]) + # handled tee and logfile based on the length of list and + # string respectively + command.append( + self._log_levels[(bool(config.tee), bool(config.logfile))] + ) - ostreams = [StringIO()] - if config.tee: - ostreams.append(sys.stdout) + ostreams = [StringIO()] + if config.tee: + ostreams.append(sys.stdout) - with TeeStream(*ostreams) as t: - timer.start('subprocess') - subprocess_result = subprocess.run( - command, stdout=t.STDOUT, stderr=t.STDERR, cwd=dname - ) - timer.stop('subprocess') - rc = subprocess_result.returncode - txt = ostreams[0].getvalue() - if config.working_dir: - logger.info("\nGAMS WORKING DIRECTORY: %s\n" % config.working_dir) - - if rc: - # If nothing was raised, or for all other cases, raise this - error_message = f"GAMS process encountered an error (returncode={rc})." - if rc == 3: - # Execution Error - # Run check_expr_evaluation, which errors if necessary - error_message += ( - "\nError rc=3 (GAMS execution error), to be determined later." + with TeeStream(*ostreams) as t: + timer.start('subprocess') + subprocess_result = subprocess.run( + command, stdout=t.STDOUT, stderr=t.STDERR, cwd=dname ) - error_message += "\nCheck listing file for details.\n" - logger.error(error_message) - logger.error(txt.strip()) - if os.path.exists(lst_filename): - with open(lst_filename, 'r') as FILE: - logger.error( - "\nGAMS Listing file:\n\n%s" % (FILE.read().strip(),) - ) - raise RuntimeError(error_message) - - timer.start('parse_results') - if config.writer_config.put_results_format == 'gdx': - model_soln, stat_vars = self._parse_gdx_results( - config, results_filename, statresults_filename - ) - else: - model_soln, stat_vars = self._parse_dat_results( - config, results_filename, statresults_filename - ) - timer.stop('parse_results') - - #################################################################### - # Postsolve (WIP) - results = self._postsolve( - model, timer, config, model_soln, stat_vars, gms_info - ) + timer.stop('subprocess') + rc = subprocess_result.returncode + txt = ostreams[0].getvalue() + if config.working_dir: + logger.info("\nGAMS WORKING DIRECTORY: %s\n" % config.working_dir) + + if rc: + # If nothing was raised, or for all other cases, raise this + error_message = ( + f"GAMS process encountered an error (returncode={rc})." + ) + if rc == 3: + # Execution Error + # Run check_expr_evaluation, which errors if necessary + error_message += "\nError rc=3 (GAMS execution error), to be determined later." + error_message += "\nCheck listing file for details.\n" + logger.error(error_message) + logger.error(txt.strip()) + if os.path.exists(lst_filename): + with open(lst_filename, 'r') as FILE: + logger.error( + "\nGAMS Listing file:\n\n%s" % (FILE.read().strip(),) + ) + raise RuntimeError(error_message) + + timer.start('parse_results') + if config.writer_config.put_results_format == 'gdx': + model_soln, stat_vars = self._parse_gdx_results( + config, results_filename, statresults_filename + ) + else: + model_soln, stat_vars = self._parse_dat_results( + config, results_filename, statresults_filename + ) + timer.stop('parse_results') - results.solver_config = config - results.solver_log = ostreams[0].getvalue() + #################################################################### + # Postsolve (WIP) + results = self._postsolve( + model, timer, config, model_soln, stat_vars, gms_info + ) - tock = time.perf_counter() - results.timing_info.start_timestamp = start_timestamp - results.timing_info.wall_time = tock - tick - results.timing_info.timer = timer + results.solver_config = config + results.solver_log = ostreams[0].getvalue() + + tock = time.perf_counter() + results.timing_info.start_timestamp = start_timestamp + results.timing_info.wall_time = tock - tick + results.timing_info.timer = timer + except InfeasibleConstraintException as err: + err_msg = f'Solution loader does not currently have a valid solution because the problem was proven to be infeasible ({str(err)}). Please check results.termination_condition and/or results.solution_status.' + results = get_infeasible_results( + config=config, + err_msg=err_msg, + solver_name=self.name, + solver_version=self.version(), + ) return results def _postsolve(self, model, timer, config, model_soln, stat_vars, gms_info): diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index bb17650a281..28c52c1a1f7 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -18,7 +18,7 @@ from pyomo.common.config import ConfigValue from pyomo.common.dependencies import attempt_import from pyomo.common.enums import ObjectiveSense -from pyomo.common.errors import ApplicationError +from pyomo.common.errors import ApplicationError, InfeasibleConstraintException from pyomo.common.shutdown import python_is_shutting_down from pyomo.common.tee import capture_output, TeeStream from pyomo.common.timing import HierarchicalTimer @@ -34,10 +34,12 @@ NoReducedCostsError, NoSolutionError, ) +from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader from pyomo.contrib.solver.common.results import ( Results, SolutionStatus, TerminationCondition, + get_infeasible_results, ) from pyomo.contrib.solver.common.solution_loader import SolutionLoader import time @@ -375,6 +377,14 @@ def solve(self, model, **kwds) -> Results: has_obj=has_obj, config=config, ) + except InfeasibleConstraintException as err: + err_msg = f'Solution loader does not currently have a valid solution because the problem was proven to be infeasible ({str(err)}). Please check results.termination_condition and/or results.solution_status.' + res = get_infeasible_results( + config=config, + err_msg=err_msg, + solver_name=self.name, + solver_version=self.version(), + ) finally: os.chdir(orig_cwd) diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index 7e9ddcdeaf9..7092b0f6558 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -504,7 +504,13 @@ def _get_expr_from_pyomo_repn(self, repn): vlist = [self._pyomo_var_to_solver_var_map[v] for v in repn.linear_vars] new_expr = gurobipy.LinExpr(coef_list, vlist) else: - new_expr = 0.0 + # this still needs to be an expression object so that + # we don't generate a bool if both the body and + # the bounds are floats (i.e., a trivially feasible or + # trivially infeasible constraint). We don't want a bool + # because the constraint could swap between feasible and + # infeasible if the bounds are mutable. + new_expr = gurobipy.LinExpr() if len(repn.quadratic_vars) > 0: for coef, (x, y) in zip(repn.quadratic_coefs, repn.quadratic_vars): @@ -535,7 +541,7 @@ def _add_constraints(self, cons: list[ConstraintData]): ) elif ub is None: rhs_expr = lb - repn.constant - gurobi_expr_list.append(float(value(rhs_expr)) <= gurobi_expr) + gurobi_expr_list.append(gurobi_expr >= float(value(rhs_expr))) if not is_constant(rhs_expr): mutable_constant = _MutableConstant( rhs_expr, con, self._pyomo_con_to_solver_con_map diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 1bbbdd4a326..40596b2917e 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -686,7 +686,9 @@ def _postsolve(self, stream: io.StringIO): results.timing_info.highs_time = highs.getRunTime() self._sol = highs.getSolution() - has_feasible_solution = self._sol.value_valid + info = highs.getInfo() + # 0: None, 1: Infeasible, 2: Feasible + has_feasible_solution = info.primal_solution_status == 2 if status == highspy.HighsModelStatus.kOptimal: results.solution_status = SolutionStatus.optimal elif has_feasible_solution: @@ -744,12 +746,15 @@ def _postsolve(self, stream: io.StringIO): results.incumbent_objective = None results.objective_bound = None - info = highs.getInfo() if self._objective is not None: if has_feasible_solution: results.incumbent_objective = info.objective_function_value if info.mip_node_count == -1: - if has_feasible_solution: + if ( + has_feasible_solution + and results.termination_condition + == TerminationCondition.convergenceCriteriaSatisfied + ): results.objective_bound = info.objective_function_value else: results.objective_bound = None 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 diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py new file mode 100644 index 00000000000..c43b243b9fd --- /dev/null +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -0,0 +1,1162 @@ +# ___________________________________________________________________________ +# +# 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 __future__ import annotations +import datetime +import io +import logging +import math +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 +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 +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 +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr +from pyomo.contrib.observer.model_observer import ( + Observer, + ModelChangeDetector, + AutoUpdateConfig, + Reason, +) + +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(SolutionLoaderBase): + 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 + + 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 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] + 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: + 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, 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 + _minimum_version = (5, 5, 0) # this is probably conservative + + CONFIG = ScipConfig() + + def __init__(self, **kwds): + super().__init__(**kwds) + self._solver_model = None + self._pyomo_var_to_solver_var_map = ComponentMap() + self._pyomo_con_to_solver_con_map = {} + self._pyomo_param_to_solver_param_map = ( + 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 + 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._pyomo_var_to_solver_var_map = ComponentMap() + self._pyomo_con_to_solver_con_map = {} + self._pyomo_param_to_solver_param_map = ComponentMap() + 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__.split('.')) + + def solve(self, model: BlockData, **kwds) -> Results: + start_timestamp = datetime.datetime.now(datetime.timezone.utc) + try: + config = self.config(value=kwds, preserve_implicit=True) + + 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, 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.writeProblem(filename='foo.lp') + scip_model.optimize() + timer.stop('optimize') + + results = self._populate_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() + 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 + + lb, ub = var.bounds + + if lb is None: + lb = -self._solver_model.infinity() + if ub is None: + ub = self._solver_model.infinity() + + return lb, ub + + def _add_var(self, var): + 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) + + 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._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 + + 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, config): + timer = 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) + ) + 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_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 + + 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): + level = con.level + if level not in [1, 2]: + raise ValueError( + f"{self.name} does not support SOS level {level} constraints" + ) + + scip_vars = [] + weights = [] + + 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) + else: + 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): + """ + This function takes a pyomo variable and returns the appropriate SCIP variable type + + Parameters + ---------- + var: pyomo.core.base.var.Var + The pyomo variable that we want to retrieve the SCIP vtype of + + Returns + ------- + vtype: str + B for Binary, I for Integer, or C for Continuous + """ + if var.is_binary(): + vtype = "B" + elif var.is_integer(): + vtype = "I" + elif var.is_continuous(): + vtype = "C" + else: + raise ValueError(f"Variable domain type is not recognized for {var.domain}") + return vtype + + def _set_objective(self, obj): + if self._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._obj_con is not None: + self._solver_model.delCons(self._obj_con) + + 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 sense == "minimize": + self._obj_con = self._solver_model.addCons(self._obj_var >= scip_expr) + else: + self._obj_con = self._solver_model.addCons(self._obj_var <= scip_expr) + + self._solver_model.setObjective(self._obj_var, sense=sense) + self._objective = obj + + def _populate_results( + self, scip_model, solution_loader: ScipDirectSolutionLoader, has_obj, config + ): + + 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: + results.solution_status = SolutionStatus.noSolution + + if ( + results.termination_condition + != TerminationCondition.convergenceCriteriaSatisfied + and config.raise_exception_on_nonoptimal_result + ): + raise NoOptimalSolutionError() + + if has_obj: + try: + if ( + scip_model.getNSols() > 0 + and scip_model.getObjVal() < scip_model.infinity() + ): + results.incumbent_objective = scip_model.getObjVal() + else: + results.incumbent_objective = None + except: + results.incumbent_objective = None + try: + 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: + results.incumbent_objective = None + results.objective_bound = None + + config.timer.start('load solution') + if config.load_solutions: + if solution_loader.get_number_of_solutions() > 0: + solution_loader.load_solution() + else: + raise NoFeasibleSolutionError() + config.timer.stop('load solution') + + results.extra_info['NNodes'] = scip_model.getNNodes() + results.solver_config = 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 different option for that + sol = self._solver_model.createPartialSol() + 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) + + +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: + 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_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_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index e4bdf32802e..375dc33ea46 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -25,6 +25,7 @@ SolutionStatus, TerminationCondition, ) +from pyomo.contrib.solver.solvers.scip.scip_direct import ScipDirect, ScipPersistent from pyomo.contrib.solver.common.util import ( NoDualsError, NoReducedCostsError, @@ -74,6 +75,8 @@ def param_as_standalone_func(cls, p, func, name): ('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt), ('highs', Highs), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ('gams', GAMS), ('knitro_direct', KnitroDirectSolver), ] @@ -82,27 +85,42 @@ def param_as_standalone_func(cls, p, func, name): ('gurobi_direct', GurobiDirect), ('gurobi_direct_minlp', GurobiDirectMINLP), ('highs', Highs), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ('knitro_direct', KnitroDirectSolver), ] nlp_solvers = [ ('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ('knitro_direct', KnitroDirectSolver), ] qcp_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ('knitro_direct', KnitroDirectSolver), ] qp_solvers = qcp_solvers + [("highs", Highs)] miqcqp_solvers = [ ('gurobi_direct_minlp', GurobiDirectMINLP), ('gurobi_persistent', GurobiPersistent), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ('knitro_direct', KnitroDirectSolver), ] nl_solvers = [('ipopt', Ipopt)] nl_solvers_set = {i[0] for i in nl_solvers} +dual_solvers = [ + ('gurobi_persistent', GurobiPersistent), + ('gurobi_direct', GurobiDirect), + ('gurobi_direct_minlp', GurobiDirectMINLP), + ('ipopt', Ipopt), + ('highs', Highs), +] def _load_tests(solver_list): @@ -129,7 +147,7 @@ def test_all_solvers_list(): class TestDualSignConvention(unittest.TestCase): - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_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(): @@ -181,7 +199,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) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_inequality( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -243,7 +261,7 @@ def test_inequality( self.assertAlmostEqual(duals[m.c1], 0.5) self.assertAlmostEqual(duals[m.c2], 0.5) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_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(): @@ -298,7 +316,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) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_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(): @@ -350,7 +368,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) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_equality_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -404,7 +422,7 @@ def test_equality_max( self.assertAlmostEqual(duals[m.c1], 0) self.assertAlmostEqual(duals[m.c2], 1) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_inequality_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -466,7 +484,7 @@ def test_inequality_max( self.assertAlmostEqual(duals[m.c1], -0.5) self.assertAlmostEqual(duals[m.c2], -0.5) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_bounds_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -523,7 +541,7 @@ def test_bounds_max( rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], 1) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_range_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -745,16 +763,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) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_reduced_costs( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -783,7 +803,7 @@ def test_reduced_costs( self.assertAlmostEqual(rc[m.x], -3) self.assertAlmostEqual(rc[m.y], -4) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_reduced_costs2( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -849,9 +869,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)) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_immutable_param( @@ -896,9 +917,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)) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): @@ -912,6 +934,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() @@ -1003,6 +1027,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() @@ -1064,9 +1090,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) @@ -1075,10 +1102,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) @@ -1087,9 +1115,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)) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_results_infeasible( @@ -1138,16 +1167,72 @@ 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() @mark_parameterized.expand(input=_load_tests(all_solvers)) + def test_trivial_constraints( + 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.') + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + opt.config.writer_config.linear_presolve = True + else: + opt.config.writer_config.linear_presolve = False + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= m.x) + m.c2 = pyo.Constraint(expr=m.y >= -m.x) + m.c3 = pyo.Constraint(expr=m.x >= 0) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 0) + + # trivially feasible constraint + m.x.fix(1) + opt.config.tee = True + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 1) + + # trivially infeasible constraint + m.x.fix(-1) + with self.assertRaises(NoOptimalSolutionError): + res = opt.solve(m) + + opt.config.load_solutions = False + opt.config.raise_exception_on_nonoptimal_result = False + res = opt.solve(m) + self.assertNotEqual(res.solution_status, SolutionStatus.optimal) + if isinstance(opt, Ipopt): + acceptable_termination_conditions = { + TerminationCondition.locallyInfeasible, + TerminationCondition.unbounded, + TerminationCondition.provenInfeasible, + } + else: + acceptable_termination_conditions = { + TerminationCondition.provenInfeasible, + TerminationCondition.infeasibleOrUnbounded, + } + self.assertIn(res.termination_condition, acceptable_termination_conditions) + self.assertIsNone(res.incumbent_objective) + + @mark_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(): @@ -1196,13 +1281,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) @mark_parameterized.expand(input=_load_tests(qcp_solvers)) def test_mutable_quadratic_objective_qcp( @@ -1227,14 +1312,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) @mark_parameterized.expand(input=_load_tests(qp_solvers)) def test_mutable_quadratic_objective_qp( @@ -1528,9 +1613,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) @@ -1539,9 +1625,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) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_add_and_remove_vars( @@ -1629,8 +1716,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) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_with_numpy( @@ -1740,24 +1827,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) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_time_limit( @@ -2238,6 +2326,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() @@ -2335,7 +2425,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: @@ -2347,8 +2438,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)) @mark_parameterized.expand(input=all_solvers) def test_load_solutions(self, name: str, opt_class: Type[SolverBase]): @@ -2359,11 +2451,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) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index caf4455b51d..2cf3e56dddf 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -18,6 +18,7 @@ In, ListOf, ) +from pyomo.common.errors import InfeasibleConstraintException from pyomo.common.gc_manager import PauseGC from pyomo.common.timing import TicTocTimer from pyomo.core.base import ( @@ -386,9 +387,14 @@ def write(self, model): if repn.linear or getattr(repn, 'quadratic', None): pass else: + if (lb is not None and lb > offset) or (ub is not None and ub < offset): + raise InfeasibleConstraintException( + f'detected a trivially infeasible constraint: {con}' + ) if ( - skip_trivial_constraints - and (lb is None or lb <= offset) + # skip_trivial_constraints + # and (lb is None or lb <= offset) + (lb is None or lb <= offset) and (ub is None or ub >= offset) ): continue diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 45859c65d9f..50cb3179aa2 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -18,6 +18,7 @@ InEnum, document_kwargs_from_configdict, ) +from pyomo.common.errors import InfeasibleConstraintException from pyomo.common.dependencies import scipy, numpy as np from pyomo.common.enums import ObjectiveSense from pyomo.common.gc_manager import PauseGC @@ -460,7 +461,7 @@ def write(self, model): # TODO: add a (configurable) feasibility tolerance if (lb is None or lb <= offset) and (ub is None or ub >= offset): continue - raise InfeasibleError( + raise InfeasibleConstraintException( f"model contains a trivially infeasible constraint, '{con.name}'" )