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
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: