From 62d6f83c9aa4468fa2dc91235bb20f3435ca9b62 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Tue, 3 Dec 2024 16:43:47 -0500 Subject: [PATCH 01/12] Add combine and split functions --- control/bdalg.py | 186 ++++++++++++++- control/tests/bdalg_test.py | 438 ++++++++++++++++++++++++++++++++++++ 2 files changed, 623 insertions(+), 1 deletion(-) diff --git a/control/bdalg.py b/control/bdalg.py index 024d95fba..9133da4c7 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -10,6 +10,8 @@ negate feedback connect +combine +split """ @@ -63,7 +65,16 @@ from . import xferfcn as tf from .iosys import InputOutputSystem -__all__ = ['series', 'parallel', 'negate', 'feedback', 'append', 'connect'] +__all__ = [ + 'series', + 'parallel', + 'negate', + 'feedback', + 'append', + 'connect', + 'combine', + 'split', +] def series(sys1, *sysn, **kwargs): @@ -507,3 +518,176 @@ def connect(sys, Q, inputv, outputv): Ytrim[i,y-1] = 1. return Ytrim * sys * Utrim + +def combine(tf_array): + """Combine array-like of transfer functions into MIMO transfer function. + + Parameters + ---------- + tf_array : List[List[Union[ArrayLike, control.TransferFunction]]] + Transfer matrix represented as a two-dimensional array or list-of-lists + containing :class:`TransferFunction` objects. The + :class:`TransferFunction` objects can have multiple outputs and inputs, + as long as the dimensions are compatible. + + Returns + ------- + control.TransferFunction + Transfer matrix represented as a single MIMO :class:`TransferFunction` + object. + + Raises + ------ + ValueError + If timesteps of transfer functions do not match. + ValueError + If ``tf_array`` has incorrect dimensions. + + Examples + -------- + Combine two transfer functions + + >>> s = control.TransferFunction.s + >>> control.combine([ + ... [1 / (s + 1)], + ... [s / (s + 2)], + ... ]) + TransferFunction([[array([1])], [array([1, 0])]], [[array([1, 1])], [array([1, 2])]]) + """ + # Find common timebase or raise error + dt_list = [] + try: + for row in tf_array: + for tfn in row: + dt_list.append(getattr(tfn, "dt", None)) + except OSError: + raise ValueError("`tf_array` has too few dimensions.") + dt_set = set(dt_list) + dt_set.discard(None) + if len(dt_set) > 1: + raise ValueError(f"Timesteps of transfer functions are mismatched: {dt_set}") + elif len(dt_set) == 0: + dt = None + else: + dt = dt_set.pop() + # Convert all entries to transfer function objects + ensured_tf_array = [] + for row in tf_array: + ensured_row = [] + for tfn in row: + ensured_row.append(_ensure_tf(tfn, dt)) + ensured_tf_array.append(ensured_row) + # Iterate over + num = [] + den = [] + for row in ensured_tf_array: + for j_out in range(row[0].noutputs): + num_row = [] + den_row = [] + for col in row: + for j_in in range(col.ninputs): + num_row.append(col.num[j_out][j_in]) + den_row.append(col.den[j_out][j_in]) + num.append(num_row) + den.append(den_row) + G_tf = tf.TransferFunction(num, den, dt=dt) + return G_tf + +def split(transfer_function): + """Split MIMO transfer function into NumPy array of SISO tranfer functions. + + Parameters + ---------- + transfer_function : control.TransferFunction + MIMO transfer function to split. + + Returns + ------- + np.ndarray + NumPy array of SISO transfer functions. + + Examples + -------- + Split a MIMO transfer function + + >>> G = control.TransferFunction( + ... [ + ... [[87.8], [-86.4]], + ... [[108.2], [-109.6]], + ... ], + ... [ + ... [[1, 1], [1, 1]], + ... [[1, 1], [1, 1]], + ... ], + ... ) + >>> control.split(G) + array([[TransferFunction(array([87.8]), array([1, 1])), + TransferFunction(array([-86.4]), array([1, 1]))], + [TransferFunction(array([108.2]), array([1, 1])), + TransferFunction(array([-109.6]), array([1, 1]))]], dtype=object) + """ + tf_split_lst = [] + for i_out in range(transfer_function.noutputs): + row = [] + for i_in in range(transfer_function.ninputs): + row.append( + tf.TransferFunction( + transfer_function.num[i_out][i_in], + transfer_function.den[i_out][i_in], + dt=transfer_function.dt, + ) + ) + tf_split_lst.append(row) + tf_split = np.array(tf_split_lst, dtype=object) + return tf_split + +def _ensure_tf(arraylike_or_tf, dt=None): + """Convert an array-like to a transfer function. + + Parameters + ---------- + arraylike_or_tf : Union[ArrayLike, control.TransferFunction] + Array-like or transfer function. + dt : Union[None, bool, float] + Timestep (s). ``True`` indicates a discrete-time system with + unspecified timestep, ``0`` indicates a continuous-time system, and + ``None`` indicates a continuous- or discrete-time system with + unspecified timestep. If ``None``, timestep is not validated. + + Returns + ------- + control.TransferFunction + Transfer function. + + Raises + ------ + ValueError + If input cannot be converted to a transfer function. + ValueError + If the timesteps do not match. + """ + # If the input is already a transfer function, return it right away + if isinstance(arraylike_or_tf, tf.TransferFunction): + # If timesteps don't match, raise an exception + if (dt is not None) and (arraylike_or_tf.dt != dt): + raise ValueError( + f"`arraylike_or_tf.dt={arraylike_or_tf.dt}` does not match argument `dt={dt}`." + ) + return arraylike_or_tf + if np.ndim(arraylike_or_tf) > 2: + raise ValueError( + "Array-like must have less than two dimensions to be converted into a transfer function." + ) + # If it's not, then convert it to a transfer function + arraylike_3d = np.atleast_3d(arraylike_or_tf) + try: + tfn = tf.TransferFunction( + arraylike_3d, + np.ones_like(arraylike_3d), + dt, + ) + except TypeError: + raise ValueError( + "`arraylike_or_tf` must only contain array-likes or transfer functions." + ) + return tfn diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 5629f27f9..67efedc21 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -12,6 +12,7 @@ from control.statesp import StateSpace from control.bdalg import feedback, append, connect from control.lti import zeros, poles +from control.bdalg import _ensure_tf class TestFeedback: @@ -362,3 +363,440 @@ def test_bdalg_udpate_names_errors(): with pytest.raises(TypeError, match="unrecognized keywords"): sys = ctrl.series(sys1, sys2, dt=1) + + +class TestEnsureTf: + """Test :func:`_ensure_tf`.""" + + @pytest.mark.parametrize( + "arraylike_or_tf, dt, tf", + [ + ( + ctrl.TransferFunction([1], [1, 2, 3]), + None, + ctrl.TransferFunction([1], [1, 2, 3]), + ), + ( + ctrl.TransferFunction([1], [1, 2, 3]), + 0, + ctrl.TransferFunction([1], [1, 2, 3]), + ), + ( + 2, + None, + ctrl.TransferFunction([2], [1]), + ), + ( + np.array([2]), + None, + ctrl.TransferFunction([2], [1]), + ), + ( + np.array([[2]]), + None, + ctrl.TransferFunction([2], [1]), + ), + ( + np.array( + [ + [2, 0, 3], + [1, 2, 3], + ] + ), + None, + ctrl.TransferFunction( + [ + [[2], [0], [3]], + [[1], [2], [3]], + ], + [ + [[1], [1], [1]], + [[1], [1], [1]], + ], + ), + ), + ( + np.array([2, 0, 3]), + None, + ctrl.TransferFunction( + [ + [[2], [0], [3]], + ], + [ + [[1], [1], [1]], + ], + ), + ), + ], + ) + def test_ensure(self, arraylike_or_tf, dt, tf): + """Test nominal cases""" + ensured_tf = _ensure_tf(arraylike_or_tf, dt) + pass + assert _tf_close_coeff(tf, ensured_tf) + + @pytest.mark.parametrize( + "arraylike_or_tf, dt, exception", + [ + ( + ctrl.TransferFunction([1], [1, 2, 3]), + 0.1, + ValueError, + ), + ( + ctrl.TransferFunction([1], [1, 2, 3], 0.1), + 0, + ValueError, + ), + ( + np.ones((1, 1, 1)), + None, + ValueError, + ), + ( + np.ones((1, 1, 1, 1)), + None, + ValueError, + ), + ], + ) + def test_error_ensure(self, arraylike_or_tf, dt, exception): + """Test error cases""" + with pytest.raises(exception): + _ensure_tf(arraylike_or_tf, dt) + + +class TestTfCombineSplit: + """Test :func:`combine` and :func:`split`.""" + + @pytest.mark.parametrize( + "tf_array, tf", + [ + # Continuous-time + ( + [ + [ctrl.TransferFunction([1], [1, 1])], + [ctrl.TransferFunction([2], [1, 0])], + ], + ctrl.TransferFunction( + [ + [[1]], + [[2]], + ], + [ + [[1, 1]], + [[1, 0]], + ], + ), + ), + # Discrete-time + ( + [ + [ctrl.TransferFunction([1], [1, 1], dt=1)], + [ctrl.TransferFunction([2], [1, 0], dt=1)], + ], + ctrl.TransferFunction( + [ + [[1]], + [[2]], + ], + [ + [[1, 1]], + [[1, 0]], + ], + dt=1, + ), + ), + # Scalar + ( + [ + [2], + [ctrl.TransferFunction([2], [1, 0])], + ], + ctrl.TransferFunction( + [ + [[2]], + [[2]], + ], + [ + [[1]], + [[1, 0]], + ], + ), + ), + # Matrix + ( + [ + [np.eye(3)], + [ + ctrl.TransferFunction( + [ + [[2], [0], [3]], + [[1], [2], [3]], + ], + [ + [[1], [1], [1]], + [[1], [1], [1]], + ], + ) + ], + ], + ctrl.TransferFunction( + [ + [[1], [0], [0]], + [[0], [1], [0]], + [[0], [0], [1]], + [[2], [0], [3]], + [[1], [2], [3]], + ], + [ + [[1], [1], [1]], + [[1], [1], [1]], + [[1], [1], [1]], + [[1], [1], [1]], + [[1], [1], [1]], + ], + ), + ), + # Inhomogeneous + ( + [ + [np.eye(3)], + [ + ctrl.TransferFunction( + [ + [[2], [0]], + [[1], [2]], + ], + [ + [[1], [1]], + [[1], [1]], + ], + ), + ctrl.TransferFunction( + [ + [[3]], + [[3]], + ], + [ + [[1]], + [[1]], + ], + ), + ], + ], + ctrl.TransferFunction( + [ + [[1], [0], [0]], + [[0], [1], [0]], + [[0], [0], [1]], + [[2], [0], [3]], + [[1], [2], [3]], + ], + [ + [[1], [1], [1]], + [[1], [1], [1]], + [[1], [1], [1]], + [[1], [1], [1]], + [[1], [1], [1]], + ], + ), + ), + # Discrete-time + ( + [ + [2], + [ctrl.TransferFunction([2], [1, 0], dt=0.1)], + ], + ctrl.TransferFunction( + [ + [[2]], + [[2]], + ], + [ + [[1]], + [[1, 0]], + ], + dt=0.1, + ), + ), + ], + ) + def test_combine(self, tf_array, tf): + """Test combining transfer functions.""" + tf_combined = ctrl.combine(tf_array) + assert _tf_close_coeff(tf_combined, tf) + + @pytest.mark.parametrize( + "tf_array, tf", + [ + ( + np.array( + [ + [ctrl.TransferFunction([1], [1, 1])], + ], + dtype=object, + ), + ctrl.TransferFunction( + [ + [[1]], + ], + [ + [[1, 1]], + ], + ), + ), + ( + np.array( + [ + [ctrl.TransferFunction([1], [1, 1])], + [ctrl.TransferFunction([2], [1, 0])], + ], + dtype=object, + ), + ctrl.TransferFunction( + [ + [[1]], + [[2]], + ], + [ + [[1, 1]], + [[1, 0]], + ], + ), + ), + ( + np.array( + [ + [ctrl.TransferFunction([1], [1, 1], dt=1)], + [ctrl.TransferFunction([2], [1, 0], dt=1)], + ], + dtype=object, + ), + ctrl.TransferFunction( + [ + [[1]], + [[2]], + ], + [ + [[1, 1]], + [[1, 0]], + ], + dt=1, + ), + ), + ( + np.array( + [ + [ctrl.TransferFunction([2], [1], dt=0.1)], + [ctrl.TransferFunction([2], [1, 0], dt=0.1)], + ], + dtype=object, + ), + ctrl.TransferFunction( + [ + [[2]], + [[2]], + ], + [ + [[1]], + [[1, 0]], + ], + dt=0.1, + ), + ), + ], + ) + def test_split(self, tf_array, tf): + """Test splitting transfer functions.""" + tf_split = ctrl.split(tf) + # Test entry-by-entry + for i in range(tf_split.shape[0]): + for j in range(tf_split.shape[1]): + assert _tf_close_coeff( + tf_split[i, j], + tf_array[i, j], + ) + # Test combined + assert _tf_close_coeff( + ctrl.combine(tf_split), + ctrl.combine(tf_array), + ) + + @pytest.mark.parametrize( + "tf_array, exception", + [ + # Wrong timesteps + ( + [ + [ctrl.TransferFunction([1], [1, 1], 0.1)], + [ctrl.TransferFunction([2], [1, 0], 0.2)], + ], + ValueError, + ), + ( + [ + [ctrl.TransferFunction([1], [1, 1], 0.1)], + [ctrl.TransferFunction([2], [1, 0], 0)], + ], + ValueError, + ), + # Too few dimensions + ( + [ + ctrl.TransferFunction([1], [1, 1]), + ctrl.TransferFunction([2], [1, 0]), + ], + ValueError, + ), + # Too many dimensions + ( + [ + [[ctrl.TransferFunction([1], [1, 1], 0.1)]], + [[ctrl.TransferFunction([2], [1, 0], 0)]], + ], + ValueError, + ), + ], + ) + def test_error_combine(self, tf_array, exception): + """Test error cases.""" + with pytest.raises(exception): + ctrl.combine(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 : control.TransferFunction + First transfer function. + tf_b : control.TransferFunction + Second transfer function. + rtol : float + Relative tolerance for :func:`np.allclose`. + atol : float + Absolute tolerance for :func:`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 8ac3adc3fd28c2fe34934653df8e7e2c5aaf189d Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Tue, 3 Dec 2024 16:55:45 -0500 Subject: [PATCH 02/12] Remove pass typo --- control/tests/bdalg_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 67efedc21..7627621a1 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -432,7 +432,6 @@ class TestEnsureTf: def test_ensure(self, arraylike_or_tf, dt, tf): """Test nominal cases""" ensured_tf = _ensure_tf(arraylike_or_tf, dt) - pass assert _tf_close_coeff(tf, ensured_tf) @pytest.mark.parametrize( From 758fa9df5fc901d91711f144f53bdae5b6452fd0 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 4 Dec 2024 11:11:48 -0500 Subject: [PATCH 03/12] Add test and fix for mismatched row outputs --- control/bdalg.py | 6 +++- control/tests/bdalg_test.py | 63 +++++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index 9133da4c7..1248efe7a 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -580,11 +580,15 @@ def combine(tf_array): # Iterate over num = [] den = [] - for row in ensured_tf_array: + for row_index, row in enumerate(ensured_tf_array): for j_out in range(row[0].noutputs): num_row = [] den_row = [] for col in row: + if col.noutputs != row[0].noutputs: + raise ValueError( + f"Mismatched number of transfer function outputs in row {row_index}." + ) for j_in in range(col.ninputs): num_row.append(col.num[j_out][j_in]) den_row.append(col.den[j_out][j_in]) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 7627621a1..81e60bc67 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -756,6 +756,65 @@ def test_split(self, tf_array, tf): ], ValueError, ), + # Incompatible dimensions + ( + [ + [ + ctrl.TransferFunction( + [ + [ + [1], + ] + ], + [ + [ + [1, 1], + ] + ], + ), + ctrl.TransferFunction( + [ + [[2], [1]], + [[1], [3]], + ], + [ + [[1, 0], [1, 0]], + [[1, 0], [1, 0]], + ], + ), + ], + ], + ValueError, + ), + ( + [ + [ + ctrl.TransferFunction( + [ + [[2], [1]], + [[1], [3]], + ], + [ + [[1, 0], [1, 0]], + [[1, 0], [1, 0]], + ], + ), + ctrl.TransferFunction( + [ + [ + [1], + ] + ], + [ + [ + [1, 1], + ] + ], + ), + ], + ], + ValueError, + ), ], ) def test_error_combine(self, tf_array, exception): @@ -769,9 +828,9 @@ def _tf_close_coeff(tf_a, tf_b, rtol=1e-5, atol=1e-8): Parameters ---------- - tf_a : control.TransferFunction + tf_a : TransferFunction First transfer function. - tf_b : control.TransferFunction + tf_b : TransferFunction Second transfer function. rtol : float Relative tolerance for :func:`np.allclose`. From 120984f8618c4a7dc91123e52083a082ebe8f264 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 4 Dec 2024 11:17:08 -0500 Subject: [PATCH 04/12] Update documentation for exception --- control/bdalg.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/control/bdalg.py b/control/bdalg.py index 1248efe7a..fa380890f 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -542,6 +542,8 @@ def combine(tf_array): If timesteps of transfer functions do not match. ValueError If ``tf_array`` has incorrect dimensions. + ValueError + If the transfer functions in a row have mismatched output dimensions. Examples -------- From 76d4ee7a714699830c496d708b738668f981b22b Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 4 Dec 2024 11:34:04 -0500 Subject: [PATCH 05/12] Add input dimension check --- control/bdalg.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index fa380890f..f936a0646 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -543,7 +543,8 @@ def combine(tf_array): ValueError If ``tf_array`` has incorrect dimensions. ValueError - If the transfer functions in a row have mismatched output dimensions. + If the transfer functions in a row have mismatched output or input + dimensions. Examples -------- @@ -554,7 +555,8 @@ def combine(tf_array): ... [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])]]) """ # Find common timebase or raise error dt_list = [] @@ -596,6 +598,16 @@ def combine(tf_array): den_row.append(col.den[j_out][j_in]) num.append(num_row) den.append(den_row) + for row_index, row in enumerate(num): + if len(row) != len(num[0]): + raise ValueError( + f"Mismatched number transfer function inputs in row {row_index} of numerator." + ) + for row_index, row in enumerate(den): + if len(row) != len(den[0]): + raise ValueError( + f"Mismatched number transfer function inputs in row {row_index} of denominator." + ) G_tf = tf.TransferFunction(num, den, dt=dt) return G_tf From 9f4bf4fdada6b2f065aee6d7f83b3dca8b583d3a Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 4 Dec 2024 11:37:17 -0500 Subject: [PATCH 06/12] Rename split and combine to split_tf and combine_tf --- control/bdalg.py | 24 ++++++++---------------- control/tests/bdalg_test.py | 18 +++++++++--------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index f936a0646..2018cc7cc 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -10,8 +10,8 @@ negate feedback connect -combine -split +combine_tf +split_tf """ @@ -65,16 +65,8 @@ from . import xferfcn as tf from .iosys import InputOutputSystem -__all__ = [ - 'series', - 'parallel', - 'negate', - 'feedback', - 'append', - 'connect', - 'combine', - 'split', -] +__all__ = ['series', 'parallel', 'negate', 'feedback', 'append', 'connect', + 'combine_tf', 'split_tf'] def series(sys1, *sysn, **kwargs): @@ -519,7 +511,7 @@ def connect(sys, Q, inputv, outputv): return Ytrim * sys * Utrim -def combine(tf_array): +def combine_tf(tf_array): """Combine array-like of transfer functions into MIMO transfer function. Parameters @@ -551,7 +543,7 @@ def combine(tf_array): Combine two transfer functions >>> s = control.TransferFunction.s - >>> control.combine([ + >>> control.combine_tf([ ... [1 / (s + 1)], ... [s / (s + 2)], ... ]) @@ -611,7 +603,7 @@ def combine(tf_array): G_tf = tf.TransferFunction(num, den, dt=dt) return G_tf -def split(transfer_function): +def split_tf(transfer_function): """Split MIMO transfer function into NumPy array of SISO tranfer functions. Parameters @@ -638,7 +630,7 @@ def split(transfer_function): ... [[1, 1], [1, 1]], ... ], ... ) - >>> control.split(G) + >>> control.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])), diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 81e60bc67..1c557ead4 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -466,7 +466,7 @@ def test_error_ensure(self, arraylike_or_tf, dt, exception): class TestTfCombineSplit: - """Test :func:`combine` and :func:`split`.""" + """Test :func:`combine_tf` and :func:`split_tf`.""" @pytest.mark.parametrize( "tf_array, tf", @@ -621,9 +621,9 @@ class TestTfCombineSplit: ), ], ) - def test_combine(self, tf_array, tf): + def test_combine_tf(self, tf_array, tf): """Test combining transfer functions.""" - tf_combined = ctrl.combine(tf_array) + tf_combined = ctrl.combine_tf(tf_array) assert _tf_close_coeff(tf_combined, tf) @pytest.mark.parametrize( @@ -706,9 +706,9 @@ def test_combine(self, tf_array, tf): ), ], ) - def test_split(self, tf_array, tf): + def test_split_tf(self, tf_array, tf): """Test splitting transfer functions.""" - tf_split = ctrl.split(tf) + tf_split = ctrl.split_tf(tf) # Test entry-by-entry for i in range(tf_split.shape[0]): for j in range(tf_split.shape[1]): @@ -718,8 +718,8 @@ def test_split(self, tf_array, tf): ) # Test combined assert _tf_close_coeff( - ctrl.combine(tf_split), - ctrl.combine(tf_array), + ctrl.combine_tf(tf_split), + ctrl.combine_tf(tf_array), ) @pytest.mark.parametrize( @@ -817,10 +817,10 @@ def test_split(self, tf_array, tf): ), ], ) - def test_error_combine(self, tf_array, exception): + def test_error_combine_tf(self, tf_array, exception): """Test error cases.""" with pytest.raises(exception): - ctrl.combine(tf_array) + ctrl.combine_tf(tf_array) def _tf_close_coeff(tf_a, tf_b, rtol=1e-5, atol=1e-8): From dc3657405962136c4858def273f5b8ee8d0bb55c Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 4 Dec 2024 11:45:36 -0500 Subject: [PATCH 07/12] Adjust naming and docstring conventions --- control/bdalg.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index 2018cc7cc..f99767a3d 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -516,17 +516,16 @@ def combine_tf(tf_array): Parameters ---------- - tf_array : List[List[Union[ArrayLike, control.TransferFunction]]] + tf_array : list of list of TransferFunction or array_like Transfer matrix represented as a two-dimensional array or list-of-lists - containing :class:`TransferFunction` objects. The - :class:`TransferFunction` objects can have multiple outputs and inputs, - as long as the dimensions are compatible. + containing TransferFunction objects. The TransferFunction objects can + have multiple outputs and inputs, as long as the dimensions are + compatible. Returns ------- - control.TransferFunction - Transfer matrix represented as a single MIMO :class:`TransferFunction` - object. + TransferFunction + Transfer matrix represented as a single MIMO TransferFunction object. Raises ------ @@ -600,15 +599,14 @@ def combine_tf(tf_array): raise ValueError( f"Mismatched number transfer function inputs in row {row_index} of denominator." ) - G_tf = tf.TransferFunction(num, den, dt=dt) - return G_tf + return tf.TransferFunction(num, den, dt=dt) def split_tf(transfer_function): """Split MIMO transfer function into NumPy array of SISO tranfer functions. Parameters ---------- - transfer_function : control.TransferFunction + transfer_function : TransferFunction MIMO transfer function to split. Returns @@ -648,25 +646,25 @@ def split_tf(transfer_function): ) ) tf_split_lst.append(row) - tf_split = np.array(tf_split_lst, dtype=object) - return tf_split + return np.array(tf_split_lst, dtype=object) def _ensure_tf(arraylike_or_tf, dt=None): """Convert an array-like to a transfer function. Parameters ---------- - arraylike_or_tf : Union[ArrayLike, control.TransferFunction] + arraylike_or_tf : TransferFunction or array_like Array-like or transfer function. - dt : Union[None, bool, float] - Timestep (s). ``True`` indicates a discrete-time system with - unspecified timestep, ``0`` indicates a continuous-time system, and - ``None`` indicates a continuous- or discrete-time system with - unspecified timestep. If ``None``, timestep is not validated. + dt : None, True or float, optional + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling + time, positive number is discrete time with specified + sampling time, None indicates unspecified timebase (either + continuous or discrete time). If None, timestep is not validated. Returns ------- - control.TransferFunction + TransferFunction Transfer function. Raises From 1cbd6b3c65417c65caf0fcb69e33494709d72377 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 4 Dec 2024 12:03:33 -0500 Subject: [PATCH 08/12] Add example with NumPy arrays --- control/bdalg.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/control/bdalg.py b/control/bdalg.py index f99767a3d..4efd0e951 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -548,6 +548,19 @@ def combine_tf(tf_array): ... ]) TransferFunction([[array([1])], [array([1, 0])]], [[array([1, 1])], [array([1, 2])]]) + + Combine NumPy arrays with transfer functions + + >>> control.combine_tf([ + ... [np.eye(2), np.zeros((2, 1))], + ... [np.zeros((1, 2)), control.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])]]) """ # Find common timebase or raise error dt_list = [] From 68d52d8be91cd73f66224456109831008fa4b887 Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 4 Dec 2024 12:27:11 -0500 Subject: [PATCH 09/12] Fix line length --- control/bdalg.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index 4efd0e951..d907cd3c5 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -573,7 +573,8 @@ def combine_tf(tf_array): dt_set = set(dt_list) dt_set.discard(None) if len(dt_set) > 1: - raise ValueError(f"Timesteps of transfer functions are mismatched: {dt_set}") + raise ValueError("Timesteps of transfer functions are " + f"mismatched: {dt_set}") elif len(dt_set) == 0: dt = None else: @@ -595,7 +596,8 @@ def combine_tf(tf_array): for col in row: if col.noutputs != row[0].noutputs: raise ValueError( - f"Mismatched number of transfer function outputs in row {row_index}." + "Mismatched number of transfer function outputs in " + f"row {row_index}." ) for j_in in range(col.ninputs): num_row.append(col.num[j_out][j_in]) @@ -605,12 +607,14 @@ def combine_tf(tf_array): for row_index, row in enumerate(num): if len(row) != len(num[0]): raise ValueError( - f"Mismatched number transfer function inputs in row {row_index} of numerator." + "Mismatched number transfer function inputs in row " + f"{row_index} of numerator." ) for row_index, row in enumerate(den): if len(row) != len(den[0]): raise ValueError( - f"Mismatched number transfer function inputs in row {row_index} of denominator." + "Mismatched number transfer function inputs in row " + f"{row_index} of denominator." ) return tf.TransferFunction(num, den, dt=dt) @@ -692,12 +696,14 @@ def _ensure_tf(arraylike_or_tf, dt=None): # If timesteps don't match, raise an exception if (dt is not None) and (arraylike_or_tf.dt != dt): raise ValueError( - f"`arraylike_or_tf.dt={arraylike_or_tf.dt}` does not match argument `dt={dt}`." + f"`arraylike_or_tf.dt={arraylike_or_tf.dt}` does not match " + f"argument `dt={dt}`." ) return arraylike_or_tf if np.ndim(arraylike_or_tf) > 2: raise ValueError( - "Array-like must have less than two dimensions to be converted into a transfer function." + "Array-like must have less than two dimensions to be converted " + "into a transfer function." ) # If it's not, then convert it to a transfer function arraylike_3d = np.atleast_3d(arraylike_or_tf) @@ -709,6 +715,7 @@ def _ensure_tf(arraylike_or_tf, dt=None): ) except TypeError: raise ValueError( - "`arraylike_or_tf` must only contain array-likes or transfer functions." + "`arraylike_or_tf` must only contain array-likes or transfer " + "functions." ) return tfn From 35fa5b314f2c20767bc2cdbeaf12e30e533af0ee Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 4 Dec 2024 12:28:39 -0500 Subject: [PATCH 10/12] Fix line length in test file --- control/tests/bdalg_test.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 1c557ead4..702be80d2 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -853,8 +853,18 @@ def _tf_close_coeff(tf_a, tf_b, rtol=1e-5, atol=1e-8): # 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): + 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): + if not np.allclose( + tf_a.den[i][j], + tf_b.den[i][j], + rtol=rtol, + atol=atol, + ): return False return True From 8bc94873153257295cb47f59e41950bbf25156fe Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 4 Dec 2024 12:30:13 -0500 Subject: [PATCH 11/12] Remove extra markup --- control/tests/bdalg_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 702be80d2..f88231888 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -366,7 +366,7 @@ def test_bdalg_udpate_names_errors(): class TestEnsureTf: - """Test :func:`_ensure_tf`.""" + """Test ``_ensure_tf``.""" @pytest.mark.parametrize( "arraylike_or_tf, dt, tf", @@ -466,7 +466,7 @@ def test_error_ensure(self, arraylike_or_tf, dt, exception): class TestTfCombineSplit: - """Test :func:`combine_tf` and :func:`split_tf`.""" + """Test ``combine_tf`` and ``split_tf``.""" @pytest.mark.parametrize( "tf_array, tf", @@ -833,9 +833,9 @@ def _tf_close_coeff(tf_a, tf_b, rtol=1e-5, atol=1e-8): tf_b : TransferFunction Second transfer function. rtol : float - Relative tolerance for :func:`np.allclose`. + Relative tolerance for ``np.allclose``. atol : float - Absolute tolerance for :func:`np.allclose`. + Absolute tolerance for ``np.allclose``. Returns ------- From 1cc84a7fd2337478589a48e7b7984ea41610343c Mon Sep 17 00:00:00 2001 From: Steven Dahdah Date: Wed, 4 Dec 2024 12:33:45 -0500 Subject: [PATCH 12/12] Add extra test case for wrong number of inputs --- control/tests/bdalg_test.py | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index f88231888..8ea67e0f7 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -815,6 +815,55 @@ def test_split_tf(self, tf_array, tf): ], ValueError, ), + ( + [ + [ + ctrl.TransferFunction( + [ + [[2], [1]], + [[1], [3]], + ], + [ + [[1, 0], [1, 0]], + [[1, 0], [1, 0]], + ], + ), + ctrl.TransferFunction( + [ + [[2], [1]], + [[1], [3]], + ], + [ + [[1, 0], [1, 0]], + [[1, 0], [1, 0]], + ], + ), + ], + [ + ctrl.TransferFunction( + [ + [[2], [1], [1]], + [[1], [3], [2]], + ], + [ + [[1, 0], [1, 0], [1, 0]], + [[1, 0], [1, 0], [1, 0]], + ], + ), + ctrl.TransferFunction( + [ + [[2], [1]], + [[1], [3]], + ], + [ + [[1, 0], [1, 0]], + [[1, 0], [1, 0]], + ], + ), + ], + ], + ValueError, + ), ], ) def test_error_combine_tf(self, tf_array, exception):