diff --git a/control/lti.py b/control/lti.py index 6dc3bc62c..216332e91 100644 --- a/control/lti.py +++ b/control/lti.py @@ -20,7 +20,7 @@ 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 @@ def _dcgain(self, warn_infinite): 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 1a524b33f..ef14248c0 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -189,7 +189,7 @@ == ========================== ============================================ \* :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/tests/lti_test.py b/control/tests/lti_test.py index 8e45ea482..e0f7f35bf 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -6,7 +6,7 @@ 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 @@ def test_dcgain(self): 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), diff --git a/control/xferfcn.py b/control/xferfcn.py index 89e2546f8..a6a00c5d7 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -74,6 +74,7 @@ '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