From 578cfa667d80f1c4ec0373d7ac97b80dc631c3e0 Mon Sep 17 00:00:00 2001 From: bnavigator Date: Thu, 31 Dec 2020 00:34:13 +0100 Subject: [PATCH 01/13] Revert "make tests work with pre #431 source code state" from #438 This reverts commit 2b98769c9e51ca45291b39c6e5fa53f27a6357d9. --- control/tests/config_test.py | 1 - control/tests/discrete_test.py | 75 +++++++++++++++++++++--------- control/tests/iosys_test.py | 30 ++++++------ control/tests/lti_test.py | 83 +++++++++++++++++++++++++++++++++- control/tests/statesp_test.py | 34 ++++++++++++-- control/tests/xferfcn_test.py | 1 - 6 files changed, 181 insertions(+), 43 deletions(-) diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 3979ffca5..ede683fe1 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -244,7 +244,6 @@ def test_change_default_dt(self, dt): # lambda t, x, u: x, inputs=1, outputs=1) # assert nlsys.dt == dt - @pytest.mark.skip("implemented in gh-431") def test_change_default_dt_static(self): """Test that static gain systems always have dt=None""" ct.set_defaults('control', default_dt=0) diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py index 7aee216d4..ffdd1aeb4 100644 --- a/control/tests/discrete_test.py +++ b/control/tests/discrete_test.py @@ -6,9 +6,10 @@ import numpy as np import pytest -from control import StateSpace, TransferFunction, feedback, step_response, \ - isdtime, timebase, isctime, sample_system, bode, impulse_response, \ - evalfr, timebaseEqual, forced_response, rss +from control import (StateSpace, TransferFunction, bode, common_timebase, + evalfr, feedback, forced_response, impulse_response, + isctime, isdtime, rss, sample_system, step_response, + timebase) class TestDiscrete: @@ -51,13 +52,21 @@ class Tsys: return T - def testTimebaseEqual(self, tsys): - """Test for equal timebases and not so equal ones""" - assert timebaseEqual(tsys.siso_ss1, tsys.siso_tf1) - assert timebaseEqual(tsys.siso_ss1, tsys.siso_ss1c) - assert not timebaseEqual(tsys.siso_ss1d, tsys.siso_ss1c) - assert not timebaseEqual(tsys.siso_ss1d, tsys.siso_ss2d) - assert not timebaseEqual(tsys.siso_ss1d, tsys.siso_ss3d) + def testCompatibleTimebases(self, tsys): + """test that compatible timebases don't throw errors and vice versa""" + common_timebase(tsys.siso_ss1.dt, tsys.siso_tf1.dt) + common_timebase(tsys.siso_ss1.dt, tsys.siso_ss1c.dt) + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss1.dt) + common_timebase(tsys.siso_ss1.dt, tsys.siso_ss1d.dt) + common_timebase(tsys.siso_ss1.dt, tsys.siso_ss1d.dt) + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss3d.dt) + common_timebase(tsys.siso_ss3d.dt, tsys.siso_ss1d.dt) + with pytest.raises(ValueError): + # cont + discrete + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss1c.dt) + with pytest.raises(ValueError): + # incompatible discrete + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss2d.dt) def testSystemInitialization(self, tsys): # Check to make sure systems are discrete time with proper variables @@ -75,6 +84,18 @@ def testSystemInitialization(self, tsys): assert tsys.siso_tf2d.dt == 0.2 assert tsys.siso_tf3d.dt is True + # keyword argument check + # dynamic systems + assert TransferFunction(1, [1, 1], dt=0.1).dt == 0.1 + assert TransferFunction(1, [1, 1], 0.1).dt == 0.1 + assert StateSpace(1,1,1,1, dt=0.1).dt == 0.1 + assert StateSpace(1,1,1,1, 0.1).dt == 0.1 + # static gain system, dt argument should still override default dt + assert TransferFunction(1, [1,], dt=0.1).dt == 0.1 + assert TransferFunction(1, [1,], 0.1).dt == 0.1 + assert StateSpace(0,0,1,1, dt=0.1).dt == 0.1 + assert StateSpace(0,0,1,1, 0.1).dt == 0.1 + def testCopyConstructor(self, tsys): for sys in (tsys.siso_ss1, tsys.siso_ss1c, tsys.siso_ss1d): newsys = StateSpace(sys) @@ -114,6 +135,7 @@ def test_timebase_conversions(self, tsys): assert timebase(tf1*tf2) == timebase(tf2) assert timebase(tf1*tf3) == timebase(tf3) assert timebase(tf1*tf4) == timebase(tf4) + assert timebase(tf3*tf4) == timebase(tf4) assert timebase(tf2*tf1) == timebase(tf2) assert timebase(tf3*tf1) == timebase(tf3) assert timebase(tf4*tf1) == timebase(tf4) @@ -128,33 +150,36 @@ def test_timebase_conversions(self, tsys): # Make sure discrete time without sampling is converted correctly assert timebase(tf3*tf3) == timebase(tf3) + assert timebase(tf3*tf4) == timebase(tf4) assert timebase(tf3+tf3) == timebase(tf3) + assert timebase(tf3+tf4) == timebase(tf4) assert timebase(feedback(tf3, tf3)) == timebase(tf3) + assert timebase(feedback(tf3, tf4)) == timebase(tf4) # Make sure all other combinations are errors - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf2 * tf3 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf3 * tf2 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf2 * tf4 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf4 * tf2 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf2 + tf3 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf3 + tf2 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf2 + tf4 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf4 + tf2 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): feedback(tf2, tf3) - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): feedback(tf3, tf2) - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): feedback(tf2, tf4) - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): feedback(tf4, tf2) def testisdtime(self, tsys): @@ -212,6 +237,7 @@ def testAddition(self, tsys): sys = tsys.siso_ss1c + tsys.siso_ss1c sys = tsys.siso_ss1d + tsys.siso_ss1d sys = tsys.siso_ss3d + tsys.siso_ss3d + sys = tsys.siso_ss1d + tsys.siso_ss3d with pytest.raises(ValueError): StateSpace.__add__(tsys.mimo_ss1c, tsys.mimo_ss1d) @@ -228,6 +254,7 @@ def testAddition(self, tsys): sys = tsys.siso_tf1c + tsys.siso_tf1c sys = tsys.siso_tf1d + tsys.siso_tf1d sys = tsys.siso_tf2d + tsys.siso_tf2d + sys = tsys.siso_tf1d + tsys.siso_tf3d with pytest.raises(ValueError): TransferFunction.__add__(tsys.siso_tf1c, tsys.siso_tf1d) @@ -252,6 +279,7 @@ def testMultiplication(self, tsys): sys = tsys.siso_ss1d * tsys.siso_ss1 sys = tsys.siso_ss1c * tsys.siso_ss1c sys = tsys.siso_ss1d * tsys.siso_ss1d + sys = tsys.siso_ss1d * tsys.siso_ss3d with pytest.raises(ValueError): StateSpace.__mul__(tsys.mimo_ss1c, tsys.mimo_ss1d) @@ -267,6 +295,7 @@ def testMultiplication(self, tsys): sys = tsys.siso_tf1d * tsys.siso_tf1 sys = tsys.siso_tf1c * tsys.siso_tf1c sys = tsys.siso_tf1d * tsys.siso_tf1d + sys = tsys.siso_tf1d * tsys.siso_tf3d with pytest.raises(ValueError): TransferFunction.__mul__(tsys.siso_tf1c, tsys.siso_tf1d) @@ -293,6 +322,7 @@ def testFeedback(self, tsys): sys = feedback(tsys.siso_ss1d, tsys.siso_ss1) sys = feedback(tsys.siso_ss1c, tsys.siso_ss1c) sys = feedback(tsys.siso_ss1d, tsys.siso_ss1d) + sys = feedback(tsys.siso_ss1d, tsys.siso_ss3d) with pytest.raises(ValueError): feedback(tsys.mimo_ss1c, tsys.mimo_ss1d) @@ -308,6 +338,7 @@ def testFeedback(self, tsys): sys = feedback(tsys.siso_tf1d, tsys.siso_tf1) sys = feedback(tsys.siso_tf1c, tsys.siso_tf1c) sys = feedback(tsys.siso_tf1d, tsys.siso_tf1d) + sys = feedback(tsys.siso_tf1d, tsys.siso_tf3d) with pytest.raises(ValueError): feedback(tsys.siso_tf1c, tsys.siso_tf1d) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 740416507..2bb6f066c 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -29,17 +29,17 @@ class TSys: """Return some test systems""" # Create a single input/single output linear system T.siso_linsys = ct.StateSpace( - [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[0]], 0) + [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[0]]) # Create a multi input/multi output linear system T.mimo_linsys1 = ct.StateSpace( [[-1, 1], [0, -2]], [[1, 0], [0, 1]], - [[1, 0], [0, 1]], np.zeros((2, 2)), 0) + [[1, 0], [0, 1]], np.zeros((2, 2))) # Create a multi input/multi output linear system T.mimo_linsys2 = ct.StateSpace( [[-1, 1], [0, -2]], [[0, 1], [1, 0]], - [[1, 0], [0, 1]], np.zeros((2, 2)), 0) + [[1, 0], [0, 1]], np.zeros((2, 2))) # Create simulation parameters T.T = np.linspace(0, 10, 100) @@ -281,7 +281,7 @@ def test_algebraic_loop(self, tsys): linsys = tsys.siso_linsys lnios = ios.LinearIOSystem(linsys) nlios = ios.NonlinearIOSystem(None, \ - lambda t, x, u, params: u*u, inputs=1, outputs=1, dt=0) + lambda t, x, u, params: u*u, inputs=1, outputs=1) nlios1 = nlios.copy() nlios2 = nlios.copy() @@ -310,7 +310,7 @@ def test_algebraic_loop(self, tsys): iosys = ios.InterconnectedSystem( (lnios, nlios), # linear system w/ nonlinear feedback ((1,), # feedback interconnection (sig to 0) - (0, (1, 0, -1))), + (0, (1, 0, -1))), 0, # input to linear system 0 # output from linear system ) @@ -331,7 +331,7 @@ def test_algebraic_loop(self, tsys): # Algebraic loop due to feedthrough term linsys = ct.StateSpace( - [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[1]], 0) + [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[1]]) lnios = ios.LinearIOSystem(linsys) iosys = ios.InterconnectedSystem( (nlios, lnios), # linear system w/ nonlinear feedback @@ -374,7 +374,7 @@ def test_rmul(self, tsys): # Also creates a nested interconnected system ioslin = ios.LinearIOSystem(tsys.siso_linsys) nlios = ios.NonlinearIOSystem(None, \ - lambda t, x, u, params: u*u, inputs=1, outputs=1, dt=0) + lambda t, x, u, params: u*u, inputs=1, outputs=1) sys1 = nlios * ioslin sys2 = ios.InputOutputSystem.__rmul__(nlios, sys1) @@ -414,7 +414,7 @@ def test_feedback(self, tsys): # Linear system with constant feedback (via "nonlinear" mapping) ioslin = ios.LinearIOSystem(tsys.siso_linsys) nlios = ios.NonlinearIOSystem(None, \ - lambda t, x, u, params: u, inputs=1, outputs=1, dt=0) + lambda t, x, u, params: u, inputs=1, outputs=1) iosys = ct.feedback(ioslin, nlios) linsys = ct.feedback(tsys.siso_linsys, 1) @@ -740,7 +740,7 @@ def test_named_signals(self, tsys): inputs = ('u[0]', 'u[1]'), outputs = ('y[0]', 'y[1]'), states = tsys.mimo_linsys1.states, - name = 'sys1', dt=0) + name = 'sys1') sys2 = ios.LinearIOSystem(tsys.mimo_linsys2, inputs = ('u[0]', 'u[1]'), outputs = ('y[0]', 'y[1]'), @@ -1015,7 +1015,7 @@ def test_duplicates(self, tsys): nlios = ios.NonlinearIOSystem(lambda t, x, u, params: x, lambda t, x, u, params: u * u, inputs=1, outputs=1, states=1, - name="sys", dt=0) + name="sys") # Duplicate objects with pytest.warns(UserWarning, match="Duplicate object"): @@ -1024,7 +1024,7 @@ def test_duplicates(self, tsys): # Nonduplicate objects nlios1 = nlios.copy() nlios2 = nlios.copy() - with pytest.warns(UserWarning, match="Duplicate name"): + with pytest.warns(UserWarning, match="copy of sys") as record: ios_series = nlios1 * nlios2 assert "copy of sys_1.x[0]" in ios_series.state_index.keys() assert "copy of sys.x[0]" in ios_series.state_index.keys() @@ -1033,10 +1033,10 @@ def test_duplicates(self, tsys): iosys_siso = ct.LinearIOSystem(tsys.siso_linsys) nlios1 = ios.NonlinearIOSystem(None, lambda t, x, u, params: u * u, - inputs=1, outputs=1, name="sys", dt=0) + inputs=1, outputs=1, name="sys") nlios2 = ios.NonlinearIOSystem(None, lambda t, x, u, params: u * u, - inputs=1, outputs=1, name="sys", dt=0) + inputs=1, outputs=1, name="sys") with pytest.warns(UserWarning, match="Duplicate name"): ct.InterconnectedSystem((nlios1, iosys_siso, nlios2), @@ -1045,10 +1045,10 @@ def test_duplicates(self, tsys): # Same system, different names => everything should be OK nlios1 = ios.NonlinearIOSystem(None, lambda t, x, u, params: u * u, - inputs=1, outputs=1, name="nlios1", dt=0) + inputs=1, outputs=1, name="nlios1") nlios2 = ios.NonlinearIOSystem(None, lambda t, x, u, params: u * u, - inputs=1, outputs=1, name="nlios2", dt=0) + inputs=1, outputs=1, name="nlios2") with pytest.warns(None) as record: ct.InterconnectedSystem((nlios1, iosys_siso, nlios2), inputs=0, outputs=0, states=0) diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index 762e1435a..ee9d95a09 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -4,7 +4,7 @@ import pytest from control import c2d, tf, tf2ss, NonlinearIOSystem -from control.lti import (LTI, damp, dcgain, isctime, isdtime, +from control.lti import (LTI, common_timebase, damp, dcgain, isctime, isdtime, issiso, pole, timebaseEqual, zero) from control.tests.conftest import slycotonly @@ -72,3 +72,84 @@ def test_dcgain(self): sys = tf(84, [1, 2]) np.testing.assert_equal(sys.dcgain(), 42) np.testing.assert_equal(dcgain(sys), 42) + + @pytest.mark.parametrize("dt1, dt2, expected", + [(None, None, True), + (None, 0, True), + (None, 1, True), + pytest.param(None, True, True, + marks=pytest.mark.xfail( + reason="returns false")), + (0, 0, True), + (0, 1, False), + (0, True, False), + (1, 1, True), + (1, 2, False), + (1, True, False), + (True, True, True)]) + def test_timebaseEqual_deprecated(self, dt1, dt2, expected): + """Test that timbaseEqual throws a warning and returns as documented""" + sys1 = tf([1], [1, 2, 3], dt1) + sys2 = tf([1], [1, 4, 5], dt2) + + print(sys1.dt) + print(sys2.dt) + + with pytest.deprecated_call(): + assert timebaseEqual(sys1, sys2) is expected + # Make sure behaviour is symmetric + with pytest.deprecated_call(): + assert timebaseEqual(sys2, sys1) is expected + + @pytest.mark.parametrize("dt1, dt2, expected", + [(None, None, None), + (None, 0, 0), + (None, 1, 1), + (None, True, True), + (True, True, True), + (True, 1, 1), + (1, 1, 1), + (0, 0, 0), + ]) + @pytest.mark.parametrize("sys1", [True, False]) + @pytest.mark.parametrize("sys2", [True, False]) + def test_common_timebase(self, dt1, dt2, expected, sys1, sys2): + """Test that common_timbase adheres to :ref:`conventions-ref`""" + i1 = tf([1], [1, 2, 3], dt1) if sys1 else dt1 + i2 = tf([1], [1, 4, 5], dt2) if sys2 else dt2 + assert common_timebase(i1, i2) == expected + # Make sure behaviour is symmetric + assert common_timebase(i2, i1) == expected + + @pytest.mark.parametrize("i1, i2", + [(True, 0), + (0, 1), + (1, 2)]) + def test_common_timebase_errors(self, i1, i2): + """Test that common_timbase throws errors on invalid combinations""" + with pytest.raises(ValueError): + common_timebase(i1, i2) + # Make sure behaviour is symmetric + with pytest.raises(ValueError): + common_timebase(i2, i1) + + @pytest.mark.parametrize("dt, ref, strictref", + [(None, True, False), + (0, False, False), + (1, True, True), + (True, True, True)]) + @pytest.mark.parametrize("objfun, arg", + [(LTI, ()), + (NonlinearIOSystem, (lambda x: x, ))]) + def test_isdtime(self, objfun, arg, dt, ref, strictref): + """Test isdtime and isctime functions to follow convention""" + obj = objfun(*arg, dt=dt) + + assert isdtime(obj) == ref + assert isdtime(obj, strict=True) == strictref + + if dt is not None: + ref = not ref + strictref = not strictref + assert isctime(obj) == ref + assert isctime(obj, strict=True) == strictref diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index c7b0a0aaf..9dbb6da94 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -80,13 +80,16 @@ def sys623(self): @pytest.mark.parametrize( "dt", - [(None, ), (0, ), (1, ), (0.1, ), (True, )], + [(), (None, ), (0, ), (1, ), (0.1, ), (True, )], ids=lambda i: "dt " + ("unspec" if len(i) == 0 else str(i[0]))) @pytest.mark.parametrize( "argfun", [pytest.param( lambda ABCDdt: (ABCDdt, {}), id="A, B, C, D[, dt]"), + pytest.param( + lambda ABCDdt: (ABCDdt[:4], {'dt': dt_ for dt_ in ABCDdt[4:]}), + id="A, B, C, D[, dt=dt]"), pytest.param( lambda ABCDdt: ((StateSpace(*ABCDdt), ), {}), id="sys") @@ -106,7 +109,7 @@ def test_constructor(self, sys322ABCD, dt, argfun): @pytest.mark.parametrize("args, exc, errmsg", [((True, ), TypeError, "(can only take in|sys must be) a StateSpace"), - ((1, 2), ValueError, "1 or 4 arguments"), + ((1, 2), ValueError, "1, 4, or 5 arguments"), ((np.ones((3, 2)), np.ones((3, 2)), np.ones((2, 2)), np.ones((2, 2))), ValueError, "A must be square"), @@ -130,6 +133,16 @@ def test_constructor_invalid(self, args, exc, errmsg): with pytest.raises(exc, match=errmsg): ss(*args) + def test_constructor_warns(self, sys322ABCD): + """Test ambiguos input to StateSpace() constructor""" + with pytest.warns(UserWarning, match="received multiple dt"): + sys = StateSpace(*(sys322ABCD + (0.1, )), dt=0.2) + np.testing.assert_almost_equal(sys.A, sys322ABCD[0]) + np.testing.assert_almost_equal(sys.B, sys322ABCD[1]) + np.testing.assert_almost_equal(sys.C, sys322ABCD[2]) + np.testing.assert_almost_equal(sys.D, sys322ABCD[3]) + assert sys.dt == 0.1 + def test_copy_constructor(self): """Test the copy constructor""" # Create a set of matrices for a simple linear system @@ -151,6 +164,22 @@ def test_copy_constructor(self): linsys.A[0, 0] = -3 np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value + def test_copy_constructor_nodt(self, sys322): + """Test the copy constructor when an object without dt is passed + + FIXME: may be obsolete in case gh-431 is updated + """ + sysin = sample_system(sys322, 1.) + del sysin.dt + sys = StateSpace(sysin) + assert sys.dt == defaults['control.default_dt'] + + # test for static gain + sysin = StateSpace([], [], [], [[1, 2], [3, 4]], 1.) + del sysin.dt + sys = StateSpace(sysin) + assert sys.dt is None + def test_matlab_style_constructor(self): """Use (deprecated) matrix-style construction string""" with pytest.deprecated_call(): @@ -353,7 +382,6 @@ def test_freq_resp(self): np.testing.assert_almost_equal(phase, true_phase) np.testing.assert_equal(omega, true_omega) - @pytest.mark.skip("is_static_gain is introduced in gh-431") def test_is_static_gain(self): A0 = np.zeros((2,2)) A1 = A0.copy() diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 62c4bfb23..b0673de1e 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -409,7 +409,6 @@ def test_evalfr_siso(self, dt, omega, resp): resp, atol=1e-3) - @pytest.mark.skip("is_static_gain is introduced in gh-431") def test_is_static_gain(self): numstatic = 1.1 denstatic = 1.2 From 86401d66e0f3b42e0c88f4544f23556280d0dab5 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 16 Jul 2020 21:01:58 -0700 Subject: [PATCH 02/13] set default dt to be 0 instead of None. --- control/statesp.py | 6 +++--- control/xferfcn.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index d23fbd7be..acefe3e1e 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -72,9 +72,9 @@ # Define module default parameter values _statesp_defaults = { 'statesp.use_numpy_matrix': False, # False is default in 0.9.0 and above - 'statesp.default_dt': None, + 'statesp.default_dt': 0, 'statesp.remove_useless_states': True, - } +} def _ssmatrix(data, axis=1): @@ -974,7 +974,7 @@ def dcgain(self): return np.squeeze(gain) def is_static_gain(self): - """True if and only if the system has no dynamics, that is, + """True if and only if the system has no dynamics, that is, if A and B are zero. """ return not np.any(self.A) and not np.any(self.B) diff --git a/control/xferfcn.py b/control/xferfcn.py index 4077080e3..0c5e1fdcb 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -71,7 +71,7 @@ # Define module default parameter values _xferfcn_defaults = { - 'xferfcn.default_dt': None} + 'xferfcn.default_dt': 0} class TransferFunction(LTI): @@ -1597,6 +1597,20 @@ def _clean_part(data): return data +def _isstaticgain(num, den): + """returns true if and only if all of the numerator and denominator + polynomials of the (possibly MIMO) transfer funnction are zeroth order, + that is, if the system has no dynamics. """ + num, den = _clean_part(num), _clean_part(den) + for m in range(len(num)): + for n in range(len(num[m])): + if len(num[m][n]) > 1: + return False + for m in range(len(den)): + for n in range(len(den[m])): + if len(den[m][n]) > 1: + return False + return True # Define constants to represent differentiation, unit delay TransferFunction.s = TransferFunction([1, 0], [1], 0) From 1ed9ee272d631206ea762b4ac5dc33dad32800cd Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 17 Jul 2020 15:40:45 -0700 Subject: [PATCH 03/13] changes so that a default dt=0 passes unit tests. fixed code everywhere that combines systems with different timebases --- control/config.py | 10 ++++- control/iosys.py | 31 ++++++------- control/lti.py | 71 +++++++++++------------------ control/statesp.py | 71 +++++++++++------------------ control/tests/config_test.py | 3 +- control/tests/matlab_test.py | 26 +++++------ control/xferfcn.py | 86 +++++++++++------------------------- 7 files changed, 116 insertions(+), 182 deletions(-) diff --git a/control/config.py b/control/config.py index 800fe7f26..7d0d6907f 100644 --- a/control/config.py +++ b/control/config.py @@ -59,6 +59,9 @@ def reset_defaults(): from .statesp import _statesp_defaults defaults.update(_statesp_defaults) + from .iosys import _iosys_defaults + defaults.update(_iosys_defaults) + def _get_param(module, param, argval=None, defval=None, pop=False): """Return the default value for a configuration option. @@ -208,8 +211,13 @@ def use_legacy_defaults(version): # Go backwards through releases and reset defaults # - # Version 0.9.0: switched to 'array' as default for state space objects + # Version 0.9.0: if major == 0 and minor < 9: + # switched to 'array' as default for state space objects set_defaults('statesp', use_numpy_matrix=True) + # switched to 0 (=continuous) as default timestep + set_defaults('statesp', default_dt=None) + set_defaults('xferfcn', default_dt=None) + set_defaults('iosys', default_dt=None) return (major, minor, patch) diff --git a/control/iosys.py b/control/iosys.py index a90b5193c..720767289 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -38,12 +38,16 @@ from .statesp import StateSpace, tf2ss from .timeresp import _check_convert_array -from .lti import isctime, isdtime, _find_timebase +from .lti import isctime, isdtime, common_timebase +from . import config __all__ = ['InputOutputSystem', 'LinearIOSystem', 'NonlinearIOSystem', 'InterconnectedSystem', 'input_output_response', 'find_eqpt', 'linearize', 'ss2io', 'tf2io'] +# Define module default parameter values +_iosys_defaults = { + 'iosys.default_dt': 0} class InputOutputSystem(object): """A class for representing input/output systems. @@ -118,7 +122,7 @@ def name_or_default(self, name=None): return name def __init__(self, inputs=None, outputs=None, states=None, params={}, - dt=None, name=None): + name=None, **kwargs): """Create an input/output system. The InputOutputSystem contructor is used to create an input/output @@ -163,7 +167,7 @@ def __init__(self, inputs=None, outputs=None, states=None, params={}, """ # Store the input arguments self.params = params.copy() # default parameters - self.dt = dt # timebase + self.dt = kwargs.get('dt', config.defaults['iosys.default_dt']) # timebase self.name = self.name_or_default(name) # system name # Parse and store the number of inputs, outputs, and states @@ -210,9 +214,7 @@ def __mul__(sys2, sys1): "inputs and outputs") # Make sure timebase are compatible - dt = _find_timebase(sys1, sys2) - if dt is False: - raise ValueError("System timebases are not compabile") + dt = common_timebase(sys1.dt, sys2.dt) inplist = [(0,i) for i in range(sys1.ninputs)] outlist = [(1,i) for i in range(sys2.noutputs)] @@ -464,12 +466,11 @@ def feedback(self, other=1, sign=-1, params={}): "inputs and outputs") # Make sure timebases are compatible - dt = _find_timebase(self, other) - if dt is False: - raise ValueError("System timebases are not compabile") + dt = common_timebase(self.dt, other.dt) inplist = [(0,i) for i in range(self.ninputs)] outlist = [(0,i) for i in range(self.noutputs)] + # Return the series interconnection between the systems newsys = InterconnectedSystem((self, other), inplist=inplist, outlist=outlist, params=params, dt=dt) @@ -650,7 +651,8 @@ class NonlinearIOSystem(InputOutputSystem): """ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, - states=None, params={}, dt=None, name=None): + states=None, params={}, + name=None, **kwargs): """Create a nonlinear I/O system given update and output functions. Creates an `InputOutputSystem` for a nonlinear system by specifying a @@ -722,6 +724,7 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, self.outfcn = outfcn # Initialize the rest of the structure + dt = kwargs.get('dt', config.defaults['iosys.default_dt']) super(NonlinearIOSystem, self).__init__( inputs=inputs, outputs=outputs, states=states, params=params, dt=dt, name=name @@ -888,7 +891,6 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], # Check to make sure all systems are consistent self.syslist = syslist self.syslist_index = {} - dt = None nstates = 0; self.state_offset = [] ninputs = 0; self.input_offset = [] noutputs = 0; self.output_offset = [] @@ -896,12 +898,7 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], sysname_count_dct = {} for sysidx, sys in enumerate(syslist): # Make sure time bases are consistent - # TODO: Use lti._find_timebase() instead? - if dt is None and sys.dt is not None: - # Timebase was not specified; set to match this system - dt = sys.dt - elif dt != sys.dt: - raise TypeError("System timebases are not compatible") + dt = common_timebase(dt, sys.dt) # Make sure number of inputs, outputs, states is given if sys.ninputs is None or sys.noutputs is None or \ diff --git a/control/lti.py b/control/lti.py index 8db14794b..d0802d760 100644 --- a/control/lti.py +++ b/control/lti.py @@ -9,13 +9,13 @@ isdtime() isctime() timebase() -timebaseEqual() +common_timebase() """ import numpy as np from numpy import absolute, real -__all__ = ['issiso', 'timebase', 'timebaseEqual', 'isdtime', 'isctime', +__all__ = ['issiso', 'timebase', 'common_timebase', 'isdtime', 'isctime', 'pole', 'zero', 'damp', 'evalfr', 'freqresp', 'dcgain'] class LTI: @@ -157,48 +157,31 @@ def timebase(sys, strict=True): return sys.dt -# Check to see if two timebases are equal -def timebaseEqual(sys1, sys2): - """Check to see if two systems have the same timebase - - timebaseEqual(sys1, sys2) - - returns True if the timebases for the two systems are compatible. By - default, systems with timebase 'None' are compatible with either - discrete or continuous timebase systems. If two systems have a discrete - timebase (dt > 0) then their timebases must be equal. - """ - - if (type(sys1.dt) == bool or type(sys2.dt) == bool): - # Make sure both are unspecified discrete timebases - return type(sys1.dt) == type(sys2.dt) and sys1.dt == sys2.dt - elif (sys1.dt is None or sys2.dt is None): - # One or the other is unspecified => the other can be anything - return True - else: - return sys1.dt == sys2.dt - -# Find a common timebase between two or more systems -def _find_timebase(sys1, *sysn): - """Find the common timebase between systems, otherwise return False""" - - # Create a list of systems to check - syslist = [sys1] - syslist.append(*sysn) - - # Look for a common timebase - dt = None - - for sys in syslist: - # Make sure time bases are consistent - if (dt is None and sys.dt is not None) or \ - (dt is True and isdiscrete(sys)): - # Timebase was not specified; set to match this system - dt = sys.dt - elif dt != sys.dt: - return False - return dt - +def common_timebase(dt1, dt2): + """Find the common timebase when interconnecting systems.""" + # cases: + # if either dt is None, they are compatible with anything + # if either dt is True (discrete with unspecified time base), + # use the timebase of the other, if it is also discrete + # otherwise they must be equal (holds for both cont and discrete systems) + if dt1 is None: + return dt2 + elif dt2 is None: + return dt1 + elif dt1 is True: + if dt2 > 0: + return dt2 + else: + raise ValueError("Systems have incompatible timebases") + elif dt2 is True: + if dt1 > 0: + return dt1 + else: + raise ValueError("Systems have incompatible timebases") + elif np.isclose(dt1, dt2): + return dt1 + else: + raise ValueError("Systems have incompatible timebases") # Check to see if a system is a discrete time system def isdtime(sys, strict=False): diff --git a/control/statesp.py b/control/statesp.py index acefe3e1e..09d4da55b 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -62,7 +62,7 @@ from scipy.signal import cont2discrete from scipy.signal import StateSpace as signalStateSpace from warnings import warn -from .lti import LTI, timebase, timebaseEqual, isdtime +from .lti import LTI, common_timebase, isdtime from . import config from copy import deepcopy @@ -180,7 +180,10 @@ def __init__(self, *args, **kw): if len(args) == 4: # The user provided A, B, C, and D matrices. (A, B, C, D) = args - dt = config.defaults['statesp.default_dt'] + if _isstaticgain(A, B, C, D): + dt = None + else: + dt = config.defaults['statesp.default_dt'] elif len(args) == 5: # Discrete time system (A, B, C, D, dt) = args @@ -196,9 +199,12 @@ def __init__(self, *args, **kw): try: dt = args[0].dt except NameError: - dt = config.defaults['statesp.default_dt'] + if _isstaticgain(A, B, C, D): + dt = None + else: + dt = config.defaults['statesp.default_dt'] else: - raise ValueError("Needs 1 or 4 arguments; received %i." % len(args)) + raise ValueError("Expected 1, 4, or 5 arguments; received %i." % len(args)) # Process keyword arguments remove_useless = kw.get('remove_useless', @@ -330,14 +336,7 @@ def __add__(self, other): (self.outputs != other.outputs)): raise ValueError("Systems have different shapes.") - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (timebaseEqual(self, other)): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) # Concatenate the various arrays A = concatenate(( @@ -386,16 +385,8 @@ def __mul__(self, other): # Check to make sure the dimensions are OK if self.inputs != other.outputs: raise ValueError("C = A * B: A has %i column(s) (input(s)), \ -but B has %i row(s)\n(output(s))." % (self.inputs, other.outputs)) - - # Figure out the sampling time to use - if (self.dt == None and other.dt != None): - dt = other.dt # use dt from second argument - elif (other.dt == None and self.dt != None) or \ - (timebaseEqual(self, other)): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + but B has %i row(s)\n(output(s))." % (self.inputs, other.outputs)) + dt = common_timebase(self.dt, other.dt) # Concatenate the various arrays A = concatenate( @@ -467,9 +458,8 @@ def _evalfr(self, omega): """Evaluate a SS system's transfer function at a single frequency""" # Figure out the point to evaluate the transfer function if isdtime(self, strict=True): - dt = timebase(self) - s = exp(1.j * omega * dt) - if omega * dt > math.pi: + s = exp(1.j * omega * self.dt) + if omega * self.dt > math.pi: warn("_evalfr: frequency evaluation above Nyquist frequency") else: s = omega * 1.j @@ -526,9 +516,8 @@ def freqresp(self, omega): # axis (continuous time) or unit circle (discrete time). omega.sort() if isdtime(self, strict=True): - dt = timebase(self) - cmplx_freqs = exp(1.j * omega * dt) - if max(np.abs(omega)) * dt > math.pi: + cmplx_freqs = exp(1.j * omega * self.dt) + if max(np.abs(omega)) * self.dt > math.pi: warn("freqresp: frequency evaluation above Nyquist frequency") else: cmplx_freqs = omega * 1.j @@ -631,14 +620,7 @@ def feedback(self, other=1, sign=-1): if (self.inputs != other.outputs) or (self.outputs != other.inputs): raise ValueError("State space systems don't have compatible inputs/outputs for " "feedback.") - - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif other.dt is None and self.dt is not None or timebaseEqual(self, other): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) A1 = self.A B1 = self.B @@ -708,14 +690,7 @@ def lft(self, other, nu=-1, ny=-1): # dimension check # TODO - # Figure out the sampling time to use - if (self.dt == None and other.dt != None): - dt = other.dt # use dt from second argument - elif (other.dt == None and self.dt != None) or \ - timebaseEqual(self, other): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different time bases") + dt = common_timebase(self.dt, other.dt) # submatrices A = self.A @@ -858,8 +833,7 @@ def append(self, other): if not isinstance(other, StateSpace): other = _convertToStateSpace(other) - if self.dt != other.dt: - raise ValueError("Systems must have the same time step") + self.dt = common_timebase(self.dt, other.dt) n = self.states + other.states m = self.inputs + other.inputs @@ -1293,6 +1267,11 @@ def _mimo2simo(sys, input, warn_conversion=False): return sys +def _isstaticgain(A, B, C, D): + """returns True if and only if the system has no dynamics, that is, + if A and B are zero. """ + return not np.any(np.matrix(A, dtype=float)) \ + and not np.any(np.matrix(B, dtype=float)) def ss(*args): """ss(A, B, C, D[, dt]) diff --git a/control/tests/config_test.py b/control/tests/config_test.py index ede683fe1..9b03853db 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -246,7 +246,8 @@ def test_change_default_dt(self, dt): def test_change_default_dt_static(self): """Test that static gain systems always have dt=None""" - ct.set_defaults('control', default_dt=0) + ct.set_defaults('xferfcn', default_dt=0) assert ct.tf(1, 1).dt is None + ct.set_defaults('statesp', default_dt=0) assert ct.ss(0, 0, 0, 1).dt is None # TODO: add in test for static gain iosys diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index 6c7f6f14f..3a15a5aff 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -736,20 +736,20 @@ def testCombi01(self): margin command should remove the solution for w = nearly zero. """ # Example is a concocted two-body satellite with flexible link - Jb = 400; - Jp = 1000; - k = 10; - b = 5; + Jb = 400 + Jp = 1000 + k = 10 + b = 5 # can now define an "s" variable, to make TF's - s = tf([1, 0], [1]); - hb1 = 1/(Jb*s); - hb2 = 1/s; - hp1 = 1/(Jp*s); - hp2 = 1/s; + s = tf([1, 0], [1]) + hb1 = 1/(Jb*s) + hb2 = 1/s + hp1 = 1/(Jp*s) + hp2 = 1/s # convert to ss and append - sat0 = append(ss(hb1), ss(hb2), k, b, ss(hp1), ss(hp2)); + sat0 = append(ss(hb1), ss(hb2), k, b, ss(hp1), ss(hp2)) # connection of the elements with connect call Q = [[1, -3, -4], # link moment (spring, damper), feedback to body @@ -758,9 +758,9 @@ def testCombi01(self): [4, 1, -5], # damper input [5, 3, 4], # link moment, acting on payload [6, 5, 0]] - inputs = [1]; - outputs = [1, 2, 5, 6]; - sat1 = connect(sat0, Q, inputs, outputs); + inputs = [1] + outputs = [1, 2, 5, 6] + sat1 = connect(sat0, Q, inputs, outputs) # matched notch filter wno = 0.19 diff --git a/control/xferfcn.py b/control/xferfcn.py index 0c5e1fdcb..fb616d4d9 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -63,7 +63,7 @@ from warnings import warn from itertools import chain from re import sub -from .lti import LTI, timebaseEqual, timebase, isdtime +from .lti import LTI, common_timebase, isdtime from . import config __all__ = ['TransferFunction', 'tf', 'ss2tf', 'tfdata'] @@ -125,7 +125,10 @@ def __init__(self, *args): if len(args) == 2: # The user provided a numerator and a denominator. (num, den) = args - dt = config.defaults['xferfcn.default_dt'] + if _isstaticgain(num, den): + dt = None + else: + dt = config.defaults['xferfcn.default_dt'] elif len(args) == 3: # Discrete time transfer function (num, den, dt) = args @@ -141,7 +144,10 @@ def __init__(self, *args): try: dt = args[0].dt except NameError: # pragma: no coverage - dt = config.defaults['xferfcn.default_dt'] + if _isstaticgain(num, den): + dt = None + else: + dt = config.defaults['xferfcn.default_dt'] else: raise ValueError("Needs 1, 2 or 3 arguments; received %i." % len(args)) @@ -370,14 +376,7 @@ def __add__(self, other): "The first summand has %i output(s), but the second has %i." % (self.outputs, other.outputs)) - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (timebaseEqual(self, other)): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) # Preallocate the numerator and denominator of the sum. num = [[[] for j in range(self.inputs)] for i in range(self.outputs)] @@ -421,15 +420,8 @@ def __mul__(self, other): inputs = other.inputs outputs = self.outputs - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") - + dt = common_timebase(self.dt, other.dt) + # Preallocate the numerator and denominator of the sum. num = [[[0] for j in range(inputs)] for i in range(outputs)] den = [[[1] for j in range(inputs)] for i in range(outputs)] @@ -472,14 +464,7 @@ def __rmul__(self, other): inputs = self.inputs outputs = other.outputs - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) \ - or (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) # Preallocate the numerator and denominator of the sum. num = [[[0] for j in range(inputs)] for i in range(outputs)] @@ -519,14 +504,7 @@ def __truediv__(self, other): "TransferFunction.__truediv__ is currently \ implemented only for SISO systems.") - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) num = polymul(self.num[0][0], other.den[0][0]) den = polymul(self.den[0][0], other.num[0][0]) @@ -626,8 +604,7 @@ def _evalfr(self, omega): # TODO: implement for discrete time systems if isdtime(self, strict=True): # Convert the frequency to discrete time - dt = timebase(self) - s = exp(1.j * omega * dt) + s = exp(1.j * omega * self.dt) if np.any(omega * dt > pi): warn("_evalfr: frequency evaluation above Nyquist frequency") else: @@ -692,9 +669,8 @@ def freqresp(self, omega): # Figure out the frequencies omega.sort() if isdtime(self, strict=True): - dt = timebase(self) - slist = np.array([exp(1.j * w * dt) for w in omega]) - if max(omega) * dt > pi: + slist = np.array([exp(1.j * w * self.dt) for w in omega]) + if max(omega) * self.dt > pi: warn("freqresp: frequency evaluation above Nyquist frequency") else: slist = np.array([1j * w for w in omega]) @@ -737,15 +713,7 @@ def feedback(self, other=1, sign=-1): raise NotImplementedError( "TransferFunction.feedback is currently only implemented " "for SISO functions.") - - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) num1 = self.num[0][0] den1 = self.den[0][0] @@ -1048,7 +1016,7 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): Returns ------- - sysd : StateSpace system + sysd : TransferFunction system Discrete time system, with sampling rate Ts Notes @@ -1598,18 +1566,16 @@ def _clean_part(data): return data def _isstaticgain(num, den): - """returns true if and only if all of the numerator and denominator + """returns True if and only if all of the numerator and denominator polynomials of the (possibly MIMO) transfer funnction are zeroth order, that is, if the system has no dynamics. """ num, den = _clean_part(num), _clean_part(den) - for m in range(len(num)): - for n in range(len(num[m])): - if len(num[m][n]) > 1: - return False - for m in range(len(den)): - for n in range(len(den[m])): - if len(den[m][n]) > 1: - return False + for list_of_polys in num, den: + for row in list_of_polys: + for poly in row: + poly_trimmed = np.trim_zeros(poly, 'f') # trim leading zeros + if len(poly_trimmed) > 1: + return False return True # Define constants to represent differentiation, unit delay From 5f46b5ba872059c2f93470e023d0a0bc9d8b91f9 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 17 Jul 2020 19:16:18 -0700 Subject: [PATCH 04/13] small bugfix to xferfcn._evalfr to correctly give it dt value --- control/xferfcn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index fb616d4d9..bddc48d61 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -605,7 +605,7 @@ def _evalfr(self, omega): if isdtime(self, strict=True): # Convert the frequency to discrete time s = exp(1.j * omega * self.dt) - if np.any(omega * dt > pi): + if np.any(omega * self.dt > pi): warn("_evalfr: frequency evaluation above Nyquist frequency") else: s = 1.j * omega From 24eeb75ac494ba270346bf9c717fec93bfa218d9 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 20 Jul 2020 09:26:58 -0700 Subject: [PATCH 05/13] re-incorporated lti.timebaseEqual, with a pendingDeprecationWarning --- .vscode/settings.json | 3 +++ control/lti.py | 61 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..36e5778aa --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "restructuredtext.confPath": "${workspaceFolder}/doc" +} \ No newline at end of file diff --git a/control/lti.py b/control/lti.py index d0802d760..e41fe416b 100644 --- a/control/lti.py +++ b/control/lti.py @@ -14,9 +14,11 @@ import numpy as np from numpy import absolute, real +from warnings import warn -__all__ = ['issiso', 'timebase', 'common_timebase', 'isdtime', 'isctime', - 'pole', 'zero', 'damp', 'evalfr', 'freqresp', 'dcgain'] +__all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', + 'isdtime', 'isctime', 'pole', 'zero', 'damp', 'evalfr', + 'freqresp', 'dcgain'] class LTI: """LTI is a parent class to linear time-invariant (LTI) system objects. @@ -158,12 +160,35 @@ def timebase(sys, strict=True): return sys.dt def common_timebase(dt1, dt2): - """Find the common timebase when interconnecting systems.""" - # cases: + """ + Find the common timebase when interconnecting systems + + Parameters + ---------- + dt1, dt2: number or system with a 'dt' attribute (e.g. TransferFunction + or StateSpace system) + + Returns + ------- + dt: number + The common timebase of dt1 and dt2, as specified in + :ref:`conventions-ref`. + + Raises + ------ + ValueError + when no compatible time base can be found + """ + # explanation: # if either dt is None, they are compatible with anything # if either dt is True (discrete with unspecified time base), # use the timebase of the other, if it is also discrete - # otherwise they must be equal (holds for both cont and discrete systems) + # otherwise both dts must be equal + if hasattr(dt1, 'dt'): + dt1 = dt1.dt + if hasattr(dt2, 'dt'): + dt2 = dt2.dt + if dt1 is None: return dt2 elif dt2 is None: @@ -183,6 +208,32 @@ def common_timebase(dt1, dt2): else: raise ValueError("Systems have incompatible timebases") +# Check to see if two timebases are equal +def timebaseEqual(sys1, sys2): + """ + Check to see if two systems have the same timebase + + timebaseEqual(sys1, sys2) + + returns True if the timebases for the two systems are compatible. By + default, systems with timebase 'None' are compatible with either + discrete or continuous timebase systems. If two systems have a discrete + timebase (dt > 0) then their timebases must be equal. + """ + warn("timebaseEqual will be deprecated in a future release of " + "python-control; use :func:`common_timebase` instead", + PendingDeprecationWarning) + + if (type(sys1.dt) == bool or type(sys2.dt) == bool): + # Make sure both are unspecified discrete timebases + return type(sys1.dt) == type(sys2.dt) and sys1.dt == sys2.dt + elif (sys1.dt is None or sys2.dt is None): + # One or the other is unspecified => the other can be anything + return True + else: + return sys1.dt == sys2.dt + + # Check to see if a system is a discrete time system def isdtime(sys, strict=False): """ From 0223b82ca46d9c0bf19a1b813c07d5c44de4399c Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 20 Jul 2020 10:16:16 -0700 Subject: [PATCH 06/13] changes to enable package-wide default, 'control.default_dt') --- control/config.py | 6 ++---- control/iosys.py | 15 +++++++++------ control/statesp.py | 15 +++++++-------- control/tests/config_test.py | 15 ++++++--------- control/xferfcn.py | 35 +++++++++++++++++------------------ 5 files changed, 41 insertions(+), 45 deletions(-) diff --git a/control/config.py b/control/config.py index 7d0d6907f..4d4512af7 100644 --- a/control/config.py +++ b/control/config.py @@ -15,7 +15,7 @@ # Package level default values _control_defaults = { - # No package level defaults (yet) + 'control.default_dt':0 } defaults = dict(_control_defaults) @@ -216,8 +216,6 @@ def use_legacy_defaults(version): # switched to 'array' as default for state space objects set_defaults('statesp', use_numpy_matrix=True) # switched to 0 (=continuous) as default timestep - set_defaults('statesp', default_dt=None) - set_defaults('xferfcn', default_dt=None) - set_defaults('iosys', default_dt=None) + set_defaults('control', default_dt=None) return (major, minor, patch) diff --git a/control/iosys.py b/control/iosys.py index 720767289..483236429 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -46,8 +46,7 @@ 'linearize', 'ss2io', 'tf2io'] # Define module default parameter values -_iosys_defaults = { - 'iosys.default_dt': 0} +_iosys_defaults = {} class InputOutputSystem(object): """A class for representing input/output systems. @@ -166,9 +165,13 @@ def __init__(self, inputs=None, outputs=None, states=None, params={}, """ # Store the input arguments - self.params = params.copy() # default parameters - self.dt = kwargs.get('dt', config.defaults['iosys.default_dt']) # timebase - self.name = self.name_or_default(name) # system name + + # default parameters + self.params = params.copy() + # timebase + self.dt = kwargs.get('dt', config.defaults['control.default_dt']) + # system name + self.name = self.name_or_default(name) # Parse and store the number of inputs, outputs, and states self.set_inputs(inputs) @@ -724,7 +727,7 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, self.outfcn = outfcn # Initialize the rest of the structure - dt = kwargs.get('dt', config.defaults['iosys.default_dt']) + dt = kwargs.get('dt', config.defaults['control.default_dt']) super(NonlinearIOSystem, self).__init__( inputs=inputs, outputs=outputs, states=states, params=params, dt=dt, name=name diff --git a/control/statesp.py b/control/statesp.py index 09d4da55b..ea9a9c29e 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -72,7 +72,6 @@ # Define module default parameter values _statesp_defaults = { 'statesp.use_numpy_matrix': False, # False is default in 0.9.0 and above - 'statesp.default_dt': 0, 'statesp.remove_useless_states': True, } @@ -155,8 +154,8 @@ class StateSpace(LTI): Setting dt = 0 specifies a continuous system, while leaving dt = None means the system timebase is not specified. If 'dt' is set to True, the system will be treated as a discrete time system with unspecified sampling - time. The default value of 'dt' is None and can be changed by changing the - value of ``control.config.defaults['statesp.default_dt']``. + time. The default value of 'dt' is 0 and can be changed by changing the + value of ``control.config.defaults['control.default_dt']``. """ @@ -180,10 +179,10 @@ def __init__(self, *args, **kw): if len(args) == 4: # The user provided A, B, C, and D matrices. (A, B, C, D) = args - if _isstaticgain(A, B, C, D): + if _isstaticgain(A, B, C, D): dt = None else: - dt = config.defaults['statesp.default_dt'] + dt = config.defaults['control.default_dt'] elif len(args) == 5: # Discrete time system (A, B, C, D, dt) = args @@ -199,10 +198,10 @@ def __init__(self, *args, **kw): try: dt = args[0].dt except NameError: - if _isstaticgain(A, B, C, D): + if _isstaticgain(A, B, C, D): dt = None else: - dt = config.defaults['statesp.default_dt'] + dt = config.defaults['control.default_dt'] else: raise ValueError("Expected 1, 4, or 5 arguments; received %i." % len(args)) @@ -1268,7 +1267,7 @@ def _mimo2simo(sys, input, warn_conversion=False): return sys def _isstaticgain(A, B, C, D): - """returns True if and only if the system has no dynamics, that is, + """returns True if and only if the system has no dynamics, that is, if A and B are zero. """ return not np.any(np.matrix(A, dtype=float)) \ and not np.any(np.matrix(B, dtype=float)) diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 9b03853db..e2530fc8f 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -234,20 +234,17 @@ def test_legacy_defaults(self): @pytest.mark.parametrize("dt", [0, None]) def test_change_default_dt(self, dt): """Test that system with dynamics uses correct default dt""" - ct.set_defaults('statesp', default_dt=dt) + ct.set_defaults('control', default_dt=dt) assert ct.ss(1, 0, 0, 1).dt == dt - ct.set_defaults('xferfcn', default_dt=dt) assert ct.tf(1, [1, 1]).dt == dt - - # nlsys = ct.iosys.NonlinearIOSystem( - # lambda t, x, u: u * x * x, - # lambda t, x, u: x, inputs=1, outputs=1) - # assert nlsys.dt == dt + nlsys = ct.iosys.NonlinearIOSystem( + lambda t, x, u: u * x * x, + lambda t, x, u: x, inputs=1, outputs=1) + assert nlsys.dt == dt def test_change_default_dt_static(self): """Test that static gain systems always have dt=None""" - ct.set_defaults('xferfcn', default_dt=0) + ct.set_defaults('control', default_dt=0) assert ct.tf(1, 1).dt is None - ct.set_defaults('statesp', default_dt=0) assert ct.ss(0, 0, 0, 1).dt is None # TODO: add in test for static gain iosys diff --git a/control/xferfcn.py b/control/xferfcn.py index bddc48d61..57d6fbb7c 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -70,8 +70,7 @@ # Define module default parameter values -_xferfcn_defaults = { - 'xferfcn.default_dt': 0} +_xferfcn_defaults = {} class TransferFunction(LTI): @@ -95,8 +94,8 @@ class TransferFunction(LTI): has a non-zero value, then it must match whenever two transfer functions are combined. If 'dt' is set to True, the system will be treated as a discrete time system with unspecified sampling time. The default value of - 'dt' is None and can be changed by changing the value of - ``control.config.defaults['xferfcn.default_dt']``. + 'dt' is 0 and can be changed by changing the value of + ``control.config.defaults['control.default_dt']``. The TransferFunction class defines two constants ``s`` and ``z`` that represent the differentiation and delay operators in continuous and @@ -127,8 +126,8 @@ def __init__(self, *args): (num, den) = args if _isstaticgain(num, den): dt = None - else: - dt = config.defaults['xferfcn.default_dt'] + else: + dt = config.defaults['control.default_dt'] elif len(args) == 3: # Discrete time transfer function (num, den, dt) = args @@ -146,8 +145,8 @@ def __init__(self, *args): except NameError: # pragma: no coverage if _isstaticgain(num, den): dt = None - else: - dt = config.defaults['xferfcn.default_dt'] + else: + dt = config.defaults['control.default_dt'] else: raise ValueError("Needs 1, 2 or 3 arguments; received %i." % len(args)) @@ -421,7 +420,7 @@ def __mul__(self, other): outputs = self.outputs dt = common_timebase(self.dt, other.dt) - + # Preallocate the numerator and denominator of the sum. num = [[[0] for j in range(inputs)] for i in range(outputs)] den = [[[1] for j in range(inputs)] for i in range(outputs)] @@ -1083,16 +1082,16 @@ def _dcgain_cont(self): return np.squeeze(gain) def is_static_gain(self): - """returns True if and only if all of the numerator and denominator - polynomials of the (possibly MIMO) transfer function are zeroth order, + """returns True if and only if all of the numerator and denominator + polynomials of the (possibly MIMO) transfer function are zeroth order, that is, if the system has no dynamics. """ - for list_of_polys in self.num, self.den: + for list_of_polys in self.num, self.den: for row in list_of_polys: for poly in row: - if len(poly) > 1: + if len(poly) > 1: return False return True - + # c2d function contributed by Benjamin White, Oct 2012 def _c2d_matched(sysC, Ts): # Pole-zero match method of continuous to discrete time conversion @@ -1566,15 +1565,15 @@ def _clean_part(data): return data def _isstaticgain(num, den): - """returns True if and only if all of the numerator and denominator - polynomials of the (possibly MIMO) transfer funnction are zeroth order, + """returns True if and only if all of the numerator and denominator + polynomials of the (possibly MIMO) transfer funnction are zeroth order, that is, if the system has no dynamics. """ num, den = _clean_part(num), _clean_part(den) - for list_of_polys in num, den: + for list_of_polys in num, den: for row in list_of_polys: for poly in row: poly_trimmed = np.trim_zeros(poly, 'f') # trim leading zeros - if len(poly_trimmed) > 1: + if len(poly_trimmed) > 1: return False return True From 1ca9decb0b3d82c569a99f7a159a0f308218b5e4 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 20 Jul 2020 14:25:13 -0700 Subject: [PATCH 07/13] fixes to docstrings and conventions.rst to match convention of dt=0 as default system timebase --- control/dtime.py | 64 +++++++++++++++++++++++---------------------- control/iosys.py | 46 ++++++++++++++++++-------------- control/statesp.py | 28 ++++++++++++-------- control/xferfcn.py | 24 +++++++++++------ doc/conventions.rst | 21 +++++++-------- 5 files changed, 101 insertions(+), 82 deletions(-) diff --git a/control/dtime.py b/control/dtime.py index 89f17c4af..725bcde47 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -53,21 +53,19 @@ # Sample a continuous time system def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): - """Convert a continuous time system to discrete time - - Creates a discrete time system from a continuous time system by - sampling. Multiple methods of conversion are supported. + """ + Convert a continuous time system to discrete time by sampling Parameters ---------- - sysc : linsys + sysc : LTI (StateSpace or TransferFunction) Continuous time system to be converted - Ts : real + Ts : real > 0 Sampling period method : string - Method to use for conversion: 'matched', 'tustin', 'zoh' (default) + Method to use for conversion, e.g. 'bilinear', 'zoh' (default) - prewarp_frequency : float within [0, infinity) + prewarp_frequency : real within [0, infinity) The frequency [rad/s] at which to match with the input continuous- time system's magnitude and phase @@ -78,13 +76,13 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): Notes ----- - See `TransferFunction.sample` and `StateSpace.sample` for + See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample`` for further details. Examples -------- >>> sysc = TransferFunction([1], [1, 2, 1]) - >>> sysd = sample_system(sysc, 1, method='matched') + >>> sysd = sample_system(sysc, 1, method='bilinear') """ # Make sure we have a continuous time system @@ -95,35 +93,39 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): def c2d(sysc, Ts, method='zoh', prewarp_frequency=None): - ''' - Return a discrete-time system + """ + Convert a continuous time system to discrete time by sampling Parameters ---------- - sysc: LTI (StateSpace or TransferFunction), continuous - System to be converted + sysc : LTI (StateSpace or TransferFunction) + Continuous time system to be converted + Ts : real > 0 + Sampling period + method : string + Method to use for conversion, e.g. 'bilinear', 'zoh' (default) - Ts: number - Sample time for the conversion + prewarp_frequency : real within [0, infinity) + The frequency [rad/s] at which to match with the input continuous- + time system's magnitude and phase - method: string, optional - Method to be applied, - 'zoh' Zero-order hold on the inputs (default) - 'foh' First-order hold, currently not implemented - 'impulse' Impulse-invariant discretization, currently not implemented - 'tustin' Bilinear (Tustin) approximation, only SISO - 'matched' Matched pole-zero method, only SISO + Returns + ------- + sysd : linsys + Discrete time system, with sampling rate Ts + + Notes + ----- + See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample`` for + further details. - prewarp_frequency : float within [0, infinity) - The frequency [rad/s] at which to match with the input continuous- - time system's magnitude and phase + Examples + -------- + >>> sysc = TransferFunction([1], [1, 2, 1]) + >>> sysd = sample_system(sysc, 1, method='bilinear') + """ - ''' # Call the sample_system() function to do the work sysd = sample_system(sysc, Ts, method, prewarp_frequency) - # TODO: is this check needed? If sysc is StateSpace, sysd is too? - if isinstance(sysc, StateSpace) and not isinstance(sysd, StateSpace): - return _convertToStateSpace(sysd) # pragma: no cover - return sysd diff --git a/control/iosys.py b/control/iosys.py index 483236429..65d6c1228 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -72,9 +72,11 @@ class for a set of subclasses that are used to implement specific states : int, list of str, or None Description of the system states. Same format as `inputs`. dt : None, True or float, optional - System timebase. None (default) indicates continuous time, True - indicates discrete time with undefined sampling time, positive number - is discrete time with specified sampling time. + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling + time, positive number is discrete time with specified + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. @@ -90,9 +92,11 @@ class for a set of subclasses that are used to implement specific Dictionary of signal names for the inputs, outputs and states and the index of the corresponding array dt : None, True or float - System timebase. None (default) indicates continuous time, True - indicates discrete time with undefined sampling time, positive number - is discrete time with specified sampling time. + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling + time, positive number is discrete time with specified + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. @@ -146,10 +150,11 @@ def __init__(self, inputs=None, outputs=None, states=None, params={}, states : int, list of str, or None Description of the system states. Same format as `inputs`. dt : None, True or float, optional - System timebase. None (default) indicates continuous - time, True indicates discrete time with undefined sampling + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling time, positive number is discrete time with specified - sampling time. + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal @@ -584,10 +589,11 @@ def __init__(self, linsys, inputs=None, outputs=None, states=None, states : int, list of str, or None, optional Description of the system states. Same format as `inputs`. dt : None, True or float, optional - System timebase. None (default) indicates continuous - time, True indicates discrete time with undefined sampling + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling time, positive number is discrete time with specified - sampling time. + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal @@ -707,10 +713,10 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, operating in continuous or discrete time. It can have the following values: - * dt = None No timebase specified - * dt = 0 Continuous time system - * dt > 0 Discrete time system with sampling time dt - * dt = True Discrete time with unspecified sampling time + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified name : string, optional System name (used for specifying signals). If unspecified, a generic @@ -877,10 +883,10 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], operating in continuous or discrete time. It can have the following values: - * dt = None No timebase specified - * dt = 0 Continuous time system - * dt > 0 Discrete time system with sampling time dt - * dt = True Discrete time with unspecified sampling time + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified name : string, optional System name (used for specifying signals). If unspecified, a generic diff --git a/control/statesp.py b/control/statesp.py index ea9a9c29e..2a66cbfa9 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -148,15 +148,22 @@ class StateSpace(LTI): `numpy.ndarray` objects. The :func:`~control.use_numpy_matrix` function can be used to set the storage type. - Discrete-time state space system are implemented by using the 'dt' - instance variable and setting it to the sampling period. If 'dt' is not - None, then it must match whenever two state space systems are combined. - Setting dt = 0 specifies a continuous system, while leaving dt = None - means the system timebase is not specified. If 'dt' is set to True, the - system will be treated as a discrete time system with unspecified sampling - time. The default value of 'dt' is 0 and can be changed by changing the - value of ``control.config.defaults['control.default_dt']``. - + A discrete time system is created by specifying a nonzero 'timebase', dt + when the system is constructed: + + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified + + Systems must have compatible timebases in order to be combined. A discrete + time system with unspecified sampling time (`dt = True`) can be combined + with a system having a specified sampling time; the result will be a + discrete time system with the sample time of the latter system. Similarly, + a system with timebase `None` can be combined with a system having any + timebase; the result will have the timebase of the latter system. + The default value of dt can be changed by changing the value of + ``control.config.defaults['control.default_dt']``. """ # Allow ndarray * StateSpace to give StateSpace._rmul_() priority @@ -1317,8 +1324,7 @@ def ss(*args): Output matrix D: array_like or string Feed forward matrix - dt: If present, specifies the sampling period and a discrete time - system is created + dt: If present, specifies the timebase of the system Returns ------- diff --git a/control/xferfcn.py b/control/xferfcn.py index 57d6fbb7c..ee3424053 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -73,7 +73,6 @@ _xferfcn_defaults = {} class TransferFunction(LTI): - """TransferFunction(num, den[, dt]) A class for representing transfer functions @@ -89,12 +88,21 @@ class TransferFunction(LTI): means that the numerator of the transfer function from the 6th input to the 3rd output is set to s^2 + 4s + 8. - Discrete-time transfer functions are implemented by using the 'dt' - instance variable and setting it to something other than 'None'. If 'dt' - has a non-zero value, then it must match whenever two transfer functions - are combined. If 'dt' is set to True, the system will be treated as a - discrete time system with unspecified sampling time. The default value of - 'dt' is 0 and can be changed by changing the value of + A discrete time transfer function is created by specifying a nonzero + 'timebase' dt when the system is constructed: + + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified + + Systems must have compatible timebases in order to be combined. A discrete + time system with unspecified sampling time (`dt = True`) can be combined + with a system having a specified sampling time; the result will be a + discrete time system with the sample time of the latter system. Similarly, + a system with timebase `None` can be combined with a system having any + timebase; the result will have the timebase of the latter system. + The default value of dt can be changed by changing the value of ``control.config.defaults['control.default_dt']``. The TransferFunction class defines two constants ``s`` and ``z`` that @@ -104,8 +112,8 @@ class TransferFunction(LTI): >>> s = TransferFunction.s >>> G = (s + 1)/(s**2 + 2*s + 1) - """ + def __init__(self, *args): """TransferFunction(num, den[, dt]) diff --git a/doc/conventions.rst b/doc/conventions.rst index 99789bc9e..4a3d78926 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -80,27 +80,24 @@ Discrete time systems A discrete time system is created by specifying a nonzero 'timebase', dt. The timebase argument can be given when a system is constructed: -* dt = None: no timebase specified (default) -* dt = 0: continuous time system +* dt = 0: continuous time system (default) * dt > 0: discrete time system with sampling period 'dt' * dt = True: discrete time with unspecified sampling period +* dt = None: no timebase specified Only the :class:`StateSpace`, :class:`TransferFunction`, and :class:`InputOutputSystem` classes allow explicit representation of discrete time systems. -Systems must have compatible timebases in order to be combined. A system -with timebase `None` can be combined with a system having a specified -timebase; the result will have the timebase of the latter system. -Similarly, a discrete time system with unspecified sampling time (`dt = -True`) can be combined with a system having a specified sampling time; -the result will be a discrete time system with the sample time of the latter -system. For continuous time systems, the :func:`sample_system` function or -the :meth:`StateSpace.sample` and :meth:`TransferFunction.sample` methods +Systems must have compatible timebases in order to be combined. A discrete time +system with unspecified sampling time (`dt = True`) can be combined with a system +having a specified sampling time; the result will be a discrete time system with the sample time of the latter +system. Similarly, a system with timebase `None` can be combined with a system having a specified +timebase; the result will have the timebase of the latter system. For continuous +time systems, the :func:`sample_system` function or the :meth:`StateSpace.sample` and :meth:`TransferFunction.sample` methods can be used to create a discrete time system from a continuous time system. See :ref:`utility-and-conversions`. The default value of 'dt' can be changed by -changing the values of ``control.config.defaults['statesp.default_dt']`` and -``control.config.defaults['xferfcn.default_dt']``. +changing the value of ``control.config.defaults['control.default_dt']``. Conversion between representations ---------------------------------- From 3b2ab1884f259d93024ef3658d6c644f8680d101 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 20 Jul 2020 22:02:34 -0700 Subject: [PATCH 08/13] remove extraneous file --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 36e5778aa..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "restructuredtext.confPath": "${workspaceFolder}/doc" -} \ No newline at end of file From 351e0f7d211211bf7ebaf68ddfa23762048bcb18 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 22 Jul 2020 13:25:26 -0700 Subject: [PATCH 09/13] new method is_static_gain() for state space and transfer function systems, refactored __init__ on both to use it --- control/statesp.py | 55 +++++++++++++++++++++++++++------------------- control/xferfcn.py | 53 ++++++++++++++++++++++---------------------- 2 files changed, 59 insertions(+), 49 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 2a66cbfa9..985e37568 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -170,7 +170,7 @@ class StateSpace(LTI): __array_priority__ = 11 # override ndarray and matrix types - def __init__(self, *args, **kw): + def __init__(self, *args, **kwargs): """ StateSpace(A, B, C, D[, dt]) @@ -183,16 +183,13 @@ def __init__(self, *args, **kw): call StateSpace(sys), where sys is a StateSpace object. """ + # first get A, B, C, D matrices if len(args) == 4: # The user provided A, B, C, and D matrices. (A, B, C, D) = args - if _isstaticgain(A, B, C, D): - dt = None - else: - dt = config.defaults['control.default_dt'] elif len(args) == 5: # Discrete time system - (A, B, C, D, dt) = args + (A, B, C, D, _) = args elif len(args) == 1: # Use the copy constructor. if not isinstance(args[0], StateSpace): @@ -202,19 +199,12 @@ def __init__(self, *args, **kw): B = args[0].B C = args[0].C D = args[0].D - try: - dt = args[0].dt - except NameError: - if _isstaticgain(A, B, C, D): - dt = None - else: - dt = config.defaults['control.default_dt'] else: raise ValueError("Expected 1, 4, or 5 arguments; received %i." % len(args)) # Process keyword arguments - remove_useless = kw.get('remove_useless', - config.defaults['statesp.remove_useless_states']) + remove_useless = kwargs.get('remove_useless', + config.defaults['statesp.remove_useless_states']) # Convert all matrices to standard form A = _ssmatrix(A) @@ -233,12 +223,33 @@ def __init__(self, *args, **kw): D = _ssmatrix(D) # TODO: use super here? - LTI.__init__(self, inputs=D.shape[1], outputs=D.shape[0], dt=dt) + LTI.__init__(self, inputs=D.shape[1], outputs=D.shape[0]) self.A = A self.B = B self.C = C self.D = D + # now set dt + if len(args) == 4: + if 'dt' in kwargs: + dt = kwargs['dt'] + elif self.is_static_gain(): + dt = None + else: + dt = config.defaults['control.default_dt'] + elif len(args) == 5: + dt = args[4] + if 'dt' in kwargs: + warn('received multiple dt arguments, using positional arg'%dt) + elif len(args) == 1: + try: + dt = args[0].dt + except NameError: + if self.is_static_gain(): + dt = None + else: + dt = config.defaults['control.default_dt'] + self.dt = dt self.states = A.shape[1] if 0 == self.states: @@ -953,6 +964,12 @@ def dcgain(self): gain = np.tile(np.nan, (self.outputs, self.inputs)) return np.squeeze(gain) + def is_static_gain(self): + """True if and only if the system has no dynamics, that is, + if A and B are zero. """ + return not np.any(self.A) and not np.any(self.B) + + def is_static_gain(self): """True if and only if the system has no dynamics, that is, if A and B are zero. """ @@ -1273,12 +1290,6 @@ def _mimo2simo(sys, input, warn_conversion=False): return sys -def _isstaticgain(A, B, C, D): - """returns True if and only if the system has no dynamics, that is, - if A and B are zero. """ - return not np.any(np.matrix(A, dtype=float)) \ - and not np.any(np.matrix(B, dtype=float)) - def ss(*args): """ss(A, B, C, D[, dt]) diff --git a/control/xferfcn.py b/control/xferfcn.py index ee3424053..605289067 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -114,7 +114,7 @@ class TransferFunction(LTI): >>> G = (s + 1)/(s**2 + 2*s + 1) """ - def __init__(self, *args): + def __init__(self, *args, **kwargs): """TransferFunction(num, den[, dt]) Construct a transfer function. @@ -132,10 +132,6 @@ def __init__(self, *args): if len(args) == 2: # The user provided a numerator and a denominator. (num, den) = args - if _isstaticgain(num, den): - dt = None - else: - dt = config.defaults['control.default_dt'] elif len(args) == 3: # Discrete time transfer function (num, den, dt) = args @@ -147,14 +143,6 @@ def __init__(self, *args): % type(args[0])) num = args[0].num den = args[0].den - # TODO: not sure this can ever happen since dt is always present - try: - dt = args[0].dt - except NameError: # pragma: no coverage - if _isstaticgain(num, den): - dt = None - else: - dt = config.defaults['control.default_dt'] else: raise ValueError("Needs 1, 2 or 3 arguments; received %i." % len(args)) @@ -212,12 +200,36 @@ def __init__(self, *args): if zeronum: den[i][j] = ones(1) - LTI.__init__(self, inputs, outputs, dt) + LTI.__init__(self, inputs, outputs) self.num = num self.den = den self._truncatecoeff() + # get dt + if len(args) == 2: + # no dt given in positional arguments + if 'dt' in kwargs: + dt = kwargs['dt'] + elif self.is_static_gain(): + dt = None + else: + dt = config.defaults['control.default_dt'] + elif len(args) == 3: + # Discrete time transfer function + if 'dt' in kwargs: + warn('received multiple dt arguments, using positional arg'%dt) + elif len(args) == 1: + # TODO: not sure this can ever happen since dt is always present + try: + dt = args[0].dt + except NameError: # pragma: no coverage + if self.is_static_gain(): + dt = None + else: + dt = config.defaults['control.default_dt'] + self.dt = dt + def __call__(self, s): """Evaluate the system's transfer function for a complex variable @@ -1572,19 +1584,6 @@ def _clean_part(data): return data -def _isstaticgain(num, den): - """returns True if and only if all of the numerator and denominator - polynomials of the (possibly MIMO) transfer funnction are zeroth order, - that is, if the system has no dynamics. """ - num, den = _clean_part(num), _clean_part(den) - for list_of_polys in num, den: - for row in list_of_polys: - for poly in row: - poly_trimmed = np.trim_zeros(poly, 'f') # trim leading zeros - if len(poly_trimmed) > 1: - return False - return True - # Define constants to represent differentiation, unit delay TransferFunction.s = TransferFunction([1, 0], [1], 0) TransferFunction.z = TransferFunction([1, 0], [1], True) From e3a00701adfcbc768127665b73b9a789f826759b Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 22 Jul 2020 13:42:10 -0700 Subject: [PATCH 10/13] added keyword arguments to xferfcn.tf and statesp.ss to set dt --- control/statesp.py | 8 ++++---- control/xferfcn.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 985e37568..4fae902d1 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -240,7 +240,7 @@ def __init__(self, *args, **kwargs): elif len(args) == 5: dt = args[4] if 'dt' in kwargs: - warn('received multiple dt arguments, using positional arg'%dt) + warn('received multiple dt arguments, using positional arg dt=%s'%dt) elif len(args) == 1: try: dt = args[0].dt @@ -1290,7 +1290,7 @@ def _mimo2simo(sys, input, warn_conversion=False): return sys -def ss(*args): +def ss(*args, **kwargs): """ss(A, B, C, D[, dt]) Create a state space system. @@ -1366,7 +1366,7 @@ def ss(*args): """ if len(args) == 4 or len(args) == 5: - return StateSpace(*args) + return StateSpace(*args, **kwargs) elif len(args) == 1: from .xferfcn import TransferFunction sys = args[0] @@ -1378,7 +1378,7 @@ def ss(*args): raise TypeError("ss(sys): sys must be a StateSpace or \ TransferFunction object. It is %s." % type(sys)) else: - raise ValueError("Needs 1 or 4 arguments; received %i." % len(args)) + raise ValueError("Needs 1, 4, or 5 arguments; received %i." % len(args)) def tf2ss(*args): diff --git a/control/xferfcn.py b/control/xferfcn.py index 605289067..52866a8ec 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -218,7 +218,7 @@ def __init__(self, *args, **kwargs): elif len(args) == 3: # Discrete time transfer function if 'dt' in kwargs: - warn('received multiple dt arguments, using positional arg'%dt) + warn('received multiple dt arguments, using positional arg dt=%s'%dt) elif len(args) == 1: # TODO: not sure this can ever happen since dt is always present try: @@ -1322,7 +1322,7 @@ def _convert_to_transfer_function(sys, **kw): raise TypeError("Can't convert given type to TransferFunction system.") -def tf(*args): +def tf(*args, **kwargs): """tf(num, den[, dt]) Create a transfer function system. Can create MIMO systems. @@ -1412,7 +1412,7 @@ def tf(*args): """ if len(args) == 2 or len(args) == 3: - return TransferFunction(*args) + return TransferFunction(*args, **kwargs) elif len(args) == 1: # Look for special cases defining differential/delay operator if args[0] == 's': @@ -1433,7 +1433,7 @@ def tf(*args): raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) -def ss2tf(*args): +def ss2tf(*args, **kwargs): """ss2tf(sys) Transform a state space system to a transfer function. @@ -1498,7 +1498,7 @@ def ss2tf(*args): from .statesp import StateSpace if len(args) == 4 or len(args) == 5: # Assume we were given the A, B, C, D matrix and (optional) dt - return _convert_to_transfer_function(StateSpace(*args)) + return _convert_to_transfer_function(StateSpace(*args, **kwargs)) elif len(args) == 1: sys = args[0] From 404b23c127fb724bad3194be81bdf7bc6c4b213c Mon Sep 17 00:00:00 2001 From: bnavigator Date: Thu, 31 Dec 2020 02:08:11 +0100 Subject: [PATCH 11/13] update tests for dt=0, fix the constructor checks for missing dt --- control/statesp.py | 2 +- control/tests/discrete_test.py | 12 ------------ control/tests/iosys_test.py | 2 +- control/tests/statesp_test.py | 5 +---- control/tests/xferfcn_test.py | 16 ++++++++++++++++ control/xferfcn.py | 2 +- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 4fae902d1..ff7f23068 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -244,7 +244,7 @@ def __init__(self, *args, **kwargs): elif len(args) == 1: try: dt = args[0].dt - except NameError: + except AttributeError: if self.is_static_gain(): dt = None else: diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py index ffdd1aeb4..3dcbb7f3b 100644 --- a/control/tests/discrete_test.py +++ b/control/tests/discrete_test.py @@ -243,8 +243,6 @@ def testAddition(self, tsys): StateSpace.__add__(tsys.mimo_ss1c, tsys.mimo_ss1d) with pytest.raises(ValueError): StateSpace.__add__(tsys.mimo_ss1d, tsys.mimo_ss2d) - with pytest.raises(ValueError): - StateSpace.__add__(tsys.siso_ss1d, tsys.siso_ss3d) # Transfer function addition sys = tsys.siso_tf1 + tsys.siso_tf1d @@ -260,8 +258,6 @@ def testAddition(self, tsys): TransferFunction.__add__(tsys.siso_tf1c, tsys.siso_tf1d) with pytest.raises(ValueError): TransferFunction.__add__(tsys.siso_tf1d, tsys.siso_tf2d) - with pytest.raises(ValueError): - TransferFunction.__add__(tsys.siso_tf1d, tsys.siso_tf3d) # State space + transfer function sys = tsys.siso_ss1c + tsys.siso_tf1c @@ -285,8 +281,6 @@ def testMultiplication(self, tsys): StateSpace.__mul__(tsys.mimo_ss1c, tsys.mimo_ss1d) with pytest.raises(ValueError): StateSpace.__mul__(tsys.mimo_ss1d, tsys.mimo_ss2d) - with pytest.raises(ValueError): - StateSpace.__mul__(tsys.siso_ss1d, tsys.siso_ss3d) # Transfer function multiplication sys = tsys.siso_tf1 * tsys.siso_tf1d @@ -301,8 +295,6 @@ def testMultiplication(self, tsys): TransferFunction.__mul__(tsys.siso_tf1c, tsys.siso_tf1d) with pytest.raises(ValueError): TransferFunction.__mul__(tsys.siso_tf1d, tsys.siso_tf2d) - with pytest.raises(ValueError): - TransferFunction.__mul__(tsys.siso_tf1d, tsys.siso_tf3d) # State space * transfer function sys = tsys.siso_ss1c * tsys.siso_tf1c @@ -328,8 +320,6 @@ def testFeedback(self, tsys): feedback(tsys.mimo_ss1c, tsys.mimo_ss1d) with pytest.raises(ValueError): feedback(tsys.mimo_ss1d, tsys.mimo_ss2d) - with pytest.raises(ValueError): - feedback(tsys.siso_ss1d, tsys.siso_ss3d) # Transfer function feedback sys = feedback(tsys.siso_tf1, tsys.siso_tf1d) @@ -344,8 +334,6 @@ def testFeedback(self, tsys): feedback(tsys.siso_tf1c, tsys.siso_tf1d) with pytest.raises(ValueError): feedback(tsys.siso_tf1d, tsys.siso_tf2d) - with pytest.raises(ValueError): - feedback(tsys.siso_tf1d, tsys.siso_tf3d) # State space, transfer function sys = feedback(tsys.siso_ss1c, tsys.siso_tf1c) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 2bb6f066c..faed39e07 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -1024,7 +1024,7 @@ def test_duplicates(self, tsys): # Nonduplicate objects nlios1 = nlios.copy() nlios2 = nlios.copy() - with pytest.warns(UserWarning, match="copy of sys") as record: + with pytest.warns(UserWarning, match="Duplicate name"): ios_series = nlios1 * nlios2 assert "copy of sys_1.x[0]" in ios_series.state_index.keys() assert "copy of sys.x[0]" in ios_series.state_index.keys() diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 9dbb6da94..aee1d2433 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -165,10 +165,7 @@ def test_copy_constructor(self): np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value def test_copy_constructor_nodt(self, sys322): - """Test the copy constructor when an object without dt is passed - - FIXME: may be obsolete in case gh-431 is updated - """ + """Test the copy constructor when an object without dt is passed""" sysin = sample_system(sys322, 1.) del sysin.dt sys = StateSpace(sysin) diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index b0673de1e..f3bba4523 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -13,6 +13,7 @@ from control.tests.conftest import slycotonly, nopython2, matrixfilter from control.lti import isctime, isdtime from control.dtime import sample_system +from control.config import defaults class TestXferFcn: @@ -85,6 +86,21 @@ def test_constructor_zero_denominator(self): TransferFunction([[[1.], [2., 3.]], [[-1., 4.], [3., 2.]]], [[[1., 0.], [0.]], [[0., 0.], [2.]]]) + def test_constructor_nodt(self): + """Test the constructor when an object without dt is passed""" + sysin = TransferFunction([[[0., 1.], [2., 3.]]], + [[[5., 2.], [3., 0.]]]) + del sysin.dt + sys = TransferFunction(sysin) + assert sys.dt == defaults['control.default_dt'] + + # test for static gain + sysin = TransferFunction([[[2.], [3.]]], + [[[1.], [.1]]]) + del sysin.dt + sys = TransferFunction(sysin) + assert sys.dt is None + def test_add_inconsistent_dimension(self): """Add two transfer function matrices of different sizes.""" sys1 = TransferFunction([[[1., 2.]]], [[[4., 5.]]]) diff --git a/control/xferfcn.py b/control/xferfcn.py index 52866a8ec..36c5393b0 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -223,7 +223,7 @@ def __init__(self, *args, **kwargs): # TODO: not sure this can ever happen since dt is always present try: dt = args[0].dt - except NameError: # pragma: no coverage + except AttributeError: if self.is_static_gain(): dt = None else: From 8b82850e1a892a56d50dc73f25d989cb45301e56 Mon Sep 17 00:00:00 2001 From: bnavigator Date: Thu, 31 Dec 2020 02:34:09 +0100 Subject: [PATCH 12/13] remove duplicate is_static_gain --- control/statesp.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index ff7f23068..58d46fddd 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -970,11 +970,6 @@ def is_static_gain(self): return not np.any(self.A) and not np.any(self.B) - def is_static_gain(self): - """True if and only if the system has no dynamics, that is, - if A and B are zero. """ - return not np.any(self.A) and not np.any(self.B) - # TODO: add discrete time check def _convertToStateSpace(sys, **kw): """Convert a system to state space form (if needed). From 75f3aef754b2274a2e28c92d7c2af9c656bd4c60 Mon Sep 17 00:00:00 2001 From: bnavigator Date: Thu, 31 Dec 2020 11:32:43 +0100 Subject: [PATCH 13/13] add test for dt in arg and kwarg --- control/tests/xferfcn_test.py | 7 +++++++ control/xferfcn.py | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index f3bba4523..c0b5e227f 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -101,6 +101,13 @@ def test_constructor_nodt(self): sys = TransferFunction(sysin) assert sys.dt is None + def test_constructor_double_dt(self): + """Test that providing dt as arg and kwarg prefers arg with warning""" + with pytest.warns(UserWarning, match="received multiple dt.*" + "using positional arg"): + sys = TransferFunction(1, [1, 2, 3], 0.1, dt=0.2) + assert sys.dt == 0.1 + def test_add_inconsistent_dimension(self): """Add two transfer function matrices of different sizes.""" sys1 = TransferFunction([[[1., 2.]]], [[[4., 5.]]]) diff --git a/control/xferfcn.py b/control/xferfcn.py index 36c5393b0..93743deb1 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -218,7 +218,8 @@ def __init__(self, *args, **kwargs): elif len(args) == 3: # Discrete time transfer function if 'dt' in kwargs: - warn('received multiple dt arguments, using positional arg dt=%s'%dt) + warn('received multiple dt arguments, ' + 'using positional arg dt=%s' % dt) elif len(args) == 1: # TODO: not sure this can ever happen since dt is always present try: