Repository: python-control/python-control
Pull request #1108 (2025-02-01 04:49:37)

scipy-based implementation of ss2tf
This PR allows `ss2tf` to work using `method=scipy` for MIMO systems, allow MIMO ss2tf without requiring `slycot`.  Also re-enabled unit tests that now work without `slycot`.



Patch:
diff --git a/control/xferfcn.py b/control/xferfcn.py
index c9043bd0..4a8fd4a1 100644
--- a/control/xferfcn.py
+++ b/control/xferfcn.py
@@ -1516,6 +1516,12 @@ def _convert_to_transfer_function(
             den = [[[1.] for j in range(sys.ninputs)]
                    for i in range(sys.noutputs)]
         else:
+            # Preallocate numerator and denominator arrays
+            num = [[[] for j in range(sys.ninputs)]
+                   for i in range(sys.noutputs)]
+            den = [[[] for j in range(sys.ninputs)]
+                   for i in range(sys.noutputs)]
+
             try:
                 # Use Slycot to make the transformation
                 # Make sure to convert system matrices to numpy arrays
@@ -1524,12 +1530,6 @@ def _convert_to_transfer_function(
                     sys.nstates, sys.ninputs, sys.noutputs, array(sys.A),
                     array(sys.B), array(sys.C), array(sys.D), tol1=0.0)
 
-                # Preallocate outputs.
-                num = [[[] for j in range(sys.ninputs)]
-                       for i in range(sys.noutputs)]
-                den = [[[] for j in range(sys.ninputs)]
-                       for i in range(sys.noutputs)]
-
                 for i in range(sys.noutputs):
                     for j in range(sys.ninputs):
                         num[i][j] = list(tfout[6][i, j, :])
@@ -1538,16 +1538,13 @@ def _convert_to_transfer_function(
                         den[i][j] = list(tfout[5][i, :])
 
             except ImportError:
-                # If slycot is not available, use signal.lti (SISO only)
-                if sys.ninputs != 1 or sys.noutputs != 1:
-                    raise ControlMIMONotImplemented("Not implemented for " +
-                        "MIMO systems without slycot.")
-
-                # Do the conversion using sp.signal.ss2tf
-                # Note that this returns a 2D array for the numerator
-                num, den = sp.signal.ss2tf(sys.A, sys.B, sys.C, sys.D)
-                num = squeeze(num)  # Convert to 1D array
-                den = squeeze(den)  # Probably not needed
+                # If slycot not available, do conversion using sp.signal.ss2tf
+                for j in range(sys.ninputs):
+                    num_j, den_j = sp.signal.ss2tf(
+                        sys.A, sys.B, sys.C, sys.D, input=j)
+                    for i in range(sys.noutputs):
+                        num[i][j] = num_j[i]
+                        den[i][j] = den_j
 
         newsys = TransferFunction(num, den, sys.dt)
         if use_prefix_suffix:


Test patch:
diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py
index 5c0acd4c..9cceff38 100644
--- a/control/tests/frd_test.py
+++ b/control/tests/frd_test.py
@@ -258,7 +258,6 @@ class TestFRD:
         freqplot.nyquist(f1)
         # plt.savefig('/dev/null', format='svg')
 
-    @slycotonly
     @pytest.mark.parametrize(
         "frd_fcn", [ct.frd, ct.FRD, ct.FrequencyResponseData])
     def testMIMO(self, frd_fcn):
@@ -276,7 +275,6 @@ class TestFRD:
             sys.frequency_response(chkpts)[1],
             f1.frequency_response(chkpts)[1])
 
-    @slycotonly
     @pytest.mark.parametrize(
         "frd_fcn", [ct.frd, ct.FRD, ct.FrequencyResponseData])
     def testMIMOfb(self, frd_fcn):
@@ -295,7 +293,6 @@ class TestFRD:
             f1.frequency_response(chkpts)[1],
             f2.frequency_response(chkpts)[1])
 
-    @slycotonly
     @pytest.mark.parametrize(
         "frd_fcn", [ct.frd, ct.FRD, ct.FrequencyResponseData])
     def testMIMOfb2(self, frd_fcn):
@@ -316,7 +313,6 @@ class TestFRD:
             f1.frequency_response(chkpts)[1],
             f2.frequency_response(chkpts)[1])
 
-    @slycotonly
     @pytest.mark.parametrize(
         "frd_fcn", [ct.frd, ct.FRD, ct.FrequencyResponseData])
     def testMIMOMult(self, frd_fcn):
@@ -335,7 +331,6 @@ class TestFRD:
             (f1*f2).frequency_response(chkpts)[1],
             (sys*sys).frequency_response(chkpts)[1])
 
-    @slycotonly
     @pytest.mark.parametrize(
         "frd_fcn", [ct.frd, ct.FRD, ct.FrequencyResponseData])
     def testMIMOSmooth(self, frd_fcn):
diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py
index 78177995..48aa7179 100644
--- a/control/tests/iosys_test.py
+++ b/control/tests/iosys_test.py
@@ -2288,7 +2288,6 @@ def test_signal_indexing():
         resp.outputs['y[0]', 'u[0]']
 
 
-@slycotonly
 @pytest.mark.parametrize("fcn, spec, expected, missing", [
     (ct.ss, {}, "states=4, outputs=3, inputs=2", r"dt|name"),
     (ct.tf, {}, "outputs=3, inputs=2", r"dt|states|name"),
diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py
index e93138af..1b059ef6 100644
--- a/control/tests/lti_test.py
+++ b/control/tests/lti_test.py
@@ -309,7 +309,6 @@ class TestLTI:
             evalfr(sys, [[0.1j, 1j], [1j, 10j]])
 
 
-@slycotonly
 @pytest.mark.parametrize(
     "outdx, inpdx, key",
     [('y[0]', 'u[1]', (0, 1)),
@@ -356,7 +355,6 @@ def test_subsys_indexing(fcn, outdx, inpdx, key):
                 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])
diff --git a/control/tests/matlab2_test.py b/control/tests/matlab2_test.py
index 5eedfc2e..d7c0c1ce 100644
--- a/control/tests/matlab2_test.py
+++ b/control/tests/matlab2_test.py
@@ -49,7 +49,6 @@ class TestControlMatlab:
         D = zeros((2, 2))
         return A, B, C, D
 
-    @slycotonly
     def test_dcgain_mimo(self, MIMO_mats):
         """Test function dcgain with MIMO systems"""
         #Test MIMO systems
diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py
index 366c5ef3..6d57a38a 100644
--- a/control/tests/statesp_test.py
+++ b/control/tests/statesp_test.py
@@ -248,7 +248,6 @@ class TestStateSpace:
 
         np.testing.assert_almost_equal(true_z, z)
 
-    @slycotonly
     def test_zero_mimo_sys322_square(self, sys322):
         """Evaluate the zeros of a square MIMO system."""
 
@@ -256,7 +255,6 @@ class TestStateSpace:
         true_z = np.sort([44.41465, -0.490252, -5.924398])
         np.testing.assert_array_almost_equal(z, true_z)
 
-    @slycotonly
     def test_zero_mimo_sys222_square(self, sys222):
         """Evaluate the zeros of a square MIMO system."""
 
@@ -320,7 +318,6 @@ class TestStateSpace:
         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(
@@ -702,8 +699,6 @@ class TestStateSpace:
         with pytest.raises(AttributeError):
             sys.evalfr(omega)
 
-
-    @slycotonly
     def test_freq_resp(self):
         """Evaluate the frequency response at multiple frequencies."""
 
diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py
index cab556cc..00000f52 100644
--- a/control/tests/xferfcn_test.py
+++ b/control/tests/xferfcn_test.py
@@ -10,9 +10,9 @@ import numpy as np
 import pytest
 
 import control as ct
-from control import (StateSpace, TransferFunction, defaults, evalfr, isctime,
-                     isdtime, reset_defaults, rss, sample_system, set_defaults,
-                     ss, ss2tf, tf, tf2ss, zpk)
+from control import StateSpace, TransferFunction, defaults, evalfr, isctime, \
+    isdtime, reset_defaults, rss, sample_system, set_defaults, 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, _tf_close_coeff
@@ -186,7 +186,6 @@ class TestXferFcn:
         np.testing.assert_allclose(sys2.num, [[[-1., -3., -5.]]])
         np.testing.assert_allclose(sys2.den, [[[1., 6., 2., -1.]]])
 
-    @slycotonly
     def test_reverse_sign_mimo(self):
         """Negate a MIMO system."""
         num1 = [[[1., 2.], [0., 3.], [2., -1.]],
@@ -228,7 +227,6 @@ class TestXferFcn:
         np.testing.assert_allclose(sys3.num, [[[20., 4., -8]]])
         np.testing.assert_allclose(sys3.den, [[[1., 6., 1., -7., -2., 1.]]])
 
-    @slycotonly
     def test_add_mimo(self):
         """Add two MIMO systems."""
         num1 = [[[1., 2.], [0., 3.], [2., -1.]],
@@ -276,7 +274,6 @@ class TestXferFcn:
         np.testing.assert_allclose(sys4.num, [[[-2., -6., 12., 10., 2.]]])
         np.testing.assert_allclose(sys4.den, [[[1., 6., 1., -7., -2., 1.]]])
 
-    @slycotonly
     def test_subtract_mimo(self):
         """Subtract two MIMO systems."""
         num1 = [[[1., 2.], [0., 3.], [2., -1.]],
@@ -327,7 +324,6 @@ class TestXferFcn:
         np.testing.assert_allclose(sys3.num, sys4.num)
         np.testing.assert_allclose(sys3.den, sys4.den)
 
-    @slycotonly
     def test_multiply_mimo(self):
         """Multiply two MIMO systems."""
         num1 = [[[1., 2.], [0., 3.], [2., -1.]],
@@ -714,7 +710,6 @@ class TestXferFcn:
         sys = TransferFunction([1., 3., 5], [1., 6., 2., -1], 0.1)
         np.testing.assert_array_almost_equal(sys(1j), -0.5 - 0.5j)
 
-    @slycotonly
     def test_call_mimo(self):
         """Evaluate the frequency response of a MIMO system at one frequency."""
 
@@ -755,7 +750,6 @@ class TestXferFcn:
         np.testing.assert_array_almost_equal(phase, truephase)
         np.testing.assert_array_almost_equal(omega, trueomega)
 
-    @slycotonly
     def test_freqresp_mimo(self):
         """Evaluate the MIMO magnitude and phase at multiple frequencies."""
         num = [[[1., 2.], [0., 3.], [2., -1.]],
@@ -852,7 +846,6 @@ class TestXferFcn:
         _, den2, _ = tf2._common_den(allow_nonproper=True)
         np.testing.assert_array_almost_equal(den2, common_den_ref)
 
-    @slycotonly
     def test_pole_mimo(self):
         """Test for correct MIMO poles."""
         sys = TransferFunction(
@@ -936,7 +929,6 @@ class TestXferFcn:
         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."""
         A = [[1., -2.], [-3., 4.]]
@@ -1023,7 +1015,6 @@ class TestXferFcn:
         np.testing.assert_array_almost_equal(H.num[1][0], H2.num[1][0])
         np.testing.assert_array_almost_equal(H.den[1][0], H2.den[1][0])
 
-    @slycotonly
     def test_indexing(self):
         """Test TF scalar indexing and slice"""
         tm = ss2tf(rss(5, 3, 3))
@@ -1213,7 +1204,6 @@ class TestXferFcn:
             assert len(polystr[0].split('\n')) == 4
         assert polystr[2] == outputfmt.format(var=var)
 
-    @slycotonly
     def test_printing_mimo(self):
         """Print MIMO, continuous time"""
         sys = ss2tf(rss(4, 2, 3))
@@ -1332,7 +1322,6 @@ Input 2 to output 2:
         res = str(G)
         assert res.partition('\n\n')[2] == output
 
-    @slycotonly
     def test_size_mismatch(self):
         """Test size mismacht"""
         sys1 = ss2tf(rss(2, 2, 2))