diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index 80d50477ca4..cb1cddf04d2 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -71,4 +71,6 @@ EOF = "EOF" lst = "lst" # Abbreviation of gamma (used in stochpdegas1_automatic.py) gam = "gam" +# Regex search term from contrib/solver/solvers/ipopt +ond = "ond" # AS NEEDED: Add More Words Below diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index b239e77a516..afa071df77f 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -14,10 +14,17 @@ import subprocess import datetime import io -from typing import Mapping, Optional, Sequence +import re +import sys +from typing import Optional, Tuple, Union, Mapping, List, Dict, Any, Sequence from pyomo.common import Executable -from pyomo.common.config import ConfigValue, document_kwargs_from_configdict, ConfigDict +from pyomo.common.config import ( + ConfigValue, + document_kwargs_from_configdict, + ConfigDict, + ADVANCED_OPTION, +) from pyomo.common.errors import ( ApplicationError, DeveloperError, @@ -50,6 +57,10 @@ logger = logging.getLogger(__name__) +# Acceptable chars for the end of the alpha_pr column +# in ipopt's output, per https://coin-or.github.io/Ipopt/OUTPUT.html +_ALPHA_PR_CHARS = set("fFhHkKnNRwstTr") + class IpoptConfig(SolverConfig): def __init__( @@ -202,14 +213,14 @@ def get_reduced_costs( class Ipopt(SolverBase): CONFIG = IpoptConfig() - def __init__(self, **kwds): + def __init__(self, **kwds: Any) -> None: super().__init__(**kwds) self._writer = NLWriter() self._available_cache = None self._version_cache = None self._version_timeout = 2 - def available(self, config=None): + def available(self, config: Optional[IpoptConfig] = None) -> Availability: if config is None: config = self.config pth = config.executable.path() @@ -220,7 +231,9 @@ def available(self, config=None): self._available_cache = (pth, Availability.FullLicense) return self._available_cache[1] - def version(self, config=None): + def version( + self, config: Optional[IpoptConfig] = None + ) -> Optional[Tuple[int, int, int]]: if config is None: config = self.config pth = config.executable.path() @@ -242,7 +255,7 @@ def version(self, config=None): self._version_cache = (pth, version) return self._version_cache[1] - def has_linear_solver(self, linear_solver): + def has_linear_solver(self, linear_solver: str) -> bool: import pyomo.core as AML m = AML.ConcreteModel() @@ -257,7 +270,9 @@ def has_linear_solver(self, linear_solver): ) return 'running with linear solver' in results.solver_log - def _write_options_file(self, filename: str, options: Mapping): + def _write_options_file( + self, filename: str, options: Mapping[str, Union[str, int, float]] + ) -> bool: # First we need to determine if we even need to create a file. # If options is empty, then we return False opt_file_exists = False @@ -273,7 +288,9 @@ def _write_options_file(self, filename: str, options: Mapping): opt_file.write(str(k) + ' ' + str(val) + '\n') return opt_file_exists - def _create_command_line(self, basename: str, config: IpoptConfig, opt_file: bool): + def _create_command_line( + self, basename: str, config: IpoptConfig, opt_file: bool + ) -> List[str]: cmd = [str(config.executable), basename + '.nl', '-AMPL'] if opt_file: cmd.append('option_file_name=' + basename + '.opt') @@ -293,7 +310,7 @@ def _create_command_line(self, basename: str, config: IpoptConfig, opt_file: boo return cmd @document_kwargs_from_configdict(CONFIG) - def solve(self, model, **kwds): + def solve(self, model, **kwds) -> Results: "Solve a model using Ipopt" # Begin time tracking start_timestamp = datetime.datetime.now(datetime.timezone.utc) @@ -368,8 +385,8 @@ def solve(self, model, **kwds): cmd = self._create_command_line( basename=basename, config=config, opt_file=opt_file ) - # this seems silly, but we have to give the subprocess slightly longer to finish than - # ipopt + # this seems silly, but we have to give the subprocess slightly + # longer to finish than ipopt if config.time_limit is not None: timeout = config.time_limit + min( max(1.0, 0.01 * config.time_limit), 100 @@ -378,23 +395,28 @@ def solve(self, model, **kwds): timeout = None ostreams = [io.StringIO()] + config.tee - with TeeStream(*ostreams) as t: - timer.start('subprocess') - process = subprocess.run( - cmd, - timeout=timeout, - env=env, - universal_newlines=True, - stdout=t.STDOUT, - stderr=t.STDERR, - check=False, - ) + timer.start('subprocess') + try: + with TeeStream(*ostreams) as t: + process = subprocess.run( + cmd, + timeout=timeout, + env=env, + universal_newlines=True, + stdout=t.STDOUT, + stderr=t.STDERR, + check=False, + ) + except OSError: + err = sys.exc_info()[1] + msg = 'Could not execute the command: %s\tError message: %s' + raise ApplicationError(msg % (cmd, err)) + finally: timer.stop('subprocess') - # This is the stuff we need to parse to get the iterations - # and time - (iters, ipopt_time_nofunc, ipopt_time_func, ipopt_total_time) = ( - self._parse_ipopt_output(ostreams[0]) - ) + + # This is the data we need to parse to get the iterations + # and time + parsed_output_data = self._parse_ipopt_output(ostreams[0]) if proven_infeasible: results = Results() @@ -429,16 +451,24 @@ def solve(self, model, **kwds): results.termination_condition = TerminationCondition.error results.solution_loader = SolSolutionLoader(None, None) else: - results.iteration_count = iters - if ipopt_time_nofunc is not None: - results.timing_info.ipopt_excluding_nlp_functions = ( - ipopt_time_nofunc + try: + results.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 + results.extra_info = parsed_output_data + # Set iteration_log visibility to ADVANCED_OPTION because it's + # a lot to print out with `display` + results.extra_info.get("iteration_log")._visibility = ( + ADVANCED_OPTION + ) + except KeyError as e: + logger.log( + logging.WARNING, + "The solver output data is empty or incomplete.\n" + f"Full error message: {e}\n" + f"Parsed solver data: {parsed_output_data}\n", ) - - if ipopt_time_func is not None: - results.timing_info.nlp_function_evaluations = ipopt_time_func - if ipopt_total_time is not None: - results.timing_info.total_seconds = ipopt_total_time if ( config.raise_exception_on_nonoptimal_result and results.solution_status != SolutionStatus.optimal @@ -497,46 +527,137 @@ def solve(self, model, **kwds): results.timing_info.timer = timer return results - def _parse_ipopt_output(self, stream: io.StringIO): - """ - Parse an IPOPT output file and return: - - * number of iterations - * time in IPOPT - - """ - - iters = None - nofunc_time = None - func_time = None - total_time = None - # parse the output stream to get the iteration count and solver time - for line in stream.getvalue().splitlines(): - if line.startswith("Number of Iterations....:"): - tokens = line.split() - iters = int(tokens[-1]) - elif line.startswith( - "Total seconds in IPOPT =" - ): - # Newer versions of IPOPT no longer separate timing into - # two different values. This is so we have compatibility with - # both new and old versions - tokens = line.split() - total_time = float(tokens[-1]) - elif line.startswith( - "Total CPU secs in IPOPT (w/o function evaluations) =" - ): - tokens = line.split() - nofunc_time = float(tokens[-1]) - elif line.startswith( - "Total CPU secs in NLP function evaluations =" - ): - tokens = line.split() - func_time = float(tokens[-1]) + def _parse_ipopt_output(self, output: Union[str, io.StringIO]) -> Dict[str, Any]: + parsed_data = {} + + # Convert output to a string so we can parse it + if isinstance(output, io.StringIO): + output = output.getvalue() + + # Stop parsing if there is nothing to parse + if not output: + logger.log( + logging.WARNING, + "Returned output from ipopt was empty. Cannot parse for additional data.", + ) + return parsed_data + + # Extract number of iterations + iter_match = re.search(r'Number of Iterations.*:\s+(\d+)', output) + if iter_match: + parsed_data['iters'] = int(iter_match.group(1)) + # Gather all the iteration data + iter_table = re.findall(r'^(?:\s*\d+.*?)$', output, re.MULTILINE) + if iter_table: + columns = [ + "iter", + "objective", + "inf_pr", + "inf_du", + "lg_mu", + "d_norm", + "lg_rg", + "alpha_du", + "alpha_pr", + "ls", + ] + iterations = [] + + for line in iter_table: + tokens = line.strip().split() + if len(tokens) != len(columns): + continue + iter_data = dict(zip(columns, tokens)) + + # Extract restoration flag from 'iter' + iter_data['restoration'] = iter_data['iter'].endswith('r') + if iter_data['restoration']: + iter_data['iter'] = iter_data['iter'][:-1] + + # Separate alpha_pr into numeric part and optional tag + iter_data['step_acceptance'] = iter_data['alpha_pr'][-1] + if iter_data['step_acceptance'] in _ALPHA_PR_CHARS: + iter_data['alpha_pr'] = iter_data['alpha_pr'][:-1] + else: + iter_data['step_acceptance'] = None + + # Attempt to cast all values to float where possible + for key in columns: + if iter_data[key] == '-': + iter_data[key] = None + else: + try: + iter_data[key] = float(iter_data[key]) + except (ValueError, TypeError): + logger.warning( + "Error converting Ipopt log entry to " + f"float:\n\t{sys.exc_info()[1]}\n\t{line}" + ) + + assert len(iterations) == iter_data.pop('iter'), ( + f"Parsed row in the iterations table\n\t{line}\ndoes not " + f"match the next expected iteration number ({len(iterations)})" + ) + iterations.append(iter_data) + + parsed_data['iteration_log'] = iterations + + # Extract scaled and unscaled table + scaled_unscaled_match = re.search( + r''' + Objective\.*:\s*([-+eE0-9.]+)\s+([-+eE0-9.]+)\s* + Dual\ infeasibility\.*:\s*([-+eE0-9.]+)\s+([-+eE0-9.]+)\s* + Constraint\ violation\.*:\s*([-+eE0-9.]+)\s+([-+eE0-9.]+)\s* + (?:Variable\ bound\ violation:\s*([-+eE0-9.]+)\s+([-+eE0-9.]+)\s*)? + Complementarity\.*:\s*([-+eE0-9.]+)\s+([-+eE0-9.]+)\s* + Overall\ NLP\ error\.*:\s*([-+eE0-9.]+)\s+([-+eE0-9.]+) + ''', + output, + re.DOTALL | re.VERBOSE, + ) + + if scaled_unscaled_match: + groups = scaled_unscaled_match.groups() + all_fields = [ + "incumbent_objective", + "dual_infeasibility", + "constraint_violation", + "variable_bound_violation", # optional + "complementarity_error", + "overall_nlp_error", + ] + + # Filter out None values and create final fields and values. + # Nones occur in old-style IPOPT output (<= 3.13) + zipped = [ + (field, scaled, unscaled) + for field, scaled, unscaled in zip( + all_fields, groups[0::2], groups[1::2] + ) + if scaled is not None and unscaled is not None + ] + + scaled = {k: float(s) for k, s, _ in zipped} + unscaled = {k: float(u) for k, _, u in zipped} + + parsed_data.update(unscaled) + parsed_data['final_scaled_results'] = scaled + + # Newer versions of IPOPT no longer separate timing into + # two different values. This is so we have compatibility with + # both new and old versions + parsed_data['cpu_seconds'] = { + k.strip(): float(v) + for k, v in re.findall( + r'Total(?: CPU)? sec(?:ond)?s in ([^=]+)=\s*([0-9.]+)', output + ) + } - return iters, nofunc_time, func_time, total_time + return parsed_data - def _parse_solution(self, instream: io.TextIOBase, nl_info: NLWriterInfo): + def _parse_solution( + self, instream: io.TextIOBase, nl_info: NLWriterInfo + ) -> Results: results = Results() res, sol_data = parse_sol_file( sol_file=instream, nl_info=nl_info, result=results diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index 30eb74a2db1..38510dbdf44 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -14,8 +14,9 @@ import pyomo.environ as pyo from pyomo.common.fileutils import ExecutableData -from pyomo.common.config import ConfigDict +from pyomo.common.config import ConfigDict, ADVANCED_OPTION from pyomo.common.errors import DeveloperError +from pyomo.common.tee import capture_output import pyomo.contrib.solver.solvers.ipopt as ipopt from pyomo.contrib.solver.common.util import NoSolutionError from pyomo.contrib.solver.common.factory import SolverFactory @@ -23,17 +24,6 @@ from pyomo.common.tempfiles import TempfileManager from pyomo.repn.plugins.nl_writer import NLWriter - -""" -TODO: - - Test unique configuration options - - Test unique results options - - Ensure that `*.opt` file is only created when needed - - Ensure options are correctly parsing to env or opt file - - Failures at appropriate times -""" - - ipopt_available = ipopt.Ipopt().available() @@ -162,6 +152,170 @@ def test_version_cache(self): self.assertIsNone(opt._version_cache[0]) self.assertIsNone(opt._version_cache[1]) + def test_parse_output(self): + # Old ipopt style (<=3.13) + output = """Ipopt 3.13.2: + +****************************************************************************** +This program contains Ipopt, a library for large-scale nonlinear optimization. + Ipopt is released as open source code under the Eclipse Public License (EPL). + For more information visit http://projects.coin-or.org/Ipopt + +This version of Ipopt was compiled from source code available at + https://github.com/IDAES/Ipopt as part of the Institute for the Design of + Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE + Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse. + +This version of Ipopt was compiled using HSL, a collection of Fortran codes + for large-scale scientific computation. All technical papers, sales and + publicity material resulting from use of the HSL codes within IPOPT must + contain the following acknowledgement: + HSL, a collection of Fortran codes for large-scale scientific + computation. See http://www.hsl.rl.ac.uk. +****************************************************************************** + +This is Ipopt version 3.13.2, running with linear solver ma27. + +Number of nonzeros in equality constraint Jacobian...: 0 +Number of nonzeros in inequality constraint Jacobian.: 0 +Number of nonzeros in Lagrangian Hessian.............: 3 + +Total number of variables............................: 2 + variables with only lower bounds: 0 + variables with lower and upper bounds: 0 + variables with only upper bounds: 0 +Total number of equality constraints.................: 0 +Total number of inequality constraints...............: 0 + inequality constraints with only lower bounds: 0 + inequality constraints with lower and upper bounds: 0 + inequality constraints with only upper bounds: 0 + +iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls + 0 5.6500000e+01 0.00e+00 1.00e+02 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0 + 1 2.4669972e-01 0.00e+00 2.22e-01 -1.0 7.40e-01 - 1.00e+00 1.00e+00f 1 + 2 1.6256267e-01 0.00e+00 2.04e+00 -1.7 1.48e+00 - 1.00e+00 2.50e-01f 3 + 3 8.6119444e-02 0.00e+00 1.08e+00 -1.7 2.36e-01 - 1.00e+00 1.00e+00f 1 + 4 4.3223836e-02 0.00e+00 1.23e+00 -1.7 2.61e-01 - 1.00e+00 1.00e+00f 1 + 5 1.5610508e-02 0.00e+00 3.54e-01 -1.7 1.18e-01 - 1.00e+00 1.00e+00f 1 + 6 5.3544798e-03 0.00e+00 5.51e-01 -1.7 1.67e-01 - 1.00e+00 1.00e+00f 1 + 7 6.1281576e-04 0.00e+00 5.19e-02 -1.7 3.87e-02 - 1.00e+00 1.00e+00f 1 + 8 2.8893076e-05 0.00e+00 4.52e-02 -2.5 4.53e-02 - 1.00e+00 1.00e+00f 1 + 9 3.4591761e-08 0.00e+00 3.80e-04 -2.5 3.18e-03 - 1.00e+00 1.00e+00f 1 +iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls + 10 1.2680803e-13 0.00e+00 3.02e-06 -5.7 3.62e-04 - 1.00e+00 1.00e+00f 1 + 11 7.0136460e-25 0.00e+00 1.72e-12 -8.6 2.13e-07 - 1.00e+00 1.00e+00f 1 + +Number of Iterations....: 11 + + (scaled) (unscaled) +Objective...............: 1.5551321399859192e-25 7.0136459513364959e-25 +Dual infeasibility......: 1.7239720368203862e-12 7.7751138860599418e-12 +Constraint violation....: 0.0000000000000000e+00 0.0000000000000000e+00 +Complementarity.........: 0.0000000000000000e+00 0.0000000000000000e+00 +Overall NLP error.......: 1.7239720368203862e-12 7.7751138860599418e-12 + + +Number of objective function evaluations = 18 +Number of objective gradient evaluations = 12 +Number of equality constraint evaluations = 0 +Number of inequality constraint evaluations = 0 +Number of equality constraint Jacobian evaluations = 0 +Number of inequality constraint Jacobian evaluations = 0 +Number of Lagrangian Hessian evaluations = 11 +Total CPU secs in IPOPT (w/o function evaluations) = 0.000 +Total CPU secs in NLP function evaluations = 0.000 + +EXIT: Optimal Solution Found. + + """ + parsed_output = ipopt.Ipopt()._parse_ipopt_output(output) + self.assertEqual(parsed_output["iters"], 11) + self.assertEqual(len(parsed_output["iteration_log"]), 12) + self.assertEqual(parsed_output["incumbent_objective"], 7.0136459513364959e-25) + self.assertIn("final_scaled_results", parsed_output.keys()) + self.assertIn( + 'IPOPT (w/o function evaluations)', parsed_output['cpu_seconds'].keys() + ) + + # New ipopt style (3.14+) + output = """****************************************************************************** +This program contains Ipopt, a library for large-scale nonlinear optimization. + Ipopt is released as open source code under the Eclipse Public License (EPL). + For more information visit https://github.com/coin-or/Ipopt +****************************************************************************** + +This is Ipopt version 3.14.17, running with linear solver ma27. + +Number of nonzeros in equality constraint Jacobian...: 0 +Number of nonzeros in inequality constraint Jacobian.: 0 +Number of nonzeros in Lagrangian Hessian.............: 3 + +Total number of variables............................: 2 + variables with only lower bounds: 0 + variables with lower and upper bounds: 0 + variables with only upper bounds: 0 +Total number of equality constraints.................: 0 +Total number of inequality constraints...............: 0 + inequality constraints with only lower bounds: 0 + inequality constraints with lower and upper bounds: 0 + inequality constraints with only upper bounds: 0 + +iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls + 0 5.6500000e+01 0.00e+00 1.00e+02 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0 + 1 2.4669972e-01 0.00e+00 2.22e-01 -1.0 7.40e-01 - 1.00e+00 1.00e+00f 1 + 2 1.6256267e-01 0.00e+00 2.04e+00 -1.7 1.48e+00 - 1.00e+00 2.50e-01f 3 + 3 8.6119444e-02 0.00e+00 1.08e+00 -1.7 2.36e-01 - 1.00e+00 1.00e+00f 1 + 4 4.3223836e-02 0.00e+00 1.23e+00 -1.7 2.61e-01 - 1.00e+00 1.00e+00f 1 + 5 1.5610508e-02 0.00e+00 3.54e-01 -1.7 1.18e-01 - 1.00e+00 1.00e+00f 1 + 6 5.3544798e-03 0.00e+00 5.51e-01 -1.7 1.67e-01 - 1.00e+00 1.00e+00f 1 + 7 6.1281576e-04 0.00e+00 5.19e-02 -1.7 3.87e-02 - 1.00e+00 1.00e+00f 1 + 8 2.8893076e-05 0.00e+00 4.52e-02 -2.5 4.53e-02 - 1.00e+00 1.00e+00f 1 + 9 3.4591761e-08 0.00e+00 3.80e-04 -2.5 3.18e-03 - 1.00e+00 1.00e+00f 1 +iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls + 10 1.2680803e-13 0.00e+00 3.02e-06 -5.7 3.62e-04 - 1.00e+00 1.00e+00f 1 + 11 7.0136460e-25 0.00e+00 1.72e-12 -8.6 2.13e-07 - 1.00e+00 1.00e+00f 1 + +Number of Iterations....: 11 + + (scaled) (unscaled) +Objective...............: 1.5551321399859192e-25 7.0136459513364959e-25 +Dual infeasibility......: 1.7239720368203862e-12 7.7751138860599418e-12 +Constraint violation....: 0.0000000000000000e+00 0.0000000000000000e+00 +Variable bound violation: 0.0000000000000000e+00 0.0000000000000000e+00 +Complementarity.........: 0.0000000000000000e+00 0.0000000000000000e+00 +Overall NLP error.......: 1.7239720368203862e-12 7.7751138860599418e-12 + + +Number of objective function evaluations = 18 +Number of objective gradient evaluations = 12 +Number of equality constraint evaluations = 0 +Number of inequality constraint evaluations = 0 +Number of equality constraint Jacobian evaluations = 0 +Number of inequality constraint Jacobian evaluations = 0 +Number of Lagrangian Hessian evaluations = 11 +Total seconds in IPOPT = 0.002 + +EXIT: Optimal Solution Found. + +Ipopt 3.14.17: Optimal Solution Found + """ + parsed_output = ipopt.Ipopt()._parse_ipopt_output(output) + self.assertEqual(parsed_output["iters"], 11) + self.assertEqual(len(parsed_output["iteration_log"]), 12) + self.assertEqual(parsed_output["incumbent_objective"], 7.0136459513364959e-25) + self.assertIn("final_scaled_results", parsed_output.keys()) + self.assertIn('IPOPT', parsed_output['cpu_seconds'].keys()) + + def test_empty_output_parsing(self): + with self.assertLogs( + "pyomo.contrib.solver.solvers.ipopt", level="WARNING" + ) as logs: + ipopt.Ipopt()._parse_ipopt_output(output=None) + self.assertIn( + "Returned output from ipopt was empty. Cannot parse for additional data.", + logs.output[0], + ) + def test_write_options_file(self): # If we have no options, we should get false back opt = ipopt.Ipopt() @@ -278,6 +432,7 @@ def test_create_command_line(self): result = opt._create_command_line('myfile', opt.config, False) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") class TestIpopt(unittest.TestCase): def create_model(self): model = pyo.ConcreteModel() @@ -302,6 +457,48 @@ def test_ipopt_config(self): self.assertFalse(solver.config.tee) self.assertTrue(solver.config.executable.startswith('/path')) - # Change value on a solve call - # model = self.create_model() - # result = solver.solve(model, tee=True) + def test_ipopt_solve(self): + # Gut check - does it solve? + model = self.create_model() + ipopt.Ipopt().solve(model) + + 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.incumbent_objective, 7.013645951336496e-25) + self.assertIn('Optimal Solution Found', results.extra_info.solver_message) + + def test_ipopt_results_display(self): + model = self.create_model() + results = ipopt.Ipopt().solve(model) + # Do not show extra loud stuff + with capture_output() as OUT: + results.display() + contents = OUT.getvalue() + self.assertIn('termination_condition', contents) + self.assertIn('solution_status', contents) + self.assertIn('incumbent_objective', contents) + self.assertNotIn('iteration_log', contents) + # Now we want to see the iteration log + with capture_output() as OUT: + results.display(visibility=ADVANCED_OPTION) + contents = OUT.getvalue() + self.assertIn('termination_condition', contents) + self.assertIn('solution_status', contents) + self.assertIn('incumbent_objective', contents) + self.assertIn('iteration_log', contents) + + def test_ipopt_timer_object(self): + model = self.create_model() + ipopt_instance = ipopt.Ipopt() + results = ipopt_instance.solve(model) + timing_info = results.timing_info + if ipopt_instance.version()[0:1] <= (3, 13): + # We are running an older version of IPOPT (<= 3.13) + self.assertIn('IPOPT (w/o function evaluations)', timing_info.keys()) + self.assertIn('NLP function evaluations', timing_info.keys()) + else: + # Newer version of IPOPT + self.assertIn('IPOPT', timing_info.keys())