From fc14cc5157fe8ebbc4d5402cd9e35cbf9cff981c Mon Sep 17 00:00:00 2001 From: kanekosh Date: Thu, 2 Oct 2025 15:00:35 -0400 Subject: [PATCH 01/15] save IPOPT iteration histories --- pyoptsparse/pyIPOPT/pyIPOPT.py | 29 +++++++++++++++++++++++++---- pyoptsparse/pyOpt_history.py | 7 +++++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/pyoptsparse/pyIPOPT/pyIPOPT.py b/pyoptsparse/pyIPOPT/pyIPOPT.py index 1ef57239..4576cc98 100644 --- a/pyoptsparse/pyIPOPT/pyIPOPT.py +++ b/pyoptsparse/pyIPOPT/pyIPOPT.py @@ -207,7 +207,7 @@ def __call__( jac["coo"][ICOL].copy().astype("int_"), ) - class CyIPOPTProblem: + class CyIPOPTProblem(cyipopt.Problem): # Define the 4 call back functions that ipopt needs: def objective(_, x): fobj, fail = self._masterFunc(x, ["fobj"]) @@ -246,7 +246,29 @@ def jacobianstructure(_): # Define intermediate callback. If this method returns false, # Ipopt will terminate with the User_Requested_Stop status. - def intermediate(_, *args, **kwargs): + # Also save iteration info in the history file. This callback is called every "major" iteration but not in line search iterations. + # fmt: off + def intermediate(self_cyipopt, alg_mod, iter_count, obj_value, inf_pr, inf_du, mu, d_norm, regularization_size, alpha_du, alpha_pr, ls_trials): + # fmt: on + if self.storeHistory: + iter_dict = { + "feasibility": inf_pr, + "optimality": inf_du, + "mu": mu, + "step_primal": alpha_pr, + "step_dual": alpha_du, + } + + # Find pyoptsparse call counters for objective and constraints calls at current x. + # IPOPT calls objective and constraints separately, so we find two call counters and append iter_dict to both counters. + xuser_vec = self_cyipopt.get_current_iterate()["x"] + call_counter_1 = self.hist._searchCallCounter(xuser_vec) + call_counter_2 = self.hist._searchCallCounter(xuser_vec, last=call_counter_1 - 1) + + for call_counter in [call_counter_2, call_counter_1]: + if call_counter is not None: + self.hist.write(call_counter, iter_dict) + if self.userRequestedTermination is True: return False else: @@ -254,10 +276,9 @@ def intermediate(_, *args, **kwargs): timeA = time.time() - nlp = cyipopt.Problem( + nlp = CyIPOPTProblem( n=len(xs), m=ncon, - problem_obj=CyIPOPTProblem(), lb=blx, ub=bux, cl=blc, diff --git a/pyoptsparse/pyOpt_history.py b/pyoptsparse/pyOpt_history.py index b90d1080..7343fdf4 100644 --- a/pyoptsparse/pyOpt_history.py +++ b/pyoptsparse/pyOpt_history.py @@ -152,7 +152,7 @@ def read(self, key): except KeyError: return None - def _searchCallCounter(self, x): + def _searchCallCounter(self, x, last=None): """ Searches through existing callCounters, and finds the one corresponding to an evaluation at the design vector `x`. @@ -162,6 +162,8 @@ def _searchCallCounter(self, x): ---------- x : ndarray The unscaled DV as a single array. + last : int, optional + The last callCounter to search from. If not provided, use the last callCounter in db. Returns ------- @@ -173,7 +175,8 @@ def _searchCallCounter(self, x): ----- The tolerance used for this is the value `numpy.finfo(numpy.float64).eps`. """ - last = int(self.db["last"]) + if last is None: + last = int(self.db["last"]) callCounter = None for i in range(last, 0, -1): key = str(i) From 8b174c608f4ed77a2a186f6a63b15083f8657dcb Mon Sep 17 00:00:00 2001 From: kanekosh Date: Thu, 2 Oct 2025 15:03:16 -0400 Subject: [PATCH 02/15] optional parameters --- pyoptsparse/pyIPOPT/pyIPOPT.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/pyoptsparse/pyIPOPT/pyIPOPT.py b/pyoptsparse/pyIPOPT/pyIPOPT.py index 4576cc98..8dd4329f 100644 --- a/pyoptsparse/pyIPOPT/pyIPOPT.py +++ b/pyoptsparse/pyIPOPT/pyIPOPT.py @@ -51,6 +51,9 @@ def __init__(self, raiseError=True, options={}): # IPOPT needs Jacobians in coo format self.jacType = "coo" + # List of pyIPOPT-specific options. We remove these from the list of options so these don't go into cyipopt. + self.pythonOptions = ["save_major_iteration_variables"] + @staticmethod def _getInforms(): informs = { @@ -85,6 +88,7 @@ def _getDefaultOptions(): "print_user_options": [str, "yes"], "output_file": [str, "IPOPT.out"], "linear_solver": [str, "mumps"], + "save_major_iteration_variables": [list, []], } return defOpts @@ -258,6 +262,22 @@ def intermediate(self_cyipopt, alg_mod, iter_count, obj_value, inf_pr, inf_du, m "step_primal": alpha_pr, "step_dual": alpha_du, } + # optional parameters + for saveVar in self.getOption("save_major_iteration_variables"): + if saveVar == "alg_mod": + iterDict[saveVar] = alg_mod + elif saveVar == "d_norm": + iterDict[saveVar] = d_norm + elif saveVar == "regularization_size": + iterDict[saveVar] = regularization_size + elif saveVar == "ls_trials": + iterDict[saveVar] = ls_trials + elif saveVar in ["g_violation", "grad_lag_x"]: + iterDict[saveVar] = self_cyipopt.get_current_violations()[saveVar] + else: + raise ValueError(f"Received unknown IPOPT save variable {saveVar}. " + + "Please see 'Save major iteration variables' option in the pyOptSparse " + + "documentation under 'IPOPT'.") # Find pyoptsparse call counters for objective and constraints calls at current x. # IPOPT calls objective and constraints separately, so we find two call counters and append iter_dict to both counters. @@ -267,7 +287,7 @@ def intermediate(self_cyipopt, alg_mod, iter_count, obj_value, inf_pr, inf_du, m for call_counter in [call_counter_2, call_counter_1]: if call_counter is not None: - self.hist.write(call_counter, iter_dict) + self.hist.write(call_counter, iterDict) if self.userRequestedTermination is True: return False @@ -321,4 +341,7 @@ def _set_ipopt_options(self, nlp): # --------------------------------------------- for name, value in self.options.items(): + # skip pyIPOPT-specific options + if name in self.pythonOptions: + continue nlp.add_option(name, value) From 6bd9961682ad4acd7a8b65fb6753b42e8b8959c1 Mon Sep 17 00:00:00 2001 From: kanekosh Date: Thu, 2 Oct 2025 15:04:38 -0400 Subject: [PATCH 03/15] introduce major iteration flag to IPOPT --- pyoptsparse/pyIPOPT/pyIPOPT.py | 3 ++- pyoptsparse/pyOpt_optimizer.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pyoptsparse/pyIPOPT/pyIPOPT.py b/pyoptsparse/pyIPOPT/pyIPOPT.py index 8dd4329f..1a6701a3 100644 --- a/pyoptsparse/pyIPOPT/pyIPOPT.py +++ b/pyoptsparse/pyIPOPT/pyIPOPT.py @@ -255,7 +255,8 @@ def jacobianstructure(_): def intermediate(self_cyipopt, alg_mod, iter_count, obj_value, inf_pr, inf_du, mu, d_norm, regularization_size, alpha_du, alpha_pr, ls_trials): # fmt: on if self.storeHistory: - iter_dict = { + iterDict = { + "isMajor": True, "feasibility": inf_pr, "optimality": inf_du, "mu": mu, diff --git a/pyoptsparse/pyOpt_optimizer.py b/pyoptsparse/pyOpt_optimizer.py index 81ad0b15..929b8660 100644 --- a/pyoptsparse/pyOpt_optimizer.py +++ b/pyoptsparse/pyOpt_optimizer.py @@ -578,9 +578,9 @@ def _masterFunc2(self, x, evaluate, writeHist=True): # timing hist["time"] = time.time() - self.startTime - # Save information about major iteration counting (only matters for SNOPT). - if self.name == "SNOPT": - hist["isMajor"] = False # this will be updated in _snstop if it is major + # Save information about major iteration counting (only matters for SNOPT and IPOPT). + if self.name in ["SNOPT", "IPOPT"]: + hist["isMajor"] = False # this will be updated in _snstop or cyipopt's `intermediate` if it is major else: hist["isMajor"] = True # for other optimizers we assume everything's major From 85c370afb70b845a1f22657e22469635952e710b Mon Sep 17 00:00:00 2001 From: kanekosh Date: Thu, 2 Oct 2025 15:18:54 -0400 Subject: [PATCH 04/15] docs --- doc/api/history.rst | 3 ++- doc/optimizers/IPOPT_options.yaml | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/doc/api/history.rst b/doc/api/history.rst index 5c7c231b..90e25857 100644 --- a/doc/api/history.rst +++ b/doc/api/history.rst @@ -58,7 +58,8 @@ In this case, the history file would have the following layout:: The main optimization history is indexed via call counters, in this example ``0`` and ``1``. Note that they do not match the major/minor iterations of a given optimizer, since gradient evaluations are stored separate from the function evaluation. -For SNOPT, a number of other values can be requested and stored in each major iteration, such as the feasibility and optimality from the SNOPT print out file. +For SNOPT and IPOPT, a number of other values can be requested and stored in each major iteration, such as the feasibility and optimality. +See SNOPT and IPOPT documentation pages for more details. API diff --git a/doc/optimizers/IPOPT_options.yaml b/doc/optimizers/IPOPT_options.yaml index 271e7953..2eb8520b 100644 --- a/doc/optimizers/IPOPT_options.yaml +++ b/doc/optimizers/IPOPT_options.yaml @@ -10,3 +10,22 @@ linear_solver: desc: The linear solver used. sb: desc: This is an undocumented option which suppresses the IPOPT header from being printed to screen every time. +save_major_iteration_variables: + desc: | + This option is unique to the Python wrapper, and takes a list of values which can be saved at each major iteration to the History file. + The possible values are + + - ``alg_mod``: algorithm mode (0 for regular, 1 for restoration) + - ``d_norm``: infinity norm of the primal step + - ``regularization_size``: regularization term for the Hessian of the Lagrangian + - ``ls_trials``: number of backtracking line search iterations + - ``g_violation``: vector of constraint violations + - ``grad_lag_x``: gradient of Lagrangian + + In addition, a set of default parameters are saved to the history file and cannot be changed. These are + + - ``feasibility``: primal infeasibility (called ``inf_pr`` in IPOPT) + - ``optimality``: dual infeasibility (called ``inf_du`` in IPOPT) which is an optimality measure + - ``mu``: barrier parameter + - ``step_primal``: step size for primal variables (called ``alpha_pr`` in IPOPT) + - ``step_dual``: step size for dual variables (called ``alpha_du`` in IPOPT) \ No newline at end of file From a3566cda56afd69865a3a5ab1d36e7bba86f7066 Mon Sep 17 00:00:00 2001 From: kanekosh Date: Thu, 2 Oct 2025 16:20:11 -0400 Subject: [PATCH 05/15] test and example --- examples/hs015VarPlot.py | 21 +++++++++++++++++++++ tests/test_hs015.py | 13 ++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/examples/hs015VarPlot.py b/examples/hs015VarPlot.py index 932a08f6..22d2a572 100644 --- a/examples/hs015VarPlot.py +++ b/examples/hs015VarPlot.py @@ -74,4 +74,25 @@ plt.xlabel("x1") plt.ylabel("x2") plt.title("Simple optimizer comparison") + +# Plot optimality and feasibility history for SNOPT and IPOPT +list_opt_with_optimality = [opt for opt in db.keys() if opt in ["ipopt", "snopt"]] +if len(list_opt_with_optimality) > 0: + fig, axs = plt.subplots(2, 1) + + for opt in list_opt_with_optimality: + # get iteration count, optimality, and feasibility + hist = db[opt].getValues(names=["iter", "optimality", "feasibility"]) + + axs[0].plot(hist["iter"], hist["optimality"], "o-", label=opt) + axs[1].plot(hist["iter"], hist["feasibility"], "o-", label=opt) + + axs[0].set_yscale("log") + axs[1].set_yscale("log") + axs[0].legend() + axs[0].set_ylabel("Optimality") + axs[0].set_xticklabels([]) + axs[1].set_ylabel("Feasibility") + axs[1].set_xlabel("Iteration") + plt.show() diff --git a/tests/test_hs015.py b/tests/test_hs015.py index 7f312a77..8cab4e68 100644 --- a/tests/test_hs015.py +++ b/tests/test_hs015.py @@ -130,7 +130,8 @@ def test_optimization(self, optName): def test_ipopt(self): self.optName = "IPOPT" self.setup_optProb() - optOptions = self.optOptions.pop(self.optName, None) + store_vars = ["alg_mod", "d_norm", "regularization_size", "ls_trials", "g_violation", "grad_lag_x"] + optOptions = {"save_major_iteration_variables": store_vars} sol = self.optimize(optOptions=optOptions, storeHistory=True) # Check Solution self.assert_solution_allclose(sol, self.tol[self.optName]) @@ -144,6 +145,16 @@ def test_ipopt(self): data_last = hist.read(hist.read("last")) self.assertGreater(data_last["iter"], 0) + # Check entries in iteration data + data = hist.getValues(callCounters=["last"]) + default_store_vars = ["feasibility", "optimality", "mu", "step_primal", "step_dual"] + for var in default_store_vars + store_vars: + self.assertIn(var, data.keys()) + self.assertEqual(data["feasibility"].shape, (1, 1)) + self.assertEqual(data["optimality"].shape, (1, 1)) + self.assertEqual(data["g_violation"].shape, (1, 2)) + self.assertEqual(data["grad_lag_x"].shape, (1, 2)) + # Make sure there is no duplication in objective history data = hist.getValues(names=["obj"]) objhis_len = data["obj"].shape[0] From efd4cac02a44410a1bae3d5cd34d07b530e8919d Mon Sep 17 00:00:00 2001 From: kanekosh Date: Thu, 2 Oct 2025 16:27:26 -0400 Subject: [PATCH 06/15] improve error handling --- pyoptsparse/pyIPOPT/pyIPOPT.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyoptsparse/pyIPOPT/pyIPOPT.py b/pyoptsparse/pyIPOPT/pyIPOPT.py index 1a6701a3..7f942f39 100644 --- a/pyoptsparse/pyIPOPT/pyIPOPT.py +++ b/pyoptsparse/pyIPOPT/pyIPOPT.py @@ -276,9 +276,12 @@ def intermediate(self_cyipopt, alg_mod, iter_count, obj_value, inf_pr, inf_du, m elif saveVar in ["g_violation", "grad_lag_x"]: iterDict[saveVar] = self_cyipopt.get_current_violations()[saveVar] else: - raise ValueError(f"Received unknown IPOPT save variable {saveVar}. " - + "Please see 'Save major iteration variables' option in the pyOptSparse " - + "documentation under 'IPOPT'.") + # IPOPT doesn't handle Python error well, so print an error message and send termination signal to IPOPT + print(f"ERROR: Received unknown IPOPT save variable `{saveVar}`. " + + "Please see 'save_major_iteration_variables' option in the pyOptSparse " + + "documentation under 'IPOPT'.") + print("Terminating IPOPT...") + return False # Find pyoptsparse call counters for objective and constraints calls at current x. # IPOPT calls objective and constraints separately, so we find two call counters and append iter_dict to both counters. From 1840b5e960cfd9db35d4e85d9a061282932e6657 Mon Sep 17 00:00:00 2001 From: kanekosh Date: Fri, 3 Oct 2025 18:08:23 -0400 Subject: [PATCH 07/15] check if IPOPT>=3.14 --- pyoptsparse/pyIPOPT/pyIPOPT.py | 11 +++++++++++ tests/test_hs015.py | 19 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/pyoptsparse/pyIPOPT/pyIPOPT.py b/pyoptsparse/pyIPOPT/pyIPOPT.py index 7f942f39..ffaa2457 100644 --- a/pyoptsparse/pyIPOPT/pyIPOPT.py +++ b/pyoptsparse/pyIPOPT/pyIPOPT.py @@ -39,6 +39,17 @@ def __init__(self, raiseError=True, options={}): if cyipopt is None and raiseError: raise ImportError("Could not import cyipopt") + # IPOPT>=3.14 is required to save g_violation and grad_lag_x. Check IPOPT version. + if "save_major_iteration_variables" in options and ( + "g_violation" in options["save_major_iteration_variables"] + or "grad_lag_x" in options["save_major_iteration_variables"] + ): + ipopt_ver = cyipopt.IPOPT_VERSION + if ipopt_ver[0] < 3 or ipopt_ver[1] < 14: + raise ValueError( + f"IPOPT>=3.14 is required to save `g_violation` and `grad_lag_x`, but you have IPOPT v{ipopt_ver[0]}.{ipopt_ver[1]}." + ) + super().__init__( name, category, diff --git a/tests/test_hs015.py b/tests/test_hs015.py index 8cab4e68..048a2f1c 100644 --- a/tests/test_hs015.py +++ b/tests/test_hs015.py @@ -9,6 +9,11 @@ import numpy as np from parameterized import parameterized +try: + import cyipopt +except ImportError: + cyipopt = None + # First party modules from pyoptsparse import OPT, History, Optimization from pyoptsparse.testing import OptTest @@ -130,7 +135,14 @@ def test_optimization(self, optName): def test_ipopt(self): self.optName = "IPOPT" self.setup_optProb() - store_vars = ["alg_mod", "d_norm", "regularization_size", "ls_trials", "g_violation", "grad_lag_x"] + store_vars = ["alg_mod", "d_norm", "regularization_size", "ls_trials"] + # check IPOPT version and add more variables to save_major_iteration_variables if IPOPT>=3.14 + ipopt_314 = False + if cyipopt is not None: + ipopt_ver = cyipopt.IPOPT_VERSION + if ipopt_ver[0] >= 3 and ipopt_ver[1] >= 14: + ipopt_314 = True + store_vars.extend(["g_violation", "grad_lag_x"]) optOptions = {"save_major_iteration_variables": store_vars} sol = self.optimize(optOptions=optOptions, storeHistory=True) # Check Solution @@ -152,8 +164,9 @@ def test_ipopt(self): self.assertIn(var, data.keys()) self.assertEqual(data["feasibility"].shape, (1, 1)) self.assertEqual(data["optimality"].shape, (1, 1)) - self.assertEqual(data["g_violation"].shape, (1, 2)) - self.assertEqual(data["grad_lag_x"].shape, (1, 2)) + if ipopt_314: + self.assertEqual(data["g_violation"].shape, (1, 2)) + self.assertEqual(data["grad_lag_x"].shape, (1, 2)) # Make sure there is no duplication in objective history data = hist.getValues(names=["obj"]) From c688021b0279a7742b16be5a152e7afa6cc23b88 Mon Sep 17 00:00:00 2001 From: kanekosh Date: Fri, 3 Oct 2025 18:32:48 -0400 Subject: [PATCH 08/15] remove unnecessary get_current_iterate --- pyoptsparse/pyIPOPT/pyIPOPT.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pyoptsparse/pyIPOPT/pyIPOPT.py b/pyoptsparse/pyIPOPT/pyIPOPT.py index ffaa2457..ed652278 100644 --- a/pyoptsparse/pyIPOPT/pyIPOPT.py +++ b/pyoptsparse/pyIPOPT/pyIPOPT.py @@ -46,7 +46,7 @@ def __init__(self, raiseError=True, options={}): ): ipopt_ver = cyipopt.IPOPT_VERSION if ipopt_ver[0] < 3 or ipopt_ver[1] < 14: - raise ValueError( + raise RuntimeError( f"IPOPT>=3.14 is required to save `g_violation` and `grad_lag_x`, but you have IPOPT v{ipopt_ver[0]}.{ipopt_ver[1]}." ) @@ -296,9 +296,8 @@ def intermediate(self_cyipopt, alg_mod, iter_count, obj_value, inf_pr, inf_du, m # Find pyoptsparse call counters for objective and constraints calls at current x. # IPOPT calls objective and constraints separately, so we find two call counters and append iter_dict to both counters. - xuser_vec = self_cyipopt.get_current_iterate()["x"] - call_counter_1 = self.hist._searchCallCounter(xuser_vec) - call_counter_2 = self.hist._searchCallCounter(xuser_vec, last=call_counter_1 - 1) + call_counter_1 = self.hist._searchCallCounter(self.cache["x"]) + call_counter_2 = self.hist._searchCallCounter(self.cache["x"], last=call_counter_1 - 1) for call_counter in [call_counter_2, call_counter_1]: if call_counter is not None: From b7e039bd51811852ace02d343f63a6e917b83e45 Mon Sep 17 00:00:00 2001 From: kanekosh Date: Mon, 6 Oct 2025 12:34:03 -0400 Subject: [PATCH 09/15] isort and pre-commit fixes --- doc/optimizers/IPOPT_options.yaml | 2 +- tests/test_hs015.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/optimizers/IPOPT_options.yaml b/doc/optimizers/IPOPT_options.yaml index 2eb8520b..9b4ecd6a 100644 --- a/doc/optimizers/IPOPT_options.yaml +++ b/doc/optimizers/IPOPT_options.yaml @@ -28,4 +28,4 @@ save_major_iteration_variables: - ``optimality``: dual infeasibility (called ``inf_du`` in IPOPT) which is an optimality measure - ``mu``: barrier parameter - ``step_primal``: step size for primal variables (called ``alpha_pr`` in IPOPT) - - ``step_dual``: step size for dual variables (called ``alpha_du`` in IPOPT) \ No newline at end of file + - ``step_dual``: step size for dual variables (called ``alpha_du`` in IPOPT) diff --git a/tests/test_hs015.py b/tests/test_hs015.py index c962b080..f984ff65 100644 --- a/tests/test_hs015.py +++ b/tests/test_hs015.py @@ -10,6 +10,7 @@ from parameterized import parameterized try: + # External modules import cyipopt except ImportError: cyipopt = None From a51a7349f587732cbc8f3b42a7b308345362ed18 Mon Sep 17 00:00:00 2001 From: kanekosh Date: Sun, 12 Oct 2025 15:24:27 -0400 Subject: [PATCH 10/15] rename iter variables to follow IPOPT --- doc/optimizers/IPOPT_options.yaml | 11 +++++++---- examples/hs015VarPlot.py | 17 ++++++++++++----- pyoptsparse/pyIPOPT/pyIPOPT.py | 8 ++++---- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/doc/optimizers/IPOPT_options.yaml b/doc/optimizers/IPOPT_options.yaml index 9b4ecd6a..a80402f7 100644 --- a/doc/optimizers/IPOPT_options.yaml +++ b/doc/optimizers/IPOPT_options.yaml @@ -24,8 +24,11 @@ save_major_iteration_variables: In addition, a set of default parameters are saved to the history file and cannot be changed. These are - - ``feasibility``: primal infeasibility (called ``inf_pr`` in IPOPT) - - ``optimality``: dual infeasibility (called ``inf_du`` in IPOPT) which is an optimality measure + - ``inf_pr``: primal infeasibility + - ``inf_du``: dual infeasibility (optimality measure) - ``mu``: barrier parameter - - ``step_primal``: step size for primal variables (called ``alpha_pr`` in IPOPT) - - ``step_dual``: step size for dual variables (called ``alpha_du`` in IPOPT) + - ``alpha_pr``: step size for primal variables + - ``alpha_du``: step size for dual variables + + pyOptSparse uses the same parameter names as `IPOPT `_ and `cyipopt `_. + Detailed descriptions of these parameter can be found in their documentations. diff --git a/examples/hs015VarPlot.py b/examples/hs015VarPlot.py index 22d2a572..b1e3c4e0 100644 --- a/examples/hs015VarPlot.py +++ b/examples/hs015VarPlot.py @@ -81,11 +81,18 @@ fig, axs = plt.subplots(2, 1) for opt in list_opt_with_optimality: - # get iteration count, optimality, and feasibility - hist = db[opt].getValues(names=["iter", "optimality", "feasibility"]) - - axs[0].plot(hist["iter"], hist["optimality"], "o-", label=opt) - axs[1].plot(hist["iter"], hist["feasibility"], "o-", label=opt) + # get iteration count, optimality, and feasibility. + # SNOPT and IPOPT uses different parameter names for optimality and feasibility. + if opt == "ipopt": + optimality = "inf_du" + feasibility = "inf_pr" + elif opt == "snopt": + optimality = "optimality" + feasibility = "feasibility" + + hist = db[opt].getValues(names=["iter", optimality, feasibility]) + axs[0].plot(hist["iter"], hist[optimality], "o-", label=opt) + axs[1].plot(hist["iter"], hist[feasibility], "o-", label=opt) axs[0].set_yscale("log") axs[1].set_yscale("log") diff --git a/pyoptsparse/pyIPOPT/pyIPOPT.py b/pyoptsparse/pyIPOPT/pyIPOPT.py index 755a62f8..31f0c011 100644 --- a/pyoptsparse/pyIPOPT/pyIPOPT.py +++ b/pyoptsparse/pyIPOPT/pyIPOPT.py @@ -268,11 +268,11 @@ def intermediate(self_cyipopt, alg_mod, iter_count, obj_value, inf_pr, inf_du, m if self.storeHistory: iterDict = { "isMajor": True, - "feasibility": inf_pr, - "optimality": inf_du, + "inf_pr": inf_pr, + "inf_du": inf_du, "mu": mu, - "step_primal": alpha_pr, - "step_dual": alpha_du, + "alpha_pr": alpha_pr, + "alpha_du": alpha_du, } # optional parameters for saveVar in self.getOption("save_major_iteration_variables"): From d47913e93ee6134484982553bdfdcdc11b31e097 Mon Sep 17 00:00:00 2001 From: kanekosh Date: Sun, 12 Oct 2025 15:25:44 -0400 Subject: [PATCH 11/15] fix variable names in test --- tests/test_hs015.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_hs015.py b/tests/test_hs015.py index f984ff65..17ef24fc 100644 --- a/tests/test_hs015.py +++ b/tests/test_hs015.py @@ -160,11 +160,11 @@ def test_ipopt(self): # Check entries in iteration data data = hist.getValues(callCounters=["last"]) - default_store_vars = ["feasibility", "optimality", "mu", "step_primal", "step_dual"] + default_store_vars = ["inf_pr", "inf_du", "mu", "alpha_pr", "alpha_du"] for var in default_store_vars + store_vars: self.assertIn(var, data.keys()) - self.assertEqual(data["feasibility"].shape, (1, 1)) - self.assertEqual(data["optimality"].shape, (1, 1)) + self.assertEqual(data["inf_pr"].shape, (1, 1)) + self.assertEqual(data["inf_du"].shape, (1, 1)) if ipopt_314: self.assertEqual(data["g_violation"].shape, (1, 2)) self.assertEqual(data["grad_lag_x"].shape, (1, 2)) From 57869c617adaa1e5e6af89d9303db13928e8655f Mon Sep 17 00:00:00 2001 From: kanekosh Date: Mon, 20 Oct 2025 09:16:10 -0400 Subject: [PATCH 12/15] rename variables in hs015 plotting example --- examples/hs015VarPlot.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/hs015VarPlot.py b/examples/hs015VarPlot.py index b1e3c4e0..89c0e17a 100644 --- a/examples/hs015VarPlot.py +++ b/examples/hs015VarPlot.py @@ -84,15 +84,15 @@ # get iteration count, optimality, and feasibility. # SNOPT and IPOPT uses different parameter names for optimality and feasibility. if opt == "ipopt": - optimality = "inf_du" - feasibility = "inf_pr" + optimality_name = "inf_du" + feasibility_name = "inf_pr" elif opt == "snopt": - optimality = "optimality" - feasibility = "feasibility" + optimality_name = "optimality" + feasibility_name = "feasibility" - hist = db[opt].getValues(names=["iter", optimality, feasibility]) - axs[0].plot(hist["iter"], hist[optimality], "o-", label=opt) - axs[1].plot(hist["iter"], hist[feasibility], "o-", label=opt) + hist = db[opt].getValues(names=["iter", optimality_name, feasibility_name]) + axs[0].plot(hist["iter"], hist[optimality_name], "o-", label=opt) + axs[1].plot(hist["iter"], hist[feasibility_name], "o-", label=opt) axs[0].set_yscale("log") axs[1].set_yscale("log") From dd146c60c4006c129a57df92911aa8a8ba361e5d Mon Sep 17 00:00:00 2001 From: kanekosh Date: Thu, 23 Oct 2025 11:03:31 -0400 Subject: [PATCH 13/15] drop ipopt 3.13 support --- pyoptsparse/pyIPOPT/pyIPOPT.py | 11 ----------- tests/test_hs015.py | 20 +++----------------- 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/pyoptsparse/pyIPOPT/pyIPOPT.py b/pyoptsparse/pyIPOPT/pyIPOPT.py index cc7afdd7..00879d97 100644 --- a/pyoptsparse/pyIPOPT/pyIPOPT.py +++ b/pyoptsparse/pyIPOPT/pyIPOPT.py @@ -35,17 +35,6 @@ def __init__(self, raiseError=True, options={}): if isinstance(cyipopt, Exception) and raiseError: raise cyipopt - # IPOPT>=3.14 is required to save g_violation and grad_lag_x. Check IPOPT version. - if "save_major_iteration_variables" in options and ( - "g_violation" in options["save_major_iteration_variables"] - or "grad_lag_x" in options["save_major_iteration_variables"] - ): - ipopt_ver = cyipopt.IPOPT_VERSION - if ipopt_ver[0] < 3 or ipopt_ver[1] < 14: - raise RuntimeError( - f"IPOPT>=3.14 is required to save `g_violation` and `grad_lag_x`, but you have IPOPT v{ipopt_ver[0]}.{ipopt_ver[1]}." - ) - super().__init__( name, category, diff --git a/tests/test_hs015.py b/tests/test_hs015.py index 17ef24fc..4a4e3c8e 100644 --- a/tests/test_hs015.py +++ b/tests/test_hs015.py @@ -9,12 +9,6 @@ import numpy as np from parameterized import parameterized -try: - # External modules - import cyipopt -except ImportError: - cyipopt = None - # First party modules from pyoptsparse import OPT, History, Optimization from pyoptsparse.testing import OptTest @@ -136,14 +130,7 @@ def test_optimization(self, optName): def test_ipopt(self): self.optName = "IPOPT" self.setup_optProb() - store_vars = ["alg_mod", "d_norm", "regularization_size", "ls_trials"] - # check IPOPT version and add more variables to save_major_iteration_variables if IPOPT>=3.14 - ipopt_314 = False - if cyipopt is not None: - ipopt_ver = cyipopt.IPOPT_VERSION - if ipopt_ver[0] >= 3 and ipopt_ver[1] >= 14: - ipopt_314 = True - store_vars.extend(["g_violation", "grad_lag_x"]) + store_vars = ["alg_mod", "d_norm", "regularization_size", "ls_trials", "g_violation", "grad_lag_x"] optOptions = {"save_major_iteration_variables": store_vars} sol = self.optimize(optOptions=optOptions, storeHistory=True) # Check Solution @@ -165,9 +152,8 @@ def test_ipopt(self): self.assertIn(var, data.keys()) self.assertEqual(data["inf_pr"].shape, (1, 1)) self.assertEqual(data["inf_du"].shape, (1, 1)) - if ipopt_314: - self.assertEqual(data["g_violation"].shape, (1, 2)) - self.assertEqual(data["grad_lag_x"].shape, (1, 2)) + self.assertEqual(data["g_violation"].shape, (1, 2)) + self.assertEqual(data["grad_lag_x"].shape, (1, 2)) # Make sure there is no duplication in objective history data = hist.getValues(names=["obj"]) From fcd37081f803df697fe98289112a667112fa6d5f Mon Sep 17 00:00:00 2001 From: kanekosh Date: Thu, 23 Oct 2025 11:04:11 -0400 Subject: [PATCH 14/15] patch version bump --- pyoptsparse/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyoptsparse/__init__.py b/pyoptsparse/__init__.py index 184c7b20..1eff829b 100644 --- a/pyoptsparse/__init__.py +++ b/pyoptsparse/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.14.3" +__version__ = "2.14.4" from .pyOpt_history import History from .pyOpt_variable import Variable From a6adb55d58e0b3494fe85237fd00df59dee719a9 Mon Sep 17 00:00:00 2001 From: kanekosh Date: Fri, 31 Oct 2025 17:27:24 -0400 Subject: [PATCH 15/15] trigger CI