diff --git a/pyomo/contrib/solver/common/results.py b/pyomo/contrib/solver/common/results.py index 4129386aa5f..a10882bda3d 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,23 @@ 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.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..a71f245f891 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -19,6 +19,7 @@ from pyomo.core.base.var import VarData from pyomo.core.staleflag import StaleFlagManager from pyomo.core.base.suffix import Suffix +from .util import NoSolutionError class SolutionLoader: @@ -331,6 +332,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/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/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index e4bdf32802e..7ae7f9827e8 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -1147,6 +1147,60 @@ def test_results_infeasible( ): 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(all_solvers)) def test_duals(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() 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}'" )