Repository: python-control/python-control
Pull request #1054 (2024-11-09 05:13:21)

Update find_eqpt to find_operating_point, adding root_method + better docs
This PR is motivated by discussion #1053 in which a situation the current implementation of `find_eqpt` does not find a good solution due to the problem being over/underconstrained.  The main functional change is this PR is to help address that by adding a `root_method` method that allows a least squares solution to be obtained.

In addition, while looking through this (fairly old) code, it seemed worth making some additional changes:

* Renamed `find_eqpt` to `find_operating_point` to be more consistent with python-control naming conventions (and the fact that an operating point is not always an equilibrium point.
* Changed the return type for `find_operating_point` from a tuple of various lengths (depending on parameter settings) to a `OperatingPoint` object, which contains the states, inputs, and outputs corresponding to the operating point, along with the result of the call to the `scipy.minimize.root` function.
* Added `root_method` and `root_kwargs` parameters to allow customization of the root finding method.
* Maintained backward compatibility so that `find_eqpt` still works, as well as `return_y` (now `return_outputs`, with legacy processing of `return_y`) and `return_result`.  In addition, the return object can be accessed as a tuple for consistency with the previous return signature.
* Updated docstrings and documentation to use the new conventions.
* Added unit tests to check all new functionality.

In the context of #1053, a solution to the issue there can be obtained by calling `find_eqpt` (now `find_operating_point`) with `root_method='lm'`.


Patch:
diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py
index b02d16d5..98e6babc 100644
--- a/control/matlab/__init__.py
+++ b/control/matlab/__init__.py
@@ -84,10 +84,12 @@ from ..rlocus import rlocus
 from ..dtime import c2d
 from ..sisotool import sisotool
 from ..stochsys import lqe, dlqe
+from ..nlsys import find_operating_point
 
 # Functions that are renamed in MATLAB
 pole, zero = poles, zeros
 freqresp = frequency_response
+trim = find_operating_point
 
 # Import functions specific to Matlab compatibility package
 from .timeresp import *
diff --git a/control/nlsys.py b/control/nlsys.py
index accd24c0..835c16ef 100644
--- a/control/nlsys.py
+++ b/control/nlsys.py
@@ -13,7 +13,7 @@
 :class:`~control.NonlinearIOSystem` class that represents (possibly nonlinear)
 input/output systems.  The :class:`~control.NonlinearIOSystem` class is a
 general class that defines any continuous or discrete time dynamical system.
-Input/output systems can be simulated and also used to compute equilibrium
+Input/output systems can be simulated and also used to compute operating
 points and linearizations.
 
 """
@@ -32,7 +32,8 @@ from .timeresp import _check_convert_array, _process_time_response, \
 
 __all__ = ['NonlinearIOSystem', 'InterconnectedSystem', 'nlsys',
            'input_output_response', 'find_eqpt', 'linearize',
-           'interconnect', 'connection_table']
+           'interconnect', 'connection_table', 'OperatingPoint',
+           'find_operating_point']
 
 
 class NonlinearIOSystem(InputOutputSystem):
@@ -515,22 +516,29 @@ class NonlinearIOSystem(InputOutputSystem):
         # Return the newly created system
         return newsys
 
-    def linearize(self, x0, u0, t=0, params=None, eps=1e-6,
+    def linearize(self, x0, u0=None, t=0, params=None, eps=1e-6,
                   copy_names=False, **kwargs):
         """Linearize an input/output system at a given state and input.
 
-        Return the linearization of an input/output system at a given state
-        and input value as a StateSpace system.  See
-        :func:`~control.linearize` for complete documentation.
+        Return the linearization of an input/output system at a given
+        operating point (or state and input value) as a StateSpace system.
+        See :func:`~control.linearize` for complete documentation.
 
         """
-        from .statesp import StateSpace
-
         #
-        # If the linearization is not defined by the subclass, perform a
-        # numerical linearization use the `_rhs()` and `_out()` member
-        # functions.
+        # Default method: if the linearization is not defined by the
+        # subclass, perform a numerical linearization use the `_rhs()` and
+        # `_out()` member functions.
         #
+        from .statesp import StateSpace
+
+        # Allow first argument to be an operating point
+        if isinstance(x0, OperatingPoint):
+            u0 = x0.inputs if u0 is None else u0
+            x0 = x0.states
+        elif u0 is None:
+            u0 = 0
+
         # Process nominal states and inputs
         x0, nstates = _process_vector_argument(x0, "x0", self.nstates)
         u0, ninputs = _process_vector_argument(u0, "u0", self.ninputs)
@@ -1663,87 +1671,190 @@ def input_output_response(
         success=soln.success, message=message)
 
 
-def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None,
-              iu=None, iy=None, ix=None, idx=None, dx0=None,
-              return_y=False, return_result=False):
-    """Find the equilibrium point for an input/output system.
+class OperatingPoint():
+    """A class for representing the operating point of a nonlinear I/O system.
+
+    The ``OperatingPoint`` class stores the operating point of a nonlinear
+    system, consisting of the state and input vectors for the system.  The
+    main use for this class is as the return object for the
+    :func:`find_operating_point` function and as an input to the
+    :func:`linearize` function.
+
+    Attributes
+    ----------
+    states : array
+        State vector at the operating point.
+    inputs : array
+        Input vector at the operating point.
+    outputs : array, optional
+        Output vector at the operating point.
+    result : :class:`scipy.optimize.OptimizeResult`, optional
+        Result from the :func:`scipy.optimize.root` function, if available.
+
+    """
+    def __init__(
+            self, states, inputs, outputs=None, result=None,
+            return_outputs=False, return_result=False):
+        self.states = states
+        self.inputs = inputs
+
+        if outputs is None and return_outputs and not return_result:
+            raise ValueError("return_outputs specified but no outputs value")
+        self.outputs = outputs
+        self.return_outputs = return_outputs
+
+        if result is None and return_result:
+            raise ValueError("return_result specified but no result value")
+        self.result = result
+        self.return_result = return_result
+
+    # Implement iter to allow assigning to a tuple
+    def __iter__(self):
+        if self.return_outputs and self.return_result:
+            return iter((self.states, self.inputs, self.outputs, self.result))
+        elif self.return_outputs:
+            return iter((self.states, self.inputs, self.outputs))
+        elif self.return_result:
+            return iter((self.states, self.inputs, self.result))
+        else:
+            return iter((self.states, self.inputs))
+
+    # Implement (thin) getitem to allow access via legacy indexing
+    def __getitem__(self, index):
+        return list(self.__iter__())[index]
+
+    # Implement (thin) len to emulate legacy return value
+    def __len__(self):
+        return len(list(self.__iter__()))
+
+
+def find_operating_point(
+        sys, x0, u0=None, y0=None, t=0, params=None, iu=None, iy=None,
+        ix=None, idx=None, dx0=None, root_method=None, root_kwargs=None,
+        return_outputs=None, return_result=None, **kwargs):
+    """Find an operating point for an input/output system.
+
+    An operating point for a nonlinear system is a state and input around
+    which a nonlinear system operates.  This point is most commonly an
+    equilibrium point for the system, but in some cases a non-equilibrium
+    operating point can be used.
+
+    This function attempts to find an operating point given a specification
+    for the desired inputs, outputs, states, or state updates of the system.
 
-    Returns the value of an equilibrium point given the initial state and
-    either input value or desired output value for the equilibrium point.
+    In its simplest form, `find_operating_point` finds an equilibrium point
+    given either the desired input or desired output::
+
+        xeq, ueq = find_operating_point(sys, x0, u0)
+        xeq, ueq = find_operating_point(sys, x0, u0, y0)
+
+    The first form finds an equilibrium point for a given input u0 based on
+    an initial guess x0.  The second form fixes the desired output values
+    and uses x0 and u0 as an initial guess to find the equilibrium point.
+    If no equilibrium point can be found, the function returns the
+    operating point that minimizes the state update (state derivative for
+    continuous time systems, state difference for discrete time systems).
+
+    More complex operating points can be found by specifying which states,
+    inputs, or outputs should be used in computing the operating point, as
+    well as desired values of the states, inputs, outputs, or state
+    updates.
 
     Parameters
     ----------
     sys : NonlinearIOSystem
-        I/O system for which the equilibrium point is sought.
+        I/O system for which the operating point is sought.
     x0 : list of initial state values
-        Initial guess for the value of the state near the equilibrium point.
+        Initial guess for the value of the state near the operating point.
     u0 : list of input values, optional
-        If `y0` is not specified, sets the equilibrium value of the input.  If
-        `y0` is given, provides an initial guess for the value of the input.
-        Can be omitted if the system does not have any inputs.
+        If `y0` is not specified, sets the value of the input.  If `y0` is
+        given, provides an initial guess for the value of the input.  Can
+        be omitted if the system does not have any inputs.
     y0 : list of output values, optional
         If specified, sets the desired values of the outputs at the
-        equilibrium point.
+        operating point.
     t : float, optional
-        Evaluation time, for time-varying systems
+        Evaluation time, for time-varying systems.
     params : dict, optional
         Parameter values for the system.  Passed to the evaluation functions
         for the system as default values, overriding internal defaults.
     iu : list of input indices, optional
         If specified, only the inputs with the given indices will be fixed at
-        the specified values in solving for an equilibrium point.  All other
+        the specified values in solving for an operating point.  All other
         inputs will be varied.  Input indices can be listed in any order.
     iy : list of output indices, optional
         If specified, only the outputs with the given indices will be fixed at
-        the specified values in solving for an equilibrium point.  All other
+        the specified values in solving for an operating point.  All other
         outputs will be varied.  Output indices can be listed in any order.
     ix : list of state indices, optional
         If specified, states with the given indices will be fixed at the
-        specified values in solving for an equilibrium point.  All other
+        specified values in solving for an operating point.  All other
         states will be varied.  State indices can be listed in any order.
     dx0 : list of update values, optional
         If specified, the value of update map must match the listed value
-        instead of the default value of 0.
+        instead of the default value for an equilibrium point.
     idx : list of state indices, optional
         If specified, state updates with the given indices will have their
         update maps fixed at the values given in `dx0`.  All other update
-        values will be ignored in solving for an equilibrium point.  State
+        values will be ignored in solving for an operating point.  State
         indices can be listed in any order.  By default, all updates will be
-        fixed at `dx0` in searching for an equilibrium point.
-    return_y : bool, optional
-        If True, return the value of output at the equilibrium point.
+        fixed at `dx0` in searching for an operating point.
+    root_method : str, optional
+        Method to find the operating point.  If specified, this parameter
+        is passed to the :func:`scipy.optimize.root` function.
+    root_kwargs : dict, optional
+        Additional keyword arguments to pass :func:`scipy.optimize.root`.
+    return_outputs : bool, optional
+        If True, return the value of outputs at the operating point.
     return_result : bool, optional
         If True, return the `result` option from the
-        :func:`scipy.optimize.root` function used to compute the equilibrium
-        point.
+        :func:`scipy.optimize.root` function used to compute the
+        operating point.
 
     Returns
     -------
-    xeq : array of states
-        Value of the states at the equilibrium point, or `None` if no
-        equilibrium point was found and `return_result` was False.
-    ueq : array of input values
-        Value of the inputs at the equilibrium point, or `None` if no
-        equilibrium point was found and `return_result` was False.
-    yeq : array of output values, optional
-        If `return_y` is True, returns the value of the outputs at the
-        equilibrium point, or `None` if no equilibrium point was found and
-        `return_result` was False.
-    result : :class:`scipy.optimize.OptimizeResult`, optional
-        If `return_result` is True, returns the `result` from the
-        :func:`scipy.optimize.root` function.
+    op_point : OperatingPoint
+        The solution represented as an `OperatingPoint` object.  The main
+        attributes are `states` and `inputs`, which represent the state
+        and input arrays at the operating point.  See
+        :class:`OperatingPoint` for a description of other attributes.
+
+        If accessed as a tuple, returns `states`, `inputs`, and optionally
+        `outputs` and `result` based on the `return_outputs` and
+        `return_result` parameters.
 
     Notes
     -----
-    For continuous time systems, equilibrium points are defined as points for
-    which the right hand side of the differential equation is zero:
-    :math:`f(t, x_e, u_e) = 0`. For discrete time systems, equilibrium points
-    are defined as points for which the right hand side of the difference
-    equation returns the current state: :math:`f(t, x_e, u_e) = x_e`.
+    For continuous time systems, equilibrium points are defined as points
+    for which the right hand side of the differential equation is zero:
+    :math:`f(t, x_e, u_e) = 0`. For discrete time systems, equilibrium
+    points are defined as points for which the right hand side of the
+    difference equation returns the current state: :math:`f(t, x_e, u_e) =
+    x_e`.
+
+    Operating points are found using the :func:`scipy.optimize.root`
+    function, which will attempt to find states and inputs that satisfy the
+    specified constraints.  If no solution is found and `return_result` is
+    `False`, the returned state and input for the operating point will be
+    `None`.  If `return_result` is `True`, then the return values from
+    :func:`scipy.optimize.root` will be returned (but may not be valid).
+    If `root_method` is set to 'lm', then the least squares solution (in
+    the free variables) will be returned.
 
     """
     from scipy.optimize import root
 
+    # Process keyword arguments
+    return_outputs = config._process_legacy_keyword(
+        kwargs, 'return_y', 'return_outputs', return_outputs)
+    if kwargs:
+        raise TypeError("unrecognized keyword(s): " + str(kwargs))
+
+    # Process arguments for the root function
+    root_kwargs = dict() if root_kwargs is None else root_kwargs
+    if root_method:
+        root_kwargs['method'] = root_method
+
     # Figure out the number of states, inputs, and outputs
     x0, nstates = _process_vector_argument(x0, "x0", sys.nstates)
     u0, ninputs = _process_vector_argument(u0, "u0", sys.ninputs)
@@ -1769,7 +1880,7 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None,
             else:
                 def state_rhs(z): return sys._rhs(t, z, u0)
 
-            result = root(state_rhs, x0)
+            result = root(state_rhs, x0, **root_kwargs)
             z = (result.x, u0, sys._out(t, result.x, u0))
 
         else:
@@ -1786,9 +1897,10 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None,
                     return np.concatenate(
                         (sys._rhs(t, x, u), sys._out(t, x, u) - y0), axis=0)
 
-            z0 = np.concatenate((x0, u0), axis=0)   # Put variables together
-            result = root(rootfun, z0)              # Find the eq point
-            x, u = np.split(result.x, [nstates])    # Split result back in two
+            # Find roots with (x, u) as free variables
+            z0 = np.concatenate((x0, u0), axis=0)
+            result = root(rootfun, z0, **root_kwargs)
+            x, u = np.split(result.x, [nstates])
             z = (x, u, sys._out(t, x, u))
 
     else:
@@ -1849,8 +1961,8 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None,
         # * output_vars: indices of outputs that must be constrained
         #
         # This index lists can all be precomputed based on the `iu`, `iy`,
-        # `ix`, and `idx` lists that were passed as arguments to `find_eqpt`
-        # and were processed above.
+        # `ix`, and `idx` lists that were passed as arguments to
+        # `find_operating_point` and were processed above.
 
         # Get the states and inputs that were not listed as fixed
         state_vars = (range(nstates) if not len(ix)
@@ -1903,7 +2015,7 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None,
         z0 = np.concatenate((x[state_vars], u[input_vars]), axis=0)
 
         # Finally, call the root finding function
-        result = root(rootfun, z0)
+        result = root(rootfun, z0, **root_kwargs)
 
         # Extract out the results and insert into x and u
         x[state_vars] = result.x[:nstate_vars]
@@ -1911,7 +2023,16 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None,
         z = (x, u, sys._out(t, x, u))
 
     # Return the result based on what the user wants and what we found
-    if not return_y:
+    if return_result or result.success:
+        return OperatingPoint(
+            z[0], z[1], z[2], result, return_outputs, return_result)
+    else:
+        # Something went wrong, don't return anything
+        return OperatingPoint(
+            None, None, None, result, return_outputs, return_result)
+
+    # TODO: remove code when ready
+    if not return_outputs:
         z = z[0:2]              # Strip y from result if not desired
     if return_result:
         # Return whatever we got, along with the result dictionary
@@ -1921,27 +2042,28 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None,
         return z
     else:
         # Something went wrong, don't return anything
-        return (None, None, None) if return_y else (None, None)
+        return (None, None, None) if return_outputs else (None, None)
 
 
 # Linearize an input/output system
 def linearize(sys, xeq, ueq=None, t=0, params=None, **kw):
     """Linearize an input/output system at a given state and input.
 
-    This function computes the linearization of an input/output system at a
-    given state and input value and returns a :class:`~control.StateSpace`
-    object.  The evaluation point need not be an equilibrium point.
+    Compute the linearization of an I/O system at an operating point (state
+    and input) and returns a :class:`~control.StateSpace` object.  The
+    operating point need not be an equilibrium point.
 
     Parameters
     ----------
     sys : InputOutputSystem
         The system to be linearized.
-    xeq : array
-        The state at which the linearization will be evaluated (does not need
-        to be an equilibrium state).
-    ueq : array
+    xeq : array or :class:`~control.OperatingPoint`
+        The state or operating point at which the linearization will be
+        evaluated (does not need to be an equilibrium state).
+    ueq : array, optional
         The input at which the linearization will be evaluated (does not need
-        to correspond to an equlibrium state).
+        to correspond to an equlibrium state).  Can be omitted if `xeq` is
+        an :class:`~control.OperatingPoint`.  Defaults to 0.
     t : float, optional
         The time at which the linearization will be computed (for time-varying
         systems).
@@ -1976,6 +2098,7 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw):
         Description of the system outputs.  Same format as `inputs`.
     states : int, list of str, or None, optional
         Description of the system states.  Same format as `inputs`.
+
     """
     if not isinstance(sys, InputOutputSystem):
         raise TypeError("Can only linearize InputOutputSystem types")
@@ -2509,7 +2632,7 @@ def interconnect(
                             (syslist[isys].name,
                              syslist[isys].input_labels[isig], gain))
                 return signal_list
-                
+
             if isinstance(connection, list):
                 # Passed a list => create input map
                 dprint(f"  detected output list")
@@ -2519,7 +2642,7 @@ def interconnect(
                 new_outlist.append(signal_list)
             else:
                 new_outlist += _find_output_or_input_signal(connection)
-                
+
     outlist, outputs = new_outlist, new_outputs
     dprint(f"  {outlist=}\n  {outputs=}")
 
@@ -2679,3 +2802,7 @@ def connection_table(sys, show_names=False, column_width=32):
         "an InterconnectedSystem."
 
     sys.connection_table(show_names=show_names, column_width=column_width)
+
+
+# Short versions of function call
+find_eqpt = find_operating_point
diff --git a/control/phaseplot.py b/control/phaseplot.py
index abc050ff..6f7ed355 100644
--- a/control/phaseplot.py
+++ b/control/phaseplot.py
@@ -39,7 +39,8 @@ from . import config
 from .ctrlplot import ControlPlot, _add_arrows_to_line2D, _get_color, \
     _process_ax_keyword, _update_plot_title
 from .exception import ControlNotImplemented
-from .nlsys import NonlinearIOSystem, find_eqpt, input_output_response
+from .nlsys import NonlinearIOSystem, find_operating_point, \
+    input_output_response
 
 __all__ = ['phase_plane_plot', 'phase_plot', 'box_grid']
 
@@ -853,7 +854,7 @@ def _find_equilpts(sys, points, params=None):
     equilpts = []
     for i, x0 in enumerate(points):
         # Look for an equilibrium point near this point
-        xeq, ueq = find_eqpt(sys, x0, 0, params=params)
+        xeq, ueq = find_operating_point(sys, x0, 0, params=params)
 
         if xeq is None:
             continue            # didn't find anything
diff --git a/doc/control.rst b/doc/control.rst
index 1544f93d..dd418f2a 100644
--- a/doc/control.rst
+++ b/doc/control.rst
@@ -144,7 +144,7 @@ Nonlinear system support
    :toctree: generated/
 
     describing_function
-    find_eqpt
+    find_operating_point
     linearize
     input_output_response
     summing_junction
diff --git a/doc/conventions.rst b/doc/conventions.rst
index 21f3ab82..fb1f0715 100644
--- a/doc/conventions.rst
+++ b/doc/conventions.rst
@@ -289,10 +289,10 @@ element of the `control.config.defaults` dictionary::
 
     ct.config.defaults['module.parameter'] = value
 
-The `~control.config.set_defaults` function can also be used to set multiple
-configuration parameters at the same time::
+The :func:`~control.set_defaults` function can also be used to
+set multiple configuration parameters at the same time::
 
-    ct.config.set_defaults('module', param1=val1, param2=val2, ...]
+    ct.set_defaults('module', param1=val1, param2=val2, ...]
 
 Finally, there are also functions available set collections of variables based
 on standard configurations.
diff --git a/doc/intro.rst b/doc/intro.rst
index 2287bbac..a45bb030 100644
--- a/doc/intro.rst
+++ b/doc/intro.rst
@@ -56,7 +56,7 @@ they are not already present.
 .. note::
    Mixing packages from conda-forge and the default conda channel
    can sometimes cause problems with dependencies, so it is usually best to
-   instally NumPy, SciPy, and Matplotlib from conda-forge as well.
+   install NumPy, SciPy, and Matplotlib from conda-forge as well.
 
 To install using pip::
 
diff --git a/doc/iosys.rst b/doc/iosys.rst
index 9008a0e5..f2dfbff4 100644
--- a/doc/iosys.rst
+++ b/doc/iosys.rst
@@ -16,12 +16,13 @@ function::
   resp = ct.input_output_response(io_sys, T, U, X0, params)
   t, y, x = resp.time, resp.outputs, resp.states
 
-An input/output system can be linearized around an equilibrium point to obtain
-a :class:`~control.StateSpace` linear system.  Use the
-:func:`~control.find_eqpt` function to obtain an equilibrium point and the
-:func:`~control.linearize` function to linearize about that equilibrium point::
+An input/output system can be linearized around an equilibrium point
+to obtain a :class:`~control.StateSpace` linear system.  Use the
+:func:`~control.find_operating_point` function to obtain an
+equilibrium point and the :func:`~control.linearize` function to
+linearize about that equilibrium point::
 
-  xeq, ueq = ct.find_eqpt(io_sys, X0, U0)
+  xeq, ueq = ct.find_operating_point(io_sys, X0, U0)
   ss_sys = ct.linearize(io_sys, xeq, ueq)
 
 Input/output systems are automatically created for state space LTI systems
@@ -123,9 +124,8 @@ system and computing the linearization about that point.
 
 .. code-block:: python
 
-  eqpt = ct.find_eqpt(io_predprey, X0, 0)
-  xeq = eqpt[0]                         # choose the nonzero equilibrium point
-  lin_predprey = ct.linearize(io_predprey, xeq, 0)
+  eqpt = ct.find_operating_point(io_predprey, X0, 0)
+  lin_predprey = ct.linearize(io_predprey, eqpt)
 
 We next compute a controller that stabilizes the equilibrium point using
 eigenvalue placement and computing the feedforward gain using the number of
@@ -544,11 +544,12 @@ Module classes and functions
    ~control.InterconnectedSystem
    ~control.LinearICSystem
    ~control.NonlinearIOSystem
+   ~control.OperatingPoint
 
 .. autosummary::
    :toctree: generated/
 
-   ~control.find_eqpt
+   ~control.find_operating_point
    ~control.interconnect
    ~control.input_output_response
    ~control.linearize
diff --git a/examples/cruise-control.py b/examples/cruise-control.py
index 7c2e562a..074be8fa 100644
--- a/examples/cruise-control.py
+++ b/examples/cruise-control.py
@@ -165,7 +165,7 @@ theta_hill = np.array([
 
 for m in (1200, 1600, 2000):
     # Compute the equilibrium state for the system
-    X0, U0 = ct.find_eqpt(
+    X0, U0 = ct.find_operating_point(
         cruise_tf, [0, vref[0]], [vref[0], gear[0], theta0[0]], 
         iu=[1, 2], y0=[vref[0], 0], iy=[0], params={'m': m})
 
@@ -347,9 +347,9 @@ theta0 = np.zeros(T.shape)
 
 # Compute the equilibrium throttle setting for the desired speed (solve for x
 # and u given the gear, slope, and desired output velocity)
-X0, U0, Y0 = ct.find_eqpt(
+X0, U0, Y0 = ct.find_operating_point(
     cruise_pi, [vref[0], 0], [vref[0], gear[0], theta0[0]],
-    y0=[0, vref[0]], iu=[1, 2], iy=[1], return_y=True)
+    y0=[0, vref[0]], iu=[1, 2], iy=[1], return_outputs=True)
 
 # Now simulate the effect of a hill at t = 5 seconds
 plt.figure()
diff --git a/examples/cruise.ipynb b/examples/cruise.ipynb
index 4f1c152f..64eafbaf 100644
--- a/examples/cruise.ipynb
+++ b/examples/cruise.ipynb
@@ -804,7 +804,7 @@
     "# Compute the equilibrium throttle setting for the desired speed\n",
     "X0, U0, Y0 = ct.find_eqpt(\n",
     "    cruise_pi, [vref[0], 0], [vref[0], gear[0], theta0[0]],\n",
-    "    y0=[0, vref[0]], iu=[1, 2], iy=[1], return_y=True)\n",
+    "    y0=[0, vref[0]], iu=[1, 2], iy=[1], return_outputs=True)\n",
     "\n",
     "# Now simulate the effect of a hill at t = 5 seconds\n",
     "plt.figure()\n",


Test patch:
diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py
index 70a0cc3c..2b1368ae 100644
--- a/control/tests/docstrings_test.py
+++ b/control/tests/docstrings_test.py
@@ -53,6 +53,7 @@ keyword_skiplist = {
     control.create_estimator_iosystem: ['state_labels'],    # deprecated
     control.bode_plot: ['sharex', 'sharey', 'margin_info'], # deprecated
     control.eigensys_realization: ['arg'],                  # quasi-positional
+    control.find_operating_point: ['method'],               # internal use
 }
 
 # Decide on the level of verbosity (use -rP when running pytest)
diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py
index dd30ea71..baaee03f 100644
--- a/control/tests/iosys_test.py
+++ b/control/tests/iosys_test.py
@@ -10,10 +10,11 @@ created for that purpose.
 
 import re
 import warnings
-import pytest
+from math import sqrt
 
 import numpy as np
-from math import sqrt
+import pytest
+import scipy
 
 import control as ct
 
@@ -2087,6 +2088,100 @@ def test_find_eqpt(x0, ix, u0, iu, y0, iy, dx0, idx, dt, x_expect, u_expect):
     np.testing.assert_allclose(np.array(ueq), u_expect, atol=1e-6)
 
 
+# Test out new operating point version of find_eqpt
+def test_find_operating_point():
+    dt = 1
+    sys = ct.NonlinearIOSystem(
+        eqpt_rhs, eqpt_out, dt=dt, states=3, inputs=2, outputs=2)
+
+    # Conditions that lead to no exact solution (from previous unit test)
+    x0 = 0;       ix = None
+    u0 = [-1, 0]; iu = None
+    y0 = None;    iy = None
+    dx0 = None;  idx = None
+
+    # Default version: no equilibrium solution => returns None
+    op_point = ct.find_operating_point(
+        sys, x0, u0, y0, ix=ix, iu=iu, iy=iy, dx0=dx0, idx=idx)
+    assert op_point.states is None
+    assert op_point.inputs is None
+    assert op_point.result.success is False
+
+    # Change the method to Levenberg-Marquardt (gives nearest point)
+    op_point = ct.find_operating_point(
+        sys, x0, u0, y0, ix=ix, iu=iu, iy=iy, dx0=dx0, idx=idx,
+        root_method='lm')
+    assert op_point.states is not None
+    assert op_point.inputs is not None
+    assert op_point.result.success is True
+
+    # Make sure we get a solution if we ask for the result explicitly
+    op_point = ct.find_operating_point(
+        sys, x0, u0, y0, ix=ix, iu=iu, iy=iy, dx0=dx0, idx=idx,
+        return_result=True)
+    assert op_point.states is not None
+    assert op_point.inputs is not None
+    assert op_point.result.success is False
+
+    # Check to make sure unknown keywords are caught
+    with pytest.raises(TypeError, match="unrecognized keyword"):
+        ct.find_operating_point(sys, x0, u0, unknown=None)
+
+
+def test_operating_point():
+    dt = 1
+    sys = ct.NonlinearIOSystem(
+        eqpt_rhs, eqpt_out, dt=dt, states=3, inputs=2, outputs=2)
+
+    # Find the operating point near the origin
+    op_point = ct.find_operating_point(sys, 0, 0)
+
+    # Linearize the old fashioned way
+    linsys_orig = ct.linearize(sys, op_point.states, op_point.inputs)
+
+    # Linearize around the operating point
+    linsys_oppt = ct.linearize(sys, op_point)
+
+    np.testing.assert_allclose(linsys_orig.A, linsys_oppt.A)
+    np.testing.assert_allclose(linsys_orig.B, linsys_oppt.B)
+    np.testing.assert_allclose(linsys_orig.C, linsys_oppt.C)
+    np.testing.assert_allclose(linsys_orig.D, linsys_oppt.D)
+
+    # Call find_operating_point with method and keyword arguments
+    op_point = ct.find_operating_point(
+        sys, 0, 0, root_method='lm', root_kwargs={'tol': 1e-6})
+
+    # Make sure we can get back the right arguments in a tuple
+    op_point = ct.find_operating_point(sys, 0, 0, return_outputs=True)
+    assert len(op_point) == 3
+    assert isinstance(op_point[0], np.ndarray)
+    assert isinstance(op_point[1], np.ndarray)
+    assert isinstance(op_point[2], np.ndarray)
+
+    with pytest.warns(FutureWarning, match="return_outputs"):
+        op_point = ct.find_operating_point(sys, 0, 0, return_y=True)
+        assert len(op_point) == 3
+        assert isinstance(op_point[0], np.ndarray)
+        assert isinstance(op_point[1], np.ndarray)
+        assert isinstance(op_point[2], np.ndarray)
+
+    # Make sure we can get back the right arguments in a tuple
+    op_point = ct.find_operating_point(sys, 0, 0, return_result=True)
+    assert len(op_point) == 3
+    assert isinstance(op_point[0], np.ndarray)
+    assert isinstance(op_point[1], np.ndarray)
+    assert isinstance(op_point[2], scipy.optimize.OptimizeResult)
+
+    # Make sure we can get back the right arguments in a tuple
+    op_point = ct.find_operating_point(
+        sys, 0, 0, return_result=True, return_outputs=True)
+    assert len(op_point) == 4
+    assert isinstance(op_point[0], np.ndarray)
+    assert isinstance(op_point[1], np.ndarray)
+    assert isinstance(op_point[2], np.ndarray)
+    assert isinstance(op_point[3], scipy.optimize.OptimizeResult)
+
+
 def test_iosys_sample():
     csys = ct.rss(2, 1, 1)
     dsys = csys.sample(0.1)
diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py
index 020910e7..b98509d6 100644
--- a/control/tests/kwargs_test.py
+++ b/control/tests/kwargs_test.py
@@ -24,6 +24,7 @@ import control.tests.flatsys_test as flatsys_test
 import control.tests.frd_test as frd_test
 import control.tests.freqplot_test as freqplot_test
 import control.tests.interconnect_test as interconnect_test
+import control.tests.iosys_test as iosys_test
 import control.tests.optimal_test as optimal_test
 import control.tests.statefbk_test as statefbk_test
 import control.tests.stochsys_test as stochsys_test
@@ -252,6 +253,8 @@ kwarg_unittest = {
     'dlqr': test_unrecognized_kwargs,
     'drss': test_unrecognized_kwargs,
     'feedback': test_unrecognized_kwargs,
+    'find_eqpt': iosys_test.test_find_operating_point,
+    'find_operating_point': iosys_test.test_find_operating_point,
     'flatsys.flatsys': test_unrecognized_kwargs,
     'frd': frd_test.TestFRD.test_unrecognized_keyword,
     'gangof4': test_matplotlib_kwargs,