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', pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy