From bd0738bed8b5cfea722e9d4801eb9b0db45f2c26 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Tue, 21 Oct 2025 13:15:38 -0600 Subject: [PATCH 1/4] Remove iteration_count as a default Results object value --- pyomo/contrib/solver/common/results.py | 8 -------- pyomo/contrib/solver/solvers/gurobi_direct.py | 2 +- pyomo/contrib/solver/solvers/gurobi_persistent.py | 2 +- pyomo/contrib/solver/solvers/highs.py | 10 +++++++++- pyomo/contrib/solver/solvers/ipopt.py | 8 +++++--- pyomo/contrib/solver/solvers/knitro/base.py | 2 +- pyomo/contrib/solver/tests/solvers/test_ipopt.py | 10 +++++----- pyomo/contrib/solver/tests/solvers/test_solvers.py | 3 --- pyomo/contrib/solver/tests/unit/test_results.py | 3 --- 9 files changed, 22 insertions(+), 26 deletions(-) diff --git a/pyomo/contrib/solver/common/results.py b/pyomo/contrib/solver/common/results.py index ab639b7cbf4..ad5c17c4864 100644 --- a/pyomo/contrib/solver/common/results.py +++ b/pyomo/contrib/solver/common/results.py @@ -197,14 +197,6 @@ def __init__( description="A tuple representing the version of the solver in use.", ), ) - self.iteration_count: Optional[int] = self.declare( - 'iteration_count', - ConfigValue( - domain=NonNegativeInt, - default=None, - description="The total number of iterations.", - ), - ) self.timing_info: ConfigDict = self.declare( 'timing_info', ConfigDict(implicit=True) ) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index 4912d0b8966..9d2a36a90a9 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -442,7 +442,7 @@ def _postsolve(self, timer: HierarchicalTimer, config, loader): results.incumbent_objective = None results.objective_bound = None - results.iteration_count = grb_model.getAttr('IterCount') + results.extra_info.iteration_count = grb_model.getAttr('IterCount') timer.start('load solution') if config.load_solutions: diff --git a/pyomo/contrib/solver/solvers/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi_persistent.py index ea3693c1c70..4ce14a5ce66 100644 --- a/pyomo/contrib/solver/solvers/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi_persistent.py @@ -857,7 +857,7 @@ def _postsolve(self, timer: HierarchicalTimer): ): results.incumbent_objective = None - results.iteration_count = gprob.getAttr('IterCount') + results.extra_info.iteration_count = gprob.getAttr('IterCount') timer.start('load solution') if config.load_solutions: diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index fb83117bb81..e1f427e368d 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -750,7 +750,15 @@ def _postsolve(self, stream: io.StringIO): results.objective_bound = None else: results.objective_bound = info.mip_dual_bound - results.iteration_count = info.simplex_iteration_count + + if info.valid: + results.extra_info.iteration_counts = { + 'simplex_iteration_count': info.simplex_iteration_count, + 'ipm_iteration_count': info.ipm_iteration_count, + 'mip_node_count': info.mip_node_count, + 'pdlp_iteration_count': info.pdlp_iteration_count, + 'qp_iteration_count': info.qp_iteration_count, + } if config.load_solutions: if has_feasible_solution: diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index 7c73e07af38..1bf1fdb7bf9 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -473,7 +473,7 @@ def solve(self, model, **kwds) -> Results: results = Results() results.termination_condition = TerminationCondition.provenInfeasible results.solution_loader = SolSolutionLoader(None, None) - results.iteration_count = 0 + results.extra_info.iteration_count = 0 results.timing_info.total_seconds = 0 elif len(nl_info.variables) == 0: if len(nl_info.eliminated_vars) == 0: @@ -487,7 +487,7 @@ def solve(self, model, **kwds) -> Results: ) results.solution_status = SolutionStatus.optimal results.solution_loader = SolSolutionLoader(None, nl_info=nl_info) - results.iteration_count = 0 + results.extra_info.iteration_count = 0 results.timing_info.total_seconds = 0 else: if os.path.isfile(basename + '.sol'): @@ -503,7 +503,9 @@ def solve(self, model, **kwds) -> Results: results.solution_loader = SolSolutionLoader(None, None) else: try: - results.iteration_count = parsed_output_data.pop('iters') + results.extra_info.iteration_count = parsed_output_data.pop( + 'iters' + ) cpu_seconds = parsed_output_data.pop('cpu_seconds') for k, v in cpu_seconds.items(): results.timing_info[k] = v diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 54161eff423..827c6bd227e 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -141,7 +141,7 @@ def _postsolve(self, config: KnitroConfig, timer: HierarchicalTimer) -> Results: results.solution_status = self._get_solution_status(status) results.termination_condition = self._get_termination_condition(status) results.incumbent_objective = self._engine.get_obj_value() - results.iteration_count = self._engine.get_num_iters() + results.extra_info.iteration_count = self._engine.get_num_iters() results.timing_info.solve_time = self._engine.get_solve_time() results.timing_info.timer = timer diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index 69de9cb48a1..edd1ce23f36 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -594,13 +594,13 @@ def test_ipopt_quiet_print_level(self): result = ipopt.Ipopt().solve(model, solver_options={'print_level': 0}) # IPOPT doesn't tell us anything about the iters if the print level # is set to 0 - self.assertIsNone(result.iteration_count) + self.assertFalse(hasattr(result.extra_info, 'iteration_count')) self.assertFalse(hasattr(result.extra_info, 'iteration_log')) model = self.create_model() result = ipopt.Ipopt().solve(model, solver_options={'print_level': 3}) # At a slightly higher level, we get some of the info, like # iteration count, but NOT iteration_log - self.assertEqual(result.iteration_count, 11) + self.assertEqual(result.extra_info.iteration_count, 11) self.assertFalse(hasattr(result.extra_info, 'iteration_log')) def test_ipopt_loud_print_level(self): @@ -609,13 +609,13 @@ def test_ipopt_loud_print_level(self): result = ipopt.Ipopt().solve(model, solver_options={'print_level': 8}) # Nothing unexpected should be in the results object at this point, # except that the solver_log is significantly longer - self.assertEqual(result.iteration_count, 11) + self.assertEqual(result.extra_info.iteration_count, 11) self.assertEqual(result.incumbent_objective, 7.013645951336496e-25) self.assertIn('Optimal Solution Found', result.extra_info.solver_message) self.assertTrue(hasattr(result.extra_info, 'iteration_log')) model = self.create_model() result = ipopt.Ipopt().solve(model, solver_options={'print_level': 12}) - self.assertEqual(result.iteration_count, 11) + self.assertEqual(result.extra_info.iteration_count, 11) self.assertEqual(result.incumbent_objective, 7.013645951336496e-25) self.assertIn('Optimal Solution Found', result.extra_info.solver_message) self.assertTrue(hasattr(result.extra_info, 'iteration_log')) @@ -624,7 +624,7 @@ def test_ipopt_results(self): model = self.create_model() results = ipopt.Ipopt().solve(model) self.assertEqual(results.solver_name, 'ipopt') - self.assertEqual(results.iteration_count, 11) + self.assertEqual(results.extra_info.iteration_count, 11) self.assertEqual(results.incumbent_objective, 7.013645951336496e-25) self.assertIn('Optimal Solution Found', results.extra_info.solver_message) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 0b017108f85..8aa452444ec 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -604,9 +604,6 @@ def test_results_object_populated( 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) diff --git a/pyomo/contrib/solver/tests/unit/test_results.py b/pyomo/contrib/solver/tests/unit/test_results.py index a818f4ff4ad..df7f12c974f 100644 --- a/pyomo/contrib/solver/tests/unit/test_results.py +++ b/pyomo/contrib/solver/tests/unit/test_results.py @@ -157,7 +157,6 @@ def test_member_list(self): expected_declared = { 'extra_info', 'incumbent_objective', - 'iteration_count', 'objective_bound', 'solution_loader', 'solution_status', @@ -182,7 +181,6 @@ def test_default_initialization(self): self.assertEqual(res.solution_status, results.SolutionStatus.noSolution) self.assertIsNone(res.solver_name) self.assertIsNone(res.solver_version) - self.assertIsNone(res.iteration_count) self.assertIsInstance(res.timing_info, ConfigDict) self.assertIsInstance(res.extra_info, ConfigDict) self.assertIsNone(res.timing_info.start_timestamp) @@ -198,7 +196,6 @@ def test_display(self): objective_bound: None solver_name: None solver_version: None -iteration_count: None timing_info: start_timestamp: None wall_time: None From 1505df61c647b72e31e2c098b0e6f4dadc7c9bbc Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Mon, 27 Oct 2025 09:32:40 -0600 Subject: [PATCH 2/4] Adjust how iters are added to extra_info on highs --- pyomo/contrib/solver/solvers/highs.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index e1f427e368d..b263d5e10ff 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -752,13 +752,13 @@ def _postsolve(self, stream: io.StringIO): results.objective_bound = info.mip_dual_bound if info.valid: - results.extra_info.iteration_counts = { - 'simplex_iteration_count': info.simplex_iteration_count, - 'ipm_iteration_count': info.ipm_iteration_count, - 'mip_node_count': info.mip_node_count, - 'pdlp_iteration_count': info.pdlp_iteration_count, - 'qp_iteration_count': info.qp_iteration_count, - } + results.extra_info.simplex_iteration_count = ( + info.simplex_iteration_count + ) + results.extra_info.ipm_iteration_count = info.ipm_iteration_count + results.extra_info.mip_node_count = info.mip_node_count + results.extra_info.pdlp_iteration_count = info.pdlp_iteration_count + results.extra_info.qp_iteration_count = info.qp_iteration_count if config.load_solutions: if has_feasible_solution: From 41ef40bfbc1859ba18128210b1bdaa64e8d273be Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Mon, 27 Oct 2025 10:37:49 -0600 Subject: [PATCH 3/4] Apply iter count changes to gurobi --- pyomo/contrib/solver/solvers/gurobi_direct.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index 9d2a36a90a9..2a27eba893a 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -14,6 +14,7 @@ import math import operator import os +import logging from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.config import ConfigValue @@ -43,6 +44,7 @@ ) from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +logger = logging.getLogger(__name__) gurobipy, gurobipy_available = attempt_import('gurobipy') @@ -442,7 +444,9 @@ def _postsolve(self, timer: HierarchicalTimer, config, loader): results.incumbent_objective = None results.objective_bound = None - results.extra_info.iteration_count = grb_model.getAttr('IterCount') + results.extra_info.IterCount = grb_model.getAttr('IterCount') + results.extra_info.BarIterCount = grb_model.getAttr('BarIterCount') + results.extra_info.NodeCount = grb_model.getAttr('NodeCount') timer.start('load solution') if config.load_solutions: From 973224efc1973e9ab97cdb228cb12d6934f03725 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Tue, 28 Oct 2025 12:24:23 -0600 Subject: [PATCH 4/4] Missed gurobi_persistent iteration logic --- pyomo/contrib/solver/solvers/gurobi_persistent.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi_persistent.py index 4ce14a5ce66..dd2739b46f7 100644 --- a/pyomo/contrib/solver/solvers/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi_persistent.py @@ -857,7 +857,9 @@ def _postsolve(self, timer: HierarchicalTimer): ): results.incumbent_objective = None - results.extra_info.iteration_count = gprob.getAttr('IterCount') + results.extra_info.IterCount = gprob.getAttr('IterCount') + results.extra_info.BarIterCount = gprob.getAttr('BarIterCount') + results.extra_info.NodeCount = gprob.getAttr('NodeCount') timer.start('load solution') if config.load_solutions: