From d16bee5fd1f27cc7c3b55ff858ab28b7478a6975 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Wed, 13 Aug 2025 18:00:06 -0600 Subject: [PATCH 01/11] adding tests for trivial constraints and fixing bugs --- pyomo/contrib/observer/model_observer.py | 2 +- .../contrib/solver/common/solution_loader.py | 30 +++++++++++ .../solvers/gurobi/gurobi_direct_base.py | 23 +++++++- .../solvers/gurobi/gurobi_persistent.py | 6 ++- .../solver/tests/solvers/test_solvers.py | 53 +++++++++++++++++++ pyomo/repn/plugins/standard_form.py | 3 +- 6 files changed, 113 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/observer/model_observer.py b/pyomo/contrib/observer/model_observer.py index 4ab52100376..325e7a8aee6 100644 --- a/pyomo/contrib/observer/model_observer.py +++ b/pyomo/contrib/observer/model_observer.py @@ -614,7 +614,7 @@ def _check_for_var_changes(self): vars_to_update.append(v) elif _domain_interval != v.domain.get_interval(): vars_to_update.append(v) - elif v.value != _value: + elif v.fixed and v.value != _value: vars_to_update.append(v) cons_to_update = list(cons_to_update.keys()) return vars_to_update, cons_to_update, update_obj diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index e399d6bea55..be0ea7ad00c 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -17,6 +17,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 def load_import_suffixes(pyomo_model, solution_loader: SolutionLoaderBase, solution_id=None): @@ -194,6 +195,35 @@ def load_import_suffixes(self, solution_id=None): return NotImplemented +class NoSolutionSolutionLoader(SolutionLoaderBase): + def __init__(self) -> None: + pass + + def get_solution_ids(self) -> List[Any]: + return [] + + def get_number_of_solutions(self) -> int: + return 0 + + def load_solution(self, solution_id=None): + raise NoSolutionError() + + def load_vars(self, vars_to_load: Sequence[VarData] | None = None, solution_id=None) -> None: + raise NoSolutionError() + + def get_vars(self, vars_to_load: Sequence[VarData] | None = None, solution_id=None) -> Mapping[VarData, float]: + raise NoSolutionError() + + def get_duals(self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None) -> Dict[ConstraintData, float]: + raise NoSolutionError() + + def get_reduced_costs(self, vars_to_load: Sequence[VarData] | None = None, solution_id=None) -> Mapping[VarData, float]: + raise NoSolutionError() + + def load_import_suffixes(self, solution_id=None): + raise NoSolutionError() + + class PersistentSolutionLoader(SolutionLoaderBase): """ Loader for persistent solvers diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index e99d24025d5..ce77c31c6f7 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 @@ -33,6 +33,7 @@ NoReducedCostsError, NoSolutionError, ) +from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader from pyomo.contrib.solver.common.results import ( Results, SolutionStatus, @@ -353,6 +354,8 @@ def solve(self, model, **kwds) -> Results: res = self._postsolve( grb_model=gurobi_model, solution_loader=solution_loader, has_obj=has_obj ) + except InfeasibleConstraintException: + res = self._get_infeasible_results() finally: os.chdir(orig_cwd) @@ -390,6 +393,24 @@ def _get_tc_map(self): } return GurobiDirectBase._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.gurobi_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 _postsolve(self, grb_model, solution_loader, has_obj): status = grb_model.Status diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index 9f19bae307f..27a61e27916 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -17,6 +17,7 @@ from pyomo.common.collections import ComponentSet, OrderedSet from pyomo.common.shutdown import python_is_shutting_down from pyomo.common.timing import HierarchicalTimer +from pyomo.common.errors import InfeasibleConstraintException from pyomo.core.base.objective import ObjectiveData from pyomo.core.kernel.objective import minimize, maximize from pyomo.core.base.var import VarData @@ -464,7 +465,9 @@ def _get_expr_from_pyomo_repn(self, repn): vlist = [self._pyomo_var_to_solver_var_map[id(v)] for v in repn.linear_vars] new_expr = gurobipy.LinExpr(coef_list, vlist) else: - new_expr = 0.0 + # this can't just be zero in case the constraint is a + # trivial one + new_expr = gurobipy.LinExpr() if len(repn.quadratic_vars) > 0: missing_vars = {} @@ -714,6 +717,7 @@ def _add_constraints(self, cons: List[ConstraintData]): for ndx, con in enumerate(cons): lb, body, ub = con.to_bounded_expression(evaluate_bounds=False) repn = generate_standard_repn(body, quadratic=True, compute_values=False) + if len(repn.quadratic_vars) > 0: self._quadratic_cons.add(con) else: diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 9c67ed7b1e5..d154253475c 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -35,6 +35,7 @@ GurobiDirectQuadratic, GurobiPersistent, ) +from pyomo.contrib.solver.common.util import NoSolutionError, NoFeasibleSolutionError, NoOptimalSolutionError from pyomo.contrib.solver.solvers.highs import Highs from pyomo.core.expr.numeric_expr import LinearExpression from pyomo.core.expr.compare import assertExpressionsEqual @@ -1053,6 +1054,58 @@ def test_results_infeasible( ): res.solution_loader.get_reduced_costs() + @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) + + m.x.fix(1) + opt.config.tee = True + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 1) + + 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) + @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/standard_form.py b/pyomo/repn/plugins/standard_form.py index 314a1822e09..59c15910350 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -20,6 +20,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 @@ -462,7 +463,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}'" ) From d25e7215f4f728ff4541060f84f9b6cd92d09229 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Wed, 13 Aug 2025 18:06:51 -0600 Subject: [PATCH 02/11] run black --- .../contrib/solver/common/solution_loader.py | 30 ++++++++++++------- .../solvers/gurobi/gurobi_persistent.py | 2 +- .../solver/tests/solvers/test_solvers.py | 10 +++++-- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index b0bddeb56ba..666ea66e1e9 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -194,25 +194,33 @@ def __init__(self) -> None: def get_solution_ids(self) -> List[Any]: return [] - + def get_number_of_solutions(self) -> int: return 0 - + def load_solution(self, solution_id=None): raise NoSolutionError() - - def load_vars(self, vars_to_load: Sequence[VarData] | None = None, solution_id=None) -> None: + + def load_vars( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> None: raise NoSolutionError() - - def get_vars(self, vars_to_load: Sequence[VarData] | None = None, solution_id=None) -> Mapping[VarData, float]: + + def get_vars( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> Mapping[VarData, float]: raise NoSolutionError() - - def get_duals(self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None) -> Dict[ConstraintData, float]: + + def get_duals( + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + ) -> Dict[ConstraintData, float]: raise NoSolutionError() - - def get_reduced_costs(self, vars_to_load: Sequence[VarData] | None = None, solution_id=None) -> Mapping[VarData, float]: + + def get_reduced_costs( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> Mapping[VarData, float]: raise NoSolutionError() - + def load_import_suffixes(self, solution_id=None): raise NoSolutionError() diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index c0211604324..b9ea9a6c8e8 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -488,7 +488,7 @@ def _get_expr_from_pyomo_repn(self, repn): vlist = [self._pyomo_var_to_solver_var_map[id(v)] for v in repn.linear_vars] new_expr = gurobipy.LinExpr(coef_list, vlist) else: - # this can't just be zero in case the constraint is a + # this can't just be zero in case the constraint is a # trivial one new_expr = gurobipy.LinExpr() diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index d154253475c..189b0373780 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -35,7 +35,11 @@ GurobiDirectQuadratic, GurobiPersistent, ) -from pyomo.contrib.solver.common.util import NoSolutionError, NoFeasibleSolutionError, NoOptimalSolutionError +from pyomo.contrib.solver.common.util import ( + NoSolutionError, + NoFeasibleSolutionError, + NoOptimalSolutionError, +) from pyomo.contrib.solver.solvers.highs import Highs from pyomo.core.expr.numeric_expr import LinearExpression from pyomo.core.expr.compare import assertExpressionsEqual @@ -1073,11 +1077,11 @@ def test_trivial_constraints( 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) - + m.x.fix(1) opt.config.tee = True res = opt.solve(m) From ae7d2d9c29d1c41df3b5b7cbe934851acf6e82fe Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 27 Apr 2026 05:17:16 -0600 Subject: [PATCH 03/11] run black --- pyomo/contrib/solver/common/solution_loader.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 608e96cb51c..ce4d3ae9516 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -345,9 +345,7 @@ def get_number_of_solutions(self) -> int: def load_solution(self): raise NoSolutionError() - def load_vars( - self, vars_to_load: Sequence[VarData] | None = None - ) -> None: + def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: raise NoSolutionError() def get_vars( From d0c051513cd82d3a7b50a3c135b766e089a46545 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Fri, 1 May 2026 11:08:44 -0600 Subject: [PATCH 04/11] remove redundant code and propagate error messages --- .../contrib/solver/common/solution_loader.py | 24 +++++-------------- .../solvers/gurobi/gurobi_direct_base.py | 9 +++---- .../solvers/gurobi/gurobi_persistent.py | 1 - .../solver/tests/solvers/test_solvers.py | 1 + 4 files changed, 12 insertions(+), 23 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index ce4d3ae9516..a71f245f891 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -333,38 +333,26 @@ def load_import_suffixes(self): class NoSolutionSolutionLoader(SolutionLoader): - def __init__(self) -> None: - pass - - def get_solution_ids(self) -> List[Any]: - return [] + def __init__(self, err_msg: str) -> None: + self.err_msg = err_msg def get_number_of_solutions(self) -> int: return 0 - def load_solution(self): - raise NoSolutionError() - - def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: - raise NoSolutionError() - def get_vars( self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: - raise NoSolutionError() + raise NoSolutionError(self.err_msg) def get_duals( self, cons_to_load: Sequence[ConstraintData] | None = None - ) -> Dict[ConstraintData, float]: - raise NoSolutionError() + ) -> 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() - - def load_import_suffixes(self): - raise NoSolutionError() + raise NoSolutionError(self.err_msg) class PersistentSolutionLoader(SolutionLoader): diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index 8e8771ffad0..e89e972b77e 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -376,8 +376,9 @@ def solve(self, model, **kwds) -> Results: has_obj=has_obj, config=config, ) - except InfeasibleConstraintException: - res = self._get_infeasible_results(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 = self._get_infeasible_results(config=config, err_msg=err_msg) finally: os.chdir(orig_cwd) @@ -410,9 +411,9 @@ def _get_tc_map(self): } return GurobiDirectBase._tc_map - def _get_infeasible_results(self, config): + def _get_infeasible_results(self, config, err_msg): res = Results() - res.solution_loader = NoSolutionSolutionLoader() + res.solution_loader = NoSolutionSolutionLoader(err_msg) res.solution_status = SolutionStatus.noSolution res.termination_condition = TerminationCondition.provenInfeasible res.incumbent_objective = None diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index 091481b152c..7e9ddcdeaf9 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -16,7 +16,6 @@ from pyomo.common.errors import PyomoException from pyomo.common.shutdown import python_is_shutting_down from pyomo.common.timing import HierarchicalTimer -from pyomo.common.errors import InfeasibleConstraintException from pyomo.core.base.objective import ObjectiveData from pyomo.core.kernel.objective import minimize, maximize from pyomo.core.base.var import VarData diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index f80e1c23d3e..136a1484525 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -1171,6 +1171,7 @@ def test_trivial_constraints( 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) From bf6f6fdbbdad43a31d411f60d24ba52b56789390 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Fri, 1 May 2026 15:36:50 -0600 Subject: [PATCH 05/11] fix highs interface --- pyomo/contrib/solver/solvers/highs.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 1bbbdd4a326..acb150832f6 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,11 @@ 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 From a1ed8e0670e1fdbc4724e0aa3acb98cd4692c5b3 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sun, 3 May 2026 12:21:04 -0600 Subject: [PATCH 06/11] better handling of trivially feasible and infeasible constraints --- .../solver/solvers/gurobi/gurobi_direct_base.py | 1 - .../contrib/solver/solvers/gurobi/gurobi_persistent.py | 10 ++++++++-- pyomo/contrib/solver/tests/solvers/test_solvers.py | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index e89e972b77e..de3bc0d8d6e 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -418,7 +418,6 @@ def _get_infeasible_results(self, config, err_msg): res.termination_condition = TerminationCondition.provenInfeasible res.incumbent_objective = None res.objective_bound = None - res.iteration_count = None res.timing_info.gurobi_time = None res.solver_config = config res.solver_name = self.name diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index 7e9ddcdeaf9..32686c7341e 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/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 136a1484525..7ae7f9827e8 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -1178,6 +1178,7 @@ def test_trivial_constraints( 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) From 29b9bf3a5d9cd0255aa957649a06c7dd9dc88d5e Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sun, 3 May 2026 12:38:10 -0600 Subject: [PATCH 07/11] run black --- pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py | 4 ++-- pyomo/contrib/solver/solvers/highs.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index 32686c7341e..7092b0f6558 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -505,10 +505,10 @@ def _get_expr_from_pyomo_repn(self, repn): new_expr = gurobipy.LinExpr(coef_list, vlist) else: # this still needs to be an expression object so that - # we don't generate a bool if both the body and + # 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 + # because the constraint could swap between feasible and # infeasible if the bounds are mutable. new_expr = gurobipy.LinExpr() diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index acb150832f6..40596b2917e 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -750,7 +750,11 @@ def _postsolve(self, stream: io.StringIO): if has_feasible_solution: results.incumbent_objective = info.objective_function_value if info.mip_node_count == -1: - if has_feasible_solution and results.termination_condition == TerminationCondition.convergenceCriteriaSatisfied: + if ( + has_feasible_solution + and results.termination_condition + == TerminationCondition.convergenceCriteriaSatisfied + ): results.objective_bound = info.objective_function_value else: results.objective_bound = None From eb7abb65a54dad5267eb821fa88c04900aaef459 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sun, 3 May 2026 15:20:04 -0600 Subject: [PATCH 08/11] better handling of trivially infeasible constraints --- pyomo/contrib/solver/common/results.py | 21 ++ pyomo/contrib/solver/solvers/gams.py | 191 +++++++++--------- .../solvers/gurobi/gurobi_direct_base.py | 20 +- pyomo/repn/plugins/gams_writer_v2.py | 3 + 4 files changed, 124 insertions(+), 111 deletions(-) diff --git a/pyomo/contrib/solver/common/results.py b/pyomo/contrib/solver/common/results.py index 4129386aa5f..b54fa4680c2 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,25 @@ 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/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 0aa24cd9a8f..c55c73897f6 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,113 @@ 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(),) + 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." ) - raise RuntimeError(error_message) + 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') - 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 + #################################################################### + # Postsolve (WIP) + results = self._postsolve( + model, timer, config, model_soln, stat_vars, gms_info ) - timer.stop('parse_results') - - #################################################################### - # Postsolve (WIP) - results = self._postsolve( - model, timer, config, model_soln, stat_vars, gms_info - ) - results.solver_config = config - results.solver_log = ostreams[0].getvalue() + 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 + 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 de3bc0d8d6e..6991685423a 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -39,6 +39,7 @@ Results, SolutionStatus, TerminationCondition, + get_infeasible_results, ) from pyomo.contrib.solver.common.solution_loader import SolutionLoader import time @@ -378,7 +379,7 @@ def solve(self, model, **kwds) -> Results: ) 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 = self._get_infeasible_results(config=config, err_msg=err_msg) + res = get_infeasible_results(config=config, err_msg=err_msg, solver_name=self.name, solver_version=self.version()) finally: os.chdir(orig_cwd) @@ -411,23 +412,6 @@ def _get_tc_map(self): } return GurobiDirectBase._tc_map - def _get_infeasible_results(self, config, err_msg): - 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 = self.name - res.solver_version = self.version() - if config.raise_exception_on_nonoptimal_result: - raise NoOptimalSolutionError() - if config.load_solutions: - raise NoFeasibleSolutionError() - return res - def _populate_results(self, grb_model, solution_loader, has_obj, config): status = grb_model.Status diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index caf4455b51d..e5e60c3c898 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,6 +387,8 @@ 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) From c987a9f93c03e748cc899f29933306591ba8e103 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sun, 3 May 2026 15:21:17 -0600 Subject: [PATCH 09/11] run black --- pyomo/contrib/solver/common/results.py | 1 - pyomo/contrib/solver/solvers/gams.py | 23 +++++++++++++------ .../solvers/gurobi/gurobi_direct_base.py | 7 +++++- pyomo/repn/plugins/gams_writer_v2.py | 4 +++- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/pyomo/contrib/solver/common/results.py b/pyomo/contrib/solver/common/results.py index b54fa4680c2..b3dcbe78dce 100644 --- a/pyomo/contrib/solver/common/results.py +++ b/pyomo/contrib/solver/common/results.py @@ -262,7 +262,6 @@ def get_infeasible_results(config, err_msg, solver_name, solver_version): return res - # Everything below here preserves backwards compatibility legacy_termination_condition_map = { diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index c55c73897f6..514af8ec2a8 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -311,7 +311,9 @@ def solve(self, model, **kwds): 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: + with open( + output_filename, 'w', newline='\n', encoding='utf-8' + ) as gms_file: gms_info = GAMSWriter().write( model, gms_file, config=config.writer_config ) @@ -338,7 +340,9 @@ def solve(self, model, **kwds): # handled tee and logfile based on the length of list and # string respectively - command.append(self._log_levels[(bool(config.tee), bool(config.logfile))]) + command.append( + self._log_levels[(bool(config.tee), bool(config.logfile))] + ) ostreams = [StringIO()] if config.tee: @@ -357,13 +361,13 @@ def solve(self, model, **kwds): if rc: # If nothing was raised, or for all other cases, raise this - error_message = f"GAMS process encountered an error (returncode={rc})." + 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 += "\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()) @@ -400,7 +404,12 @@ def solve(self, model, **kwds): 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()) + 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 6991685423a..28c52c1a1f7 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -379,7 +379,12 @@ def solve(self, model, **kwds) -> Results: ) 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()) + 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/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index e5e60c3c898..1d9b39db974 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -388,7 +388,9 @@ def write(self, model): 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}') + raise InfeasibleConstraintException( + f'detected a trivially infeasible constraint: {con}' + ) if ( skip_trivial_constraints and (lb is None or lb <= offset) From fe00fdd570fc9bf5ef62f6ec17662cbc9ab9be45 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sun, 3 May 2026 16:55:15 -0600 Subject: [PATCH 10/11] better handling of trivially infeasible constraints --- pyomo/repn/plugins/gams_writer_v2.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index 1d9b39db974..2cf3e56dddf 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -392,8 +392,9 @@ def write(self, model): 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 From 0c54635ba8cbfed30aeaf0202504d8769718be1c Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 5 May 2026 09:02:21 -0600 Subject: [PATCH 11/11] remove unused line --- pyomo/contrib/solver/common/results.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyomo/contrib/solver/common/results.py b/pyomo/contrib/solver/common/results.py index b3dcbe78dce..a10882bda3d 100644 --- a/pyomo/contrib/solver/common/results.py +++ b/pyomo/contrib/solver/common/results.py @@ -251,7 +251,6 @@ def get_infeasible_results(config, err_msg, solver_name, solver_version): 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