diff --git a/control/bdalg.py b/control/bdalg.py index 2be239177..f066b72b5 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -356,14 +356,14 @@ def feedback(sys1, sys2=1, sign=-1, **kwargs): def append(*sys, **kwargs): """append(sys1, sys2, [..., sysn]) - Group LTI state space models by appending their inputs and outputs. + Group LTI models by appending their inputs and outputs. Forms an augmented system model, and appends the inputs and outputs together. Parameters ---------- - sys1, sys2, ..., sysn: scalar, array, or :class:`StateSpace` + sys1, sys2, ..., sysn : scalar, array, or :class:`LTI` I/O systems to combine. Other Parameters @@ -382,9 +382,10 @@ def append(*sys, **kwargs): Returns ------- - out: :class:`StateSpace` + out : :class:`LTI` Combined system, with input/output vectors consisting of all - input/output vectors appended. + input/output vectors appended. Specific type returned is the type of + the first argument. See Also -------- @@ -405,7 +406,7 @@ def append(*sys, **kwargs): (3, 8, 7) """ - s1 = ss._convert_to_statespace(sys[0]) + s1 = sys[0] for s in sys[1:]: s1 = s1.append(s) s1.update_names(**kwargs) diff --git a/control/frdata.py b/control/frdata.py index cb6925661..1200bfffa 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -20,6 +20,7 @@ from scipy.interpolate import splev, splprep from . import config +from . import bdalg from .exception import pandas_check from .iosys import InputOutputSystem, NamedSignal, _extended_system_name, \ _process_iosys_keywords, _process_subsys_index, common_timebase @@ -455,6 +456,12 @@ def __add__(self, other): else: other = _convert_to_frd(other, omega=self.omega) + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + self = np.ones((other.noutputs, other.ninputs)) * self + elif not self.issiso() and other.issiso(): + other = np.ones((self.noutputs, self.ninputs)) * other + # Check that the input-output sizes are consistent. if self.ninputs != other.ninputs: raise ValueError( @@ -492,6 +499,12 @@ def __mul__(self, other): else: other = _convert_to_frd(other, omega=self.omega) + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + self = bdalg.append(*([self] * other.noutputs)) + elif not self.issiso() and other.issiso(): + other = bdalg.append(*([other] * self.ninputs)) + # Check that the input-output sizes are consistent. if self.ninputs != other.noutputs: raise ValueError( @@ -519,6 +532,12 @@ def __rmul__(self, other): else: other = _convert_to_frd(other, omega=self.omega) + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + self = bdalg.append(*([self] * other.ninputs)) + elif not self.issiso() and other.issiso(): + other = bdalg.append(*([other] * self.noutputs)) + # Check that the input-output sizes are consistent. if self.noutputs != other.ninputs: raise ValueError( @@ -547,11 +566,9 @@ def __truediv__(self, other): else: other = _convert_to_frd(other, omega=self.omega) - if (self.ninputs > 1 or self.noutputs > 1 or - other.ninputs > 1 or other.noutputs > 1): - raise NotImplementedError( - "FRD.__truediv__ is currently only implemented for SISO " - "systems.") + if (other.ninputs > 1 or other.noutputs > 1): + # FRD.__truediv__ is currently only implemented for SISO systems + return NotImplemented return FRD(self.fresp/other.fresp, self.omega, smooth=(self._ifunc is not None) and @@ -566,11 +583,9 @@ def __rtruediv__(self, other): else: other = _convert_to_frd(other, omega=self.omega) - if (self.ninputs > 1 or self.noutputs > 1 or - other.ninputs > 1 or other.noutputs > 1): - raise NotImplementedError( - "FRD.__rtruediv__ is currently only implemented for " - "SISO systems.") + if (self.ninputs > 1 or self.noutputs > 1): + # FRD.__rtruediv__ is currently only implemented for SISO systems + return NotImplemented return other / self @@ -803,6 +818,26 @@ def feedback(self, other=1, sign=-1): return FRD(fresp, other.omega, smooth=(self._ifunc is not None)) + def append(self, other): + """Append a second model to the present model. + + The second model is converted to FRD if necessary, inputs and + outputs are appended and their order is preserved""" + other = _convert_to_frd(other, omega=self.omega, inputs=other.ninputs, + outputs=other.noutputs) + + # TODO: handle omega re-mapping + + new_fresp = np.zeros( + (self.noutputs + other.noutputs, self.ninputs + other.ninputs, + self.omega.shape[-1]), dtype=complex) + new_fresp[:self.noutputs, :self.ninputs, :] = np.reshape( + self.fresp, (self.noutputs, self.ninputs, -1)) + new_fresp[self.noutputs:, self.ninputs:, :] = np.reshape( + other.fresp, (other.noutputs, other.ninputs, -1)) + + return FRD(new_fresp, self.omega, smooth=(self._ifunc is not None)) + # Plotting interface def plot(self, plot_type=None, *args, **kwargs): """Plot the frequency response using a Bode plot. diff --git a/control/statesp.py b/control/statesp.py index 98adc942f..44fe8b605 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -30,6 +30,7 @@ from scipy.signal import cont2discrete from . import config +from . import bdalg from .exception import ControlMIMONotImplemented, ControlSlycot, slycot_check from .frdata import FrequencyResponseData from .iosys import InputOutputSystem, NamedSignal, _process_dt_keyword, \ @@ -572,6 +573,9 @@ def __add__(self, other): elif isinstance(other, np.ndarray): other = np.atleast_2d(other) + # Special case for SISO + if self.issiso(): + self = np.ones_like(other) * self if self.ninputs != other.shape[0]: raise ValueError("array has incompatible shape") A, B, C = self.A, self.B, self.C @@ -582,6 +586,12 @@ def __add__(self, other): return NotImplemented # let other.__rmul__ handle it else: + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + self = np.ones((other.noutputs, other.ninputs)) * self + elif not self.issiso() and other.issiso(): + other = np.ones((self.noutputs, self.ninputs)) * other + # Check to make sure the dimensions are OK if ((self.ninputs != other.ninputs) or (self.noutputs != other.noutputs)): @@ -636,6 +646,10 @@ def __mul__(self, other): elif isinstance(other, np.ndarray): other = np.atleast_2d(other) + # Special case for SISO + if self.issiso(): + self = bdalg.append(*([self] * other.shape[0])) + # Dimension check after broadcasting if self.ninputs != other.shape[0]: raise ValueError("array has incompatible shape") A, C = self.A, self.C @@ -647,6 +661,12 @@ def __mul__(self, other): return NotImplemented # let other.__rmul__ handle it else: + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + self = bdalg.append(*([self] * other.noutputs)) + elif not self.issiso() and other.issiso(): + other = bdalg.append(*([other] * self.ninputs)) + # Check to make sure the dimensions are OK if self.ninputs != other.noutputs: raise ValueError( @@ -686,23 +706,67 @@ def __rmul__(self, other): return StateSpace(self.A, B, self.C, D, self.dt) elif isinstance(other, np.ndarray): - C = np.atleast_2d(other) @ self.C - D = np.atleast_2d(other) @ self.D + other = np.atleast_2d(other) + # Special case for SISO transfer function + if self.issiso(): + self = bdalg.append(*([self] * other.shape[1])) + # Dimension check after broadcasting + if self.noutputs != other.shape[1]: + raise ValueError("array has incompatible shape") + C = other @ self.C + D = other @ self.D return StateSpace(self.A, self.B, C, D, self.dt) if not isinstance(other, StateSpace): return NotImplemented + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + self = bdalg.append(*([self] * other.ninputs)) + elif not self.issiso() and other.issiso(): + other = bdalg.append(*([other] * self.noutputs)) + return other * self # TODO: general __truediv__ requires descriptor system support def __truediv__(self, other): """Division of state space systems by TFs, FRDs, scalars, and arrays""" - if not isinstance(other, (LTI, InputOutputSystem)): - return self * (1/other) - else: + # Let ``other.__rtruediv__`` handle it + try: + return self * (1 / other) + except ValueError: return NotImplemented + def __rtruediv__(self, other): + """Division by state space system""" + return other * self**-1 + + def __pow__(self, other): + """Power of a state space system""" + if not type(other) == int: + raise ValueError("Exponent must be an integer") + if self.ninputs != self.noutputs: + # System must have same number of inputs and outputs + return NotImplemented + if other < -1: + return (self**-1)**(-other) + elif other == -1: + try: + Di = scipy.linalg.inv(self.D) + except scipy.linalg.LinAlgError: + # D matrix must be nonsingular + return NotImplemented + Ai = self.A - self.B @ Di @ self.C + Bi = self.B @ Di + Ci = -Di @ self.C + return StateSpace(Ai, Bi, Ci, Di, self.dt) + elif other == 0: + return StateSpace([], [], [], np.eye(self.ninputs), self.dt) + elif other == 1: + return self + elif other > 1: + return self * (self**(other - 1)) + def __call__(self, x, squeeze=None, warn_infinite=True): """Evaluate system's frequency response at complex frequencies. @@ -1107,7 +1171,7 @@ def minreal(self, tol=0.0): A, B, C, nr = tb01pd(self.nstates, self.ninputs, self.noutputs, self.A, B, C, tol=tol) return StateSpace(A[:nr, :nr], B[:nr, :self.ninputs], - C[:self.noutputs, :nr], self.D) + C[:self.noutputs, :nr], self.D, self.dt) except ImportError: raise TypeError("minreal requires slycot tb01pd") else: diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index e0d64f018..f69574d9a 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -8,7 +8,7 @@ import pytest import control as ctrl -from control.xferfcn import TransferFunction +from control.xferfcn import TransferFunction, _tf_close_coeff from control.statesp import StateSpace from control.bdalg import feedback, append, connect from control.lti import zeros, poles @@ -870,50 +870,3 @@ def test_error_combine_tf(self, tf_array, exception): """Test error cases.""" with pytest.raises(exception): ctrl.combine_tf(tf_array) - - -def _tf_close_coeff(tf_a, tf_b, rtol=1e-5, atol=1e-8): - """Check if two transfer functions have close coefficients. - - Parameters - ---------- - tf_a : TransferFunction - First transfer function. - tf_b : TransferFunction - Second transfer function. - rtol : float - Relative tolerance for ``np.allclose``. - atol : float - Absolute tolerance for ``np.allclose``. - - Returns - ------- - bool - True if transfer function cofficients are all close. - """ - # Check number of outputs and inputs - if tf_a.noutputs != tf_b.noutputs: - return False - if tf_a.ninputs != tf_b.ninputs: - return False - # Check timestep - if tf_a.dt != tf_b.dt: - return False - # Check coefficient arrays - for i in range(tf_a.noutputs): - for j in range(tf_a.ninputs): - if not np.allclose( - tf_a.num_array[i, j], - tf_b.num_array[i, j], - rtol=rtol, - atol=atol, - ): - return False - if not np.allclose( - tf_a.den_array[i, j], - tf_b.den_array[i, j], - rtol=rtol, - atol=atol, - ): - return False - return True diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 0c0a7904f..b1fce53e0 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -28,7 +28,7 @@ # Checksums to use for checking whether a docstring has changed function_docstring_hash = { - control.append: '48548c4c4e0083312b3ea9e56174b0b5', + control.append: '1bddbac0fe932755c85e9fb0bfb97d88', control.describing_function_plot: '95f894706b1d3eeb3b854934596af09f', control.dlqe: '9db995ed95c2214ce97074b0616a3191', control.dlqr: '896cfa651dbbd80e417635904d13c9d6', @@ -37,7 +37,7 @@ control.margin: 'f02b3034f5f1d44ce26f916cc3e51600', control.parallel: 'bfc470aef75dbb923f9c6fb8bf3c9b43', control.series: '9aede1459667738f05cf4fc46603a4f6', - control.ss2tf: '48ff25d22d28e7b396e686dd5eb58831', + control.ss2tf: 'e779b8d70205bc1218cc2a4556a66e4b', control.tf2ss: '086a3692659b7321c2af126f79f4bc11', control.markov: 'a4199c54cb50f07c0163d3790739eafe', control.gangof4: '0e52eb6cf7ce024f9a41f3ae3ebf04f7', diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index c63d9e217..5c0acd4c8 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -187,6 +187,56 @@ def testFeedback2(self): [[1.0, 0], [0, 1]], [[0.0], [0.0]]) # h2.feedback([[0.3, 0.2], [0.1, 0.1]]) + def testAppendSiso(self): + # Create frequency responses + d1 = np.array([1 + 2j, 1 - 2j, 1 + 4j, 1 - 4j, 1 + 6j, 1 - 6j]) + d2 = d1 + 2 + d3 = d1 - 1j + w = np.arange(d1.shape[-1]) + frd1 = FrequencyResponseData(d1, w) + frd2 = FrequencyResponseData(d2, w) + frd3 = FrequencyResponseData(d3, w) + # Create appended frequency responses + d_app_1 = np.zeros((2, 2, d1.shape[-1]), dtype=complex) + d_app_1[0, 0, :] = d1 + d_app_1[1, 1, :] = d2 + d_app_2 = np.zeros((3, 3, d1.shape[-1]), dtype=complex) + d_app_2[0, 0, :] = d1 + d_app_2[1, 1, :] = d2 + d_app_2[2, 2, :] = d3 + # Test appending two FRDs + frd_app_1 = frd1.append(frd2) + np.testing.assert_allclose(d_app_1, frd_app_1.fresp) + # Test appending three FRDs + frd_app_2 = frd1.append(frd2).append(frd3) + np.testing.assert_allclose(d_app_2, frd_app_2.fresp) + + def testAppendMimo(self): + # Create frequency responses + rng = np.random.default_rng(1234) + n = 100 + w = np.arange(n) + d1 = rng.uniform(size=(2, 2, n)) + 1j * rng.uniform(size=(2, 2, n)) + d2 = rng.uniform(size=(3, 1, n)) + 1j * rng.uniform(size=(3, 1, n)) + d3 = rng.uniform(size=(1, 2, n)) + 1j * rng.uniform(size=(1, 2, n)) + frd1 = FrequencyResponseData(d1, w) + frd2 = FrequencyResponseData(d2, w) + frd3 = FrequencyResponseData(d3, w) + # Create appended frequency responses + d_app_1 = np.zeros((5, 3, d1.shape[-1]), dtype=complex) + d_app_1[:2, :2, :] = d1 + d_app_1[2:, 2:, :] = d2 + d_app_2 = np.zeros((6, 5, d1.shape[-1]), dtype=complex) + d_app_2[:2, :2, :] = d1 + d_app_2[2:5, 2:3, :] = d2 + d_app_2[5:, 3:, :] = d3 + # Test appending two FRDs + frd_app_1 = frd1.append(frd2) + np.testing.assert_allclose(d_app_1, frd_app_1.fresp) + # Test appending three FRDs + frd_app_2 = frd1.append(frd2).append(frd3) + np.testing.assert_allclose(d_app_2, frd_app_2.fresp) + def testAuto(self): omega = np.logspace(-1, 2, 10) f1 = _convert_to_frd(1, omega) @@ -424,15 +474,238 @@ def test_operator_conversion(self): np.testing.assert_array_almost_equal(sys_add.omega, chk_add.omega) np.testing.assert_array_almost_equal(sys_add.fresp, chk_add.fresp) + # Test broadcasting with SISO system + sys_tf_mimo = TransferFunction([1], [1, 0]) * np.eye(2) + frd_tf_mimo = frd(sys_tf_mimo, np.logspace(-1, 1, 10)) + result = FrequencyResponseData.__rmul__(frd_tf, frd_tf_mimo) + expected = frd(sys_tf_mimo * sys_tf, np.logspace(-1, 1, 10)) + np.testing.assert_array_almost_equal(expected.omega, result.omega) + np.testing.assert_array_almost_equal(expected.fresp, result.fresp) + # Input/output mismatch size mismatch in rmul sys1 = frd(ct.rss(2, 2, 2), np.logspace(-1, 1, 10)) + sys2 = frd(ct.rss(3, 3, 3), np.logspace(-1, 1, 10)) with pytest.raises(ValueError): - FrequencyResponseData.__rmul__(frd_2, sys1) + FrequencyResponseData.__rmul__(sys2, sys1) # Make sure conversion of something random generates exception with pytest.raises(TypeError): FrequencyResponseData.__add__(frd_tf, 'string') + def test_add_sub_mimo_siso(self): + omega = np.logspace(-1, 1, 10) + sys_mimo = frd(ct.rss(2, 2, 2), omega) + sys_siso = frd(ct.rss(2, 1, 1), omega) + + for op, expected_fresp in [ + (FrequencyResponseData.__add__, sys_mimo.fresp + sys_siso.fresp), + (FrequencyResponseData.__radd__, sys_mimo.fresp + sys_siso.fresp), + (FrequencyResponseData.__sub__, sys_mimo.fresp - sys_siso.fresp), + (FrequencyResponseData.__rsub__, -sys_mimo.fresp + sys_siso.fresp), + ]: + result = op(sys_mimo, sys_siso) + np.testing.assert_array_almost_equal(omega, result.omega) + np.testing.assert_array_almost_equal(expected_fresp, result.fresp) + + @pytest.mark.parametrize( + "left, right, expected", + [ + ( + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + TransferFunction([2], [1, 0]), + np.eye(3), + TransferFunction( + [ + [[2], [0], [0]], + [[0], [2], [0]], + [[0], [0], [2]], + ], + [ + [[1, 0], [1], [1]], + [[1], [1, 0], [1]], + [[1], [1], [1, 0]], + ], + ), + ), + ] + ) + def test_mul_mimo_siso(self, left, right, expected): + result = frd(left, np.logspace(-1, 1, 10)).__mul__(right) + expected_frd = frd(expected, np.logspace(-1, 1, 10)) + np.testing.assert_array_almost_equal(expected_frd.omega, result.omega) + np.testing.assert_array_almost_equal(expected_frd.fresp, result.fresp) + + @slycotonly + def test_truediv_mimo_siso(self): + omega = np.logspace(-1, 1, 10) + tf_mimo = TransferFunction([1], [1, 0]) * np.eye(2) + frd_mimo = frd(tf_mimo, omega) + ss_mimo = ct.tf2ss(tf_mimo) + tf_siso = TransferFunction([1], [1, 1]) + frd_siso = frd(tf_siso, omega) + expected = frd(tf_mimo.__truediv__(tf_siso), omega) + ss_siso = ct.tf2ss(tf_siso) + + # Test division of MIMO FRD by SISO FRD + result = frd_mimo.__truediv__(frd_siso) + np.testing.assert_array_almost_equal(expected.omega, result.omega) + np.testing.assert_array_almost_equal(expected.fresp, result.fresp) + + # Test division of MIMO FRD by SISO TF + result = frd_mimo.__truediv__(tf_siso) + np.testing.assert_array_almost_equal(expected.omega, result.omega) + np.testing.assert_array_almost_equal(expected.fresp, result.fresp) + + # Test division of MIMO FRD by SISO TF + result = frd_mimo.__truediv__(ss_siso) + np.testing.assert_array_almost_equal(expected.omega, result.omega) + np.testing.assert_array_almost_equal(expected.fresp, result.fresp) + + @slycotonly + def test_rtruediv_mimo_siso(self): + omega = np.logspace(-1, 1, 10) + tf_mimo = TransferFunction([1], [1, 0]) * np.eye(2) + frd_mimo = frd(tf_mimo, omega) + ss_mimo = ct.tf2ss(tf_mimo) + tf_siso = TransferFunction([1], [1, 1]) + frd_siso = frd(tf_siso, omega) + ss_siso = ct.tf2ss(tf_siso) + expected = frd(tf_siso.__rtruediv__(tf_mimo), omega) + + # Test division of MIMO FRD by SISO FRD + result = frd_siso.__rtruediv__(frd_mimo) + np.testing.assert_array_almost_equal(expected.omega, result.omega) + np.testing.assert_array_almost_equal(expected.fresp, result.fresp) + + # Test division of MIMO TF by SISO FRD + result = frd_siso.__rtruediv__(tf_mimo) + np.testing.assert_array_almost_equal(expected.omega, result.omega) + np.testing.assert_array_almost_equal(expected.fresp, result.fresp) + + # Test division of MIMO SS by SISO FRD + result = frd_siso.__rtruediv__(ss_mimo) + np.testing.assert_array_almost_equal(expected.omega, result.omega) + np.testing.assert_array_almost_equal(expected.fresp, result.fresp) + + + @pytest.mark.parametrize( + "left, right, expected", + [ + ( + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + np.eye(3), + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[2], [0], [0]], + [[0], [2], [0]], + [[0], [0], [2]], + ], + [ + [[1, 0], [1], [1]], + [[1], [1, 0], [1]], + [[1], [1], [1, 0]], + ], + ), + ), + ] + ) + def test_rmul_mimo_siso(self, left, right, expected): + result = frd(right, np.logspace(-1, 1, 10)).__rmul__(left) + expected_frd = frd(expected, np.logspace(-1, 1, 10)) + np.testing.assert_array_almost_equal(expected_frd.omega, result.omega) + np.testing.assert_array_almost_equal(expected_frd.fresp, result.fresp) + def test_eval(self): sys_tf = ct.tf([1], [1, 2, 1]) frd_tf = frd(sys_tf, np.logspace(-1, 1, 3)) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index a80168649..366c5ef3b 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -20,7 +20,7 @@ from control.lti import evalfr from control.statesp import StateSpace, _convert_to_statespace, tf2ss, \ _statesp_defaults, _rss_generate, linfnorm, ss, rss, drss -from control.xferfcn import TransferFunction, ss2tf +from control.xferfcn import TransferFunction, ss2tf, _tf_close_coeff from .conftest import editsdefaults, slycotonly @@ -320,6 +320,343 @@ def test_multiply_ss(self, sys222, sys322): np.testing.assert_array_almost_equal(sys.C, C) np.testing.assert_array_almost_equal(sys.D, D) + @slycotonly + def test_add_sub_mimo_siso(self): + # Test SS with SS + ss_siso = StateSpace( + np.array([ + [1, 2], + [3, 4], + ]), + np.array([ + [1], + [4], + ]), + np.array([ + [1, 1], + ]), + np.array([ + [0], + ]), + ) + ss_siso_1 = StateSpace( + np.array([ + [1, 1], + [3, 1], + ]), + np.array([ + [3], + [-4], + ]), + np.array([ + [-1, 1], + ]), + np.array([ + [0.1], + ]), + ) + ss_siso_2 = StateSpace( + np.array([ + [1, 0], + [0, 1], + ]), + np.array([ + [0], + [2], + ]), + np.array([ + [0, 1], + ]), + np.array([ + [0], + ]), + ) + ss_mimo = ss_siso_1.append(ss_siso_2) + expected_add = ct.combine_tf([ + [ss2tf(ss_siso_1 + ss_siso), ss2tf(ss_siso)], + [ss2tf(ss_siso), ss2tf(ss_siso_2 + ss_siso)], + ]) + expected_sub = ct.combine_tf([ + [ss2tf(ss_siso_1 - ss_siso), -ss2tf(ss_siso)], + [-ss2tf(ss_siso), ss2tf(ss_siso_2 - ss_siso)], + ]) + for op, expected in [ + (StateSpace.__add__, expected_add), + (StateSpace.__radd__, expected_add), + (StateSpace.__sub__, expected_sub), + (StateSpace.__rsub__, -expected_sub), + ]: + result = op(ss_mimo, ss_siso) + assert _tf_close_coeff( + expected.minreal(), + ss2tf(result).minreal(), + ) + # Test SS with array + expected_add = ct.combine_tf([ + [ss2tf(1 + ss_siso), ss2tf(ss_siso)], + [ss2tf(ss_siso), ss2tf(1 + ss_siso)], + ]) + expected_sub = ct.combine_tf([ + [ss2tf(-1 + ss_siso), ss2tf(ss_siso)], + [ss2tf(ss_siso), ss2tf(-1 + ss_siso)], + ]) + for op, expected in [ + (StateSpace.__add__, expected_add), + (StateSpace.__radd__, expected_add), + (StateSpace.__sub__, expected_sub), + (StateSpace.__rsub__, -expected_sub), + ]: + result = op(ss_siso, np.eye(2)) + assert _tf_close_coeff( + expected.minreal(), + ss2tf(result).minreal(), + ) + + @slycotonly + @pytest.mark.parametrize( + "left, right, expected", + [ + ( + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + TransferFunction([2], [1, 0]), + np.eye(3), + TransferFunction( + [ + [[2], [0], [0]], + [[0], [2], [0]], + [[0], [0], [2]], + ], + [ + [[1, 0], [1], [1]], + [[1], [1, 0], [1]], + [[1], [1], [1, 0]], + ], + ), + ), + ] + ) + def test_mul_mimo_siso(self, left, right, expected): + result = tf2ss(left).__mul__(right) + assert _tf_close_coeff( + expected.minreal(), + ss2tf(result).minreal(), + ) + + @slycotonly + @pytest.mark.parametrize( + "left, right, expected", + [ + ( + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + np.eye(3), + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[2], [0], [0]], + [[0], [2], [0]], + [[0], [0], [2]], + ], + [ + [[1, 0], [1], [1]], + [[1], [1, 0], [1]], + [[1], [1], [1, 0]], + ], + ), + ), + ] + ) + def test_rmul_mimo_siso(self, left, right, expected): + result = tf2ss(right).__rmul__(left) + assert _tf_close_coeff( + expected.minreal(), + ss2tf(result).minreal(), + ) + + @slycotonly + def test_pow(self, sys222, sys322): + """Test state space powers.""" + for sys in [sys222, sys322]: + # Power of 0 + result = sys**0 + expected = StateSpace([], [], [], np.eye(2), dt=0) + np.testing.assert_allclose(expected.A, result.A) + np.testing.assert_allclose(expected.B, result.B) + np.testing.assert_allclose(expected.C, result.C) + np.testing.assert_allclose(expected.D, result.D) + # Power of 1 + result = sys**1 + expected = sys + np.testing.assert_allclose(expected.A, result.A) + np.testing.assert_allclose(expected.B, result.B) + np.testing.assert_allclose(expected.C, result.C) + np.testing.assert_allclose(expected.D, result.D) + # Power of -1 (inverse of biproper system) + # Testing transfer function representations to avoid the + # non-uniqueness of the state-space representation. Once MIMO + # canonical forms are supported, can check canonical state-space + # matrices instead. + result = (sys * sys**-1).minreal() + expected = StateSpace([], [], [], np.eye(2), dt=0) + assert _tf_close_coeff( + ss2tf(expected).minreal(), + ss2tf(result).minreal(), + ) + result = (sys**-1 * sys).minreal() + expected = StateSpace([], [], [], np.eye(2), dt=0) + assert _tf_close_coeff( + ss2tf(expected).minreal(), + ss2tf(result).minreal(), + ) + # Power of 3 + result = sys**3 + expected = sys * sys * sys + np.testing.assert_allclose(expected.A, result.A) + np.testing.assert_allclose(expected.B, result.B) + np.testing.assert_allclose(expected.C, result.C) + np.testing.assert_allclose(expected.D, result.D) + # Power of -3 + result = sys**-3 + expected = sys**-1 * sys**-1 * sys**-1 + np.testing.assert_allclose(expected.A, result.A) + np.testing.assert_allclose(expected.B, result.B) + np.testing.assert_allclose(expected.C, result.C) + np.testing.assert_allclose(expected.D, result.D) + + @slycotonly + def test_truediv(self, sys222, sys322): + """Test state space truediv""" + for sys in [sys222, sys322]: + # Divide by self + result = (sys.__truediv__(sys)).minreal() + expected = StateSpace([], [], [], np.eye(2), dt=0) + assert _tf_close_coeff( + ss2tf(expected).minreal(), + ss2tf(result).minreal(), + ) + # Divide by TF + result = sys.__truediv__(TransferFunction.s) + expected = ss2tf(sys) / TransferFunction.s + assert _tf_close_coeff( + expected.minreal(), + ss2tf(result).minreal(), + ) + + @slycotonly + def test_rtruediv(self, sys222, sys322): + """Test state space rtruediv""" + for sys in [sys222, sys322]: + result = (sys.__rtruediv__(sys)).minreal() + expected = StateSpace([], [], [], np.eye(2), dt=0) + assert _tf_close_coeff( + ss2tf(expected).minreal(), + ss2tf(result).minreal(), + ) + # Divide TF by SS + result = sys.__rtruediv__(TransferFunction.s) + expected = TransferFunction.s / sys + assert _tf_close_coeff( + expected.minreal(), + result.minreal(), + ) + # Divide array by SS + sys = tf2ss(TransferFunction([1, 2], [2, 1])) + result = sys.__rtruediv__(np.eye(2)) + expected = TransferFunction([2, 1], [1, 2]) * np.eye(2) + assert _tf_close_coeff( + expected.minreal(), + ss2tf(result).minreal(), + ) + @pytest.mark.parametrize("k", [2, -3.141, np.float32(2.718), np.array([[4.321], [5.678]])]) def test_truediv_ss_scalar(self, sys322, k): """Divide SS by scalar.""" diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index c7f92379a..cab556cc2 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -15,7 +15,7 @@ ss, ss2tf, tf, tf2ss, zpk) from control.statesp import _convert_to_statespace from control.tests.conftest import slycotonly -from control.xferfcn import _convert_to_transfer_function +from control.xferfcn import _convert_to_transfer_function, _tf_close_coeff class TestXferFcn: @@ -106,9 +106,28 @@ def test_constructor_double_dt(self): def test_add_inconsistent_dimension(self): """Add two transfer function matrices of different sizes.""" - sys1 = TransferFunction([[[1., 2.]]], [[[4., 5.]]]) - sys2 = TransferFunction([[[4., 3.]], [[1., 2.]]], - [[[1., 6.]], [[2., 4.]]]) + sys1 = TransferFunction( + [ + [[1., 2.]], + [[2., -2.]], + [[2., 1.]], + ], + [ + [[4., 5.]], + [[5., 2.]], + [[3., 2.]], + ], + ) + sys2 = TransferFunction( + [ + [[4., 3.]], + [[1., 2.]], + ], + [ + [[1., 6.]], + [[2., 4.]], + ] + ) with pytest.raises(ValueError): sys1.__add__(sys2) with pytest.raises(ValueError): @@ -385,6 +404,239 @@ def test_pow(self): with pytest.raises(ValueError): TransferFunction.__pow__(sys1, 0.5) + def test_add_sub_mimo_siso(self): + for op in [ + TransferFunction.__add__, + TransferFunction.__radd__, + TransferFunction.__sub__, + TransferFunction.__rsub__, + ]: + tf_mimo = TransferFunction( + [ + [[1], [1]], + [[1], [1]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ) + tf_siso = TransferFunction([1], [5, 0]) + tf_arr = ct.split_tf(tf_mimo) + expected = ct.combine_tf([ + [op(tf_arr[0, 0], tf_siso), op(tf_arr[0, 1], tf_siso)], + [op(tf_arr[1, 0], tf_siso), op(tf_arr[1, 1], tf_siso)], + ]) + result = op(tf_mimo, tf_siso) + assert _tf_close_coeff(expected.minreal(), result.minreal()) + + @pytest.mark.parametrize( + "left, right, expected", + [ + ( + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + TransferFunction([2], [1, 0]), + np.eye(3), + TransferFunction( + [ + [[2], [0], [0]], + [[0], [2], [0]], + [[0], [0], [2]], + ], + [ + [[1, 0], [1], [1]], + [[1], [1, 0], [1]], + [[1], [1], [1, 0]], + ], + ), + ), + ] + ) + def test_mul_mimo_siso(self, left, right, expected): + """Test multiplication of a MIMO and a SISO system.""" + result = left.__mul__(right) + assert _tf_close_coeff(expected.minreal(), result.minreal()) + + @pytest.mark.parametrize( + "left, right, expected", + [ + ( + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + TransferFunction( + [ + [[2], [1]], + [[-1], [4]], + ], + [ + [[10, 1], [20, 1]], + [[20, 1], [30, 1]], + ], + ), + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[4], [2]], + [[-2], [8]], + ], + [ + [[10, 1, 0], [20, 1, 0]], + [[20, 1, 0], [30, 1, 0]], + ], + ), + ), + ( + np.eye(3), + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[2], [0], [0]], + [[0], [2], [0]], + [[0], [0], [2]], + ], + [ + [[1, 0], [1], [1]], + [[1], [1, 0], [1]], + [[1], [1], [1, 0]], + ], + ), + ), + ] + ) + def test_rmul_mimo_siso(self, left, right, expected): + """Test right multiplication of a MIMO and a SISO system.""" + result = right.__rmul__(left) + assert _tf_close_coeff(expected.minreal(), result.minreal()) + + @pytest.mark.parametrize( + "left, right, expected", + [ + ( + TransferFunction( + [ + [[1], [0], [0]], + [[0], [2], [0]], + [[0], [0], [3]], + ], + [ + [[1], [1], [1]], + [[1], [1], [1]], + [[1], [1], [1]], + ], + ), + TransferFunction([-2], [1, 0]), + TransferFunction( + [ + [[1, 0], [0], [0]], + [[0], [2, 0], [0]], + [[0], [0], [3, 0]], + ], + [ + [[-2], [1], [1]], + [[1], [-2], [1]], + [[1], [1], [-2]], + ], + ), + ), + ] + ) + def test_truediv_mimo_siso(self, left, right, expected): + """Test true division of a MIMO and a SISO system.""" + result = left.__truediv__(right) + assert _tf_close_coeff(expected.minreal(), result.minreal()) + + @pytest.mark.parametrize( + "left, right, expected", + [ + ( + np.eye(3), + TransferFunction([2], [1, 0]), + TransferFunction( + [ + [[1, 0], [0], [0]], + [[0], [1, 0], [0]], + [[0], [0], [1, 0]], + ], + [ + [[2], [1], [1]], + [[1], [2], [1]], + [[1], [1], [2]], + ], + ), + ), + ] + ) + def test_rtruediv_mimo_siso(self, left, right, expected): + """Test right true division of a MIMO and a SISO system.""" + result = right.__rtruediv__(left) + assert _tf_close_coeff(expected.minreal(), result.minreal()) + @pytest.mark.parametrize("named", [False, True]) def test_slice(self, named): sys = TransferFunction( @@ -638,6 +890,52 @@ def test_feedback_siso(self): np.testing.assert_allclose(sys4.num, [[[-1., 7., -16., 16., 0.]]]) np.testing.assert_allclose(sys4.den, [[[1., 0., 2., -8., 8., 0.]]]) + def test_append(self): + """Test ``TransferFunction.append()``.""" + tf1 = TransferFunction( + [ + [[1], [1]] + ], + [ + [[10, 1], [20, 1]] + ], + ) + tf2 = TransferFunction( + [ + [[2], [2]] + ], + [ + [[10, 1], [1, 1]] + ], + ) + tf3 = TransferFunction([100], [100, 1]) + tf_exp_1 = TransferFunction( + [ + [[1], [1], [0], [0]], + [[0], [0], [2], [2]], + ], + [ + [[10, 1], [20, 1], [1], [1]], + [[1], [1], [10, 1], [1, 1]], + ], + ) + tf_exp_2 = TransferFunction( + [ + [[1], [1], [0], [0], [0]], + [[0], [0], [2], [2], [0]], + [[0], [0], [0], [0], [100]], + ], + [ + [[10, 1], [20, 1], [1], [1], [1]], + [[1], [1], [10, 1], [1, 1], [1]], + [[1], [1], [1], [1], [100, 1]], + ], + ) + tf_appended_1 = tf1.append(tf2) + assert _tf_close_coeff(tf_exp_1, tf_appended_1) + tf_appended_2 = tf1.append(tf2).append(tf3) + assert _tf_close_coeff(tf_exp_2, tf_appended_2) + @slycotonly def test_convert_to_transfer_function(self): """Test for correct state space to transfer function conversion.""" diff --git a/control/xferfcn.py b/control/xferfcn.py index dea61f9f3..c9043bd0e 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -29,6 +29,7 @@ from scipy.signal import cont2discrete, tf2zpk, zpk2tf from . import config +from . import bdalg from .exception import ControlMIMONotImplemented from .frdata import FrequencyResponseData from .iosys import InputOutputSystem, NamedSignal, _process_iosys_keywords, \ @@ -582,6 +583,12 @@ def __add__(self, other): if not isinstance(other, TransferFunction): return NotImplemented + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + self = np.ones((other.noutputs, other.ninputs)) * self + elif not self.issiso() and other.issiso(): + other = np.ones((self.noutputs, self.ninputs)) * other + # Check that the input-output sizes are consistent. if self.ninputs != other.ninputs: raise ValueError( @@ -631,6 +638,12 @@ def __mul__(self, other): if not isinstance(other, TransferFunction): return NotImplemented + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + self = bdalg.append(*([self] * other.noutputs)) + elif not self.issiso() and other.issiso(): + other = bdalg.append(*([other] * self.ninputs)) + # Check that the input-output sizes are consistent. if self.ninputs != other.noutputs: raise ValueError( @@ -674,6 +687,12 @@ def __rmul__(self, other): else: other = _convert_to_transfer_function(other) + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + self = bdalg.append(*([self] * other.ninputs)) + elif not self.issiso() and other.issiso(): + other = bdalg.append(*([other] * self.noutputs)) + # Check that the input-output sizes are consistent. if other.ninputs != self.noutputs: raise ValueError( @@ -718,12 +737,16 @@ def __truediv__(self, other): else: other = _convert_to_transfer_function(other) + # Special case for SISO ``other`` + if not self.issiso() and other.issiso(): + other = bdalg.append(*([other**-1] * self.noutputs)) + return self * other + if (self.ninputs > 1 or self.noutputs > 1 or other.ninputs > 1 or other.noutputs > 1): - raise NotImplementedError( - "TransferFunction.__truediv__ is currently \ - implemented only for SISO systems.") - + # TransferFunction.__truediv__ is currently implemented only for + # SISO systems. + return NotImplemented dt = common_timebase(self.dt, other.dt) num = polymul(self.num_array[0, 0], other.den_array[0, 0]) @@ -741,11 +764,16 @@ def __rtruediv__(self, other): else: other = _convert_to_transfer_function(other) + # Special case for SISO ``self`` + if self.issiso() and not other.issiso(): + self = bdalg.append(*([self**-1] * other.ninputs)) + return other * self + if (self.ninputs > 1 or self.noutputs > 1 or other.ninputs > 1 or other.noutputs > 1): - raise NotImplementedError( - "TransferFunction.__rtruediv__ is currently implemented only " - "for SISO systems.") + # TransferFunction.__rtruediv__ is currently implemented only for + # SISO systems + return NotImplemented return other / self @@ -850,6 +878,20 @@ def feedback(self, other=1, sign=-1): # But this does not work correctly because the state size will be too # large. + def append(self, other): + """Append a second model to the present model. + + The second model is converted to a transfer function if necessary, + inputs and outputs are appended and their order is preserved""" + other = _convert_to_transfer_function(other) + + new_tf = bdalg.combine_tf([ + [self, np.zeros((self.noutputs, other.ninputs))], + [np.zeros((other.noutputs, self.ninputs)), other], + ]) + + return new_tf + def minreal(self, tol=None): """Remove cancelling pole/zero pairs from a transfer function.""" # based on octave minreal @@ -1751,7 +1793,7 @@ def zpk(zeros, poles, gain, *args, **kwargs): Returns ------- - out: `TransferFunction` + out : `TransferFunction` Transfer function with given zeros, poles, and gain. Examples @@ -1803,7 +1845,7 @@ def ss2tf(*args, **kwargs): Returns ------- - out: TransferFunction + out : TransferFunction New linear system in transfer function form Other Parameters @@ -1968,6 +2010,53 @@ def _float2str(value): return f"{value:{_num_format}}" +def _tf_close_coeff(tf_a, tf_b, rtol=1e-5, atol=1e-8): + """Check if two transfer functions have close coefficients. + + Parameters + ---------- + tf_a : TransferFunction + First transfer function. + tf_b : TransferFunction + Second transfer function. + rtol : float + Relative tolerance for ``np.allclose``. + atol : float + Absolute tolerance for ``np.allclose``. + + Returns + ------- + bool + True if transfer function cofficients are all close. + """ + # Check number of outputs and inputs + if tf_a.noutputs != tf_b.noutputs: + return False + if tf_a.ninputs != tf_b.ninputs: + return False + # Check timestep + if tf_a.dt != tf_b.dt: + return False + # Check coefficient arrays + for i in range(tf_a.noutputs): + for j in range(tf_a.ninputs): + if not np.allclose( + tf_a.num[i][j], + tf_b.num[i][j], + rtol=rtol, + atol=atol, + ): + return False + if not np.allclose( + tf_a.den[i][j], + tf_b.den[i][j], + rtol=rtol, + atol=atol, + ): + return False + return True + + def _create_poly_array(shape, default=None): out = np.empty(shape, dtype=np.ndarray) if default is not None: diff --git a/doc/control.rst b/doc/control.rst index dd418f2af..766e593d8 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -37,6 +37,8 @@ System interconnections parallel series connection_table + combine_tf + split_tf Frequency domain plotting