From 9acbfbbbf3b4e2c9e8453c0cc18d21df88d6230b Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 6 Dec 2024 17:36:37 -0800 Subject: [PATCH 01/39] fix issue with multiplying MIMO LTI system by scalar --- control/frdata.py | 15 +++++++++++++-- control/tests/lti_test.py | 18 ++++++++++++++++++ control/xferfcn.py | 17 ++++++++--------- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 1bdf28528..ac032d3f7 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -261,6 +261,11 @@ def __init__(self, *args, **kwargs): # create interpolation functions if smooth: + # Set the order of the fit + if self.omega.size < 2: + raise ValueError("can't smooth with only 1 frequency") + degree = 3 if self.omega.size > 3 else self.omega.size - 1 + self.ifunc = empty((self.fresp.shape[0], self.fresp.shape[1]), dtype=tuple) for i in range(self.fresp.shape[0]): @@ -268,7 +273,8 @@ def __init__(self, *args, **kwargs): self.ifunc[i, j], u = splprep( u=self.omega, x=[real(self.fresp[i, j, :]), imag(self.fresp[i, j, :])], - w=1.0/(absolute(self.fresp[i, j, :]) + 0.001), s=0.0) + w=1.0/(absolute(self.fresp[i, j, :]) + 0.001), + s=0.0, k=degree) else: self.ifunc = None @@ -392,7 +398,12 @@ def __add__(self, other): # Convert the second argument to a frequency response function. # or re-base the frd to the current omega (if needed) - other = _convert_to_frd(other, omega=self.omega) + if isinstance(other, (int, float, complex, np.number)): + other = _convert_to_frd( + other, omega=self.omega, + inputs=self.ninputs, outputs=self.noutputs) + else: + other = _convert_to_frd(other, omega=self.omega) # Check that the input-output sizes are consistent. if self.ninputs != other.ninputs: diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index 3f001c17b..5359ceea3 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -350,3 +350,21 @@ def test_subsys_indexing(fcn, outdx, inpdx, key): np.testing.assert_almost_equal( subsys_fcn.frequency_response(omega).response, subsys_chk.frequency_response(omega).response) + + +@slycotonly +@pytest.mark.parametrize("op", [ + '__mul__', '__rmul__', '__add__', '__radd__', '__sub__', '__rsub__']) +@pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd]) +def test_scalar_algebra(op, fcn): + sys_ss = ct.rss(4, 2, 2) + match fcn: + case ct.ss: + sys = sys_ss + case ct.tf: + sys = ct.tf(sys_ss) + case ct.frd: + sys = ct.frd(sys_ss, [0.1, 1, 10]) + + scaled = getattr(sys, op)(2) + np.testing.assert_almost_equal(getattr(sys(1j), op)(2), scaled(1j)) diff --git a/control/xferfcn.py b/control/xferfcn.py index 56ec7395f..b7daa9a2d 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -634,11 +634,11 @@ def __mul__(self, other): from .statesp import StateSpace # Convert the second argument to a transfer function. - if isinstance(other, StateSpace): + if isinstance(other, (StateSpace, np.ndarray)): other = _convert_to_transfer_function(other) - elif isinstance(other, (int, float, complex, np.number, np.ndarray)): - other = _convert_to_transfer_function(other, inputs=self.ninputs, - outputs=self.noutputs) + elif isinstance(other, (int, float, complex, np.number)): + # Multiply by a scaled identify matrix (transfer function) + other = _convert_to_transfer_function(np.eye(self.ninputs) * other) if not isinstance(other, TransferFunction): return NotImplemented @@ -681,8 +681,8 @@ def __rmul__(self, other): # Convert the second argument to a transfer function. if isinstance(other, (int, float, complex, np.number)): - other = _convert_to_transfer_function(other, inputs=self.ninputs, - outputs=self.ninputs) + # Multiply by a scaled identify matrix (transfer function) + other = _convert_to_transfer_function(np.eye(self.noutputs) * other) else: other = _convert_to_transfer_function(other) @@ -723,9 +723,8 @@ def __truediv__(self, other): """Divide two LTI objects.""" if isinstance(other, (int, float, complex, np.number)): - other = _convert_to_transfer_function( - other, inputs=self.ninputs, - outputs=self.ninputs) + # Multiply by a scaled identify matrix (transfer function) + other = _convert_to_transfer_function(np.eye(self.ninputs) * other) else: other = _convert_to_transfer_function(other) From acc50862c9673dd632682830888b9bef86c109e1 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Mon, 9 Dec 2024 15:43:39 -0500 Subject: [PATCH 02/39] Add append for FRD --- control/frdata.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/control/frdata.py b/control/frdata.py index ac032d3f7..bc92a5d8c 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -753,6 +753,35 @@ 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. From 686a9b35d809c98a57193824273f86a5b940e453 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Mon, 9 Dec 2024 16:16:54 -0500 Subject: [PATCH 03/39] Add SISO FRD append test --- control/tests/frd_test.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index c2a29ee2e..f2a0c4ec9 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -187,6 +187,30 @@ 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 testAuto(self): omega = np.logspace(-1, 2, 10) f1 = _convert_to_frd(1, omega) From 94481ac7ba9fa11c0e2dd5f715f3408b5245c6fa Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Mon, 9 Dec 2024 16:19:28 -0500 Subject: [PATCH 04/39] Add MIMO FRD test --- control/tests/frd_test.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index f2a0c4ec9..11dd9116d 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -211,6 +211,32 @@ def testAppendSiso(self): 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) From 053c3c63d1a54ec1dc1ed5a1ee3842aef973e219 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Mon, 9 Dec 2024 16:31:58 -0500 Subject: [PATCH 05/39] Add append for TFs --- control/xferfcn.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/control/xferfcn.py b/control/xferfcn.py index b7daa9a2d..d588f4a27 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -68,6 +68,7 @@ from .iosys import InputOutputSystem, NamedSignal, _process_iosys_keywords, \ _process_subsys_index, common_timebase, isdtime from .lti import LTI, _process_frequency_response +from .bdalg import combine_tf __all__ = ['TransferFunction', 'tf', 'zpk', 'ss2tf', 'tfdata'] @@ -861,6 +862,21 @@ 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) + common_timebase(self.dt, other.dt) # Call just to validate ``dt``s + + new_tf = 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 From b6b032ffbcb7bee0f0f4c363c85e7d7399a5442d Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Mon, 9 Dec 2024 16:36:31 -0500 Subject: [PATCH 06/39] Move tf_close_coeff to xferfcn --- control/tests/bdalg_test.py | 49 +------------------------------------ control/xferfcn.py | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 8ea67e0f7..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[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 diff --git a/control/xferfcn.py b/control/xferfcn.py index d588f4a27..ea8383444 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1924,3 +1924,50 @@ def _clean_part(data): def _float2str(value): _num_format = config.defaults.get('xferfcn.floating_point_format', ':.4g') 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 From 09fca2c9d1d99c92312d4c076ecb768a97e64df4 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Mon, 9 Dec 2024 16:42:47 -0500 Subject: [PATCH 07/39] Add append TF tests --- control/tests/xferfcn_test.py | 48 ++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index d480cef6e..db7e279df 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: @@ -643,6 +643,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.""" From aafaa06347f017b5ed5bc15bf46e10237358dfcf Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 13 Dec 2024 13:40:15 -0500 Subject: [PATCH 08/39] Make append() return type of first argument --- control/bdalg.py | 11 ++++++----- control/tests/docstrings_test.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index d907cd3c5..59423db9b 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/tests/docstrings_test.py b/control/tests/docstrings_test.py index 27ced105f..e08b4a061 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -25,7 +25,7 @@ # Checksums to use for checking whether a docstring has changed function_docstring_hash = { - control.append: '48548c4c4e0083312b3ea9e56174b0b5', + control.append: '25e3a7e5f1c21eb7ec6562f199e2d7fd', control.describing_function_plot: '95f894706b1d3eeb3b854934596af09f', control.dlqe: '9db995ed95c2214ce97074b0616a3191', control.dlqr: '896cfa651dbbd80e417635904d13c9d6', From 4cf26b5aa9347cea6ec43c44d0cbdb9c2158a234 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 13 Dec 2024 13:58:04 -0500 Subject: [PATCH 09/39] Implement transfer function __mul__ dimension promotion --- control/xferfcn.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index ea8383444..916cd7a01 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -68,7 +68,7 @@ from .iosys import InputOutputSystem, NamedSignal, _process_iosys_keywords, \ _process_subsys_index, common_timebase, isdtime from .lti import LTI, _process_frequency_response -from .bdalg import combine_tf +from .bdalg import combine_tf, append __all__ = ['TransferFunction', 'tf', 'zpk', 'ss2tf', 'tfdata'] @@ -643,16 +643,25 @@ def __mul__(self, other): if not isinstance(other, TransferFunction): return NotImplemented + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + promoted_self = append(*([self] * other.noutputs)) + elif not self.issiso() and other.issiso(): + other = append(*([other] * self.ninputs)) + promoted_self = self + else: + promoted_self = self + # Check that the input-output sizes are consistent. - if self.ninputs != other.noutputs: + if promoted_self.ninputs != other.noutputs: raise ValueError( "C = A * B: A has %i column(s) (input(s)), but B has %i " - "row(s)\n(output(s))." % (self.ninputs, other.noutputs)) + "row(s)\n(output(s))." % (promoted_self.ninputs, other.noutputs)) inputs = other.ninputs - outputs = self.noutputs + outputs = promoted_self.noutputs - dt = common_timebase(self.dt, other.dt) + dt = common_timebase(promoted_self.dt, other.dt) # Preallocate the numerator and denominator of the sum. num = [[[0] for j in range(inputs)] for i in range(outputs)] @@ -660,17 +669,17 @@ def __mul__(self, other): # Temporary storage for the summands needed to find the (i, j)th # element of the product. - num_summand = [[] for k in range(self.ninputs)] - den_summand = [[] for k in range(self.ninputs)] + num_summand = [[] for k in range(promoted_self.ninputs)] + den_summand = [[] for k in range(promoted_self.ninputs)] # Multiply & add. for row in range(outputs): for col in range(inputs): - for k in range(self.ninputs): + for k in range(promoted_self.ninputs): num_summand[k] = polymul( - self.num[row][k], other.num[k][col]) + promoted_self.num[row][k], other.num[k][col]) den_summand[k] = polymul( - self.den[row][k], other.den[k][col]) + promoted_self.den[row][k], other.den[k][col]) num[row][col], den[row][col] = _add_siso( num[row][col], den[row][col], num_summand[k], den_summand[k]) From 0f5970122c35e0a73973a7a26445bd61bbfec0c4 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 13 Dec 2024 14:55:03 -0500 Subject: [PATCH 10/39] Implement TF __rmul__ --- control/xferfcn.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index 916cd7a01..876c0058c 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -696,16 +696,25 @@ def __rmul__(self, other): else: other = _convert_to_transfer_function(other) + # Promote SISO object to compatible dimension + if self.issiso() and not other.issiso(): + promoted_self = append(*([self] * other.ninputs)) + elif not self.issiso() and other.issiso(): + other = append(*([other] * self.noutputs)) + promoted_self = self + else: + promoted_self = self + # Check that the input-output sizes are consistent. - if other.ninputs != self.noutputs: + if other.ninputs != promoted_self.noutputs: raise ValueError( "C = A * B: A has %i column(s) (input(s)), but B has %i " - "row(s)\n(output(s))." % (other.ninputs, self.noutputs)) + "row(s)\n(output(s))." % (other.ninputs, promoted_self.noutputs)) - inputs = self.ninputs + inputs = promoted_self.ninputs outputs = other.noutputs - dt = common_timebase(self.dt, other.dt) + dt = common_timebase(promoted_self.dt, other.dt) # Preallocate the numerator and denominator of the sum. num = [[[0] for j in range(inputs)] for i in range(outputs)] @@ -720,8 +729,8 @@ def __rmul__(self, other): for i in range(outputs): # Iterate through rows of product. for j in range(inputs): # Iterate through columns of product. for k in range(other.ninputs): # Multiply & add. - num_summand[k] = polymul(other.num[i][k], self.num[k][j]) - den_summand[k] = polymul(other.den[i][k], self.den[k][j]) + num_summand[k] = polymul(other.num[i][k], promoted_self.num[k][j]) + den_summand[k] = polymul(other.den[i][k], promoted_self.den[k][j]) num[i][j], den[i][j] = _add_siso( num[i][j], den[i][j], num_summand[k], den_summand[k]) From 7aaf3552c658254c3b159d4da55e918f50fd54a6 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 13 Dec 2024 15:10:01 -0500 Subject: [PATCH 11/39] Add TF __truediv__ and __rtruediv__ --- control/xferfcn.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/control/xferfcn.py b/control/xferfcn.py index 876c0058c..398e68ddf 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -747,6 +747,11 @@ def __truediv__(self, other): else: other = _convert_to_transfer_function(other) + # Special case for SISO ``other`` + if not self.issiso() and other.issiso(): + other = 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( @@ -770,6 +775,11 @@ def __rtruediv__(self, other): else: other = _convert_to_transfer_function(other) + # Special case for SISO ``self`` + if self.issiso() and not other.issiso(): + promoted_self = append(*([self**-1] * other.ninputs)) + return other * promoted_self + if (self.ninputs > 1 or self.noutputs > 1 or other.ninputs > 1 or other.noutputs > 1): raise NotImplementedError( From 3cea5c5960b49a68c28c97f4122ec93e0169c768 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 13 Dec 2024 15:36:41 -0500 Subject: [PATCH 12/39] Add __mul__, __rmul__, __truediv__, and __rtruediv__ tests --- control/tests/xferfcn_test.py | 207 ++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index db7e279df..5964c0f97 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -390,6 +390,213 @@ def test_pow(self): with pytest.raises(ValueError): TransferFunction.__pow__(sys1, 0.5) + @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( From 7dcf256bbb36c7203f502b33f27ef95c6c607cc4 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 13 Dec 2024 15:43:52 -0500 Subject: [PATCH 13/39] Rename promoted_self to self --- control/xferfcn.py | 44 +++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index 398e68ddf..ae999b99b 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -645,23 +645,20 @@ def __mul__(self, other): # Promote SISO object to compatible dimension if self.issiso() and not other.issiso(): - promoted_self = append(*([self] * other.noutputs)) + self = append(*([self] * other.noutputs)) elif not self.issiso() and other.issiso(): other = append(*([other] * self.ninputs)) - promoted_self = self - else: - promoted_self = self # Check that the input-output sizes are consistent. - if promoted_self.ninputs != other.noutputs: + if self.ninputs != other.noutputs: raise ValueError( "C = A * B: A has %i column(s) (input(s)), but B has %i " - "row(s)\n(output(s))." % (promoted_self.ninputs, other.noutputs)) + "row(s)\n(output(s))." % (self.ninputs, other.noutputs)) inputs = other.ninputs - outputs = promoted_self.noutputs + outputs = self.noutputs - dt = common_timebase(promoted_self.dt, other.dt) + 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)] @@ -669,17 +666,17 @@ def __mul__(self, other): # Temporary storage for the summands needed to find the (i, j)th # element of the product. - num_summand = [[] for k in range(promoted_self.ninputs)] - den_summand = [[] for k in range(promoted_self.ninputs)] + num_summand = [[] for k in range(self.ninputs)] + den_summand = [[] for k in range(self.ninputs)] # Multiply & add. for row in range(outputs): for col in range(inputs): - for k in range(promoted_self.ninputs): + for k in range(self.ninputs): num_summand[k] = polymul( - promoted_self.num[row][k], other.num[k][col]) + self.num[row][k], other.num[k][col]) den_summand[k] = polymul( - promoted_self.den[row][k], other.den[k][col]) + self.den[row][k], other.den[k][col]) num[row][col], den[row][col] = _add_siso( num[row][col], den[row][col], num_summand[k], den_summand[k]) @@ -698,23 +695,20 @@ def __rmul__(self, other): # Promote SISO object to compatible dimension if self.issiso() and not other.issiso(): - promoted_self = append(*([self] * other.ninputs)) + self = append(*([self] * other.ninputs)) elif not self.issiso() and other.issiso(): other = append(*([other] * self.noutputs)) - promoted_self = self - else: - promoted_self = self # Check that the input-output sizes are consistent. - if other.ninputs != promoted_self.noutputs: + if other.ninputs != self.noutputs: raise ValueError( "C = A * B: A has %i column(s) (input(s)), but B has %i " - "row(s)\n(output(s))." % (other.ninputs, promoted_self.noutputs)) + "row(s)\n(output(s))." % (other.ninputs, self.noutputs)) - inputs = promoted_self.ninputs + inputs = self.ninputs outputs = other.noutputs - dt = common_timebase(promoted_self.dt, other.dt) + 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)] @@ -729,8 +723,8 @@ def __rmul__(self, other): for i in range(outputs): # Iterate through rows of product. for j in range(inputs): # Iterate through columns of product. for k in range(other.ninputs): # Multiply & add. - num_summand[k] = polymul(other.num[i][k], promoted_self.num[k][j]) - den_summand[k] = polymul(other.den[i][k], promoted_self.den[k][j]) + num_summand[k] = polymul(other.num[i][k], self.num[k][j]) + den_summand[k] = polymul(other.den[i][k], self.den[k][j]) num[i][j], den[i][j] = _add_siso( num[i][j], den[i][j], num_summand[k], den_summand[k]) @@ -777,8 +771,8 @@ def __rtruediv__(self, other): # Special case for SISO ``self`` if self.issiso() and not other.issiso(): - promoted_self = append(*([self**-1] * other.ninputs)) - return other * promoted_self + self = append(*([self**-1] * other.ninputs)) + return other * self if (self.ninputs > 1 or self.noutputs > 1 or other.ninputs > 1 or other.noutputs > 1): From 8212a865e054c9bddb6cb68d6ecdb51117d2772f Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 13 Dec 2024 16:31:59 -0500 Subject: [PATCH 14/39] Change way bdalg is imported to avoid circular import and add broadcasting for SS __mul__ and __rmul__ --- control/statesp.py | 13 +++++++++++++ control/xferfcn.py | 16 ++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index bfe5f996b..e02c195a7 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -63,6 +63,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, \ @@ -681,6 +682,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( @@ -727,6 +734,12 @@ def __rmul__(self, other): 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 diff --git a/control/xferfcn.py b/control/xferfcn.py index ae999b99b..c6d7999af 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -63,12 +63,12 @@ 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, \ _process_subsys_index, common_timebase, isdtime from .lti import LTI, _process_frequency_response -from .bdalg import combine_tf, append __all__ = ['TransferFunction', 'tf', 'zpk', 'ss2tf', 'tfdata'] @@ -645,9 +645,9 @@ def __mul__(self, other): # Promote SISO object to compatible dimension if self.issiso() and not other.issiso(): - self = append(*([self] * other.noutputs)) + self = bdalg.append(*([self] * other.noutputs)) elif not self.issiso() and other.issiso(): - other = append(*([other] * self.ninputs)) + other = bdalg.append(*([other] * self.ninputs)) # Check that the input-output sizes are consistent. if self.ninputs != other.noutputs: @@ -695,9 +695,9 @@ def __rmul__(self, other): # Promote SISO object to compatible dimension if self.issiso() and not other.issiso(): - self = append(*([self] * other.ninputs)) + self = bdalg.append(*([self] * other.ninputs)) elif not self.issiso() and other.issiso(): - other = append(*([other] * self.noutputs)) + other = bdalg.append(*([other] * self.noutputs)) # Check that the input-output sizes are consistent. if other.ninputs != self.noutputs: @@ -743,7 +743,7 @@ def __truediv__(self, other): # Special case for SISO ``other`` if not self.issiso() and other.issiso(): - other = append(*([other**-1] * self.noutputs)) + other = bdalg.append(*([other**-1] * self.noutputs)) return self * other if (self.ninputs > 1 or self.noutputs > 1 or @@ -771,7 +771,7 @@ def __rtruediv__(self, other): # Special case for SISO ``self`` if self.issiso() and not other.issiso(): - self = append(*([self**-1] * other.ninputs)) + self = bdalg.append(*([self**-1] * other.ninputs)) return other * self if (self.ninputs > 1 or self.noutputs > 1 or @@ -892,7 +892,7 @@ def append(self, other): other = _convert_to_transfer_function(other) common_timebase(self.dt, other.dt) # Call just to validate ``dt``s - new_tf = combine_tf([ + new_tf = bdalg.combine_tf([ [self, np.zeros((self.noutputs, other.ninputs))], [np.zeros((other.noutputs, self.ninputs)), other], ]) From 525e2456f1eea40b390d751f0b08f6b4f7c399c0 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 13 Dec 2024 16:50:10 -0500 Subject: [PATCH 15/39] Add failing unit test --- control/tests/statesp_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index cb200c4ab..2cc8ba117 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -319,6 +319,12 @@ 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) + def test_multiply_mimo_siso(self): + assert False + + def test_divide_mimo_siso(self): + assert False + @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.""" From cf5a0ab71b56f2f48b185cce3d1a63d40e354e0f Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Mon, 16 Dec 2024 10:47:00 -0500 Subject: [PATCH 16/39] Implement SS __rmul__ and add __mul__ unit tests --- control/statesp.py | 15 +++- control/tests/statesp_test.py | 158 ++++++++++++++++++++++++++++++++-- 2 files changed, 166 insertions(+), 7 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index e02c195a7..38d38625b 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -671,6 +671,10 @@ def __mul__(self, other): elif isinstance(other, np.ndarray): other = np.atleast_2d(other) + # Special case for SISO transfer function + 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 @@ -727,8 +731,15 @@ 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): diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 2cc8ba117..25adf1938 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 @@ -319,11 +319,159 @@ 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) - def test_multiply_mimo_siso(self): - assert False + @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(), + ) - def test_divide_mimo_siso(self): - assert False + @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(), + ) + + # def test_truediv_mimo_siso(self, left, right, expected): + # assert False + # + # def test_rtruediv_mimo_siso(self, left, right, expected): + # assert False @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): From 7de9715147cbcb2690e4739053850f88c6f58273 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 18 Dec 2024 17:50:49 -0500 Subject: [PATCH 17/39] Add pow, truediv, and rtruediv --- control/statesp.py | 37 +++++++++++++++++++-- control/tests/statesp_test.py | 60 ++++++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 38d38625b..d1ee3ed5c 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -756,11 +756,42 @@ def __rmul__(self, other): # 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) + if not isinstance(other, InputOutputSystem): + # Let ``other.__rtruediv__`` handle it + return self * (1 / other) else: 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. @@ -1165,7 +1196,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/statesp_test.py b/control/tests/statesp_test.py index 25adf1938..ca66134f6 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -467,10 +467,62 @@ def test_rmul_mimo_siso(self, left, right, expected): ss2tf(result).minreal(), ) - # def test_truediv_mimo_siso(self, left, right, expected): - # assert False - # - # def test_rtruediv_mimo_siso(self, left, right, expected): + 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) + assert _tf_close_coeff( + ss2tf(expected).minreal(), + ss2tf(result).minreal(), + ) + # Power of 1 + result = sys**1 + expected = sys + assert _tf_close_coeff( + ss2tf(expected).minreal(), + ss2tf(result).minreal(), + ) + # Power of -1 (inverse of biproper system) + 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 + assert _tf_close_coeff( + ss2tf(expected).minreal(), + ss2tf(result).minreal(), + ) + # Power of -3 + result = sys**-3 + expected = sys**-1 * sys**-1 * sys**-1 + assert _tf_close_coeff( + ss2tf(expected).minreal(), + ss2tf(result).minreal(), + ) + + def test_truediv(self, sys222, sys322): + """Test state space truediv""" + for sys in [sys222, sys322]: + result = (sys.__truediv__(sys)).minreal() + expected = StateSpace([], [], [], np.eye(2), dt=0) + assert _tf_close_coeff( + ss2tf(expected).minreal(), + ss2tf(result).minreal(), + ) + + # def test_rtruediv(self): # assert False @pytest.mark.parametrize("k", [2, -3.141, np.float32(2.718), np.array([[4.321], [5.678]])]) From 76f9c9fe5a906baced512e2c702d90365425bd82 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 18 Dec 2024 17:59:15 -0500 Subject: [PATCH 18/39] Fix type conversion error --- control/statesp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index d1ee3ed5c..75555f808 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -756,10 +756,10 @@ def __rmul__(self, other): # 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, InputOutputSystem): - # Let ``other.__rtruediv__`` handle it + # Let ``other.__rtruediv__`` handle it + try: return self * (1 / other) - else: + except ValueError: return NotImplemented def __rtruediv__(self, other): From 56360616bc30fba9bad50b9123c654ef1072f8eb Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 18 Dec 2024 18:38:30 -0500 Subject: [PATCH 19/39] Add more truediv and rtruediv tests --- control/tests/statesp_test.py | 34 ++++++++++++++++++++++++++++++++-- control/xferfcn.py | 13 ++++++------- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index ca66134f6..497bbabe5 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -515,15 +515,45 @@ def test_pow(self, sys222, sys322): 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(), + ) - # def test_rtruediv(self): - # assert False + 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): diff --git a/control/xferfcn.py b/control/xferfcn.py index c6d7999af..b180562a9 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -748,10 +748,9 @@ def __truediv__(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[0][0], other.den[0][0]) @@ -776,9 +775,9 @@ def __rtruediv__(self, other): 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 From a5fe1c17bdbac64bddb73652c460aff59ceabf1c Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 10:30:25 -0500 Subject: [PATCH 20/39] Add __mul__ and __rmul__ for frdata --- control/frdata.py | 13 +++++++++++++ control/tests/frd_test.py | 11 ++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/control/frdata.py b/control/frdata.py index bc92a5d8c..e6f203521 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, _process_iosys_keywords, \ _process_subsys_index, common_timebase @@ -442,6 +443,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( @@ -469,6 +476,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( diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 11dd9116d..0fd160016 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -474,10 +474,19 @@ 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): From cda9afeee0f3ae25ecf16e6523a3be62da24aea4 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 10:34:13 -0500 Subject: [PATCH 21/39] Add more __mul__ and __rmul__ frd tests --- control/tests/frd_test.py | 146 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 0fd160016..3e39dc06f 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -492,6 +492,152 @@ def test_operator_conversion(self): with pytest.raises(TypeError): FrequencyResponseData.__add__(frd_tf, 'string') + @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) + + @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)) From bbf605d894342aca22b69fa2949eb4d845b396b7 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 11:08:07 -0500 Subject: [PATCH 22/39] Add MIMO-SISO truediv and rtruediv --- control/frdata.py | 16 +++++------- control/tests/frd_test.py | 51 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index e6f203521..9f28a9d73 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -510,11 +510,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 @@ -529,11 +527,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 diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 3e39dc06f..61c12a75d 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -565,6 +565,57 @@ def test_mul_mimo_siso(self, left, right, expected): np.testing.assert_array_almost_equal(expected_frd.omega, result.omega) np.testing.assert_array_almost_equal(expected_frd.fresp, result.fresp) + 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) + + 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", [ From 508bc8b9dbd4d03b7055c2fe472fc0a912f34978 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 11:20:28 -0500 Subject: [PATCH 23/39] Add MIMO-SISO add for TF --- control/tests/xferfcn_test.py | 25 ++++++++++++++++++++++--- control/xferfcn.py | 6 ++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 5964c0f97..8c83752e4 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -113,9 +113,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): diff --git a/control/xferfcn.py b/control/xferfcn.py index b180562a9..09c88eac6 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -594,6 +594,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( From 021e34c5140a8ef2562bb9c928cc5f1579573fae Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 11:36:51 -0500 Subject: [PATCH 24/39] Add TF add, sub, radd, rsub tests --- control/tests/xferfcn_test.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 8c83752e4..d5770252a 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -409,6 +409,32 @@ 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", [ From 4f6fab7292f18f43f025945fbcbc45144fb80b8d Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 12:10:57 -0500 Subject: [PATCH 25/39] Add SS SISO MIMO add, sub, radd, rsub tests --- control/statesp.py | 11 ++++++++- control/tests/statesp_test.py | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/control/statesp.py b/control/statesp.py index 75555f808..c61751a3a 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -607,6 +607,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 @@ -617,6 +620,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)): @@ -671,7 +680,7 @@ def __mul__(self, other): elif isinstance(other, np.ndarray): other = np.atleast_2d(other) - # Special case for SISO transfer function + # Special case for SISO if self.issiso(): self = bdalg.append(*([self] * other.shape[0])) # Dimension check after broadcasting diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 497bbabe5..5e3810e7d 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -319,6 +319,52 @@ 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) + def test_add_sub_mimo_siso(self): + # Test SS with SS + ss_siso = rss(2, 1, 1) + ss_siso_1 = rss(2, 1, 1) + ss_siso_2 = rss(2, 1, 1) + 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(), + ) + @pytest.mark.parametrize( "left, right, expected", [ From e0c86a3fd79300d3a07941d88ab8bfa25379ac8f Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 12:12:33 -0500 Subject: [PATCH 26/39] Add FRD add promotion --- control/frdata.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/control/frdata.py b/control/frdata.py index 9f28a9d73..f99b822f9 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -406,6 +406,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( From a4afa3960f5da50a9849fdf9993960554340ab16 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 12:21:19 -0500 Subject: [PATCH 27/39] Add FRD add, sub, radd, rsub tests --- control/tests/frd_test.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 61c12a75d..8e8ec9c6f 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -492,6 +492,21 @@ def test_operator_conversion(self): 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", [ From e5358df2ccf5ea1fdcd23d8554ff6c44c18df450 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 16:07:06 -0500 Subject: [PATCH 28/39] Remove randomized state-space matrices --- control/tests/statesp_test.py | 51 ++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 326541395..b4edc0bd0 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -322,9 +322,54 @@ def test_multiply_ss(self, sys222, sys322): def test_add_sub_mimo_siso(self): # Test SS with SS - ss_siso = rss(2, 1, 1) - ss_siso_1 = rss(2, 1, 1) - ss_siso_2 = rss(2, 1, 1) + 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)], From 123914b23d86e43cdfb20c80018f64f83784b942 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 16:10:51 -0500 Subject: [PATCH 29/39] Add slycotonly to tests --- control/tests/frd_test.py | 2 ++ control/tests/statesp_test.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 8e8ec9c6f..51f87bed5 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -580,6 +580,7 @@ def test_mul_mimo_siso(self, left, right, expected): 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) @@ -605,6 +606,7 @@ def test_truediv_mimo_siso(self): 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) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index b4edc0bd0..ee5a9932b 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -320,6 +320,7 @@ 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( @@ -411,6 +412,7 @@ def test_add_sub_mimo_siso(self): ss2tf(result).minreal(), ) + @slycotonly @pytest.mark.parametrize( "left, right, expected", [ @@ -485,6 +487,7 @@ def test_mul_mimo_siso(self, left, right, expected): ss2tf(result).minreal(), ) + @slycotonly @pytest.mark.parametrize( "left, right, expected", [ @@ -559,6 +562,7 @@ def test_rmul_mimo_siso(self, left, right, expected): ss2tf(result).minreal(), ) + @slycotonly def test_pow(self, sys222, sys322): """Test state space powers.""" for sys in [sys222, sys322]: @@ -604,6 +608,7 @@ def test_pow(self, sys222, sys322): ss2tf(result).minreal(), ) + @slycotonly def test_truediv(self, sys222, sys322): """Test state space truediv""" for sys in [sys222, sys322]: @@ -622,6 +627,7 @@ def test_truediv(self, sys222, sys322): ss2tf(result).minreal(), ) + @slycotonly def test_rtruediv(self, sys222, sys322): """Test state space rtruediv""" for sys in [sys222, sys322]: From 0de8c289a254d37a124939fe66041a032d00a827 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 16:32:01 -0500 Subject: [PATCH 30/39] Add split_tf and combine_tf to docs --- doc/control.rst | 2 ++ 1 file changed, 2 insertions(+) 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 From ff63613491fef22ca8ca4726c76d758ef29abbe9 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 16:37:20 -0500 Subject: [PATCH 31/39] Replace control with ct to fix doctests --- control/bdalg.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index 59423db9b..a66139dab 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -542,8 +542,8 @@ def combine_tf(tf_array): -------- Combine two transfer functions - >>> s = control.TransferFunction.s - >>> control.combine_tf([ + >>> s = ct.TransferFunction.s + >>> ct.combine_tf([ ... [1 / (s + 1)], ... [s / (s + 2)], ... ]) @@ -552,9 +552,9 @@ def combine_tf(tf_array): Combine NumPy arrays with transfer functions - >>> control.combine_tf([ + >>> ct.combine_tf([ ... [np.eye(2), np.zeros((2, 1))], - ... [np.zeros((1, 2)), control.TransferFunction([1], [1, 0])], + ... [np.zeros((1, 2)), ct.TransferFunction([1], [1, 0])], ... ]) TransferFunction([[array([1.]), array([0.]), array([0.])], [array([0.]), array([1.]), array([0.])], @@ -636,7 +636,7 @@ def split_tf(transfer_function): -------- Split a MIMO transfer function - >>> G = control.TransferFunction( + >>> G = ct.TransferFunction( ... [ ... [[87.8], [-86.4]], ... [[108.2], [-109.6]], @@ -646,7 +646,7 @@ def split_tf(transfer_function): ... [[1, 1], [1, 1]], ... ], ... ) - >>> control.split_tf(G) + >>> ct.split_tf(G) array([[TransferFunction(array([87.8]), array([1, 1])), TransferFunction(array([-86.4]), array([1, 1]))], [TransferFunction(array([108.2]), array([1, 1])), From bf7f40d49930cba7525ce1da738294a707a82457 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 17:02:21 -0500 Subject: [PATCH 32/39] Remove line breaks messing up doctest --- control/bdalg.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index a66139dab..bc362c693 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -543,12 +543,11 @@ def combine_tf(tf_array): Combine two transfer functions >>> s = ct.TransferFunction.s - >>> ct.combine_tf([ + >>> ct.ombine_tf([ ... [1 / (s + 1)], ... [s / (s + 2)], ... ]) - TransferFunction([[array([1])], [array([1, 0])]], - [[array([1, 1])], [array([1, 2])]]) + TransferFunction([[array([1])], [array([1, 0])]], [[array([1, 1])], [array([1, 2])]]) Combine NumPy arrays with transfer functions @@ -556,12 +555,7 @@ def combine_tf(tf_array): ... [np.eye(2), np.zeros((2, 1))], ... [np.zeros((1, 2)), ct.TransferFunction([1], [1, 0])], ... ]) - TransferFunction([[array([1.]), array([0.]), array([0.])], - [array([0.]), array([1.]), array([0.])], - [array([0.]), array([0.]), array([1])]], - [[array([1.]), array([1.]), array([1.])], - [array([1.]), array([1.]), array([1.])], - [array([1.]), array([1.]), array([1, 0])]]) + TransferFunction([[array([1.]), array([0.]), array([0.])], [array([0.]), array([1.]), array([0.])], [array([0.]), array([0.]), array([1])]], [[array([1.]), array([1.]), array([1.])], [array([1.]), array([1.]), array([1.])], [array([1.]), array([1.]), array([1, 0])]]) """ # Find common timebase or raise error dt_list = [] From 612d19b2b899f39f7c8005de0402e1eafe900dc2 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Thu, 19 Dec 2024 19:11:53 -0500 Subject: [PATCH 33/39] Fix combine_tf docstring typo --- control/bdalg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/bdalg.py b/control/bdalg.py index bc362c693..1b6598cfc 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -543,7 +543,7 @@ def combine_tf(tf_array): Combine two transfer functions >>> s = ct.TransferFunction.s - >>> ct.ombine_tf([ + >>> ct.combine_tf([ ... [1 / (s + 1)], ... [s / (s + 2)], ... ]) From e89a727332f52f4d10e0a6ca876501fd2f99e37f Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 17 Jan 2025 16:13:32 -0500 Subject: [PATCH 34/39] Use new _ifunc instead of ifunc --- control/frdata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/frdata.py b/control/frdata.py index d0a888bdf..ba416ffe2 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -845,7 +845,7 @@ def append(self, other): (other.noutputs, other.ninputs, -1), ) - return FRD(new_fresp, self.omega, smooth=(self.ifunc is not None)) + return FRD(new_fresp, self.omega, smooth=(self._ifunc is not None)) # Plotting interface def plot(self, plot_type=None, *args, **kwargs): From 2b59fab6356f064ffb6fea35ed8de561b7cc9ef8 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 17 Jan 2025 16:20:32 -0500 Subject: [PATCH 35/39] Fix typos in docstrings --- control/bdalg.py | 4 ++-- control/xferfcn.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index 639e1ffc6..f066b72b5 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -363,7 +363,7 @@ def append(*sys, **kwargs): Parameters ---------- - sys1, sys2, ..., sysn: scalar, array, or :class:`LTI` + sys1, sys2, ..., sysn : scalar, array, or :class:`LTI` I/O systems to combine. Other Parameters @@ -382,7 +382,7 @@ def append(*sys, **kwargs): Returns ------- - out: :class:`LTI` + out : :class:`LTI` Combined system, with input/output vectors consisting of all input/output vectors appended. Specific type returned is the type of the first argument. diff --git a/control/xferfcn.py b/control/xferfcn.py index 08b165a6c..4c65bad37 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1794,7 +1794,7 @@ def zpk(zeros, poles, gain, *args, **kwargs): Returns ------- - out: `TransferFunction` + out : `TransferFunction` Transfer function with given zeros, poles, and gain. Examples @@ -1846,7 +1846,7 @@ def ss2tf(*args, **kwargs): Returns ------- - out: TransferFunction + out : TransferFunction New linear system in transfer function form Other Parameters From 4a1e0348a20ffbfd9cd8a399ae18e0c1b9d8b5b2 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 17 Jan 2025 16:24:41 -0500 Subject: [PATCH 36/39] Adjust indentation style mismatch --- control/frdata.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index ba416ffe2..1200bfffa 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -829,21 +829,12 @@ def append(self, other): # TODO: handle omega re-mapping new_fresp = np.zeros( - ( - self.noutputs + other.noutputs, - self.ninputs + other.ninputs, - self.omega.shape[-1], - ), - dtype=complex, - ) + (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), - ) + self.fresp, (self.noutputs, self.ninputs, -1)) new_fresp[self.noutputs:, self.ninputs:, :] = np.reshape( - other.fresp, - (other.noutputs, other.ninputs, -1), - ) + other.fresp, (other.noutputs, other.ninputs, -1)) return FRD(new_fresp, self.omega, smooth=(self._ifunc is not None)) From 9a03dd224353e905e4c4e2adb1fb25d53b56226e Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 17 Jan 2025 16:34:54 -0500 Subject: [PATCH 37/39] Change some tests to SS comparison instead of TF --- control/tests/statesp_test.py | 36 +++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 017f3527b..366c5ef3b 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -569,18 +569,22 @@ def test_pow(self, sys222, sys322): # Power of 0 result = sys**0 expected = StateSpace([], [], [], np.eye(2), dt=0) - assert _tf_close_coeff( - ss2tf(expected).minreal(), - ss2tf(result).minreal(), - ) + 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 - assert _tf_close_coeff( - ss2tf(expected).minreal(), - ss2tf(result).minreal(), - ) + 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( @@ -596,17 +600,17 @@ def test_pow(self, sys222, sys322): # Power of 3 result = sys**3 expected = sys * sys * sys - assert _tf_close_coeff( - ss2tf(expected).minreal(), - ss2tf(result).minreal(), - ) + 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 - assert _tf_close_coeff( - ss2tf(expected).minreal(), - ss2tf(result).minreal(), - ) + 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): From ce2f2aa45d04355eea76db2b9f2d5410a9860604 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 17 Jan 2025 16:53:16 -0500 Subject: [PATCH 38/39] Get rid of redundant check --- control/xferfcn.py | 1 - 1 file changed, 1 deletion(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index 4c65bad37..c9043bd0e 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -884,7 +884,6 @@ def append(self, other): 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) - common_timebase(self.dt, other.dt) # Call just to validate ``dt``s new_tf = bdalg.combine_tf([ [self, np.zeros((self.noutputs, other.ninputs))], From ccf9ce1548596d251eb85900837cd9dff045f8ae Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Fri, 17 Jan 2025 17:01:43 -0500 Subject: [PATCH 39/39] Update docstring hashes --- control/tests/docstrings_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 7ab7f1469..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: '25e3a7e5f1c21eb7ec6562f199e2d7fd', + 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',