Skip to content

Commit 740750d

Browse files
authored
Merge pull request #3738 from mrmundt/ipopt-parser
Bugfix: IPOPT log parser and no objective case
2 parents 8441b15 + d9b15db commit 740750d

File tree

4 files changed

+330
-53
lines changed

4 files changed

+330
-53
lines changed

pyomo/contrib/solver/solvers/ipopt.py

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262

6363
# Acceptable chars for the end of the alpha_pr column
6464
# in ipopt's output, per https://coin-or.github.io/Ipopt/OUTPUT.html
65-
_ALPHA_PR_CHARS = set("fFhHkKnNRwstTr")
65+
_ALPHA_PR_CHARS = set("fFhHkKnNRwSstTr")
6666

6767

6868
class IpoptConfig(SolverConfig):
@@ -115,6 +115,9 @@ def get_reduced_costs(
115115
self, vars_to_load: Optional[Sequence[VarData]] = None
116116
) -> Mapping[VarData, float]:
117117
self._error_check()
118+
# If the NL instance has no objectives, report zeros
119+
if not len(self._nl_info.objectives):
120+
return ComponentMap()
118121
if self._nl_info.scaling is None:
119122
scale_list = [1] * len(self._nl_info.variables)
120123
obj_scale = 1
@@ -294,7 +297,7 @@ def has_linear_solver(self, linear_solver: str) -> bool:
294297
def _verify_ipopt_options(self, config: IpoptConfig) -> None:
295298
for key, msg in unallowed_ipopt_options.items():
296299
if key in config.solver_options:
297-
raise ValueError(f"unallowed ipopt option '{key}': {msg}")
300+
raise ValueError(f"unallowed Ipopt option '{key}': {msg}")
298301
# Map standard Pyomo solver options to Ipopt options: standard
299302
# options override ipopt-specific options.
300303
if config.time_limit is not None:
@@ -505,12 +508,10 @@ def solve(self, model, **kwds) -> Results:
505508
for k, v in cpu_seconds.items():
506509
results.timing_info[k] = v
507510
results.extra_info = parsed_output_data
508-
# Set iteration_log visibility to ADVANCED_OPTION because it's
509-
# a lot to print out with `display`
510-
results.extra_info.get("iteration_log")._visibility = (
511-
ADVANCED_OPTION
512-
)
513-
except KeyError as e:
511+
iter_log = results.extra_info.get("iteration_log", None)
512+
if iter_log is not None:
513+
iter_log._visibility = ADVANCED_OPTION
514+
except Exception as e:
514515
logger.log(
515516
logging.WARNING,
516517
"The solver output data is empty or incomplete.\n"
@@ -610,42 +611,70 @@ def _parse_ipopt_output(self, output: Union[str, io.StringIO]) -> Dict[str, Any]
610611
"ls",
611612
]
612613
iterations = []
614+
n_expected_columns = len(columns)
613615

614616
for line in iter_table:
615617
tokens = line.strip().split()
616-
if len(tokens) != len(columns):
617-
continue
618+
# IPOPT sometimes mashes the first two column values together
619+
# (e.g., "2r-4.93e-03"). We need to split them.
620+
try:
621+
idx = tokens[0].index('-')
622+
head = tokens[0][:idx]
623+
if head and head.rstrip('r').isdigit():
624+
tokens[:1] = (head, tokens[0][idx:])
625+
except ValueError:
626+
pass
627+
618628
iter_data = dict(zip(columns, tokens))
629+
extra_tokens = tokens[n_expected_columns:]
619630

620631
# Extract restoration flag from 'iter'
621-
iter_data['restoration'] = iter_data['iter'].endswith('r')
622-
if iter_data['restoration']:
623-
iter_data['iter'] = iter_data['iter'][:-1]
632+
iter_num = iter_data.pop("iter")
633+
restoration = iter_num.endswith("r")
634+
if restoration:
635+
iter_num = iter_num[:-1]
636+
637+
try:
638+
iter_num = int(iter_num)
639+
except ValueError:
640+
logger.warning(
641+
f"Could not parse Ipopt iteration number: {iter_num}"
642+
)
643+
644+
iter_data["restoration"] = restoration
645+
iter_data["iter"] = iter_num
624646

625-
# Separate alpha_pr into numeric part and optional tag
626-
iter_data['step_acceptance'] = iter_data['alpha_pr'][-1]
627-
if iter_data['step_acceptance'] in _ALPHA_PR_CHARS:
647+
# Separate alpha_pr into numeric part and optional tag (f, D, R, etc.)
648+
step_acceptance_tag = iter_data['alpha_pr'][-1]
649+
if step_acceptance_tag in _ALPHA_PR_CHARS:
650+
iter_data['step_acceptance'] = step_acceptance_tag
628651
iter_data['alpha_pr'] = iter_data['alpha_pr'][:-1]
629652
else:
630653
iter_data['step_acceptance'] = None
631654

655+
# Capture optional IPOPT diagnostic tags if present
656+
if extra_tokens:
657+
iter_data['diagnostic_tags'] = " ".join(extra_tokens)
658+
632659
# Attempt to cast all values to float where possible
633-
for key in columns:
634-
if iter_data[key] == '-':
660+
for key in columns[1:]:
661+
val = iter_data[key]
662+
if val == '-':
635663
iter_data[key] = None
636664
else:
637665
try:
638-
iter_data[key] = float(iter_data[key])
666+
iter_data[key] = float(val)
639667
except (ValueError, TypeError):
640668
logger.warning(
641669
"Error converting Ipopt log entry to "
642670
f"float:\n\t{sys.exc_info()[1]}\n\t{line}"
643671
)
644672

645-
assert len(iterations) == iter_data.pop('iter'), (
646-
f"Parsed row in the iterations table\n\t{line}\ndoes not "
647-
f"match the next expected iteration number ({len(iterations)})"
648-
)
673+
if len(iterations) != iter_num:
674+
logger.warning(
675+
f"Total number of iterations parsed {len(iterations)} "
676+
f"does not match the expected iteration number ({iter_num})."
677+
)
649678
iterations.append(iter_data)
650679

651680
parsed_data['iteration_log'] = iterations
@@ -674,7 +703,6 @@ def _parse_ipopt_output(self, output: Union[str, io.StringIO]) -> Dict[str, Any]
674703
"complementarity_error",
675704
"overall_nlp_error",
676705
]
677-
678706
# Filter out None values and create final fields and values.
679707
# Nones occur in old-style IPOPT output (<= 3.13)
680708
zipped = [
@@ -684,10 +712,8 @@ def _parse_ipopt_output(self, output: Union[str, io.StringIO]) -> Dict[str, Any]
684712
)
685713
if scaled is not None and unscaled is not None
686714
]
687-
688715
scaled = {k: float(s) for k, s, _ in zipped}
689716
unscaled = {k: float(u) for k, _, u in zipped}
690-
691717
parsed_data.update(unscaled)
692718
parsed_data['final_scaled_results'] = scaled
693719

pyomo/contrib/solver/solvers/sol_reader.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ def get_duals(
122122
'Solution loader does not currently have a valid solution. Please '
123123
'check results.termination_condition and/or results.solution_status.'
124124
)
125+
# If the NL instance has no objectives, report zeros
126+
if not self._nl_info.objectives:
127+
cons = (
128+
cons_to_load if cons_to_load is not None else self._nl_info.constraints
129+
)
130+
return {c: 0.0 for c in cons}
125131
if len(self._nl_info.eliminated_vars) > 0:
126132
raise NotImplementedError(
127133
'For now, turn presolve off (opt.config.writer_config.linear_presolve=False) '
@@ -133,21 +139,20 @@ def get_duals(
133139
"have happened. Report this error to the Pyomo Developers."
134140
)
135141
res = {}
136-
if self._nl_info.scaling is None:
137-
scale_list = [1] * len(self._nl_info.constraints)
138-
obj_scale = 1
139-
else:
140-
scale_list = self._nl_info.scaling.constraints
142+
scaling = self._nl_info.scaling
143+
if scaling:
144+
_iter = zip(
145+
self._nl_info.constraints, self._sol_data.duals, scaling.constraints
146+
)
141147
obj_scale = self._nl_info.scaling.objectives[0]
142-
if cons_to_load is None:
143-
cons_to_load = set(self._nl_info.constraints)
144148
else:
145-
cons_to_load = set(cons_to_load)
146-
for con, val, scale in zip(
147-
self._nl_info.constraints, self._sol_data.duals, scale_list
148-
):
149-
if con in cons_to_load:
150-
res[con] = val * scale / obj_scale
149+
_iter = zip(self._nl_info.constraints, self._sol_data.duals)
150+
if cons_to_load is not None:
151+
_iter = filter(lambda x: x[0] in cons_to_load, _iter)
152+
if scaling:
153+
res = {con: val * scale / obj_scale for con, val, scale in _iter}
154+
else:
155+
res = {con: val for con, val in _iter}
151156
return res
152157

153158

pyomo/contrib/solver/tests/solvers/test_ipopt.py

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
# This software is distributed under the 3-clause BSD License.
1010
# ___________________________________________________________________________
1111

12-
import os
12+
import os, sys
1313
import subprocess
14+
from contextlib import contextmanager
1415

1516
import pyomo.environ as pyo
1617
from pyomo.common.envvar import is_windows
@@ -29,6 +30,23 @@
2930
ipopt_available = ipopt.Ipopt().available()
3031

3132

33+
@contextmanager
34+
def windows_tee_buffer(size=1 << 20):
35+
"""Temporarily increase TeeStream buffer size on Windows"""
36+
if not sys.platform.startswith("win"):
37+
# Only windows has an issue
38+
yield
39+
return
40+
import pyomo.common.tee as tee
41+
42+
old = tee._pipe_buffersize
43+
tee._pipe_buffersize = size
44+
try:
45+
yield
46+
finally:
47+
tee._pipe_buffersize = old
48+
49+
3250
@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available")
3351
class TestIpoptSolverConfig(unittest.TestCase):
3452
def test_default_instantiation(self):
@@ -321,6 +339,99 @@ def test_empty_output_parsing(self):
321339
logs.output[0],
322340
)
323341

342+
def test_parse_output_diagnostic_tags(self):
343+
output = """******************************************************************************
344+
This program contains Ipopt, a library for large-scale nonlinear optimization.
345+
Ipopt is released as open source code under the Eclipse Public License (EPL).
346+
For more information visit http://projects.coin-or.org/Ipopt
347+
348+
This version of Ipopt was compiled from source code available at
349+
https://github.com/IDAES/Ipopt as part of the Institute for the Design of
350+
Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE
351+
Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.
352+
353+
This version of Ipopt was compiled using HSL, a collection of Fortran codes
354+
for large-scale scientific computation. All technical papers, sales and
355+
publicity material resulting from use of the HSL codes within IPOPT must
356+
contain the following acknowledgement:
357+
HSL, a collection of Fortran codes for large-scale scientific
358+
computation. See http://www.hsl.rl.ac.uk/.
359+
******************************************************************************
360+
361+
This is Ipopt version 3.13.2, running with linear solver ma57.
362+
363+
Number of nonzeros in equality constraint Jacobian...: 77541
364+
Number of nonzeros in inequality constraint Jacobian.: 0
365+
Number of nonzeros in Lagrangian Hessian.............: 51855
366+
367+
Total number of variables............................: 15468
368+
variables with only lower bounds: 3491
369+
variables with lower and upper bounds: 5026
370+
variables with only upper bounds: 186
371+
Total number of equality constraints.................: 15417
372+
Total number of inequality constraints...............: 0
373+
inequality constraints with only lower bounds: 0
374+
inequality constraints with lower and upper bounds: 0
375+
inequality constraints with only upper bounds: 0
376+
377+
iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls
378+
0 4.3126674e+00 1.34e+00 1.00e+00 -5.0 0.00e+00 - 0.00e+00 0.00e+00 0
379+
Reallocating memory for MA57: lfact (2247250)
380+
1r 4.3126674e+00 1.34e+00 9.99e+02 0.1 0.00e+00 -4.0 0.00e+00 3.29e-10R 2
381+
2r 3.0519246e+08 1.13e+00 9.90e+02 0.1 2.30e+02 - 2.60e-02 9.32e-03f 1
382+
3r 2.2712595e+09 1.69e+00 9.73e+02 0.1 2.23e+02 - 2.54e-02 1.71e-02f 1 Nhj
383+
4 2.2712065e+09 1.69e+00 1.37e+09 -5.0 3.08e+03 - 1.32e-05 1.17e-05f 1 q
384+
5 1.9062986e+09 1.55e+00 1.25e+09 -5.0 5.13e+03 - 1.19e-01 8.38e-02f 1
385+
6 1.7041594e+09 1.46e+00 1.18e+09 -5.0 5.66e+03 - 7.06e-02 5.45e-02f 1
386+
7 1.4763158e+09 1.36e+00 1.10e+09 -5.0 3.94e+03 - 2.30e-01 6.92e-02f 1
387+
8 8.5873108e+08 1.04e+00 8.41e+08 -5.0 2.38e+05 - 3.49e-06 2.37e-01f 1
388+
9 4.4215572e+08 7.45e-01 6.03e+08 -5.0 1.63e+06 - 7.97e-02 2.82e-01f 1
389+
iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls
390+
10 5.0251884e+01 1.65e-01 1.57e+04 -5.0 1.24e+06 - 3.92e-05 1.00e+00f 1
391+
11 4.9121733e+01 4.97e-02 4.68e+03 -5.0 8.11e+04 - 4.31e-02 7.01e-01h 1
392+
12 4.1483985e+01 2.24e-02 5.97e+03 -5.0 1.15e+06 - 5.93e-02 1.00e+00f 1
393+
13 3.5762585e+01 1.75e-02 5.00e+03 -5.0 1.03e+06 - 1.25e-01 1.00e+00f 1
394+
14 3.2291014e+01 1.08e-02 3.51e+03 -5.0 8.25e+05 - 6.68e-01 1.00e+00f 1
395+
15 3.2274630e+01 3.31e-05 1.17e+00 -5.0 4.26e+04 - 9.92e-01 1.00e+00h 1
396+
16 3.2274631e+01 7.45e-09 2.71e-03 -5.0 6.11e+02 - 8.97e-01 1.00e+00h 1
397+
17 3.2274635e+01 7.45e-09 2.35e-03 -5.0 2.71e+04 - 1.32e-01 1.00e+00f 1
398+
18 3.2274635e+01 7.45e-09 1.15e-04 -5.0 5.53e+03 - 9.51e-01 1.00e+00h 1
399+
19 3.2274635e+01 7.45e-09 2.84e-05 -5.0 4.41e+04 - 7.54e-01 1.00e+00f 1
400+
iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls
401+
20 3.2274635e+01 7.45e-09 8.54e-07 -5.0 1.83e+04 - 1.00e+00 1.00e+00h 1
402+
403+
Number of Iterations....: 20
404+
405+
(scaled) (unscaled)
406+
Objective...............: 3.2274635418964841e+01 3.2274635418964841e+01
407+
Dual infeasibility......: 8.5365078678328669e-07 8.5365078678328669e-07
408+
Constraint violation....: 8.0780625068607930e-13 7.4505805969238281e-09
409+
Complementarity.........: 1.2275904566414160e-05 1.2275904566414160e-05
410+
Overall NLP error.......: 1.2275904566414160e-05 1.2275904566414160e-05
411+
412+
413+
Number of objective function evaluations = 23
414+
Number of objective gradient evaluations = 20
415+
Number of equality constraint evaluations = 23
416+
Number of inequality constraint evaluations = 0
417+
Number of equality constraint Jacobian evaluations = 22
418+
Number of inequality constraint Jacobian evaluations = 0
419+
Number of Lagrangian Hessian evaluations = 20
420+
Total CPU secs in IPOPT (w/o function evaluations) = 10.450
421+
Total CPU secs in NLP function evaluations = 1.651
422+
423+
EXIT: Optimal Solution Found.
424+
"""
425+
parsed_output = ipopt.Ipopt()._parse_ipopt_output(output)
426+
self.assertEqual(parsed_output["iters"], 20)
427+
self.assertEqual(len(parsed_output["iteration_log"]), 21)
428+
self.assertEqual(parsed_output["incumbent_objective"], 3.2274635418964841e01)
429+
self.assertEqual(parsed_output["iteration_log"][3]["diagnostic_tags"], 'Nhj')
430+
self.assertIn("final_scaled_results", parsed_output.keys())
431+
self.assertIn(
432+
'IPOPT (w/o function evaluations)', parsed_output['cpu_seconds'].keys()
433+
)
434+
324435
def test_verify_ipopt_options(self):
325436
opt = ipopt.Ipopt(solver_options={'max_iter': 4})
326437
opt._verify_ipopt_options(opt.config)
@@ -478,6 +589,37 @@ def test_ipopt_solve(self):
478589
self.assertAlmostEqual(model.x.value, 1)
479590
self.assertAlmostEqual(model.y.value, 1)
480591

592+
def test_ipopt_quiet_print_level(self):
593+
model = self.create_model()
594+
result = ipopt.Ipopt().solve(model, solver_options={'print_level': 0})
595+
# IPOPT doesn't tell us anything about the iters if the print level
596+
# is set to 0
597+
self.assertIsNone(result.iteration_count)
598+
self.assertFalse(hasattr(result.extra_info, 'iteration_log'))
599+
model = self.create_model()
600+
result = ipopt.Ipopt().solve(model, solver_options={'print_level': 3})
601+
# At a slightly higher level, we get some of the info, like
602+
# iteration count, but NOT iteration_log
603+
self.assertEqual(result.iteration_count, 11)
604+
self.assertFalse(hasattr(result.extra_info, 'iteration_log'))
605+
606+
def test_ipopt_loud_print_level(self):
607+
with windows_tee_buffer(1 << 20):
608+
model = self.create_model()
609+
result = ipopt.Ipopt().solve(model, solver_options={'print_level': 8})
610+
# Nothing unexpected should be in the results object at this point,
611+
# except that the solver_log is significantly longer
612+
self.assertEqual(result.iteration_count, 11)
613+
self.assertEqual(result.incumbent_objective, 7.013645951336496e-25)
614+
self.assertIn('Optimal Solution Found', result.extra_info.solver_message)
615+
self.assertTrue(hasattr(result.extra_info, 'iteration_log'))
616+
model = self.create_model()
617+
result = ipopt.Ipopt().solve(model, solver_options={'print_level': 12})
618+
self.assertEqual(result.iteration_count, 11)
619+
self.assertEqual(result.incumbent_objective, 7.013645951336496e-25)
620+
self.assertIn('Optimal Solution Found', result.extra_info.solver_message)
621+
self.assertTrue(hasattr(result.extra_info, 'iteration_log'))
622+
481623
def test_ipopt_results(self):
482624
model = self.create_model()
483625
results = ipopt.Ipopt().solve(model)
@@ -612,14 +754,13 @@ def test_map_OF_options(self):
612754
solver_options={'OF_bogus_option': 5},
613755
load_solutions=False,
614756
)
615-
print(LOG.getvalue())
616757
self.assertIn('OPTION_INVALID', LOG.getvalue())
617758
# Note: OF_ is stripped
618759
self.assertIn(
619760
'Read Option: "bogus_option". It is not a valid option', LOG.getvalue()
620761
)
621762

622-
with self.assertRaisesRegex(ValueError, "unallowed ipopt option 'wantsol'"):
763+
with self.assertRaisesRegex(ValueError, "unallowed Ipopt option 'wantsol'"):
623764
results = ipopt.LegacyIpoptSolver().solve(
624765
model,
625766
tee=True,

0 commit comments

Comments
 (0)