Repository: python-control/python-control
Pull request #1073 (2024-12-03 21:55:08)

Add `combine()` and `split()` functions for transfer matrices
RE #325 

I've run into a few times where it would be convenient to construct MIMO `TransferMatrix` objects from SISO ones, or the reverse. In particular, this came up as I've been working on a D-K iteration package, [`dkpy`](https://github.com/decargroup/dkpy). The specific implementation here is from [utilities.py](https://github.com/decargroup/dkpy/blob/main/src/dkpy/utilities.py) in `dkpy`.

I saw there was a related issue (#325 ) and people seemed open to it. Specifically, I've added

- `control.combine()` that takes an `ArrayLike` of `TransferFunction` objects, scalars, and NumPy arrays, and creates a MIMO transfer matrix, and
- `control.split()` that takes a MIMO `TransferFunction` and returns a NumPy array with SISO `TransferFunction` objectss.

I'm new to the `python-control` codebase, so I'm not 100% sure if I've placed things in the correct files, but I've added unit tests and would appreciate some feedback.


Patch:
diff --git a/control/bdalg.py b/control/bdalg.py
index 024d95fb..d907cd3c 100644
--- a/control/bdalg.py
+++ b/control/bdalg.py
@@ -10,6 +10,8 @@ parallel
 negate
 feedback
 connect
+combine_tf
+split_tf
 
 """
 
@@ -63,7 +65,8 @@ from . import statesp as ss
 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


Test patch:
diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py
index 5629f27f..8ea67e0f 100644
--- a/control/tests/bdalg_test.py
+++ b/control/tests/bdalg_test.py
@@ -12,6 +12,7 @@ from control.xferfcn import TransferFunction
 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