Repository: python-control/python-control
Pull request #889 (2023-05-01 05:01:34)

bandwidth feature 
As discussed in #690, the bandwidth feature is added to the LTI class.

Implementation:
1. The range of possible frequency is extracted from `freqplot._default_frequency_range`. 
2. The index, $idx$, is identified, which corresponds to the first frequency dropped by a desired amount relative to the DC gain.
3. Bisection search (`scipy.optimize.root_scalar`) is used to solve for the bandwidth in the frequency bracket $[\omega[idx-1], \omega[idx]]$.

Notes:
1. The bandwidth of a system with infinite DC gain is set to be `np.nan`, which follows the convention of Matlab.
2. The above approach is chosen to solve the bandwidth instead of the `scipy.optimize.root` suggested in issue #690. The suggested approach requires an initial guess and might fail, e.g., a system with a resonant peak at a frequency higher than the bandwidth. 

Notes on git commits:
git commits are duplicated as I was having some issues to push. I pulled (suggested by git error hint), and it prompts `"merge made by the 'recursive' strategy.` Not sure whether this is a problem. 


Patch:
diff --git a/control/lti.py b/control/lti.py
index 6dc3bc62..216332e9 100644
--- a/control/lti.py
+++ b/control/lti.py
@@ -20,7 +20,7 @@ from . import config
 from .namedio import NamedIOSystem, isdtime
 
 __all__ = ['poles', 'zeros', 'damp', 'evalfr', 'frequency_response',
-           'freqresp', 'dcgain', 'pole', 'zero']
+           'freqresp', 'dcgain', 'bandwidth', 'pole', 'zero']
 
 
 class LTI(NamedIOSystem):
@@ -202,6 +202,68 @@ class LTI(NamedIOSystem):
         else:
             return zeroresp
 
+    def bandwidth(self, dbdrop=-3):
+        """Evaluate the bandwidth of the LTI system for a given dB drop.
+
+        Evaluate the first frequency that the response magnitude is lower than
+        DC gain by dbdrop dB.
+
+        Parameters
+        ----------
+        dpdrop : float, optional
+            A strictly negative scalar in dB (default = -3) defines the
+            amount of gain drop for deciding bandwidth.
+
+        Returns
+        -------
+        bandwidth : ndarray
+            The first frequency (rad/time-unit) where the gain drops below
+            dbdrop of the dc gain of the system, or nan if the system has
+            infinite dc gain, inf if the gain does not drop for all frequency
+
+        Raises
+        ------
+        TypeError
+            if 'sys' is not an SISO LTI instance
+        ValueError
+            if 'dbdrop' is not a negative scalar
+        """
+        # check if system is SISO and dbdrop is a negative scalar
+        if not self.issiso():
+            raise TypeError("system should be a SISO system")
+
+        if (not np.isscalar(dbdrop)) or dbdrop >= 0:
+            raise ValueError("expecting dbdrop be a negative scalar in dB")
+
+        dcgain = self.dcgain()
+        if np.isinf(dcgain):
+            # infinite dcgain, return np.nan
+            return np.nan
+
+        # use frequency range to identify the 0-crossing (dbdrop) bracket
+        from control.freqplot import _default_frequency_range
+        omega = _default_frequency_range(self)
+        mag, phase, omega = self.frequency_response(omega)
+        idx_dropped = np.nonzero(mag - dcgain*10**(dbdrop/20) < 0)[0]
+
+        if idx_dropped.shape[0] == 0:
+            # no frequency response is dbdrop below the dc gain, return np.inf
+            return np.inf
+        else:
+            # solve for the bandwidth, use scipy.optimize.root_scalar() to
+            # solve using bisection
+            import scipy
+            result = scipy.optimize.root_scalar(
+                lambda w: np.abs(self(w*1j)) - np.abs(dcgain)*10**(dbdrop/20),
+                bracket=[omega[idx_dropped[0] - 1], omega[idx_dropped[0]]],
+                method='bisect')
+
+            # check solution
+            if result.converged:
+                return np.abs(result.root)
+            else:
+                raise Exception(result.message)
+
     def ispassive(self):
         # importing here prevents circular dependancy
         from control.passivity import ispassive
@@ -499,6 +561,51 @@ def dcgain(sys):
     return sys.dcgain()
 
 
+def bandwidth(sys, dbdrop=-3):
+    """Return the first freqency where the gain drop by dbdrop of the system.
+
+    Parameters
+    ----------
+    sys: StateSpace or TransferFunction
+        Linear system
+    dbdrop : float, optional
+        By how much the gain drop in dB (default = -3) that defines the
+        bandwidth. Should be a negative scalar
+
+    Returns
+    -------
+    bandwidth : ndarray
+        The first frequency (rad/time-unit) where the gain drops below dbdrop
+        of the dc gain of the system, or nan if the system has infinite dc
+        gain, inf if the gain does not drop for all frequency
+
+    Raises
+    ------
+    TypeError
+        if 'sys' is not an SISO LTI instance
+    ValueError
+        if 'dbdrop' is not a negative scalar
+
+    Example
+    -------
+    >>> G = ct.tf([1], [1, 1])
+    >>> ct.bandwidth(G)
+    0.9976
+
+    >>> G1 = ct.tf(0.1, [1, 0.1])
+    >>> wn2 = 1
+    >>> zeta2 = 0.001
+    >>> G2 = ct.tf(wn2**2, [1, 2*zeta2*wn2, wn2**2])
+    >>> ct.bandwidth(G1*G2)
+    0.1018
+
+    """
+    if not isinstance(sys, LTI):
+        raise TypeError("sys must be a LTI instance.")
+
+    return sys.bandwidth(dbdrop)
+
+
 # Process frequency responses in a uniform way
 def _process_frequency_response(sys, omega, out, squeeze=None):
     # Set value of squeeze argument if not set
diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py
index 1a524b33..ef14248c 100644
--- a/control/matlab/__init__.py
+++ b/control/matlab/__init__.py
@@ -189,7 +189,7 @@ System gain and dynamics
 
 ==  ==========================  ============================================
 \*  :func:`dcgain`              steady-state (D.C.) gain
-\   lti/bandwidth               system bandwidth
+\*  :func:`bandwidth`           system bandwidth
 \   lti/norm                    h2 and Hinfinity norms of LTI models
 \*  :func:`pole`                system poles
 \*  :func:`zero`                system (transmission) zeros
diff --git a/control/xferfcn.py b/control/xferfcn.py
index 89e2546f..a6a00c5d 100644
--- a/control/xferfcn.py
+++ b/control/xferfcn.py
@@ -74,6 +74,7 @@ _xferfcn_defaults = {
     'xferfcn.floating_point_format': '.4g'
 }
 
+
 def _float2str(value):
     _num_format = config.defaults.get('xferfcn.floating_point_format', ':.4g')
     return f"{value:{_num_format}}"
@@ -1407,6 +1408,7 @@ def _tf_factorized_polynomial_to_string(roots, gain=1, var='s'):
 
     return multiplier + " ".join(factors)
 
+
 def _tf_string_to_latex(thestr, var='s'):
     """ make sure to superscript all digits in a polynomial string
         and convert float coefficients in scientific notation


Test patch:
diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py
index 8e45ea48..e0f7f35b 100644
--- a/control/tests/lti_test.py
+++ b/control/tests/lti_test.py
@@ -6,7 +6,7 @@ from .conftest import editsdefaults
 
 import control as ct
 from control import c2d, tf, ss, tf2ss, NonlinearIOSystem
-from control.lti import LTI, evalfr, damp, dcgain, zeros, poles
+from control.lti import LTI, evalfr, damp, dcgain, zeros, poles, bandwidth
 from control import common_timebase, isctime, isdtime, issiso, timebaseEqual
 from control.tests.conftest import slycotonly
 from control.exception import slycot_check
@@ -104,6 +104,38 @@ class TestLTI:
         np.testing.assert_allclose(sys.dcgain(), 42)
         np.testing.assert_allclose(dcgain(sys), 42)
 
+    def test_bandwidth(self):
+        # test a first-order system, compared with matlab
+        sys1 = tf(0.1, [1, 0.1])
+        np.testing.assert_allclose(sys1.bandwidth(), 0.099762834511098)
+        np.testing.assert_allclose(bandwidth(sys1), 0.099762834511098)
+
+        # test a second-order system, compared with matlab
+        wn2 = 1
+        zeta2 = 0.001
+        sys2 = sys1 * tf(wn2**2, [1, 2*zeta2*wn2, wn2**2])
+        np.testing.assert_allclose(sys2.bandwidth(), 0.101848388240241)
+        np.testing.assert_allclose(bandwidth(sys2), 0.101848388240241)
+
+        # test constant gain, bandwidth should be infinity
+        sysAP = tf(1,1)
+        np.testing.assert_allclose(bandwidth(sysAP), np.inf)
+
+        # test integrator, bandwidth should return np.nan
+        sysInt = tf(1, [1, 0])
+        np.testing.assert_allclose(bandwidth(sysInt), np.nan)
+
+        # test exception for system other than LTI
+        np.testing.assert_raises(TypeError, bandwidth, 1)
+
+        # test exception for system other than SISO system
+        sysMIMO = tf([[[-1, 41], [1]], [[1, 2], [3, 4]]], 
+                     [[[1, 10], [1, 20]], [[1, 30], [1, 40]]])
+        np.testing.assert_raises(TypeError, bandwidth, sysMIMO)
+
+        # test if raise exception if dbdrop is positive scalar
+        np.testing.assert_raises(ValueError, bandwidth, sys1, 3)
+
     @pytest.mark.parametrize("dt1, dt2, expected",
                              [(None, None, True),
                               (None, 0, True),