From 65988d12cbab5d895767da195a3767d922b1bb6e Mon Sep 17 00:00:00 2001 From: ShihChi Date: Fri, 28 Apr 2023 00:41:26 -0400 Subject: [PATCH 01/13] solve bandwidth by bisection for zero-crossing --- control/lti.py | 62 +++++++++++++++++++++++++++++++++++++++++++++- control/statesp.py | 4 +++ control/xferfcn.py | 4 +++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/control/lti.py b/control/lti.py index 6dc3bc62c..27cf3019d 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,39 @@ def _dcgain(self, warn_infinite): else: return zeroresp + def bandwidth(self, dbdrop=-3): + """Return the bandwidth""" + raise NotImplementedError("bandwidth not implemented for %s objects" % + str(self.__class__)) + + def _bandwidth(self, dbdrop=-3): + # check if system is SISO and dbdrop is a negative scalar + if (not self.issiso()) and (dbdrop >= 0): + raise ValueError("NOT sure what to raise #TODO ") + + # result = scipy.optimize.root(lambda w: np.abs(self(w*1j)) - np.abs(self.dcgain())*10**(dbdrop/20), x0=1) + # # this will probabily fail if there is a resonant frequency larger than the bandwidth, the initial guess can be around that peak + + # use bodeplot to identify the 0-crossing bracket + from control.freqplot import _default_frequency_range + omega = _default_frequency_range(self) + mag, phase, omega = self.frequency_response(omega) + + dcgain = self.dcgain() + idx_out = np.nonzero(mag - dcgain*10**(dbdrop/20) < 0)[0][0] + + # 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_out-1], omega[idx_out]], + 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 +532,33 @@ 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 : #TODO data-type + The first frequency where the gain drops below dbdrop of the dc gain + of the system. + + Example + ------- + >>> G = ct.tf([1], [1, 2]) + >>> ct.bandwidth(G) + 0.9976 + + """ + 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/statesp.py b/control/statesp.py index 41f92ae21..0a72f487c 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1424,6 +1424,10 @@ def dcgain(self, warn_infinite=False): """ return self._dcgain(warn_infinite) + def bandwidth(self, dbdrop=-3): + """Return the bandwith""" + return self._bandwidth(dbdrop) + def dynamics(self, t, x, u=None, params=None): """Compute the dynamics of the system diff --git a/control/xferfcn.py b/control/xferfcn.py index 89e2546f8..cfc9c9a5e 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1246,6 +1246,10 @@ def dcgain(self, warn_infinite=False): """ return self._dcgain(warn_infinite) + + def bandwidth(self, dbdrop=-3): + """Return the bandwith""" + return self._bandwidth(dbdrop) def _isstatic(self): """returns True if and only if all of the numerator and denominator From 86ff95c47f648dc859cfab61c8972eeccbd1039b Mon Sep 17 00:00:00 2001 From: ShihChi Date: Sun, 30 Apr 2023 22:05:51 -0400 Subject: [PATCH 02/13] testing suggested method by Kreijstal --- control/lti.py | 26 +++++++++++++++++++++----- control/matlab/__init__.py | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/control/lti.py b/control/lti.py index 27cf3019d..f43039c44 100644 --- a/control/lti.py +++ b/control/lti.py @@ -210,10 +210,19 @@ def bandwidth(self, dbdrop=-3): def _bandwidth(self, dbdrop=-3): # check if system is SISO and dbdrop is a negative scalar if (not self.issiso()) and (dbdrop >= 0): - raise ValueError("NOT sure what to raise #TODO ") - + raise ValueError("#TODO ") + + # # # this will probabily fail if there is a resonant frequency larger than the bandwidth, the initial guess can be around that peak + # G1 = ct.tf(0.1, [1, 0.1]) + # wn2 = 0.9 + # zeta2 = 0.001 + # G2 = ct.tf(wn2**2, [1, 2*zeta2*wn2, wn2**2]) + # ct.bandwidth(G1*G2) + # import scipy # result = scipy.optimize.root(lambda w: np.abs(self(w*1j)) - np.abs(self.dcgain())*10**(dbdrop/20), x0=1) - # # this will probabily fail if there is a resonant frequency larger than the bandwidth, the initial guess can be around that peak + + # if result.success: + # return np.abs(result.x)[0] # use bodeplot to identify the 0-crossing bracket from control.freqplot import _default_frequency_range @@ -221,12 +230,12 @@ def _bandwidth(self, dbdrop=-3): mag, phase, omega = self.frequency_response(omega) dcgain = self.dcgain() - idx_out = np.nonzero(mag - dcgain*10**(dbdrop/20) < 0)[0][0] + idx_dropped = np.nonzero(mag - dcgain*10**(dbdrop/20) < 0)[0][0] # 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_out-1], omega[idx_out]], + bracket=[omega[idx_dropped-1], omega[idx_dropped]], method='bisect') # check solution @@ -555,6 +564,13 @@ def bandwidth(sys, dbdrop=-3): >>> 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 + """ return sys.bandwidth(dbdrop) 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 From 70e912f031574167eef683f090dbb59317dff6ac Mon Sep 17 00:00:00 2001 From: ShihChi Date: Sun, 30 Apr 2023 23:00:59 -0400 Subject: [PATCH 03/13] Implemented and passed nominal test --- control/lti.py | 9 ++++++--- control/tests/lti_test.py | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/control/lti.py b/control/lti.py index f43039c44..d43040084 100644 --- a/control/lti.py +++ b/control/lti.py @@ -209,8 +209,11 @@ def bandwidth(self, dbdrop=-3): def _bandwidth(self, dbdrop=-3): # check if system is SISO and dbdrop is a negative scalar - if (not self.issiso()) and (dbdrop >= 0): - raise ValueError("#TODO ") + 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") # # # this will probabily fail if there is a resonant frequency larger than the bandwidth, the initial guess can be around that peak # G1 = ct.tf(0.1, [1, 0.1]) @@ -560,7 +563,7 @@ def bandwidth(sys, dbdrop=-3): Example ------- - >>> G = ct.tf([1], [1, 2]) + >>> G = ct.tf([1], [1, 1]) >>> ct.bandwidth(G) 0.9976 diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index 8e45ea482..bd35e25a6 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,27 @@ 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 if raise exception given 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), From 0c81cb2b9812932096fd7d68c80b1457a02ef083 Mon Sep 17 00:00:00 2001 From: ShihChi Date: Mon, 1 May 2023 00:03:40 -0400 Subject: [PATCH 04/13] Handle integrator, all-pass filters --- control/lti.py | 64 +++++++++++++++++++++------------------ control/tests/lti_test.py | 13 +++++++- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/control/lti.py b/control/lti.py index d43040084..ee3f57f5e 100644 --- a/control/lti.py +++ b/control/lti.py @@ -215,37 +215,31 @@ def _bandwidth(self, dbdrop=-3): if not(np.isscalar(dbdrop)) or dbdrop >= 0: raise ValueError("expecting dbdrop be a negative scalar in dB") - # # # this will probabily fail if there is a resonant frequency larger than the bandwidth, the initial guess can be around that peak - # G1 = ct.tf(0.1, [1, 0.1]) - # wn2 = 0.9 - # zeta2 = 0.001 - # G2 = ct.tf(wn2**2, [1, 2*zeta2*wn2, wn2**2]) - # ct.bandwidth(G1*G2) - # import scipy - # result = scipy.optimize.root(lambda w: np.abs(self(w*1j)) - np.abs(self.dcgain())*10**(dbdrop/20), x0=1) - - # if result.success: - # return np.abs(result.x)[0] - - # use bodeplot to identify the 0-crossing bracket + dcgain = self.dcgain() + if np.isinf(dcgain): + 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] - dcgain = self.dcgain() - idx_dropped = np.nonzero(mag - dcgain*10**(dbdrop/20) < 0)[0][0] - - # 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-1], omega[idx_dropped]], - method='bisect') - - # check solution - if result.converged: - return np.abs(result.root) + if idx_dropped.shape[0] == 0: + # no frequency response is dbdrop below the dc gain. + return np.inf else: - raise Exception(result.message) + # 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 @@ -557,10 +551,17 @@ def bandwidth(sys, dbdrop=-3): Returns ------- - bandwidth : #TODO data-type - The first frequency where the gain drops below dbdrop of the dc gain - of the system. - + 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]) @@ -575,6 +576,9 @@ def bandwidth(sys, dbdrop=-3): 0.1018 """ + if not isinstance(sys, LTI): + raise TypeError("sys must be a LTI instance.") + return sys.bandwidth(dbdrop) diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index bd35e25a6..e0f7f35bf 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -117,7 +117,18 @@ def test_bandwidth(self): np.testing.assert_allclose(sys2.bandwidth(), 0.101848388240241) np.testing.assert_allclose(bandwidth(sys2), 0.101848388240241) - # test if raise exception given other than SISO system + # 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) From d59d19a299d2455e35826f6206715bba6197274c Mon Sep 17 00:00:00 2001 From: ShihChi Date: Mon, 1 May 2023 00:09:45 -0400 Subject: [PATCH 05/13] adjust for PEP8 coding style --- control/lti.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/control/lti.py b/control/lti.py index ee3f57f5e..2cf54cac2 100644 --- a/control/lti.py +++ b/control/lti.py @@ -211,12 +211,13 @@ def _bandwidth(self, dbdrop=-3): # 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: + + 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 @@ -226,14 +227,16 @@ def _bandwidth(self, dbdrop=-3): 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. + # 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 + # 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') + 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: @@ -552,8 +555,9 @@ def bandwidth(sys, dbdrop=-3): 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 + 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 ------ @@ -561,7 +565,7 @@ def bandwidth(sys, dbdrop=-3): if 'sys' is not an SISO LTI instance ValueError if 'dbdrop' is not a negative scalar - + Example ------- >>> G = ct.tf([1], [1, 1]) From bc2ddff637169cea8010794cae7b2488ffa16006 Mon Sep 17 00:00:00 2001 From: ShihChi Date: Fri, 28 Apr 2023 00:41:26 -0400 Subject: [PATCH 06/13] solve bandwidth by bisection for zero-crossing --- control/lti.py | 62 +++++++++++++++++++++++++++++++++++++++++++++- control/statesp.py | 4 +++ control/xferfcn.py | 4 +++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/control/lti.py b/control/lti.py index 6dc3bc62c..27cf3019d 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,39 @@ def _dcgain(self, warn_infinite): else: return zeroresp + def bandwidth(self, dbdrop=-3): + """Return the bandwidth""" + raise NotImplementedError("bandwidth not implemented for %s objects" % + str(self.__class__)) + + def _bandwidth(self, dbdrop=-3): + # check if system is SISO and dbdrop is a negative scalar + if (not self.issiso()) and (dbdrop >= 0): + raise ValueError("NOT sure what to raise #TODO ") + + # result = scipy.optimize.root(lambda w: np.abs(self(w*1j)) - np.abs(self.dcgain())*10**(dbdrop/20), x0=1) + # # this will probabily fail if there is a resonant frequency larger than the bandwidth, the initial guess can be around that peak + + # use bodeplot to identify the 0-crossing bracket + from control.freqplot import _default_frequency_range + omega = _default_frequency_range(self) + mag, phase, omega = self.frequency_response(omega) + + dcgain = self.dcgain() + idx_out = np.nonzero(mag - dcgain*10**(dbdrop/20) < 0)[0][0] + + # 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_out-1], omega[idx_out]], + 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 +532,33 @@ 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 : #TODO data-type + The first frequency where the gain drops below dbdrop of the dc gain + of the system. + + Example + ------- + >>> G = ct.tf([1], [1, 2]) + >>> ct.bandwidth(G) + 0.9976 + + """ + 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/statesp.py b/control/statesp.py index 41f92ae21..0a72f487c 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1424,6 +1424,10 @@ def dcgain(self, warn_infinite=False): """ return self._dcgain(warn_infinite) + def bandwidth(self, dbdrop=-3): + """Return the bandwith""" + return self._bandwidth(dbdrop) + def dynamics(self, t, x, u=None, params=None): """Compute the dynamics of the system diff --git a/control/xferfcn.py b/control/xferfcn.py index 89e2546f8..cfc9c9a5e 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1246,6 +1246,10 @@ def dcgain(self, warn_infinite=False): """ return self._dcgain(warn_infinite) + + def bandwidth(self, dbdrop=-3): + """Return the bandwith""" + return self._bandwidth(dbdrop) def _isstatic(self): """returns True if and only if all of the numerator and denominator From 409d0c62dd7c940d1d5575af229a31fc526e5f90 Mon Sep 17 00:00:00 2001 From: ShihChi Date: Sun, 30 Apr 2023 22:05:51 -0400 Subject: [PATCH 07/13] testing suggested method by Kreijstal --- control/lti.py | 26 +++++++++++++++++++++----- control/matlab/__init__.py | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/control/lti.py b/control/lti.py index 27cf3019d..f43039c44 100644 --- a/control/lti.py +++ b/control/lti.py @@ -210,10 +210,19 @@ def bandwidth(self, dbdrop=-3): def _bandwidth(self, dbdrop=-3): # check if system is SISO and dbdrop is a negative scalar if (not self.issiso()) and (dbdrop >= 0): - raise ValueError("NOT sure what to raise #TODO ") - + raise ValueError("#TODO ") + + # # # this will probabily fail if there is a resonant frequency larger than the bandwidth, the initial guess can be around that peak + # G1 = ct.tf(0.1, [1, 0.1]) + # wn2 = 0.9 + # zeta2 = 0.001 + # G2 = ct.tf(wn2**2, [1, 2*zeta2*wn2, wn2**2]) + # ct.bandwidth(G1*G2) + # import scipy # result = scipy.optimize.root(lambda w: np.abs(self(w*1j)) - np.abs(self.dcgain())*10**(dbdrop/20), x0=1) - # # this will probabily fail if there is a resonant frequency larger than the bandwidth, the initial guess can be around that peak + + # if result.success: + # return np.abs(result.x)[0] # use bodeplot to identify the 0-crossing bracket from control.freqplot import _default_frequency_range @@ -221,12 +230,12 @@ def _bandwidth(self, dbdrop=-3): mag, phase, omega = self.frequency_response(omega) dcgain = self.dcgain() - idx_out = np.nonzero(mag - dcgain*10**(dbdrop/20) < 0)[0][0] + idx_dropped = np.nonzero(mag - dcgain*10**(dbdrop/20) < 0)[0][0] # 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_out-1], omega[idx_out]], + bracket=[omega[idx_dropped-1], omega[idx_dropped]], method='bisect') # check solution @@ -555,6 +564,13 @@ def bandwidth(sys, dbdrop=-3): >>> 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 + """ return sys.bandwidth(dbdrop) 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 From 723ddc88781912e858887cdec93154d8c9e26466 Mon Sep 17 00:00:00 2001 From: ShihChi Date: Sun, 30 Apr 2023 23:00:59 -0400 Subject: [PATCH 08/13] Implemented and passed nominal test --- control/lti.py | 9 ++++++--- control/tests/lti_test.py | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/control/lti.py b/control/lti.py index f43039c44..d43040084 100644 --- a/control/lti.py +++ b/control/lti.py @@ -209,8 +209,11 @@ def bandwidth(self, dbdrop=-3): def _bandwidth(self, dbdrop=-3): # check if system is SISO and dbdrop is a negative scalar - if (not self.issiso()) and (dbdrop >= 0): - raise ValueError("#TODO ") + 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") # # # this will probabily fail if there is a resonant frequency larger than the bandwidth, the initial guess can be around that peak # G1 = ct.tf(0.1, [1, 0.1]) @@ -560,7 +563,7 @@ def bandwidth(sys, dbdrop=-3): Example ------- - >>> G = ct.tf([1], [1, 2]) + >>> G = ct.tf([1], [1, 1]) >>> ct.bandwidth(G) 0.9976 diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index 8e45ea482..bd35e25a6 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,27 @@ 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 if raise exception given 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), From 6b5143236212bf5d9ab4c3fdf82c6299a3e27e55 Mon Sep 17 00:00:00 2001 From: ShihChi Date: Mon, 1 May 2023 00:03:40 -0400 Subject: [PATCH 09/13] Handle integrator, all-pass filters --- control/lti.py | 64 +++++++++++++++++++++------------------ control/tests/lti_test.py | 13 +++++++- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/control/lti.py b/control/lti.py index d43040084..ee3f57f5e 100644 --- a/control/lti.py +++ b/control/lti.py @@ -215,37 +215,31 @@ def _bandwidth(self, dbdrop=-3): if not(np.isscalar(dbdrop)) or dbdrop >= 0: raise ValueError("expecting dbdrop be a negative scalar in dB") - # # # this will probabily fail if there is a resonant frequency larger than the bandwidth, the initial guess can be around that peak - # G1 = ct.tf(0.1, [1, 0.1]) - # wn2 = 0.9 - # zeta2 = 0.001 - # G2 = ct.tf(wn2**2, [1, 2*zeta2*wn2, wn2**2]) - # ct.bandwidth(G1*G2) - # import scipy - # result = scipy.optimize.root(lambda w: np.abs(self(w*1j)) - np.abs(self.dcgain())*10**(dbdrop/20), x0=1) - - # if result.success: - # return np.abs(result.x)[0] - - # use bodeplot to identify the 0-crossing bracket + dcgain = self.dcgain() + if np.isinf(dcgain): + 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] - dcgain = self.dcgain() - idx_dropped = np.nonzero(mag - dcgain*10**(dbdrop/20) < 0)[0][0] - - # 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-1], omega[idx_dropped]], - method='bisect') - - # check solution - if result.converged: - return np.abs(result.root) + if idx_dropped.shape[0] == 0: + # no frequency response is dbdrop below the dc gain. + return np.inf else: - raise Exception(result.message) + # 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 @@ -557,10 +551,17 @@ def bandwidth(sys, dbdrop=-3): Returns ------- - bandwidth : #TODO data-type - The first frequency where the gain drops below dbdrop of the dc gain - of the system. - + 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]) @@ -575,6 +576,9 @@ def bandwidth(sys, dbdrop=-3): 0.1018 """ + if not isinstance(sys, LTI): + raise TypeError("sys must be a LTI instance.") + return sys.bandwidth(dbdrop) diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index bd35e25a6..e0f7f35bf 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -117,7 +117,18 @@ def test_bandwidth(self): np.testing.assert_allclose(sys2.bandwidth(), 0.101848388240241) np.testing.assert_allclose(bandwidth(sys2), 0.101848388240241) - # test if raise exception given other than SISO system + # 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) From 9f86b41c21e9d81b93921892f875d5940e9da4c8 Mon Sep 17 00:00:00 2001 From: ShihChi Date: Mon, 1 May 2023 00:09:45 -0400 Subject: [PATCH 10/13] adjust for PEP8 coding style --- control/lti.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/control/lti.py b/control/lti.py index ee3f57f5e..2cf54cac2 100644 --- a/control/lti.py +++ b/control/lti.py @@ -211,12 +211,13 @@ def _bandwidth(self, dbdrop=-3): # 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: + + 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 @@ -226,14 +227,16 @@ def _bandwidth(self, dbdrop=-3): 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. + # 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 + # 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') + 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: @@ -552,8 +555,9 @@ def bandwidth(sys, dbdrop=-3): 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 + 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 ------ @@ -561,7 +565,7 @@ def bandwidth(sys, dbdrop=-3): if 'sys' is not an SISO LTI instance ValueError if 'dbdrop' is not a negative scalar - + Example ------- >>> G = ct.tf([1], [1, 1]) From a370cdb65e7038cae6ffe88f881dba00ff87b7d9 Mon Sep 17 00:00:00 2001 From: ShihChi Date: Wed, 3 May 2023 23:28:59 -0400 Subject: [PATCH 11/13] remove _bandwidth method in lti.py --- control/lti.py | 5 ----- control/statesp.py | 4 ---- control/xferfcn.py | 4 ---- 3 files changed, 13 deletions(-) diff --git a/control/lti.py b/control/lti.py index 2cf54cac2..ec552e9ae 100644 --- a/control/lti.py +++ b/control/lti.py @@ -203,11 +203,6 @@ def _dcgain(self, warn_infinite): return zeroresp def bandwidth(self, dbdrop=-3): - """Return the bandwidth""" - raise NotImplementedError("bandwidth not implemented for %s objects" % - str(self.__class__)) - - def _bandwidth(self, dbdrop=-3): # check if system is SISO and dbdrop is a negative scalar if not self.issiso(): raise TypeError("system should be a SISO system") diff --git a/control/statesp.py b/control/statesp.py index 0a72f487c..41f92ae21 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1424,10 +1424,6 @@ def dcgain(self, warn_infinite=False): """ return self._dcgain(warn_infinite) - def bandwidth(self, dbdrop=-3): - """Return the bandwith""" - return self._bandwidth(dbdrop) - def dynamics(self, t, x, u=None, params=None): """Compute the dynamics of the system diff --git a/control/xferfcn.py b/control/xferfcn.py index cfc9c9a5e..96be243df 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1247,10 +1247,6 @@ def dcgain(self, warn_infinite=False): """ return self._dcgain(warn_infinite) - def bandwidth(self, dbdrop=-3): - """Return the bandwith""" - return self._bandwidth(dbdrop) - def _isstatic(self): """returns True if and only if all of the numerator and denominator polynomials of the (possibly MIMO) transfer function are zeroth order, From 02172b7a2238adc0b4c49bfefdfb475368984d7e Mon Sep 17 00:00:00 2001 From: ShihChi Date: Sun, 7 May 2023 19:27:23 -0400 Subject: [PATCH 12/13] resolving format issues in xfrefcn.py --- control/xferfcn.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index 96be243df..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}}" @@ -1246,7 +1247,7 @@ def dcgain(self, warn_infinite=False): """ return self._dcgain(warn_infinite) - + def _isstatic(self): """returns True if and only if all of the numerator and denominator polynomials of the (possibly MIMO) transfer function are zeroth order, @@ -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 From ab685623a40853d66d2ac617545307e75ef50a80 Mon Sep 17 00:00:00 2001 From: ShihChi Date: Sun, 7 May 2023 20:29:30 -0400 Subject: [PATCH 13/13] add docstring to lti.bandwidth --- control/lti.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/control/lti.py b/control/lti.py index ec552e9ae..216332e91 100644 --- a/control/lti.py +++ b/control/lti.py @@ -203,6 +203,31 @@ def _dcgain(self, warn_infinite): 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")