From ca8e67025c1b429ef8fb57541eb449059eddd8a5 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 5 Nov 2021 13:00:54 -0700 Subject: [PATCH 1/4] misc bugfixes: fixed prewarp not working in c2d and sample_system, incorrect order of return arguments in margin, typos and changed to ControlMIMONotImplemented error where needed. --- control/dtime.py | 6 ++++-- control/margins.py | 8 ++++---- control/tests/margin_test.py | 20 ++++++++++---------- control/tests/matlab_test.py | 6 +++--- control/xferfcn.py | 12 +++++++----- 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/control/dtime.py b/control/dtime.py index 8c0fe53e9..8f3e00071 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -89,7 +89,8 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): if not isctime(sysc): raise ValueError("First argument must be continuous time system") - return sysc.sample(Ts, method, alpha, prewarp_frequency) + return sysc.sample(Ts, + method=method, alpha=alpha, prewarp_frequency=prewarp_frequency) def c2d(sysc, Ts, method='zoh', prewarp_frequency=None): @@ -126,6 +127,7 @@ def c2d(sysc, Ts, method='zoh', prewarp_frequency=None): """ # Call the sample_system() function to do the work - sysd = sample_system(sysc, Ts, method, prewarp_frequency) + sysd = sample_system(sysc, Ts, + method=method, prewarp_frequency=prewarp_frequency) return sysd diff --git a/control/margins.py b/control/margins.py index 0b53f26ed..e3c5ab14a 100644 --- a/control/margins.py +++ b/control/margins.py @@ -283,7 +283,7 @@ def stability_margins(sysdata, returnall=False, epsw=0.0, method='best'): ------- gm : float or array_like Gain margin - pm : float or array_loke + pm : float or array_like Phase margin sm : float or array_like Stability margin, the minimum distance from the Nyquist plot to -1 @@ -522,10 +522,10 @@ def margin(*args): Gain margin pm : float Phase margin (in degrees) - wpc : float or array_like - Phase crossover frequency (where phase crosses -180 degrees) wgc : float or array_like Gain crossover frequency (where gain crosses 1) + wpc : float or array_like + Phase crossover frequency (where phase crosses -180 degrees) Margins are calculated for a SISO open-loop system. @@ -548,4 +548,4 @@ def margin(*args): raise ValueError("Margin needs 1 or 3 arguments; received %i." % len(args)) - return margin[0], margin[1], margin[3], margin[4] + return margin[0], margin[1], margin[4], margin[3] diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index a1246103f..8c91ade29 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -102,7 +102,7 @@ def test_margin_sys(tsys): sys, refout, refoutall = tsys """Test margin() function with system input""" out = margin(sys) - assert_allclose(out, np.array(refout)[[0, 1, 3, 4]], atol=1.5e-3) + assert_allclose(out, np.array(refout)[[0, 1, 4, 3]], atol=1.5e-3) def test_margin_3input(tsys): sys, refout, refoutall = tsys @@ -110,7 +110,7 @@ def test_margin_3input(tsys): omega = np.logspace(-2, 2, 2000) mag, phase, omega_ = sys.frequency_response(omega) out = margin((mag, phase*180/np.pi, omega_)) - assert_allclose(out, np.array(refout)[[0, 1, 3, 4]], atol=1.5e-3) + assert_allclose(out, np.array(refout)[[0, 1, 4, 3]], atol=1.5e-3) @pytest.mark.parametrize( @@ -276,23 +276,23 @@ def tsys_zmore(request, tsys_zmoresystems): @pytest.mark.parametrize( 'tsys_zmore', [dict(sysname='typem1', K=2.0, atol=1.5e-3, - result=(float('Inf'), -120.0007, float('NaN'), 0.5774)), + result=(float('Inf'), -120.0007, 0.5774, float('NaN'))), dict(sysname='type0', K=0.8, atol=1.5e-3, - result=(10.0014, float('inf'), 1.7322, float('nan'))), + result=(10.0014, float('inf'), float('nan'), 1.7322)), dict(sysname='type0', K=2.0, atol=1e-2, - result=(4.000, 67.6058, 1.7322, 0.7663)), + result=(4.000, 67.6058, 0.7663, 1.7322)), dict(sysname='type1', K=1.0, atol=1e-4, - result=(float('Inf'), 144.9032, float('NaN'), 0.3162)), + result=(float('Inf'), 144.9032, 0.3162, float('NaN'))), dict(sysname='type2', K=1.0, atol=1e-4, - result=(float('Inf'), 44.4594, float('NaN'), 0.7907)), + result=(float('Inf'), 44.4594, 0.7907, float('NaN'))), dict(sysname='type3', K=1.0, atol=1.5e-3, - result=(0.0626, 37.1748, 0.1119, 0.7951)), + result=(0.0626, 37.1748, 0.7951, 0.1119)), dict(sysname='example21', K=1.0, atol=1e-2, result=(0.0100, -14.5640, 0, 0.0022)), dict(sysname='example21', K=1000.0, atol=1e-2, - result=(0.1793, 22.5215, 0.0243, 0.0630)), + result=(0.1793, 22.5215, 0.0630, 0.0243)), dict(sysname='example21', K=5000.0, atol=1.5e-3, - result=(4.5596, 21.2101, 0.4385, 0.1868)), + result=(4.5596, 21.2101, 0.1868, 0.4385)), ], indirect=True) def test_zmore_margin(tsys_zmore): diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index 6957e0bfe..7d51e7fbe 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -361,7 +361,7 @@ def testMargin(self, siso): gm, pm, wg, wp = margin(siso.ss2) gm, pm, wg, wp = margin(siso.ss2 * siso.ss2 * 2) np.testing.assert_array_almost_equal( - [gm, pm, wg, wp], [1.5451, 75.9933, 1.2720, 0.6559], decimal=3) + [gm, pm, wg, wp], [1.5451, 75.9933, 0.6559, 1.2720], decimal=3) def testDcgain(self, siso): """Test dcgain() for SISO system""" @@ -785,8 +785,8 @@ def testCombi01(self): # print("%f %f %f %f" % (gm, pm, wg, wp)) np.testing.assert_allclose(gm, 3.32065569155) np.testing.assert_allclose(pm, 46.9740430224) - np.testing.assert_allclose(wg, 0.176469728448) - np.testing.assert_allclose(wp, 0.0616288455466) + np.testing.assert_allclose(wg, 0.0616288455466) + np.testing.assert_allclose(wp, 0.176469728448) def test_tf_string_args(self): """Make sure s and z are defined properly""" diff --git a/control/xferfcn.py b/control/xferfcn.py index cb3bb4d41..dc6672b33 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -64,6 +64,7 @@ from itertools import chain from re import sub from .lti import LTI, common_timebase, isdtime, _process_frequency_response +from .exception import ControlMIMONotImplemented from . import config __all__ = ['TransferFunction', 'tf', 'ss2tf', 'tfdata'] @@ -793,9 +794,9 @@ def feedback(self, other=1, sign=-1): if (self.ninputs > 1 or self.noutputs > 1 or other.ninputs > 1 or other.noutputs > 1): # TODO: MIMO feedback - raise NotImplementedError( - "TransferFunction.feedback is currently only implemented " - "for SISO functions.") + raise ControlMIMONotImplemented( + "TransferFunction.feedback is currently not implemented for " + "MIMO systems.") dt = common_timebase(self.dt, other.dt) num1 = self.num[0][0] @@ -1117,7 +1118,7 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): if not self.isctime(): raise ValueError("System must be continuous time system") if not self.issiso(): - raise NotImplementedError("MIMO implementation not available") + raise ControlMIMONotImplemented("Not implemented for MIMO systems") if method == "matched": return _c2d_matched(self, Ts) sys = (self.num[0][0], self.den[0][0]) @@ -1373,7 +1374,8 @@ def _convert_to_transfer_function(sys, **kw): except ImportError: # If slycot is not available, use signal.lti (SISO only) if sys.ninputs != 1 or sys.noutputs != 1: - raise TypeError("No support for MIMO without slycot.") + raise ControlMIMONotImplemented("Not implemented for " + + "MIMO systems without slycot.") # Do the conversion using sp.signal.ss2tf # Note that this returns a 2D array for the numerator From bc5079bfa940559397da4402acf7eaaf72359a8e Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 5 Nov 2021 14:17:11 -0700 Subject: [PATCH 2/4] reverted mistaken margin argument rearrangement and clarified definitions in docstring of margin --- control/margins.py | 14 ++++++++------ control/tests/margin_test.py | 20 ++++++++++---------- control/tests/matlab_test.py | 20 ++++++++++---------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/control/margins.py b/control/margins.py index e3c5ab14a..c602d3627 100644 --- a/control/margins.py +++ b/control/margins.py @@ -522,10 +522,12 @@ def margin(*args): Gain margin pm : float Phase margin (in degrees) - wgc : float or array_like - Gain crossover frequency (where gain crosses 1) - wpc : float or array_like - Phase crossover frequency (where phase crosses -180 degrees) + wcg : float or array_like + Crossover frequency associated with gain margin (phase crossover + frequency), where phase crosses below -180 degrees. + wcp : float or array_like + Crossover frequency associated with phase margin (gain crossover + frequency), where gain crosses below 1. Margins are calculated for a SISO open-loop system. @@ -536,7 +538,7 @@ def margin(*args): Examples -------- >>> sys = tf(1, [1, 2, 1, 0]) - >>> gm, pm, wg, wp = margin(sys) + >>> gm, pm, wcg, wcp = margin(sys) """ if len(args) == 1: @@ -548,4 +550,4 @@ def margin(*args): raise ValueError("Margin needs 1 or 3 arguments; received %i." % len(args)) - return margin[0], margin[1], margin[4], margin[3] + return margin[0], margin[1], margin[3], margin[4] diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 8c91ade29..a1246103f 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -102,7 +102,7 @@ def test_margin_sys(tsys): sys, refout, refoutall = tsys """Test margin() function with system input""" out = margin(sys) - assert_allclose(out, np.array(refout)[[0, 1, 4, 3]], atol=1.5e-3) + assert_allclose(out, np.array(refout)[[0, 1, 3, 4]], atol=1.5e-3) def test_margin_3input(tsys): sys, refout, refoutall = tsys @@ -110,7 +110,7 @@ def test_margin_3input(tsys): omega = np.logspace(-2, 2, 2000) mag, phase, omega_ = sys.frequency_response(omega) out = margin((mag, phase*180/np.pi, omega_)) - assert_allclose(out, np.array(refout)[[0, 1, 4, 3]], atol=1.5e-3) + assert_allclose(out, np.array(refout)[[0, 1, 3, 4]], atol=1.5e-3) @pytest.mark.parametrize( @@ -276,23 +276,23 @@ def tsys_zmore(request, tsys_zmoresystems): @pytest.mark.parametrize( 'tsys_zmore', [dict(sysname='typem1', K=2.0, atol=1.5e-3, - result=(float('Inf'), -120.0007, 0.5774, float('NaN'))), + result=(float('Inf'), -120.0007, float('NaN'), 0.5774)), dict(sysname='type0', K=0.8, atol=1.5e-3, - result=(10.0014, float('inf'), float('nan'), 1.7322)), + result=(10.0014, float('inf'), 1.7322, float('nan'))), dict(sysname='type0', K=2.0, atol=1e-2, - result=(4.000, 67.6058, 0.7663, 1.7322)), + result=(4.000, 67.6058, 1.7322, 0.7663)), dict(sysname='type1', K=1.0, atol=1e-4, - result=(float('Inf'), 144.9032, 0.3162, float('NaN'))), + result=(float('Inf'), 144.9032, float('NaN'), 0.3162)), dict(sysname='type2', K=1.0, atol=1e-4, - result=(float('Inf'), 44.4594, 0.7907, float('NaN'))), + result=(float('Inf'), 44.4594, float('NaN'), 0.7907)), dict(sysname='type3', K=1.0, atol=1.5e-3, - result=(0.0626, 37.1748, 0.7951, 0.1119)), + result=(0.0626, 37.1748, 0.1119, 0.7951)), dict(sysname='example21', K=1.0, atol=1e-2, result=(0.0100, -14.5640, 0, 0.0022)), dict(sysname='example21', K=1000.0, atol=1e-2, - result=(0.1793, 22.5215, 0.0630, 0.0243)), + result=(0.1793, 22.5215, 0.0243, 0.0630)), dict(sysname='example21', K=5000.0, atol=1.5e-3, - result=(4.5596, 21.2101, 0.1868, 0.4385)), + result=(4.5596, 21.2101, 0.4385, 0.1868)), ], indirect=True) def test_zmore_margin(tsys_zmore): diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index 7d51e7fbe..8b2a0951e 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -355,13 +355,13 @@ def testLsim_mimo(self, mimo): def testMargin(self, siso): """Test margin()""" #! TODO: check results to make sure they are OK - gm, pm, wg, wp = margin(siso.tf1) - gm, pm, wg, wp = margin(siso.tf2) - gm, pm, wg, wp = margin(siso.ss1) - gm, pm, wg, wp = margin(siso.ss2) - gm, pm, wg, wp = margin(siso.ss2 * siso.ss2 * 2) + gm, pm, wcg, wcp = margin(siso.tf1) + gm, pm, wcg, wcp = margin(siso.tf2) + gm, pm, wcg, wcp = margin(siso.ss1) + gm, pm, wcg, wcp = margin(siso.ss2) + gm, pm, wcg, wcp = margin(siso.ss2 * siso.ss2 * 2) np.testing.assert_array_almost_equal( - [gm, pm, wg, wp], [1.5451, 75.9933, 0.6559, 1.2720], decimal=3) + [gm, pm, wcg, wcp], [1.5451, 75.9933, 1.2720, 0.6559], decimal=3) def testDcgain(self, siso): """Test dcgain() for SISO system""" @@ -781,12 +781,12 @@ def testCombi01(self): # total open loop Hol = Hc*Hno*Hp - gm, pm, wg, wp = margin(Hol) - # print("%f %f %f %f" % (gm, pm, wg, wp)) + gm, pm, wcg, wcp = margin(Hol) + # print("%f %f %f %f" % (gm, pm, wcg, wcp)) np.testing.assert_allclose(gm, 3.32065569155) np.testing.assert_allclose(pm, 46.9740430224) - np.testing.assert_allclose(wg, 0.0616288455466) - np.testing.assert_allclose(wp, 0.176469728448) + np.testing.assert_allclose(wcg, 0.176469728448) + np.testing.assert_allclose(wcp, 0.0616288455466) def test_tf_string_args(self): """Make sure s and z are defined properly""" From cfb6e86e76a908d3cbac79bea7e45fff9bd08db8 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 5 Nov 2021 14:19:25 -0700 Subject: [PATCH 3/4] clarified docstring in stability-margins --- control/margins.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/control/margins.py b/control/margins.py index c602d3627..48e0c6cc2 100644 --- a/control/margins.py +++ b/control/margins.py @@ -288,9 +288,11 @@ def stability_margins(sysdata, returnall=False, epsw=0.0, method='best'): sm : float or array_like Stability margin, the minimum distance from the Nyquist plot to -1 wpc : float or array_like - Phase crossover frequency (where phase crosses -180 degrees) + Phase crossover frequency (where phase crosses -180 degrees), which is + associated with the gain margin. wgc : float or array_like - Gain crossover frequency (where gain crosses 1) + Gain crossover frequency (where gain crosses 1), which is associated + with the phase margin. wms : float or array_like Stability margin frequency (where Nyquist plot is closest to -1) From 4c66fb1ea47f462941b9f382756274895211a8ff Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 8 Nov 2021 11:04:53 -0800 Subject: [PATCH 4/4] test prewarp in c2d and sample_system --- control/dtime.py | 25 ++++++++++++++----------- control/tests/discrete_test.py | 18 ++++++++++++++---- control/xferfcn.py | 6 ++---- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/control/dtime.py b/control/dtime.py index 8f3e00071..c60778d00 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -5,6 +5,7 @@ Routines in this module: sample_system() +c2d() """ """Copyright (c) 2012 by California Institute of Technology @@ -58,16 +59,19 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): Parameters ---------- - sysc : LTI (StateSpace or TransferFunction) + sysc : LTI (:class:`StateSpace` or :class:`TransferFunction`) Continuous time system to be converted - Ts : real > 0 + Ts : float > 0 Sampling period method : string Method to use for conversion, e.g. 'bilinear', 'zoh' (default) - - prewarp_frequency : real within [0, infinity) + alpha : float within [0, 1] + The generalized bilinear transformation weighting parameter, which + should only be specified with method="gbt", and is ignored + otherwise. See :func:`scipy.signal.cont2discrete`. + prewarp_frequency : float within [0, infinity) The frequency [rad/s] at which to match with the input continuous- - time system's magnitude and phase + time system's magnitude and phase (only valid for method='bilinear') Returns ------- @@ -76,7 +80,7 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): Notes ----- - See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample`` for + See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample` for further details. Examples @@ -99,20 +103,19 @@ def c2d(sysc, Ts, method='zoh', prewarp_frequency=None): Parameters ---------- - sysc : LTI (StateSpace or TransferFunction) + sysc : LTI (:class:`StateSpace` or :class:`TransferFunction`) Continuous time system to be converted - Ts : real > 0 + Ts : float > 0 Sampling period method : string Method to use for conversion, e.g. 'bilinear', 'zoh' (default) - prewarp_frequency : real within [0, infinity) The frequency [rad/s] at which to match with the input continuous- - time system's magnitude and phase + time system's magnitude and phase (only valid for method='bilinear') Returns ------- - sysd : linsys + sysd : LTI of the same class Discrete time system, with sampling rate Ts Notes diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py index 379098ff2..5a1a367ab 100644 --- a/control/tests/discrete_test.py +++ b/control/tests/discrete_test.py @@ -7,8 +7,8 @@ import pytest from control import (StateSpace, TransferFunction, bode, common_timebase, - evalfr, feedback, forced_response, impulse_response, - isctime, isdtime, rss, sample_system, step_response, + feedback, forced_response, impulse_response, + isctime, isdtime, rss, c2d, sample_system, step_response, timebase) @@ -382,10 +382,20 @@ def test_sample_system_prewarp(self, tsys, plantname): Ts = 0.025 # test state space version plant = getattr(tsys, plantname) + plant_fr = plant(wwarp * 1j) + plant_d_warped = plant.sample(Ts, 'bilinear', prewarp_frequency=wwarp) - plant_fr = evalfr(plant, wwarp * 1j) dt = plant_d_warped.dt - plant_d_fr = evalfr(plant_d_warped, np.exp(wwarp * 1.j * dt)) + plant_d_fr = plant_d_warped(np.exp(wwarp * 1.j * dt)) + np.testing.assert_array_almost_equal(plant_fr, plant_d_fr) + + plant_d_warped = sample_system(plant, Ts, 'bilinear', + prewarp_frequency=wwarp) + plant_d_fr = plant_d_warped(np.exp(wwarp * 1.j * dt)) + np.testing.assert_array_almost_equal(plant_fr, plant_d_fr) + + plant_d_warped = c2d(plant, Ts, 'bilinear', prewarp_frequency=wwarp) + plant_d_fr = plant_d_warped(np.exp(wwarp * 1.j * dt)) np.testing.assert_array_almost_equal(plant_fr, plant_d_fr) def test_sample_system_errors(self, tsys): diff --git a/control/xferfcn.py b/control/xferfcn.py index dc6672b33..356bf0e18 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1086,12 +1086,10 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): * euler: Euler (or forward difference) method ("gbt" with alpha=0) * backward_diff: Backwards difference ("gbt" with alpha=1.0) * zoh: zero-order hold (default) - alpha : float within [0, 1] The generalized bilinear transformation weighting parameter, which should only be specified with method="gbt", and is ignored - otherwise. - + otherwise. See :func:`scipy.signal.cont2discrete`. prewarp_frequency : float within [0, infinity) The frequency [rad/s] at which to match with the input continuous- time system's magnitude and phase (the gain=1 crossover frequency, @@ -1101,7 +1099,7 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): Returns ------- sysd : TransferFunction system - Discrete time system, with sampling rate Ts + Discrete time system, with sample period Ts Notes -----