Repository: python-control/python-control
Pull request #903 (2023-06-04 15:47:55)

Change name of converted LinearIOSystems
This PR implements that change suggested in PR #884 and issue #901 regarding changing the system name when a transfer function is converted to a state space system and vice versa.  Converted systems now have "$converted" appended to the system name.  This is only done when you do an explicit conversation via `ss` or `tf`.  Implicit conversions from a `TransferFunction` to a `StateSpace` object, for example as part of using `interconnect` use the original name.

In addition, prefix/suffix processing has been moved into the `NamedIOSystem` class, under `_copy_names` and `_name_or_default` methods.


Patch:
diff --git a/control/iosys.py b/control/iosys.py
index dca00d3e..22159fc4 100644
--- a/control/iosys.py
+++ b/control/iosys.py
@@ -591,13 +591,8 @@ class InputOutputSystem(NamedIOSystem):
                 DeprecationWarning)
 
         if copy_names:
-            linsys._copy_names(self)
-            if name is None:
-                linsys.name = \
-                    config.defaults['namedio.linearized_system_name_prefix']+\
-                    linsys.name+\
-                    config.defaults['namedio.linearized_system_name_suffix']
-            else:
+            linsys._copy_names(self, prefix_suffix_name='linearized')
+            if name is not None:
                 linsys.name = name
 
         # re-init to include desired signal names if names were provided
@@ -2400,7 +2395,9 @@ def ss(*args, **kwargs):
                      "non-unique state space realization")
 
             # Create a state space system from an LTI system
-            sys = LinearIOSystem(_convert_to_statespace(sys), **kwargs)
+            sys = LinearIOSystem(
+                _convert_to_statespace(sys, use_prefix_suffix=True), **kwargs)
+
         else:
             raise TypeError("ss(sys): sys must be a StateSpace or "
                             "TransferFunction object.  It is %s." % type(sys))
diff --git a/control/namedio.py b/control/namedio.py
index 8f61514f..c0d5f11d 100644
--- a/control/namedio.py
+++ b/control/namedio.py
@@ -12,6 +12,7 @@ from . import config
 
 __all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual',
            'isdtime', 'isctime']
+
 # Define module default parameter values
 _namedio_defaults = {
     'namedio.state_name_delim': '_',
@@ -20,7 +21,9 @@ _namedio_defaults = {
     'namedio.linearized_system_name_prefix': '',
     'namedio.linearized_system_name_suffix': '$linearized',
     'namedio.sampled_system_name_prefix': '',
-    'namedio.sampled_system_name_suffix': '$sampled'
+    'namedio.sampled_system_name_suffix': '$sampled',
+    'namedio.converted_system_name_prefix': '',
+    'namedio.converted_system_name_suffix': '$converted',
 }
 
 
@@ -49,11 +52,15 @@ class NamedIOSystem(object):
     _idCounter = 0              # Counter for creating generic system name
 
     # Return system name
-    def _name_or_default(self, name=None):
+    def _name_or_default(self, name=None, prefix_suffix_name=None):
         if name is None:
             name = "sys[{}]".format(NamedIOSystem._idCounter)
             NamedIOSystem._idCounter += 1
-        return name
+        prefix = "" if prefix_suffix_name is None else config.defaults[
+            'namedio.' + prefix_suffix_name + '_system_name_prefix']
+        suffix = "" if prefix_suffix_name is None else config.defaults[
+            'namedio.' + prefix_suffix_name + '_system_name_suffix']
+        return prefix + name + suffix
 
     # Check if system name is generic
     def _generic_name_check(self):
@@ -99,16 +106,24 @@ class NamedIOSystem(object):
     def _find_signal(self, name, sigdict):
         return sigdict.get(name, None)
 
-    def _copy_names(self, sys):
+    def _copy_names(self, sys, prefix="", suffix="", prefix_suffix_name=None):
         """copy the signal and system name of sys. Name is given as a keyword
         in case a specific name (e.g. append 'linearized') is desired. """
-        self.name = sys.name
-        self.ninputs, self.input_index = \
-            sys.ninputs, sys.input_index.copy()
-        self.noutputs, self.output_index = \
-            sys.noutputs, sys.output_index.copy()
-        self.nstates, self.state_index = \
-            sys.nstates, sys.state_index.copy()
+        # Figure out the system name and assign it
+        if prefix == "" and prefix_suffix_name is not None:
+            prefix = config.defaults[
+                'namedio.' + prefix_suffix_name + '_system_name_prefix']
+        if suffix == "" and prefix_suffix_name is not None:
+            suffix = config.defaults[
+                'namedio.' + prefix_suffix_name + '_system_name_suffix']
+        self.name = prefix + sys.name + suffix
+
+        # Name the inputs, outputs, and states
+        self.input_index = sys.input_index.copy()
+        self.output_index = sys.output_index.copy()
+        if self.nstates and sys.nstates:
+            # only copy state names for state space systems
+            self.state_index = sys.state_index.copy()
 
     def copy(self, name=None, use_prefix_suffix=True):
         """Make a copy of an input/output system
@@ -128,10 +143,8 @@ class NamedIOSystem(object):
         # Update the system name
         if name is None and use_prefix_suffix:
             # Get the default prefix and suffix to use
-            dup_prefix = config.defaults['namedio.duplicate_system_name_prefix']
-            dup_suffix = config.defaults['namedio.duplicate_system_name_suffix']
             newsys.name = self._name_or_default(
-                dup_prefix + self.name + dup_suffix)
+                self.name, prefix_suffix_name='duplicate')
         else:
             newsys.name = self._name_or_default(name)
 
diff --git a/control/statesp.py b/control/statesp.py
index f74f39e2..d3d1ab1d 100644
--- a/control/statesp.py
+++ b/control/statesp.py
@@ -1382,13 +1382,8 @@ class StateSpace(LTI):
         sysd = StateSpace(Ad, Bd, C, D, Ts)
         # copy over the system name, inputs, outputs, and states
         if copy_names:
-            sysd._copy_names(self)
-            if name is None:
-                sysd.name = \
-                    config.defaults['namedio.sampled_system_name_prefix'] +\
-                    sysd.name + \
-                    config.defaults['namedio.sampled_system_name_suffix']
-            else:
+            sysd._copy_names(self, prefix_suffix_name='sampled')
+            if name is not None:
                 sysd.name = name
         # pass desired signal names if names were provided
         return StateSpace(sysd, **kwargs)
@@ -1524,7 +1519,7 @@ class StateSpace(LTI):
 
 
 # TODO: add discrete time check
-def _convert_to_statespace(sys):
+def _convert_to_statespace(sys, use_prefix_suffix=False):
     """Convert a system to state space form (if needed).
 
     If sys is already a state space, then it is returned.  If sys is a
@@ -1561,11 +1556,10 @@ def _convert_to_statespace(sys):
                            denorder, den, num, tol=0)
 
             states = ssout[0]
-            return StateSpace(
+            newsys = StateSpace(
                 ssout[1][:states, :states], ssout[2][:states, :sys.ninputs],
-                ssout[3][:sys.noutputs, :states], ssout[4], sys.dt,
-                inputs=sys.input_labels, outputs=sys.output_labels,
-                name=sys.name)
+                ssout[3][:sys.noutputs, :states], ssout[4], sys.dt)
+
         except ImportError:
             # No Slycot.  Scipy tf->ss can't handle MIMO, but static
             # MIMO is an easy special case we can check for here
@@ -1578,9 +1572,7 @@ def _convert_to_statespace(sys):
                 for i, j in itertools.product(range(sys.noutputs),
                                               range(sys.ninputs)):
                     D[i, j] = sys.num[i][j][0] / sys.den[i][j][0]
-                return StateSpace([], [], [], D, sys.dt,
-                    inputs=sys.input_labels, outputs=sys.output_labels,
-                    name=sys.name)
+                newsys = StateSpace([], [], [], D, sys.dt)
             else:
                 if sys.ninputs != 1 or sys.noutputs != 1:
                     raise TypeError("No support for MIMO without slycot")
@@ -1590,9 +1582,13 @@ def _convert_to_statespace(sys):
                 # the squeeze
                 A, B, C, D = \
                     sp.signal.tf2ss(squeeze(sys.num), squeeze(sys.den))
-                return StateSpace(
-                    A, B, C, D, sys.dt, inputs=sys.input_labels,
-                    outputs=sys.output_labels, name=sys.name)
+                newsys = StateSpace(A, B, C, D, sys.dt)
+
+        # Copy over the signal (and system) names
+        newsys._copy_names(
+            sys,
+            prefix_suffix_name='converted' if use_prefix_suffix else None)
+        return newsys
 
     elif isinstance(sys, FrequencyResponseData):
         raise TypeError("Can't convert FRD to StateSpace system.")
@@ -1605,7 +1601,6 @@ def _convert_to_statespace(sys):
     except Exception:
         raise TypeError("Can't convert given type to StateSpace system.")
 
-
 # TODO: add discrete time option
 def _rss_generate(
         states, inputs, outputs, cdtype, strictly_proper=False, name=None):
@@ -1919,7 +1914,8 @@ def tf2ss(*args, **kwargs):
         if not isinstance(sys, TransferFunction):
             raise TypeError("tf2ss(sys): sys must be a TransferFunction "
                             "object.")
-        return StateSpace(_convert_to_statespace(sys), **kwargs)
+        return StateSpace(_convert_to_statespace(sys, use_prefix_suffix=True),
+                          **kwargs)
     else:
         raise ValueError("Needs 1 or 2 arguments; received %i." % len(args))
 
diff --git a/control/xferfcn.py b/control/xferfcn.py
index 8ef7e108..60a40eca 100644
--- a/control/xferfcn.py
+++ b/control/xferfcn.py
@@ -1206,13 +1206,8 @@ class TransferFunction(LTI):
         sysd = TransferFunction(numd[0, :], dend, Ts)
         # copy over the system name, inputs, outputs, and states
         if copy_names:
-            sysd._copy_names(self)
-            if name is None:
-                sysd.name = \
-                    config.defaults['namedio.sampled_system_name_prefix'] +\
-                    sysd.name + \
-                    config.defaults['namedio.sampled_system_name_suffix']
-            else:
+            sysd._copy_names(self, prefix_suffix_name='sampled')
+            if name is not None:
                 sysd.name = name
         # pass desired signal names if names were provided
         return TransferFunction(sysd, name=name, **kwargs)
@@ -1438,7 +1433,8 @@ def _add_siso(num1, den1, num2, den2):
     return num, den
 
 
-def _convert_to_transfer_function(sys, inputs=1, outputs=1):
+def _convert_to_transfer_function(
+        sys, inputs=1, outputs=1, use_prefix_suffix=False):
     """Convert a system to transfer function form (if needed).
 
     If sys is already a transfer function, then it is returned.  If sys is a
@@ -1514,7 +1510,10 @@ def _convert_to_transfer_function(sys, inputs=1, outputs=1):
                 num = squeeze(num)  # Convert to 1D array
                 den = squeeze(den)  # Probably not needed
 
-        return TransferFunction(num, den, sys.dt)
+        newsys = TransferFunction(num, den, sys.dt)
+        if use_prefix_suffix:
+            newsys._copy_names(sys, prefix_suffix_name='converted')
+        return newsys
 
     elif isinstance(sys, (int, float, complex, np.number)):
         num = [[[sys] for j in range(inputs)] for i in range(outputs)]
@@ -1814,7 +1813,8 @@ def ss2tf(*args, **kwargs):
             if not kwargs.get('outputs'):
                 kwargs['outputs'] = sys.output_labels
             return TransferFunction(
-                _convert_to_transfer_function(sys), **kwargs)
+                _convert_to_transfer_function(sys, use_prefix_suffix=True),
+                **kwargs)
         else:
             raise TypeError(
                 "ss2tf(sys): sys must be a StateSpace object.  It is %s."


Test patch:
diff --git a/control/tests/interconnect_test.py b/control/tests/interconnect_test.py
index 3d2f0c7d..cf59c8c1 100644
--- a/control/tests/interconnect_test.py
+++ b/control/tests/interconnect_test.py
@@ -246,23 +246,54 @@ def test_string_inputoutput():
     assert P_s2.output_index == {'y2' : 0}
 
 def test_linear_interconnect():
-    tf_ctrl = ct.tf(1, (10.1, 1), inputs='e', outputs='u')
-    tf_plant = ct.tf(1, (10.1, 1), inputs='u', outputs='y')
-    ss_ctrl = ct.ss(1, 2, 1, 2, inputs='e', outputs='u')
-    ss_plant = ct.ss(1, 2, 1, 2, inputs='u', outputs='y')
+    tf_ctrl = ct.tf(1, (10.1, 1), inputs='e', outputs='u', name='ctrl')
+    tf_plant = ct.tf(1, (10.1, 1), inputs='u', outputs='y', name='plant')
+    ss_ctrl = ct.ss(1, 2, 1, 0, inputs='e', outputs='u', name='ctrl')
+    ss_plant = ct.ss(1, 2, 1, 0, inputs='u', outputs='y', name='plant')
     nl_ctrl = ct.NonlinearIOSystem(
-        lambda t, x, u, params: x*x,
-        lambda t, x, u, params: u*x, states=1, inputs='e', outputs='u')
+        lambda t, x, u, params: x*x, lambda t, x, u, params: u*x,
+        states=1, inputs='e', outputs='u', name='ctrl')
     nl_plant = ct.NonlinearIOSystem(
-        lambda t, x, u, params: x*x,
-        lambda t, x, u, params: u*x, states=1, inputs='u', outputs='y')
-
-    assert isinstance(ct.interconnect((tf_ctrl, tf_plant), inputs='e', outputs='y'), ct.LinearIOSystem)
-    assert isinstance(ct.interconnect((ss_ctrl, ss_plant), inputs='e', outputs='y'), ct.LinearIOSystem)
-    assert isinstance(ct.interconnect((tf_ctrl, ss_plant), inputs='e', outputs='y'), ct.LinearIOSystem)
-    assert isinstance(ct.interconnect((ss_ctrl, tf_plant), inputs='e', outputs='y'), ct.LinearIOSystem)
-
-    assert ~isinstance(ct.interconnect((nl_ctrl, ss_plant), inputs='e', outputs='y'), ct.LinearIOSystem)
-    assert ~isinstance(ct.interconnect((nl_ctrl, tf_plant), inputs='e', outputs='y'), ct.LinearIOSystem)
-    assert ~isinstance(ct.interconnect((ss_ctrl, nl_plant), inputs='e', outputs='y'), ct.LinearIOSystem)
-    assert ~isinstance(ct.interconnect((tf_ctrl, nl_plant), inputs='e', outputs='y'), ct.LinearIOSystem)
\ No newline at end of file
+        lambda t, x, u, params: x*x, lambda t, x, u, params: u*x,
+        states=1, inputs='u', outputs='y', name='plant')
+    sumblk = ct.summing_junction(inputs=['r', '-y'], outputs=['e'], name='sum')
+
+    # Interconnections of linear I/O systems should be linear I/O system
+    assert isinstance(
+        ct.interconnect([tf_ctrl, tf_plant, sumblk], inputs='r', outputs='y'),
+        ct.LinearIOSystem)
+    assert isinstance(
+        ct.interconnect([ss_ctrl, ss_plant, sumblk], inputs='r', outputs='y'),
+        ct.LinearIOSystem)
+    assert isinstance(
+        ct.interconnect([tf_ctrl, ss_plant, sumblk], inputs='r', outputs='y'),
+        ct.LinearIOSystem)
+    assert isinstance(
+        ct.interconnect([ss_ctrl, tf_plant, sumblk], inputs='r', outputs='y'),
+        ct.LinearIOSystem)
+
+    # Interconnections with nonliner I/O systems should not be linear
+    assert ~isinstance(
+        ct.interconnect([nl_ctrl, ss_plant, sumblk], inputs='r', outputs='y'),
+        ct.LinearIOSystem)
+    assert ~isinstance(
+        ct.interconnect([nl_ctrl, tf_plant, sumblk], inputs='r', outputs='y'),
+        ct.LinearIOSystem)
+    assert ~isinstance(
+        ct.interconnect([ss_ctrl, nl_plant, sumblk], inputs='r', outputs='y'),
+        ct.LinearIOSystem)
+    assert ~isinstance(
+        ct.interconnect([tf_ctrl, nl_plant, sumblk], inputs='r', outputs='y'),
+        ct.LinearIOSystem)
+
+    # Implicit converstion of transfer function should retain name
+    clsys = ct.interconnect(
+        [tf_ctrl, ss_plant, sumblk],
+        connections=[
+            ['plant.u', 'ctrl.u'],
+            ['ctrl.e', 'sum.e'],
+            ['sum.y', 'plant.y']
+        ],
+        inplist=['sum.r'], inputs='r',
+        outlist=['plant.y'], outputs='y')
+    assert clsys.syslist[0].name == 'ctrl'
diff --git a/control/tests/namedio_test.py b/control/tests/namedio_test.py
index 3203214d..cf30b94a 100644
--- a/control/tests/namedio_test.py
+++ b/control/tests/namedio_test.py
@@ -169,6 +169,9 @@ def test_io_naming(fun, args, kwargs):
         assert sys_ss != sys_r
         assert sys_ss.input_labels == input_labels
         assert sys_ss.output_labels == output_labels
+        if not isinstance(sys_r, ct.StateSpace):
+            # System should get unique name
+            assert sys_ss.name != sys_r.name
 
         # Reassign system and signal names
         sys_ss = ct.ss(
@@ -247,11 +250,11 @@ def test_convert_to_statespace():
 
     # check that name, inputs, and outputs passed through
     sys_new = ct.ss(sys)
-    assert sys_new.name == 'sys'
+    assert sys_new.name == 'sys$converted'
     assert sys_new.input_labels == ['u']
     assert sys_new.output_labels == ['y']
     sys_new = ct.ss(sys_static)
-    assert sys_new.name == 'sys_static'
+    assert sys_new.name == 'sys_static$converted'
     assert sys_new.input_labels == ['u']
     assert sys_new.output_labels == ['y']
 
diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py
index ebb781b8..7d561e77 100644
--- a/control/tests/xferfcn_test.py
+++ b/control/tests/xferfcn_test.py
@@ -1262,6 +1262,12 @@ def test_copy_names(create, args, kwargs, convert):
     if cpy.nstates is not None and sys.nstates is not None:
         assert cpy.state_labels == sys.state_labels
 
+    # Make sure that names aren't the same if system changed type
+    if not isinstance(cpy, create):
+        assert cpy.name == sys.name + '$converted'
+    else:
+        assert cpy.name == sys.name
+
     # Relabel inputs and outputs
     cpy = convert(sys, inputs='myin', outputs='myout')
     assert cpy.input_labels == ['myin']