diff --git a/pyoptsparse/pyCONMIN/pyCONMIN.py b/pyoptsparse/pyCONMIN/pyCONMIN.py index a2b2eb4a..82cfedff 100644 --- a/pyoptsparse/pyCONMIN/pyCONMIN.py +++ b/pyoptsparse/pyCONMIN/pyCONMIN.py @@ -212,7 +212,7 @@ def cnmngrad(n1, n2, x, f, g, ct, df, a, ic, nac): dabfun = self.getOption("DABFUN") itrm = self.getOption("ITRM") - nfeasct = self.getOption("ITRM") + nfeasct = self.getOption("NFEASCT") nfdg = 1 # User will supply all gradients # Counters for functions and gradients diff --git a/pyoptsparse/pyOpt_gradient.py b/pyoptsparse/pyOpt_gradient.py index 42b9b6aa..0d1330b9 100644 --- a/pyoptsparse/pyOpt_gradient.py +++ b/pyoptsparse/pyOpt_gradient.py @@ -1,3 +1,6 @@ +# Standard Python modules +from typing import Literal + # External modules import numpy as np import numpy.typing as npt @@ -9,7 +12,14 @@ class Gradient: - def __init__(self, optProb: Optimization, sensType: str, sensStep: float = None, sensMode: str = "", comm=None): + def __init__( + self, + optProb: Optimization, + sensType: Literal["fd", "cd", "fdr", "cdr", "cs"], + sensStep: float | complex | None = None, + sensMode: str = "", + comm=None, + ): """ Gradient class for automatically computing gradients with finite difference or complex step. @@ -20,14 +30,15 @@ def __init__(self, optProb: Optimization, sensType: str, sensStep: float = None, This is the complete description of the optimization problem. sensType : str - - ``FD`` for forward difference - - ``CD`` for central difference - - ``FDR`` for forward difference with relative step size - - ``CDR`` for central difference with relative step size - - ``CS`` for complex step + - ``fd`` for forward difference + - ``cd`` for central difference + - ``fdr`` for forward difference with relative step size + - ``cdr`` for central difference with relative step size + - ``cs`` for complex step - sensStep : float - Step size to use for differencing + sensStep : float | complex, optional + Step size to use for differencing. By default ``1e-6`` for ``fd/fdr``, ``1e-4`` for ``cd/cdr``, ``1e-40j`` for ``cs``. + Must be a purely imaginary value for ``cs``. sensMode : str Flag to compute gradients in parallel. @@ -44,6 +55,18 @@ def __init__(self, optProb: Optimization, sensType: str, sensStep: float = None, self.sensStep = 1e-40j else: self.sensStep = sensStep + + if self.sensType == "cs": + # Complex step divides by the imaginary part of the step, so a purely + # real step would silently yield NaN gradients. + if np.imag(self.sensStep) == 0: + raise ValueError(f"The complex step size must have a nonzero imaginary part, got {self.sensStep}.") + + # A nonzero real part would perturb x along the real axis as well, corrupting the function + # value used implicitly in the complex-step formula. + if np.real(self.sensStep) != 0: + raise ValueError(f"The complex step size must have a zero real part, got {self.sensStep}.") + self.sensMode = sensMode self.comm = comm diff --git a/pyoptsparse/pyOpt_optimizer.py b/pyoptsparse/pyOpt_optimizer.py index 2a13d14c..f99e7e55 100644 --- a/pyoptsparse/pyOpt_optimizer.py +++ b/pyoptsparse/pyOpt_optimizer.py @@ -109,7 +109,7 @@ def _clearTimings(self) -> None: self.userObjCalls = 0 self.userSensCalls = 0 - def _setSens(self, sens: str | Callable | None, sensStep: float, sensMode: str) -> None: + def _setSens(self, sens: str | Callable | None, sensStep: float | complex | None, sensMode: str) -> None: """ Common function to setup sens function """ diff --git a/pyoptsparse/testing/pyOpt_testing.py b/pyoptsparse/testing/pyOpt_testing.py index 6c1ea6c5..48061c53 100644 --- a/pyoptsparse/testing/pyOpt_testing.py +++ b/pyoptsparse/testing/pyOpt_testing.py @@ -63,7 +63,7 @@ def get_dict_distance(d, d2): DEFAULT_OPTIMIZERS = {"SLSQP", "PSQP", "CONMIN", "ALPSO", "NSGA2"} # Define gradient-based optimizers -GRAD_BASED_OPTIMIZERS = {"CONMIN", "IPOPT", "NLPQLP", "ParOpt", "PSQP", "SLSQP", "SNOPT", "Uno"} +GRAD_BASED_OPTIMIZERS = {"CONMIN", "IPOPT", "NLPQLP", "PSQP", "SLSQP", "SNOPT", "Uno"} class OptTest(unittest.TestCase): diff --git a/tests/test_gradient.py b/tests/test_gradient.py index 78579ebc..9679e01f 100644 --- a/tests/test_gradient.py +++ b/tests/test_gradient.py @@ -93,6 +93,12 @@ def test_scaling(self): funcsSens, _ = grad(X0, funcs) assert_sens_matches_analytic(funcsSens, 1e-12) + @parameterized.expand([("real_step", 1e-40), ("nonzero_real_part", 1 + 1e-40j)]) + def test_cs_invalid_step_raises(self, _, sensStep): + optProb = build_optProb() + with self.assertRaises(ValueError): + Gradient(optProb, sensType="cs", sensStep=sensStep) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sphere.py b/tests/test_sphere.py index 2dc77014..8179436a 100644 --- a/tests/test_sphere.py +++ b/tests/test_sphere.py @@ -1,6 +1,7 @@ """Test solution of Sphere problem""" # Standard Python modules +from itertools import product import unittest # External modules @@ -10,7 +11,7 @@ # First party modules from pyoptsparse import Optimization from pyoptsparse.pyOpt_optimizer import Optimizers -from pyoptsparse.testing import OptTest +from pyoptsparse.testing import GRAD_BASED_OPTIMIZERS, OptTest ALL_OPTIMIZERS = sorted({e.name for e in Optimizers} - {"ParOpt", "NSGA2"}) @@ -57,6 +58,9 @@ class TestSphere(OptTest): "maxGen": 100, "seed": 123, }, + "CONMIN": { # CONMIN diverges when gradient is near zero, here we stop on first optimal iterate + "ITRM": 1, + }, "SNOPT": { "Major iterations limit": 10, }, @@ -101,6 +105,17 @@ def test_optimization(self, optName): optOptions = self.optOptions.get(optName, {}) self.optimize_with_hotstart(self.tol[optName], optOptions=optOptions) + @parameterized.expand( + product(sorted(GRAD_BASED_OPTIMIZERS), ["fd", "fdr", "cd", "cdr", "cs"]), + name_func=lambda f, n, p: f"{f.__name__}_{p.args[0]}_{p.args[1]}", + ) + def test_optimization_approx_deriv(self, optName, sens): + self.optName = optName + self.setup_optProb() + optOptions = self.optOptions.get(optName, {}) + sol = self.optimize(optOptions=optOptions, sens=sens) + self.assert_solution_allclose(sol, self.tol[optName]) + @parameterized.expand(["filtersqp", "funnelsqp"]) def test_uno_presets(self, preset): self.optName = "Uno"