Skip to content

Commit 2ddc9ea

Browse files
authored
Merge pull request #937 from murrayrm/tf2ss_unstable_bug-21Oct2023
2 parents a44def1 + 31793e6 commit 2ddc9ea

File tree

3 files changed

+85
-12
lines changed

3 files changed

+85
-12
lines changed

control/statesp.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@
6060
from scipy.signal import StateSpace as signalStateSpace
6161
from warnings import warn
6262

63-
from .exception import ControlSlycot
63+
from .exception import ControlSlycot, slycot_check, ControlMIMONotImplemented
6464
from .frdata import FrequencyResponseData
6565
from .lti import LTI, _process_frequency_response
66-
from .iosys import InputOutputSystem, common_timebase, isdtime, \
66+
from .iosys import InputOutputSystem, common_timebase, isdtime, issiso, \
6767
_process_iosys_keywords, _process_dt_keyword, _process_signal_list
6868
from .nlsys import NonlinearIOSystem, InterconnectedSystem
6969
from . import config
@@ -1583,6 +1583,13 @@ def ss(*args, **kwargs):
15831583
--------
15841584
tf, ss2tf, tf2ss
15851585
1586+
Notes
1587+
-----
1588+
If a transfer function is passed as the sole positional argument, the
1589+
system will be converted to state space form in the same way as calling
1590+
:func:`~control.tf2ss`. The `method` keyword can be used to select the
1591+
method for conversion.
1592+
15861593
Examples
15871594
--------
15881595
Create a Linear I/O system object from matrices.
@@ -1615,10 +1622,13 @@ def ss(*args, **kwargs):
16151622
warn("state labels specified for "
16161623
"non-unique state space realization")
16171624

1625+
# Allow method to be specified (eg, tf2ss)
1626+
method = kwargs.pop('method', None)
1627+
16181628
# Create a state space system from an LTI system
16191629
sys = StateSpace(
16201630
_convert_to_statespace(
1621-
sys,
1631+
sys, method=method,
16221632
use_prefix_suffix=not sys._generic_name_check()),
16231633
**kwargs)
16241634

@@ -1765,6 +1775,10 @@ def tf2ss(*args, **kwargs):
17651775
name : string, optional
17661776
System name. If unspecified, a generic name <sys[id]> is generated
17671777
with a unique integer id.
1778+
method : str, optional
1779+
Set the method used for computing the result. Current methods are
1780+
'slycot' and 'scipy'. If set to None (default), try 'slycot' first
1781+
and then 'scipy' (SISO only).
17681782
17691783
Raises
17701784
------
@@ -1781,6 +1795,13 @@ def tf2ss(*args, **kwargs):
17811795
tf
17821796
ss2tf
17831797
1798+
Notes
1799+
-----
1800+
The ``slycot`` routine used to convert a transfer function into state
1801+
space form appears to have a bug and in some (rare) instances may not
1802+
return a system with the same poles as the input transfer function.
1803+
For SISO systems, setting ``method=scipy`` can be used as an alternative.
1804+
17841805
Examples
17851806
--------
17861807
>>> num = [[[1., 2.], [3., 4.]], [[5., 6.], [7., 8.]]]
@@ -2189,7 +2210,7 @@ def _f2s(f):
21892210
return s
21902211

21912212

2192-
def _convert_to_statespace(sys, use_prefix_suffix=False):
2213+
def _convert_to_statespace(sys, use_prefix_suffix=False, method=None):
21932214
"""Convert a system to state space form (if needed).
21942215
21952216
If sys is already a state space, then it is returned. If sys is a
@@ -2213,13 +2234,17 @@ def _convert_to_statespace(sys, use_prefix_suffix=False):
22132234
raise ValueError("transfer function is non-proper; can't "
22142235
"convert to StateSpace system")
22152236

2216-
try:
2237+
if method is None and slycot_check() or method == 'slycot':
2238+
if not slycot_check():
2239+
raise ValueError("method='slycot' requires slycot")
2240+
22172241
from slycot import td04ad
22182242

22192243
# Change the numerator and denominator arrays so that the transfer
22202244
# function matrix has a common denominator.
22212245
# matrices are also sized/padded to fit td04ad
22222246
num, den, denorder = sys.minreal()._common_den()
2247+
num, den, denorder = sys._common_den()
22232248

22242249
# transfer function to state space conversion now should work!
22252250
ssout = td04ad('C', sys.ninputs, sys.noutputs,
@@ -2230,9 +2255,8 @@ def _convert_to_statespace(sys, use_prefix_suffix=False):
22302255
ssout[1][:states, :states], ssout[2][:states, :sys.ninputs],
22312256
ssout[3][:sys.noutputs, :states], ssout[4], sys.dt)
22322257

2233-
except ImportError:
2234-
# No Slycot. Scipy tf->ss can't handle MIMO, but static
2235-
# MIMO is an easy special case we can check for here
2258+
elif method in [None, 'scipy']:
2259+
# Scipy tf->ss can't handle MIMO, but SISO is OK
22362260
maxn = max(max(len(n) for n in nrow)
22372261
for nrow in sys.num)
22382262
maxd = max(max(len(d) for d in drow)
@@ -2244,12 +2268,15 @@ def _convert_to_statespace(sys, use_prefix_suffix=False):
22442268
D[i, j] = sys.num[i][j][0] / sys.den[i][j][0]
22452269
newsys = StateSpace([], [], [], D, sys.dt)
22462270
else:
2247-
if sys.ninputs != 1 or sys.noutputs != 1:
2248-
raise TypeError("No support for MIMO without slycot")
2271+
if not issiso(sys):
2272+
raise ControlMIMONotImplemented(
2273+
"MIMO system conversion not supported without Slycot")
22492274

22502275
A, B, C, D = \
22512276
sp.signal.tf2ss(squeeze(sys.num), squeeze(sys.den))
22522277
newsys = StateSpace(A, B, C, D, sys.dt)
2278+
else:
2279+
raise ValueError(f"unknown {method=}")
22532280

22542281
# Copy over the signal (and system) names
22552282
newsys._copy_names(

control/tests/convert_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from control import rss, ss, ss2tf, tf, tf2ss
2222
from control.statefbk import ctrb, obsv
2323
from control.freqplot import bode
24-
from control.exception import slycot_check
24+
from control.exception import slycot_check, ControlMIMONotImplemented
2525
from control.tests.conftest import slycotonly
2626

2727

@@ -167,7 +167,7 @@ def testConvertMIMO(self):
167167

168168
# Convert to state space and look for an error
169169
if (not slycot_check()):
170-
with pytest.raises(TypeError):
170+
with pytest.raises(ControlMIMONotImplemented):
171171
tf2ss(tsys)
172172
else:
173173
ssys = tf2ss(tsys)

control/tests/statesp_test.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,3 +1203,49 @@ def test_params_warning():
12031203
sys.output(0, [0], [0], {'k': 5})
12041204

12051205

1206+
# Check that tf2ss returns stable system (see issue #935)
1207+
@pytest.mark.parametrize("method", [
1208+
# pytest.param(None), # use this one when SLICOT bug is sorted out
1209+
pytest.param( # remove this one when SLICOT bug is sorted out
1210+
None, marks=pytest.mark.xfail(
1211+
ct.slycot_check(), reason="tf2ss SLICOT bug")),
1212+
pytest.param(
1213+
'slycot', marks=[
1214+
pytest.mark.xfail(
1215+
not ct.slycot_check(), reason="slycot not installed"),
1216+
pytest.mark.xfail( # remove this one when SLICOT bug is sorted out
1217+
ct.slycot_check(), reason="tf2ss SLICOT bug")]),
1218+
pytest.param('scipy')
1219+
])
1220+
def test_tf2ss_unstable(method):
1221+
num = np.array([
1222+
9.94004350e-13, 2.67602795e-11, 2.31058712e-10, 1.15119493e-09,
1223+
5.04635153e-09, 1.34066064e-08, 2.11938725e-08, 2.39940325e-08,
1224+
2.05897777e-08, 1.17092854e-08, 4.71236875e-09, 1.19497537e-09,
1225+
1.90815347e-10, 1.00655454e-11, 1.47388887e-13, 8.40314881e-16,
1226+
1.67195685e-18])
1227+
den = np.array([
1228+
9.43513863e-11, 6.05312352e-08, 7.92752628e-07, 5.23764693e-06,
1229+
1.82502556e-05, 1.24355899e-05, 8.68206174e-06, 2.73818482e-06,
1230+
4.29133144e-07, 3.85554417e-08, 1.62631575e-09, 8.41098151e-12,
1231+
9.85278302e-15, 4.07646645e-18, 5.55496497e-22, 3.06560494e-26,
1232+
5.98908988e-31])
1233+
1234+
tf_sys = ct.tf(num, den)
1235+
ss_sys = ct.tf2ss(tf_sys, method=method)
1236+
1237+
tf_poles = np.sort(tf_sys.poles())
1238+
ss_poles = np.sort(ss_sys.poles())
1239+
np.testing.assert_allclose(tf_poles, ss_poles, rtol=1e-4)
1240+
1241+
1242+
def test_tf2ss_mimo():
1243+
sys_tf = ct.tf([[[1], [1, 1, 1]]], [[[1, 1, 1], [1, 2, 1]]])
1244+
1245+
if ct.slycot_check():
1246+
sys_ss = ct.ss(sys_tf)
1247+
np.testing.assert_allclose(
1248+
np.sort(sys_tf.poles()), np.sort(sys_ss.poles()))
1249+
else:
1250+
with pytest.raises(ct.ControlMIMONotImplemented):
1251+
sys_ss = ct.ss(sys_tf)

0 commit comments

Comments
 (0)