Repository: python-control/python-control
Pull request #797 (2022-11-23 06:04:08)

Preserve signal names upon conversion to discrete-time
This PR updates `sys.sample` in both `StateSpace` and `TransferFunction` to return a system with the same input and output labels, which is convenient when constructing interconnected systems using `interconnect`. 

The changes in this PR only copy signal names, but the name of the system is not preserved. I haven't used the system name feature before but my impression was that it should be unique, but please let me know if you think it should be passed as well. 


Patch:
diff --git a/control/config.py b/control/config.py
index 32f5f2ee..ccee252f 100644
--- a/control/config.py
+++ b/control/config.py
@@ -97,6 +97,9 @@ def reset_defaults():
     from .rlocus import _rlocus_defaults
     defaults.update(_rlocus_defaults)
 
+    from .namedio import _namedio_defaults
+    defaults.update(_namedio_defaults)
+
     from .xferfcn import _xferfcn_defaults
     defaults.update(_xferfcn_defaults)
 
@@ -285,7 +288,7 @@ def use_legacy_defaults(version):
         set_defaults('control', default_dt=None)
 
         # changed iosys naming conventions
-        set_defaults('iosys', state_name_delim='.',
+        set_defaults('namedio', state_name_delim='.',
                      duplicate_system_name_prefix='copy of ',
                      duplicate_system_name_suffix='',
                      linearized_system_name_prefix='',
diff --git a/control/dtime.py b/control/dtime.py
index b05d22b9..9dddd86a 100644
--- a/control/dtime.py
+++ b/control/dtime.py
@@ -53,7 +53,8 @@ from .statesp import StateSpace
 __all__ = ['sample_system', 'c2d']
 
 # Sample a continuous time system
-def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None):
+def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None,
+        name=None, copy_names=True, **kwargs):
     """
     Convert a continuous time system to discrete time by sampling
 
@@ -72,12 +73,35 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None):
     prewarp_frequency : float within [0, infinity)
         The frequency [rad/s] at which to match with the input continuous-
         time system's magnitude and phase (only valid for method='bilinear')
+    name : string, optional
+        Set the name of the sampled system.  If not specified and
+        if `copy_names` is `False`, a generic name <sys[id]> is generated
+        with a unique integer id.  If `copy_names` is `True`, the new system
+        name is determined by adding the prefix and suffix strings in
+        config.defaults['namedio.sampled_system_name_prefix'] and
+        config.defaults['namedio.sampled_system_name_suffix'], with the
+        default being to add the suffix '$sampled'.
+    copy_names : bool, Optional
+        If True, copy the names of the input signals, output
+        signals, and states to the sampled system.
 
     Returns
     -------
     sysd : linsys
         Discrete time system, with sampling rate Ts
 
+    Additional Parameters
+    ---------------------
+    inputs : int, list of str or None, optional
+        Description of the system inputs.  If not specified, the origional
+        system inputs are used.  See :class:`NamedIOSystem` for more
+        information.
+    outputs : int, list of str or None, optional
+        Description of the system outputs.  Same format as `inputs`.
+    states : int, list of str, or None, optional
+        Description of the system states.  Same format as `inputs`. Only
+        available if the system is :class:`StateSpace`.
+
     Notes
     -----
     See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample` for
@@ -94,7 +118,8 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None):
         raise ValueError("First argument must be continuous time system")
 
     return sysc.sample(Ts,
-        method=method, alpha=alpha, prewarp_frequency=prewarp_frequency)
+        method=method, alpha=alpha, prewarp_frequency=prewarp_frequency,
+        name=name, copy_names=copy_names, **kwargs)
 
 
 def c2d(sysc, Ts, method='zoh', prewarp_frequency=None):
diff --git a/control/iosys.py b/control/iosys.py
index 9b33d216..205bfe1f 100644
--- a/control/iosys.py
+++ b/control/iosys.py
@@ -47,13 +47,7 @@ __all__ = ['InputOutputSystem', 'LinearIOSystem', 'NonlinearIOSystem',
            'interconnect', 'summing_junction']
 
 # Define module default parameter values
-_iosys_defaults = {
-    'iosys.state_name_delim': '_',
-    'iosys.duplicate_system_name_prefix': '',
-    'iosys.duplicate_system_name_suffix': '$copy',
-    'iosys.linearized_system_name_prefix': '',
-    'iosys.linearized_system_name_suffix': '$linearized'
-}
+_iosys_defaults = {}
 
 
 class InputOutputSystem(NamedIOSystem):
@@ -515,7 +509,7 @@ class InputOutputSystem(NamedIOSystem):
         return newsys
 
     def linearize(self, x0, u0, t=0, params=None, eps=1e-6,
-                  name=None, copy=False, **kwargs):
+                  name=None, copy_names=False, **kwargs):
         """Linearize an input/output system at a given state and input.
 
         Return the linearization of an input/output system at a given state
@@ -571,25 +565,26 @@ class InputOutputSystem(NamedIOSystem):
 
         # Create the state space system
         linsys = LinearIOSystem(
-            StateSpace(A, B, C, D, self.dt, remove_useless_states=False),
-            name=name, **kwargs)
+            StateSpace(A, B, C, D, self.dt, remove_useless_states=False))
 
-        # Set the names the system, inputs, outputs, and states
-        if copy:
+        # Set the system name, inputs, outputs, and states
+        if 'copy' in kwargs:
+            copy_names = kwargs.pop('copy')
+            warn("keyword 'copy' is deprecated. please use 'copy_names'",
+                DeprecationWarning)
+
+        if copy_names:
+            linsys._copy_names(self)
             if name is None:
                 linsys.name = \
-                    config.defaults['iosys.linearized_system_name_prefix'] + \
-                    self.name + \
-                    config.defaults['iosys.linearized_system_name_suffix']
-            linsys.ninputs, linsys.input_index = self.ninputs, \
-                self.input_index.copy()
-            linsys.noutputs, linsys.output_index = \
-                self.noutputs, self.output_index.copy()
-            linsys.nstates, linsys.state_index = \
-                self.nstates, self.state_index.copy()
-
-        return linsys
+                    config.defaults['namedio.linearized_system_name_prefix']+\
+                    linsys.name+\
+                    config.defaults['namedio.linearized_system_name_suffix']
+            else:
+                linsys.name = name
 
+        # re-init to include desired signal names if names were provided
+        return LinearIOSystem(linsys, **kwargs)
 
 class LinearIOSystem(InputOutputSystem, StateSpace):
     """Input/output representation of a linear (state space) system.
@@ -966,7 +961,7 @@ class InterconnectedSystem(InputOutputSystem):
 
         if states is None:
             states = []
-            state_name_delim = config.defaults['iosys.state_name_delim']
+            state_name_delim = config.defaults['namedio.state_name_delim']
             for sys, sysname in sysobj_name_dct.items():
                 states += [sysname + state_name_delim +
                            statename for statename in sys.state_index.keys()]
@@ -2192,19 +2187,17 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw):
     params : dict, optional
         Parameter values for the systems.  Passed to the evaluation functions
         for the system as default values, overriding internal defaults.
-    copy : bool, Optional
-        If `copy` is True, copy the names of the input signals, output signals,
-        and states to the linearized system.  If `name` is not specified,
-        the system name is set to the input system name with the string
-        '_linearized' appended.
     name : string, optional
         Set the name of the linearized system.  If not specified and
-        if `copy` is `False`, a generic name <sys[id]> is generated
-        with a unique integer id.  If `copy` is `True`, the new system
+        if `copy_names` is `False`, a generic name <sys[id]> is generated
+        with a unique integer id.  If `copy_names` is `True`, the new system
         name is determined by adding the prefix and suffix strings in
-        config.defaults['iosys.linearized_system_name_prefix'] and
-        config.defaults['iosys.linearized_system_name_suffix'], with the
+        config.defaults['namedio.linearized_system_name_prefix'] and
+        config.defaults['namedio.linearized_system_name_suffix'], with the
         default being to add the suffix '$linearized'.
+    copy_names : bool, Optional
+        If True, Copy the names of the input signals, output signals, and
+        states to the linearized system.
 
     Returns
     -------
@@ -2216,7 +2209,7 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw):
     ---------------------
     inputs : int, list of str or None, optional
         Description of the system inputs.  If not specified, the origional
-        system inputs are used.  See :class:`InputOutputSystem` for more
+        system inputs are used.  See :class:`NamedIOSystem` for more
         information.
     outputs : int, list of str or None, optional
         Description of the system outputs.  Same format as `inputs`.
@@ -2728,8 +2721,8 @@ def interconnect(syslist, connections=None, inplist=None, outlist=None,
     If a system is duplicated in the list of systems to be connected,
     a warning is generated and a copy of the system is created with the
     name of the new system determined by adding the prefix and suffix
-    strings in config.defaults['iosys.linearized_system_name_prefix']
-    and config.defaults['iosys.linearized_system_name_suffix'], with the
+    strings in config.defaults['namedio.linearized_system_name_prefix']
+    and config.defaults['namedio.linearized_system_name_suffix'], with the
     default being to add the suffix '$copy'$ to the system name.
 
     It is possible to replace lists in most of arguments with tuples instead,
diff --git a/control/namedio.py b/control/namedio.py
index 254f310f..a94d1a9f 100644
--- a/control/namedio.py
+++ b/control/namedio.py
@@ -6,13 +6,24 @@
 # and other similar classes to allow naming of signals.
 
 import numpy as np
-from copy import copy
+from copy import deepcopy
 from warnings import warn
 from . import config
 
 __all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual',
            'isdtime', 'isctime']
-
+# Define module default parameter values
+_namedio_defaults = {
+    'namedio.state_name_delim': '_',
+    'namedio.duplicate_system_name_prefix': '',
+    'namedio.duplicate_system_name_suffix': '$copy',
+    'namedio.linearized_system_name_prefix': '',
+    'namedio.linearized_system_name_suffix': '$linearized',
+    'namedio.sampled_system_name_prefix': '',
+    'namedio.sampled_system_name_suffix': '$sampled'
+}
+    
+    
 class NamedIOSystem(object):
     def __init__(
             self, name=None, inputs=None, outputs=None, states=None, **kwargs):
@@ -88,26 +99,37 @@ class NamedIOSystem(object):
     def _find_signal(self, name, sigdict):
         return sigdict.get(name, None)
 
+    def _copy_names(self, sys):
+        """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()
+
     def copy(self, name=None, use_prefix_suffix=True):
         """Make a copy of an input/output system
 
         A copy of the system is made, with a new name.  The `name` keyword
         can be used to specify a specific name for the system.  If no name
         is given and `use_prefix_suffix` is True, the name is constructed
-        by prepending config.defaults['iosys.duplicate_system_name_prefix']
-        and appending config.defaults['iosys.duplicate_system_name_suffix'].
+        by prepending config.defaults['namedio.duplicate_system_name_prefix']
+        and appending config.defaults['namedio.duplicate_system_name_suffix'].
         Otherwise, a generic system name of the form `sys[<id>]` is used,
         where `<id>` is based on an internal counter.
 
         """
         # Create a copy of the system
-        newsys = copy(self)
+        newsys = deepcopy(self)
 
         # Update the system name
         if name is None and use_prefix_suffix:
             # Get the default prefix and suffix to use
-            dup_prefix = config.defaults['iosys.duplicate_system_name_prefix']
-            dup_suffix = config.defaults['iosys.duplicate_system_name_suffix']
+            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)
         else:
diff --git a/control/statesp.py b/control/statesp.py
index af549cff..7843cb33 100644
--- a/control/statesp.py
+++ b/control/statesp.py
@@ -63,8 +63,8 @@ from warnings import warn
 from .exception import ControlSlycot
 from .frdata import FrequencyResponseData
 from .lti import LTI, _process_frequency_response
-from .namedio import common_timebase, isdtime
-from .namedio import _process_namedio_keywords
+from .namedio import common_timebase, isdtime, _process_namedio_keywords, \
+    _process_dt_keyword
 from . import config
 from copy import deepcopy
 
@@ -359,7 +359,7 @@ class StateSpace(LTI):
             raise TypeError("unrecognized keyword(s): ", str(kwargs))
 
         # Reset shapes (may not be needed once np.matrix support is removed)
-        if 0 == self.nstates:
+        if self._isstatic():
             # static gain
             # matrix's default "empty" shape is 1x0
             A.shape = (0, 0)
@@ -1298,7 +1298,8 @@ class StateSpace(LTI):
         return StateSpace(self.A, self.B[:, j], self.C[i, :],
                           self.D[i, j], self.dt)
 
-    def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None):
+    def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None,
+               name=None, copy_names=True, **kwargs):
         """Convert a continuous time system to discrete time
 
         Creates a discrete-time system from a continuous-time system by
@@ -1317,22 +1318,42 @@ class StateSpace(LTI):
               alpha=0)
             * backward_diff: Backwards differencing ("gbt" with alpha=1.0)
             * zoh: zero-order hold (default)
-
         alpha : float within [0, 1]
             The generalized bilinear transformation weighting parameter, which
             should only be specified with method="gbt", and is ignored
             otherwise
-
         prewarp_frequency : float within [0, infinity)
             The frequency [rad/s] at which to match with the input continuous-
             time system's magnitude and phase (the gain=1 crossover frequency,
             for example). Should only be specified with method='bilinear' or
             'gbt' with alpha=0.5 and ignored otherwise.
+        name : string, optional
+            Set the name of the sampled system.  If not specified and
+            if `copy_names` is `False`, a generic name <sys[id]> is generated
+            with a unique integer id.  If `copy_names` is `True`, the new system
+            name is determined by adding the prefix and suffix strings in
+            config.defaults['namedio.sampled_system_name_prefix'] and
+            config.defaults['namedio.sampled_system_name_suffix'], with the
+            default being to add the suffix '$sampled'.
+        copy_names : bool, Optional
+            If True, copy the names of the input signals, output
+            signals, and states to the sampled system.
 
         Returns
         -------
         sysd : StateSpace
-            Discrete time system, with sampling rate Ts
+            Discrete-time system, with sampling rate Ts
+
+        Additional Parameters
+        ---------------------
+        inputs : int, list of str or None, optional
+            Description of the system inputs.  If not specified, the origional
+            system inputs are used.  See :class:`InputOutputSystem` for more
+            information.
+        outputs : int, list of str or None, optional
+            Description of the system outputs.  Same format as `inputs`.
+        states : int, list of str, or None, optional
+            Description of the system states.  Same format as `inputs`.
 
         Notes
         -----
@@ -1347,14 +1368,26 @@ class StateSpace(LTI):
         if not self.isctime():
             raise ValueError("System must be continuous time system")
 
-        sys = (self.A, self.B, self.C, self.D)
         if (method == 'bilinear' or (method == 'gbt' and alpha == 0.5)) and \
                 prewarp_frequency is not None:
             Twarp = 2 * np.tan(prewarp_frequency * Ts/2)/prewarp_frequency
         else:
             Twarp = Ts
+        sys = (self.A, self.B, self.C, self.D)
         Ad, Bd, C, D, _ = cont2discrete(sys, Twarp, method, alpha)
-        return StateSpace(Ad, Bd, C, D, Ts)
+        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.name = name
+        # pass desired signal names if names were provided
+        return StateSpace(sysd, **kwargs)
 
     def dcgain(self, warn_infinite=False):
         """Return the zero-frequency gain
diff --git a/control/xferfcn.py b/control/xferfcn.py
index d3671c53..84188a63 100644
--- a/control/xferfcn.py
+++ b/control/xferfcn.py
@@ -1090,7 +1090,8 @@ class TransferFunction(LTI):
 
         return num, den, denorder
 
-    def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None):
+    def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None,
+               name=None, copy_names=True, **kwargs):
         """Convert a continuous-time system to discrete time
 
         Creates a discrete-time system from a continuous-time system by
@@ -1118,11 +1119,31 @@ class TransferFunction(LTI):
             time system's magnitude and phase (the gain=1 crossover frequency,
             for example). Should only be specified with method='bilinear' or
             'gbt' with alpha=0.5 and ignored otherwise.
+        name : string, optional
+            Set the name of the sampled system.  If not specified and
+            if `copy_names` is `False`, a generic name <sys[id]> is generated
+            with a unique integer id.  If `copy_names` is `True`, the new system
+            name is determined by adding the prefix and suffix strings in
+            config.defaults['namedio.sampled_system_name_prefix'] and
+            config.defaults['namedio.sampled_system_name_suffix'], with the
+            default being to add the suffix '$sampled'.
+        copy_names : bool, Optional
+            If True, copy the names of the input signals, output
+            signals, and states to the sampled system.
 
         Returns
         -------
         sysd : TransferFunction system
-            Discrete time system, with sample period Ts
+            Discrete-time system, with sample period Ts
+
+        Additional Parameters
+        ---------------------
+        inputs : int, list of str or None, optional
+            Description of the system inputs.  If not specified, the origional
+            system inputs are used.  See :class:`NamedIOSystem` for more
+            information.
+        outputs : int, list of str or None, optional
+            Description of the system outputs.  Same format as `inputs`.
 
         Notes
         -----
@@ -1149,7 +1170,20 @@ class TransferFunction(LTI):
         else:
             Twarp = Ts
         numd, dend, _ = cont2discrete(sys, Twarp, method, alpha)
-        return TransferFunction(numd[0, :], dend, Ts)
+
+        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.name = name
+        # pass desired signal names if names were provided
+        return TransferFunction(sysd, name=name, **kwargs)
 
     def dcgain(self, warn_infinite=False):
         """Return the zero-frequency (or DC) gain


Test patch:
diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py
index cb0ce3c7..4cf28a21 100644
--- a/control/tests/discrete_test.py
+++ b/control/tests/discrete_test.py
@@ -446,3 +446,59 @@ class TestDiscrete:
         np.testing.assert_array_almost_equal(omega, omega_out)
         np.testing.assert_array_almost_equal(mag_out, np.absolute(H_z))
         np.testing.assert_array_almost_equal(phase_out, np.angle(H_z))
+    
+    def test_signal_names(self, tsys):
+        "test that signal names are preserved in conversion to discrete-time"
+        ssc = StateSpace(tsys.siso_ss1c, 
+            inputs='u', outputs='y', states=['a', 'b', 'c']) 
+        ssd = ssc.sample(0.1)
+        tfc = TransferFunction(tsys.siso_tf1c, inputs='u', outputs='y')
+        tfd = tfc.sample(0.1)
+        assert ssd.input_labels == ['u']
+        assert ssd.state_labels == ['a', 'b', 'c']
+        assert ssd.output_labels == ['y']
+        assert tfd.input_labels == ['u']
+        assert tfd.output_labels == ['y']
+
+        ssd = sample_system(ssc, 0.1)
+        tfd = sample_system(tfc, 0.1)
+        assert ssd.input_labels == ['u']
+        assert ssd.state_labels == ['a', 'b', 'c']
+        assert ssd.output_labels == ['y']
+        assert tfd.input_labels == ['u']
+        assert tfd.output_labels == ['y']
+    
+        # system names and signal name override
+        sysc = StateSpace(1.1, 1, 1, 1, inputs='u', outputs='y', states='a')
+
+        sysd = sample_system(sysc, 0.1, name='sampled')
+        assert sysd.name == 'sampled'
+        assert sysd.find_input('u') == 0
+        assert sysd.find_output('y') == 0
+        assert sysd.find_state('a') == 0
+
+        # If we copy signal names w/out a system name, append '$sampled'
+        sysd = sample_system(sysc, 0.1)
+        assert sysd.name == sysc.name + '$sampled'
+
+        # If copy is False, signal names should not be copied
+        sysd_nocopy = sample_system(sysc, 0.1, copy_names=False)
+        assert sysd_nocopy.find_input('u') is None
+        assert sysd_nocopy.find_output('y') is None
+        assert sysd_nocopy.find_state('a') is None
+
+        # if signal names are provided, they should override those of sysc
+        sysd_newnames = sample_system(sysc, 0.1, 
+            inputs='v', outputs='x', states='b')
+        assert sysd_newnames.find_input('v') == 0
+        assert sysd_newnames.find_input('u') is None
+        assert sysd_newnames.find_output('x') == 0
+        assert sysd_newnames.find_output('y') is None
+        assert sysd_newnames.find_state('b') == 0
+        assert sysd_newnames.find_state('a') is None        
+        # test just one name
+        sysd_newnames = sample_system(sysc, 0.1, inputs='v')
+        assert sysd_newnames.find_input('v') == 0
+        assert sysd_newnames.find_input('u') is None
+        assert sysd_newnames.find_output('y') == 0
+        assert sysd_newnames.find_output('x') is None
diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py
index 7d3b9fee..cc9c3e72 100644
--- a/control/tests/iosys_test.py
+++ b/control/tests/iosys_test.py
@@ -216,7 +216,7 @@ class TestIOSys:
     @pytest.mark.usefixtures("editsdefaults")
     def test_linearize_named_signals(self, kincar):
         # Full form of the call
-        linearized = kincar.linearize([0, 0, 0], [0, 0], copy=True,
+        linearized = kincar.linearize([0, 0, 0], [0, 0], copy_names=True,
                                       name='linearized')
         assert linearized.name == 'linearized'
         assert linearized.find_input('v') == 0
@@ -228,21 +228,35 @@ class TestIOSys:
         assert linearized.find_state('theta') == 2
 
         # If we copy signal names w/out a system name, append '$linearized'
-        linearized = kincar.linearize([0, 0, 0], [0, 0], copy=True)
+        linearized = kincar.linearize([0, 0, 0], [0, 0], copy_names=True)
         assert linearized.name == kincar.name + '$linearized'
 
-        # Test legacy version as well
-        ct.use_legacy_defaults('0.8.4')
-        ct.config.use_numpy_matrix(False)       # np.matrix deprecated
-        linearized = kincar.linearize([0, 0, 0], [0, 0], copy=True)
-        assert linearized.name == kincar.name + '_linearized'
-
         # If copy is False, signal names should not be copied
-        lin_nocopy = kincar.linearize(0, 0, copy=False)
+        lin_nocopy = kincar.linearize(0, 0, copy_names=False)
         assert lin_nocopy.find_input('v') is None
         assert lin_nocopy.find_output('x') is None
         assert lin_nocopy.find_state('x') is None
 
+        # if signal names are provided, they should override those of kincar
+        linearized_newnames = kincar.linearize([0, 0, 0], [0, 0], 
+            name='linearized',
+            copy_names=True, inputs=['v2', 'phi2'], outputs=['x2','y2'])
+        assert linearized_newnames.name == 'linearized'
+        assert linearized_newnames.find_input('v2') == 0
+        assert linearized_newnames.find_input('phi2') == 1
+        assert linearized_newnames.find_input('v') is None
+        assert linearized_newnames.find_input('phi') is None
+        assert linearized_newnames.find_output('x2') == 0
+        assert linearized_newnames.find_output('y2') == 1
+        assert linearized_newnames.find_output('x') is None
+        assert linearized_newnames.find_output('y') is None
+
+        # Test legacy version as well
+        ct.use_legacy_defaults('0.8.4')
+        ct.config.use_numpy_matrix(False)       # np.matrix deprecated
+        linearized = kincar.linearize([0, 0, 0], [0, 0], copy_names=True)
+        assert linearized.name == kincar.name + '_linearized'
+
     def test_connect(self, tsys):
         # Define a couple of (linear) systems to interconnection
         linsys1 = tsys.siso_linsys
diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py
index 2dc7f056..20a1c8e9 100644
--- a/control/tests/kwargs_test.py
+++ b/control/tests/kwargs_test.py
@@ -183,6 +183,7 @@ kwarg_unittest = {
     'tf': test_unrecognized_kwargs,
     'tf2io' : test_unrecognized_kwargs,
     'tf2ss' : test_unrecognized_kwargs,
+    'sample_system' : test_unrecognized_kwargs,
     'flatsys.point_to_point':
         flatsys_test.TestFlatSys.test_point_to_point_errors,
     'flatsys.solve_flat_ocp':
@@ -198,8 +199,10 @@ kwarg_unittest = {
     'NonlinearIOSystem.__init__':
         interconnect_test.test_interconnect_exceptions,
     'StateSpace.__init__': test_unrecognized_kwargs,
+    'StateSpace.sample': test_unrecognized_kwargs, 
     'TimeResponseData.__call__': trdata_test.test_response_copy,
     'TransferFunction.__init__': test_unrecognized_kwargs,
+    'TransferFunction.sample': test_unrecognized_kwargs, 
 }
 
 #
diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py
index 6fcdade2..41f0c893 100644
--- a/control/tests/statesp_test.py
+++ b/control/tests/statesp_test.py
@@ -820,8 +820,42 @@ class TestStateSpace:
             sys222.dynamics(0, (1, 1), u)
         with pytest.raises(ValueError):
             sys222.output(0, (1, 1), u)
-
-
+    
+    def test_sample_named_signals(self):
+        sysc = ct.StateSpace(1.1, 1, 1, 1, inputs='u', outputs='y', states='a')
+
+        # Full form of the call
+        sysd = sysc.sample(0.1, name='sampled')
+        assert sysd.name == 'sampled'
+        assert sysd.find_input('u') == 0
+        assert sysd.find_output('y') == 0
+        assert sysd.find_state('a') == 0
+
+        # If we copy signal names w/out a system name, append '$sampled'
+        sysd = sysc.sample(0.1)
+        assert sysd.name == sysc.name + '$sampled'
+
+        # If copy is False, signal names should not be copied
+        sysd_nocopy = sysc.sample(0.1, copy_names=False)
+        assert sysd_nocopy.find_input('u') is None
+        assert sysd_nocopy.find_output('y') is None
+        assert sysd_nocopy.find_state('a') is None
+
+        # if signal names are provided, they should override those of sysc
+        sysd_newnames = sysc.sample(0.1, inputs='v', outputs='x', states='b')
+        assert sysd_newnames.find_input('v') == 0
+        assert sysd_newnames.find_input('u') is None
+        assert sysd_newnames.find_output('x') == 0
+        assert sysd_newnames.find_output('y') is None
+        assert sysd_newnames.find_state('b') == 0
+        assert sysd_newnames.find_state('a') is None        
+        # test just one name
+        sysd_newnames = sysc.sample(0.1, inputs='v')
+        assert sysd_newnames.find_input('v') == 0
+        assert sysd_newnames.find_input('u') is None
+        assert sysd_newnames.find_output('y') == 0
+        assert sysd_newnames.find_output('x') is None
+        
 class TestRss:
     """These are tests for the proper functionality of statesp.rss."""
 
@@ -1164,3 +1198,5 @@ def test_params_warning():
 
     with pytest.warns(UserWarning, match="params keyword ignored"):
         sys.output(0, [0], [0], {'k': 5})
+
+
diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py
index 894da559..e4a2b3ec 100644
--- a/control/tests/xferfcn_test.py
+++ b/control/tests/xferfcn_test.py
@@ -986,6 +986,37 @@ class TestXferFcn:
                 np.testing.assert_array_almost_equal(H.num[p][m], H2.num[p][m])
                 np.testing.assert_array_almost_equal(H.den[p][m], H2.den[p][m])
             assert H.dt == H2.dt
+    
+    def test_sample_named_signals(self):
+        sysc = ct.TransferFunction(1.1, (1, 2), inputs='u', outputs='y')
+
+        # Full form of the call
+        sysd = sysc.sample(0.1, name='sampled')
+        assert sysd.name == 'sampled'
+        assert sysd.find_input('u') == 0
+        assert sysd.find_output('y') == 0
+
+        # If we copy signal names w/out a system name, append '$sampled'
+        sysd = sysc.sample(0.1)
+        assert sysd.name == sysc.name + '$sampled'
+
+        # If copy is False, signal names should not be copied
+        sysd_nocopy = sysc.sample(0.1, copy_names=False)
+        assert sysd_nocopy.find_input('u') is None
+        assert sysd_nocopy.find_output('y') is None
+
+        # if signal names are provided, they should override those of sysc
+        sysd_newnames = sysc.sample(0.1, inputs='v', outputs='x')
+        assert sysd_newnames.find_input('v') == 0
+        assert sysd_newnames.find_input('u') is None
+        assert sysd_newnames.find_output('x') == 0
+        assert sysd_newnames.find_output('y') is None
+        # test just one name
+        sysd_newnames = sysc.sample(0.1, inputs='v')
+        assert sysd_newnames.find_input('v') == 0
+        assert sysd_newnames.find_input('u') is None
+        assert sysd_newnames.find_output('y') == 0
+        assert sysd_newnames.find_output('x') is None
 
 
 class TestLTIConverter: