diff --git a/control/bdalg.py b/control/bdalg.py index 024d95fba..d907cd3c5 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -10,6 +10,8 @@ negate feedback connect +combine_tf +split_tf """ @@ -63,7 +65,8 @@ from . import xferfcn as tf from .iosys import InputOutputSystem -__all__ = ['series', 'parallel', 'negate', 'feedback', 'append', 'connect'] +__all__ = ['series', 'parallel', 'negate', 'feedback', 'append', 'connect', + 'combine_tf', 'split_tf'] def series(sys1, *sysn, **kwargs): @@ -507,3 +510,212 @@ def connect(sys, Q, inputv, outputv): Ytrim[i,y-1] = 1. return Ytrim * sys * Utrim + +def combine_tf(tf_array): + """Combine array-like of transfer functions into MIMO transfer function. + + Parameters + ---------- + tf_array : list of list of TransferFunction or array_like + Transfer matrix represented as a two-dimensional array or list-of-lists + containing TransferFunction objects. The TransferFunction objects can + have multiple outputs and inputs, as long as the dimensions are + compatible. + + Returns + ------- + TransferFunction + Transfer matrix represented as a single MIMO TransferFunction object. + + Raises + ------ + ValueError + 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 or input + dimensions. + + Examples + -------- + Combine two transfer functions + + >>> s = control.TransferFunction.s + >>> control.combine_tf([ + ... [1 / (s + 1)], + ... [s / (s + 2)], + ... ]) + 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 = [] + 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("Timesteps of transfer functions are " + f"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_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( + "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]) + 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( + "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( + "Mismatched number transfer function inputs in row " + f"{row_index} of denominator." + ) + 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 : 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_tf(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) + 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 : TransferFunction or array_like + Array-like or transfer function. + 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 + ------- + 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 " + 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." + ) + # 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..8ea67e0f7 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,557 @@ def test_bdalg_udpate_names_errors(): with pytest.raises(TypeError, match="unrecognized keywords"): sys = ctrl.series(sys1, sys2, dt=1) + + +class TestEnsureTf: + """Test ``_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) + 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 ``combine_tf`` and ``split_tf``.""" + + @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_tf(self, tf_array, tf): + """Test combining transfer functions.""" + tf_combined = ctrl.combine_tf(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_tf(self, tf_array, tf): + """Test splitting transfer functions.""" + 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]): + assert _tf_close_coeff( + tf_split[i, j], + tf_array[i, j], + ) + # Test combined + assert _tf_close_coeff( + ctrl.combine_tf(tf_split), + ctrl.combine_tf(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, + ), + # 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, + ), + ( + [ + [ + 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): + """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