diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 2fdac4942c8..fb83117bb81 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -306,7 +306,7 @@ def _solve(self): self._solver_model.run() timer.stop('optimize') - return self._postsolve() + return self._postsolve(ostreams[0]) def _process_domain_and_bounds(self, var_id): _v, _lb, _ub, _fixed, _domain_interval, _value = self._vars[var_id] @@ -664,7 +664,7 @@ def _set_objective(self, obj): ) self._mutable_objective.update() - def _postsolve(self): + def _postsolve(self, stream: io.StringIO): config = self._active_config timer = config.timer timer.start('load solution') @@ -674,6 +674,10 @@ def _postsolve(self): results = Results() results.solution_loader = PersistentSolutionLoader(self) + results.solver_name = self.name + results.solver_version = self.version() + results.solver_config = config + results.solver_log = stream.getvalue() results.timing_info.highs_time = highs.getRunTime() self._sol = highs.getSolution() @@ -746,6 +750,7 @@ def _postsolve(self): results.objective_bound = None else: results.objective_bound = info.mip_dual_bound + results.iteration_count = info.simplex_iteration_count if config.load_solutions: if has_feasible_solution: diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 5ab36554061..a3225f43d8a 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -9,6 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import datetime import random import math from typing import Type @@ -17,6 +18,14 @@ from pyomo import gdp from pyomo.common.dependencies import attempt_import import pyomo.common.unittest as unittest + +from pyomo.contrib.solver.common.base import SolverBase +from pyomo.contrib.solver.common.config import SolverConfig +from pyomo.contrib.solver.common.factory import SolverFactory +from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent +from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect +from pyomo.contrib.solver.solvers.highs import Highs +from pyomo.contrib.solver.solvers.ipopt import Ipopt from pyomo.contrib.solver.common.results import ( TerminationCondition, SolutionStatus, @@ -27,12 +36,6 @@ NoSolutionError, NoReducedCostsError, ) -from pyomo.contrib.solver.common.base import SolverBase -from pyomo.contrib.solver.common.factory import SolverFactory -from pyomo.contrib.solver.solvers.ipopt import Ipopt -from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent -from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect -from pyomo.contrib.solver.solvers.highs import Highs from pyomo.core.expr.numeric_expr import LinearExpression from pyomo.core.expr.compare import assertExpressionsEqual @@ -99,7 +102,7 @@ def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bo if any(name.startswith(i) for i in nl_solvers_set): if use_presolve: raise unittest.SkipTest( - f'cannot yet get duals if NLWriter presolve is on' + 'cannot yet get duals if NLWriter presolve is on' ) else: opt.config.writer_config.linear_presolve = False @@ -153,7 +156,7 @@ def test_inequality( if any(name.startswith(i) for i in nl_solvers_set): if use_presolve: raise unittest.SkipTest( - f'cannot yet get duals if NLWriter presolve is on' + 'cannot yet get duals if NLWriter presolve is on' ) else: opt.config.writer_config.linear_presolve = False @@ -213,7 +216,7 @@ def test_bounds(self, name: str, opt_class: Type[SolverBase], use_presolve: bool if any(name.startswith(i) for i in nl_solvers_set): if use_presolve: raise unittest.SkipTest( - f'cannot yet get duals if NLWriter presolve is on' + 'cannot yet get duals if NLWriter presolve is on' ) else: opt.config.writer_config.linear_presolve = False @@ -268,7 +271,7 @@ def test_range(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) if any(name.startswith(i) for i in nl_solvers_set): if use_presolve: raise unittest.SkipTest( - f'cannot yet get duals if NLWriter presolve is on' + 'cannot yet get duals if NLWriter presolve is on' ) else: opt.config.writer_config.linear_presolve = False @@ -322,7 +325,7 @@ def test_equality_max( if any(name.startswith(i) for i in nl_solvers_set): if use_presolve: raise unittest.SkipTest( - f'cannot yet get duals if NLWriter presolve is on' + 'cannot yet get duals if NLWriter presolve is on' ) else: opt.config.writer_config.linear_presolve = False @@ -376,7 +379,7 @@ def test_inequality_max( if any(name.startswith(i) for i in nl_solvers_set): if use_presolve: raise unittest.SkipTest( - f'cannot yet get duals if NLWriter presolve is on' + 'cannot yet get duals if NLWriter presolve is on' ) else: opt.config.writer_config.linear_presolve = False @@ -438,7 +441,7 @@ def test_bounds_max( if any(name.startswith(i) for i in nl_solvers_set): if use_presolve: raise unittest.SkipTest( - f'cannot yet get duals if NLWriter presolve is on' + 'cannot yet get duals if NLWriter presolve is on' ) else: opt.config.writer_config.linear_presolve = False @@ -495,7 +498,7 @@ def test_range_max( if any(name.startswith(i) for i in nl_solvers_set): if use_presolve: raise unittest.SkipTest( - f'cannot yet get duals if NLWriter presolve is on' + 'cannot yet get duals if NLWriter presolve is on' ) else: opt.config.writer_config.linear_presolve = False @@ -544,6 +547,73 @@ class TestSolvers(unittest.TestCase): def test_config_overwrite(self, name: str, opt_class: Type[SolverBase]): self.assertIsNot(SolverBase.CONFIG, opt_class.CONFIG) + @parameterized.expand(input=_load_tests(all_solvers)) + def test_results_object_populated( + 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(bounds=(2, None)) + m.obj = pyo.Objective(expr=m.x) + res = opt.solve(m, load_solutions=False) + pyo.assert_optimal_termination(res) + + # Initial gut check - is it the right type? + self.assertIsInstance(res, Results) + + # termination_condition is set to a valid enum and not unknown + self.assertIsInstance(res.termination_condition, TerminationCondition) + self.assertNotEqual(res.termination_condition, TerminationCondition.unknown) + + # solution_status is a valid enum and indicates a usable solution + self.assertIsInstance(res.solution_status, SolutionStatus) + self.assertIn( + res.solution_status, {SolutionStatus.feasible, SolutionStatus.optimal} + ) + + # solver_name is a nonempty string + self.assertIsInstance(res.solver_name, str) + self.assertTrue(res.solver_name.strip()) + + # solver_version is a tuple of ints + self.assertIsInstance(res.solver_version, tuple) + for v in res.solver_version: + self.assertIsInstance(v, int) + + # iteration_count is nonnegative + self.assertGreaterEqual(res.iteration_count, 0) + + # timing_info should exist + self.assertIsNotNone(res.timing_info) + + # start_timestamp must be a valid datetime + self.assertIsInstance(res.timing_info.start_timestamp, datetime.datetime) + + # wall_time must be a float (=> 0) + self.assertIsInstance(res.timing_info.wall_time, float) + self.assertGreaterEqual(res.timing_info.wall_time, 0.0) + + # incumbent_objective should be populated for a feasible/optimal solve + self.assertIsNotNone(res.incumbent_objective) + + # Should have a solution loader available + self.assertTrue(hasattr(res, "solution_loader")) + + # Should have a copy of the config used + self.assertIsInstance(res.solver_config, SolverConfig) + + # All solvers should be implementing some sort of TeeStream, + # so they should be able to capture anything logged to the console + self.assertIsNotNone(res.solver_log) + self.assertIsInstance(res.solver_log, str) + @parameterized.expand(input=_load_tests(all_solvers)) def test_remove_variable_and_objective( self, name: str, opt_class: Type[SolverBase], use_presolve