Repository: python-control/python-control
Pull request #1069 (2024-12-01 20:47:22)

Allow signal names to be used for time/freq responses and subsystem indexing
This PR adds the ability to access response signals and subsystem inputs and outputs using signal names.  As described in the documentation:

Time signal indexing:
> The input, output, and state elements of the response can be access using signal names in place of integer offsets::
> 
>     plt.plot(response. time, response.states['x[1]']
> 
> For multi-trace systems generated by :func:`step_response` and :func:`impulse_response`, the input name used to generate the trace can be used to access the appropriate input output pair::
> 
>     plt.plot(response.time, response.outputs['y[0]', 'u[1]'])

Frequency response indexing:
> Frequency response objects are also available as named properties of the `response` object: `response.magnitude`, `response.phase`, and `response.response` (for the complex response).  For MIMO systems, these elements of the frequency response can be accessed using the names of the inputs and outputs::
> 
>     response.magnitude['y[0]', 'u[1]']
> 
> where the signal names are based on the system that generated the frequency response.

Subsystem indexing:
> Subsets of input/output pairs for LTI systems can be obtained by indexing the system using either numerical indices (including slices) or signal names::
> 
>     subsys = sys[[0, 2], 0:2]
>     subsys = sys[['y[0]', 'y[2]'], ['u[0]', 'u[1]']]
> 
> Signal names for an indexed subsystem are preserved from the original system and the subsystem name is set according to the values of `control.config.defaults['iosys.indexed_system_name_prefix'] and `control.config.defaults['iosys.indexed_system_name_suffix']`.  The default subsystem name is the original system name with '$indexed' appended.

Summary of changes:
* Signals are now returned as a `NamedSignal` object, which is a subclass of `np.ndarray` that overrides the ``__getitem__`` method to allow processing of signal names via the ``_parse_key`` method.
* All signal/subsystem indexing now allows integers, strings (new), lists of integers or strings (new), or slices.
* Refactored code for processing signal names to make everything consistent across time and frequency domain signals as well as state and input/output vectors.  See `iosys.NamedSignal._parse_key` and changes in `frdata.py` and `timeresp.py`.
* Refactored code for processing subsystem indices and made index processing consistent for `StateSpace`, `TransferFunction`, and `FrequencyResponseData` systems.  See `iosys._process_subsys_index` and changes in `frdata.py`, `statesp.py`, and `xferfcn.py`.
* Unit tests, docstrings, and user documentation updated to cover new functiionality.


Patch:
diff --git a/control/exception.py b/control/exception.py
index e4758cc4..add5d01a 100644
--- a/control/exception.py
+++ b/control/exception.py
@@ -52,6 +52,10 @@ class ControlArgument(TypeError):
     """Raised when arguments to a function are not correct"""
     pass
 
+class ControlIndexError(IndexError):
+    """Raised when arguments to an indexed object are not correct"""
+    pass
+
 class ControlMIMONotImplemented(NotImplementedError):
     """Function is not currently implemented for MIMO systems"""
     pass
diff --git a/control/frdata.py b/control/frdata.py
index 42ecee0d..1bdf2852 100644
--- a/control/frdata.py
+++ b/control/frdata.py
@@ -10,6 +10,7 @@ This module contains the FRD class and also functions that operate on
 FRD data.
 """
 
+from collections.abc import Iterable
 from copy import copy
 from warnings import warn
 
@@ -20,7 +21,8 @@ from scipy.interpolate import splev, splprep
 
 from . import config
 from .exception import pandas_check
-from .iosys import InputOutputSystem, _process_iosys_keywords, common_timebase
+from .iosys import InputOutputSystem, NamedSignal, _process_iosys_keywords, \
+    _process_subsys_index, common_timebase
 from .lti import LTI, _process_frequency_response
 
 __all__ = ['FrequencyResponseData', 'FRD', 'frd']
@@ -33,8 +35,8 @@ class FrequencyResponseData(LTI):
 
     The FrequencyResponseData (FRD) class is used to represent systems in
     frequency response data form.  It can be created manually using the
-    class constructor, using the :func:~~control.frd` factory function
-    (preferred), or via the :func:`~control.frequency_response` function.
+    class constructor, using the :func:`~control.frd` factory function or
+    via the :func:`~control.frequency_response` function.
 
     Parameters
     ----------
@@ -65,6 +67,28 @@ class FrequencyResponseData(LTI):
         frequency point.
     dt : float, True, or None
         System timebase.
+    squeeze : bool
+        By default, if a system is single-input, single-output (SISO) then
+        the outputs (and inputs) are returned as a 1D array (indexed by
+        frequency) and if a system is multi-input or multi-output, then the
+        outputs are returned as a 2D array (indexed by output and
+        frequency) or a 3D array (indexed by output, trace, and frequency).
+        If ``squeeze=True``, access to the output response will remove
+        single-dimensional entries from the shape of the inputs and outputs
+        even if the system is not SISO. If ``squeeze=False``, the output is
+        returned as a 3D array (indexed by the output, input, and
+        frequency) even if the system is SISO. The default value can be set
+        using config.defaults['control.squeeze_frequency_response'].
+    ninputs, noutputs, nstates : int
+        Number of inputs, outputs, and states of the underlying system.
+    input_labels, output_labels : array of str
+        Names for the input and output variables.
+    sysname : str, optional
+        Name of the system.  For data generated using
+        :func:`~control.frequency_response`, stores the name of the system
+        that created the data.
+    title : str, optional
+        Set the title to use when plotting.
 
     See Also
     --------
@@ -89,6 +113,20 @@ class FrequencyResponseData(LTI):
     the imaginary access).  See :meth:`~control.FrequencyResponseData.__call__`
     for a more detailed description.
 
+    A state space system is callable and returns the value of the transfer
+    function evaluated at a point in the complex plane.  See
+    :meth:`~control.StateSpace.__call__` for a more detailed description.
+
+    Subsystem response corresponding to selected input/output pairs can be
+    created by indexing the frequency response data object::
+
+        subsys = sys[output_spec, input_spec]
+
+    The input and output specifications can be single integers, lists of
+    integers, or slices.  In addition, the strings representing the names
+    of the signals can be used and will be replaced with the equivalent
+    signal offsets.
+
     """
     #
     # Class attributes
@@ -243,21 +281,72 @@ class FrequencyResponseData(LTI):
 
     @property
     def magnitude(self):
-        return np.abs(self.fresp)
+        """Magnitude of the frequency response.
+
+        Magnitude of the frequency response, indexed by either the output
+        and frequency (if only a single input is given) or the output,
+        input, and frequency (for multi-input systems).  See
+        :attr:`FrequencyResponseData.squeeze` for a description of how this
+        can be modified using the `squeeze` keyword.
+
+        Input and output signal names can be used to index the data in
+        place of integer offsets.
+
+        :type: 1D, 2D, or 3D array
+
+        """
+        return NamedSignal(
+            np.abs(self.fresp), self.output_labels, self.input_labels)
 
     @property
     def phase(self):
-        return np.angle(self.fresp)
+        """Phase of the frequency response.
+
+        Phase of the frequency response in radians/sec, indexed by either
+        the output and frequency (if only a single input is given) or the
+        output, input, and frequency (for multi-input systems).  See
+        :attr:`FrequencyResponseData.squeeze` for a description of how this
+        can be modified using the `squeeze` keyword.
+
+        Input and output signal names can be used to index the data in
+        place of integer offsets.
+
+        :type: 1D, 2D, or 3D array
+
+        """
+        return NamedSignal(
+            np.angle(self.fresp), self.output_labels, self.input_labels)
 
     @property
     def frequency(self):
+        """Frequencies at which the response is evaluated.
+
+        :type: 1D array
+
+        """
         return self.omega
 
     @property
     def response(self):
-        return self.fresp
+        """Complex value of the frequency response.
+
+        Value of the frequency response as a complex number, indexed by
+        either the output and frequency (if only a single input is given)
+        or the output, input, and frequency (for multi-input systems).  See
+        :attr:`FrequencyResponseData.squeeze` for a description of how this
+        can be modified using the `squeeze` keyword.
+
+        Input and output signal names can be used to index the data in
+        place of integer offsets.
+
+        :type: 1D, 2D, or 3D array
+
+        """
+        return NamedSignal(
+            self.fresp, self.output_labels, self.input_labels)
 
     def __str__(self):
+
         """String representation of the transfer function."""
 
         mimo = self.ninputs > 1 or self.noutputs > 1
@@ -593,9 +682,25 @@ class FrequencyResponseData(LTI):
             return iter((self.omega, fresp))
         return iter((np.abs(fresp), np.angle(fresp), self.omega))
 
-    # Implement (thin) getitem to allow access via legacy indexing
-    def __getitem__(self, index):
-        return list(self.__iter__())[index]
+    def __getitem__(self, key):
+        if not isinstance(key, Iterable) or len(key) != 2:
+            # Implement (thin) getitem to allow access via legacy indexing
+            return list(self.__iter__())[key]
+
+        # Convert signal names to integer offsets (via NamedSignal object)
+        iomap = NamedSignal(
+            self.fresp[:, :, 0], self.output_labels, self.input_labels)
+        indices = iomap._parse_key(key, level=1)  # ignore index checks
+        outdx, outputs = _process_subsys_index(indices[0], self.output_labels)
+        inpdx, inputs = _process_subsys_index(indices[1], self.input_labels)
+
+        # Create the system name
+        sysname = config.defaults['iosys.indexed_system_name_prefix'] + \
+            self.name + config.defaults['iosys.indexed_system_name_suffix']
+
+        return FrequencyResponseData(
+            self.fresp[outdx, :][:, inpdx], self.omega, self.dt,
+            inputs=inputs, outputs=outputs, name=sysname)
 
     # Implement (thin) len to emulate legacy testing interface
     def __len__(self):
diff --git a/control/iosys.py b/control/iosys.py
index 9092b672..dd1566eb 100644
--- a/control/iosys.py
+++ b/control/iosys.py
@@ -13,9 +13,10 @@ from warnings import warn
 import numpy as np
 
 from . import config
+from .exception import ControlIndexError
 
-__all__ = ['InputOutputSystem', 'issiso', 'timebase', 'common_timebase',
-           'isdtime', 'isctime']
+__all__ = ['InputOutputSystem', 'NamedSignal', 'issiso', 'timebase',
+           'common_timebase', 'isdtime', 'isctime']
 
 # Define module default parameter values
 _iosys_defaults = {
@@ -33,6 +34,69 @@ _iosys_defaults = {
 }
 
 
+# Named signal class
+class NamedSignal(np.ndarray):
+    def __new__(cls, input_array, signal_labels=None, trace_labels=None):
+        # See https://numpy.org/doc/stable/user/basics.subclassing.html
+        obj = np.asarray(input_array).view(cls)     # Cast to our class type
+        obj.signal_labels = signal_labels           # Save signal labels
+        obj.trace_labels = trace_labels             # Save trace labels
+        obj.data_shape = input_array.shape          # Save data shape
+        return obj                                  # Return new object
+
+    def __array_finalize__(self, obj):
+        # See https://numpy.org/doc/stable/user/basics.subclassing.html
+        if obj is None:
+            return
+        self.signal_labels = getattr(obj, 'signal_labels', None)
+        self.trace_labels = getattr(obj, 'trace_labels', None)
+        self.data_shape = getattr(obj, 'data_shape', None)
+
+    def _parse_key(self, key, labels=None, level=0):
+        if labels is None:
+            labels = self.signal_labels
+        try:
+            if isinstance(key, str):
+                key = labels.index(item := key)
+                if level == 0 and len(self.data_shape) < 2:
+                    raise ControlIndexError
+            elif isinstance(key, list):
+                keylist = []
+                for item in key:        # use for loop to save item for error
+                    keylist.append(
+                        self._parse_key(item, labels=labels, level=level+1))
+                if level == 0 and key != keylist and len(self.data_shape) < 2:
+                    raise ControlIndexError
+                key = keylist
+            elif isinstance(key, tuple) and len(key) > 0:
+                keylist = []
+                keylist.append(
+                    self._parse_key(
+                        item := key[0], labels=self.signal_labels,
+                        level=level+1))
+                if len(key) > 1:
+                    keylist.append(
+                        self._parse_key(
+                            item := key[1], labels=self.trace_labels,
+                            level=level+1))
+                if level == 0 and key[:len(keylist)] != tuple(keylist) \
+                   and len(keylist) > len(self.data_shape) - 1:
+                    raise ControlIndexError
+                for i in range(2, len(key)):
+                    keylist.append(key[i])      # pass on remaining elements
+                key = tuple(keylist)
+        except ValueError:
+            raise ValueError(f"unknown signal name '{item}'")
+        except ControlIndexError:
+            raise ControlIndexError(
+                "signal name(s) not valid for squeezed data")
+
+        return key
+
+    def __getitem__(self, key):
+        return super().__getitem__(self._parse_key(key))
+
+
 class InputOutputSystem(object):
     """A class for representing input/output systems.
 
@@ -965,3 +1029,31 @@ def _parse_spec(syslist, spec, signame, dictname=None):
             ValueError(f"signal index '{index}' is out of range")
 
     return system_index, signal_indices, gain
+
+
+#
+# Utility function for processing subsystem indices
+#
+# This function processes an index specification (int, list, or slice) and
+# returns a index specification that can be used to create a subsystem
+#
+def _process_subsys_index(idx, sys_labels, slice_to_list=False):
+    if not isinstance(idx, (slice, list, int)):
+        raise TypeError("system indices must be integers, slices, or lists")
+
+    # Convert singleton lists to integers for proper slicing (below)
+    if isinstance(idx, (list, tuple)) and len(idx) == 1:
+        idx = idx[0]
+
+    # Convert int to slice so that numpy doesn't drop dimension
+    if isinstance(idx, int):
+        idx = slice(idx, idx+1, 1)
+
+    # Get label names (taking care of possibility that we were passed a list)
+    labels = [sys_labels[i] for i in idx] if isinstance(idx, list) \
+        else sys_labels[idx]
+
+    if slice_to_list and isinstance(idx, slice):
+        idx = range(len(sys_labels))[idx]
+
+    return idx, labels
diff --git a/control/nlsys.py b/control/nlsys.py
index 835c16ef..62e4bf78 100644
--- a/control/nlsys.py
+++ b/control/nlsys.py
@@ -193,8 +193,8 @@ class NonlinearIOSystem(InputOutputSystem):
 
         # Evaluate the function on the argument
         out = sys._out(0, np.array((0,)), np.asarray(u))
-        _, out = _process_time_response(
-            None, out, issiso=sys.issiso(), squeeze=squeeze)
+        out = _process_time_response(
+            out, issiso=sys.issiso(), squeeze=squeeze)
         return out
 
     def __mul__(self, other):
diff --git a/control/statesp.py b/control/statesp.py
index aa1c7221..bfe5f996 100644
--- a/control/statesp.py
+++ b/control/statesp.py
@@ -48,15 +48,15 @@ $Id$
 """
 
 import math
+from collections.abc import Iterable
 from copy import deepcopy
 from warnings import warn
-from collections.abc import Iterable
 
 import numpy as np
 import scipy as sp
 import scipy.linalg
-from numpy import (any, asarray, concatenate, cos, delete, empty, exp, eye,
-                   isinf, ones, pad, sin, squeeze, zeros)
+from numpy import any, asarray, concatenate, cos, delete, empty, exp, eye, \
+    isinf, ones, pad, sin, squeeze, zeros
 from numpy.linalg import LinAlgError, eigvals, matrix_rank, solve
 from numpy.random import rand, randn
 from scipy.signal import StateSpace as signalStateSpace
@@ -65,9 +65,9 @@ from scipy.signal import cont2discrete
 from . import config
 from .exception import ControlMIMONotImplemented, ControlSlycot, slycot_check
 from .frdata import FrequencyResponseData
-from .iosys import (InputOutputSystem, _process_dt_keyword,
-                    _process_iosys_keywords, _process_signal_list,
-                    common_timebase, isdtime, issiso)
+from .iosys import InputOutputSystem, NamedSignal, _process_dt_keyword, \
+    _process_iosys_keywords, _process_signal_list, _process_subsys_index, \
+    common_timebase, isdtime, issiso
 from .lti import LTI, _process_frequency_response
 from .nlsys import InterconnectedSystem, NonlinearIOSystem
 
@@ -153,6 +153,17 @@ class StateSpace(NonlinearIOSystem, LTI):
     function evaluated at a point in the complex plane.  See
     :meth:`~control.StateSpace.__call__` for a more detailed description.
 
+    Subsystems corresponding to selected input/output pairs can be
+    created by indexing the state space system::
+
+        subsys = sys[output_spec, input_spec]
+
+    The input and output specifications can be single integers, lists of
+    integers, or slices.  In addition, the strings representing the names
+    of the signals can be used and will be replaced with the equivalent
+    signal offsets.  The subsystem is created by truncating the inputs and
+    outputs, but leaving the full set of system states.
+
     StateSpace instances have support for IPython LaTeX output,
     intended for pretty-printing in Jupyter notebooks.  The LaTeX
     output can be configured using
@@ -1214,25 +1225,25 @@ class StateSpace(NonlinearIOSystem, LTI):
         D[self.noutputs:, self.ninputs:] = other.D
         return StateSpace(A, B, C, D, self.dt)
 
-    def __getitem__(self, indices):
+    def __getitem__(self, key):
         """Array style access"""
-        if not isinstance(indices, Iterable) or len(indices) != 2:
-            raise IOError('must provide indices of length 2 for state space')
-        outdx, inpdx = indices
-        
-        # Convert int to slice to ensure that numpy doesn't drop the dimension
-        if isinstance(outdx, int): outdx = slice(outdx, outdx+1, 1)
-        if isinstance(inpdx, int): inpdx = slice(inpdx, inpdx+1, 1)
+        if not isinstance(key, Iterable) or len(key) != 2:
+            raise IOError("must provide indices of length 2 for state space")
 
-        if not isinstance(outdx, slice) or not isinstance(inpdx, slice):
-            raise TypeError(f"system indices must be integers or slices")
+        # Convert signal names to integer offsets
+        iomap = NamedSignal(self.D, self.output_labels, self.input_labels)
+        indices = iomap._parse_key(key, level=1)  # ignore index checks
+        outdx, output_labels = _process_subsys_index(
+            indices[0], self.output_labels)
+        inpdx, input_labels = _process_subsys_index(
+            indices[1], self.input_labels)
 
         sysname = config.defaults['iosys.indexed_system_name_prefix'] + \
             self.name + config.defaults['iosys.indexed_system_name_suffix']
         return StateSpace(
-            self.A, self.B[:, inpdx], self.C[outdx, :], self.D[outdx, inpdx],
-            self.dt, name=sysname, 
-            inputs=self.input_labels[inpdx], outputs=self.output_labels[outdx])
+            self.A, self.B[:, inpdx], self.C[outdx, :],
+            self.D[outdx, :][:, inpdx], self.dt,
+            name=sysname, inputs=input_labels, outputs=output_labels)
 
     def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None,
                name=None, copy_names=True, **kwargs):
diff --git a/control/timeresp.py b/control/timeresp.py
index 5813c166..072db60d 100644
--- a/control/timeresp.py
+++ b/control/timeresp.py
@@ -80,7 +80,7 @@ from scipy.linalg import eig, eigvals, matrix_balance, norm
 
 from . import config
 from .exception import pandas_check
-from .iosys import isctime, isdtime
+from .iosys import NamedSignal, isctime, isdtime
 from .timeplot import time_response_plot
 
 __all__ = ['forced_response', 'step_response', 'step_info',
@@ -204,34 +204,46 @@ class TimeResponseData:
 
     Notes
     -----
-    1. For backward compatibility with earlier versions of python-control,
-       this class has an ``__iter__`` method that allows it to be assigned
-       to a tuple with a variable number of elements.  This allows the
-       following patterns to work:
+    The responses for individual elements of the time response can be
+    accessed using integers, slices, or lists of signal offsets or the
+    names of the appropriate signals::
 
-         t, y = step_response(sys)
-         t, y, x = step_response(sys, return_x=True)
+      sys = ct.rss(4, 2, 1)
+      resp = ct.initial_response(sys, X0=[1, 1, 1, 1])
+      plt.plot(resp.time, resp.outputs['y[0]'])
 
-       When using this (legacy) interface, the state vector is not affected by
-       the `squeeze` parameter.
+    In the case of multi-trace data, the responses should be indexed using
+    the output signal name (or offset) and the input signal name (or
+    offset)::
 
-    2. For backward compatibility with earlier version of python-control,
-       this class has ``__getitem__`` and ``__len__`` methods that allow the
-       return value to be indexed:
+      sys = ct.rss(4, 2, 2, strictly_proper=True)
+      resp = ct.step_response(sys)
+      plt.plot(resp.time, resp.outputs[['y[0]', 'y[1]'], 'u[0]'].T)
 
-         response[0]: returns the time vector
-         response[1]: returns the output vector
-         response[2]: returns the state vector
+    For backward compatibility with earlier versions of python-control,
+    this class has an ``__iter__`` method that allows it to be assigned to
+    a tuple with a variable number of elements.  This allows the following
+    patterns to work::
 
-       When using this (legacy) interface, the state vector is not affected by
-       the `squeeze` parameter.
+       t, y = step_response(sys)
+       t, y, x = step_response(sys, return_x=True)
 
-    3. The default settings for ``return_x``, ``squeeze`` and ``transpose``
-       can be changed by calling the class instance and passing new values:
+    Similarly, the class has ``__getitem__`` and ``__len__`` methods that
+    allow the return value to be indexed:
+
+    * response[0]: returns the time vector
+    * response[1]: returns the output vector
+    * response[2]: returns the state vector
+
+    When using this (legacy) interface, the state vector is not affected
+    by the `squeeze` parameter.
+
+    The default settings for ``return_x``, ``squeeze`` and ``transpose``
+    can be changed by calling the class instance and passing new values::
 
          response(tranpose=True).input
 
-       See :meth:`TimeResponseData.__call__` for more information.
+    See :meth:`TimeResponseData.__call__` for more information.
 
     """
 
@@ -564,13 +576,18 @@ class TimeResponseData:
         (for multiple traces).  See :attr:`TimeResponseData.squeeze` for a
         description of how this can be modified using the `squeeze` keyword.
 
+        Input and output signal names can be used to index the data in
+        place of integer offsets, with the input signal names being used to
+        access multi-input data.
+
         :type: 1D, 2D, or 3D array
 
         """
-        t, y = _process_time_response(
-            self.t, self.y, issiso=self.issiso,
+        # TODO: move to __init__ to avoid recomputing each time?
+        y = _process_time_response(
+            self.y, issiso=self.issiso,
             transpose=self.transpose, squeeze=self.squeeze)
-        return y
+        return NamedSignal(y, self.output_labels, self.input_labels)
 
     # Getter for states (implements squeeze processing)
     @property
@@ -583,30 +600,25 @@ class TimeResponseData:
         for a description of how this can be modified using the `squeeze`
         keyword.
 
+        Input and output signal names can be used to index the data in
+        place of integer offsets, with the input signal names being used to
+        access multi-input data.
+
         :type: 2D or 3D array
 
         """
-        if self.x is None:
-            return None
-
-        elif self.squeeze is True:
-            x = self.x.squeeze()
+        # TODO: move to __init__ to avoid recomputing each time?
+        x = _process_time_response(
+            self.x, transpose=self.transpose,
+            squeeze=self.squeeze, issiso=False)
 
-        elif self.ninputs == 1 and self.noutputs == 1 and \
-             self.ntraces == 1 and self.x.ndim == 3 and \
+        # Special processing for SISO case: always retain state index
+        if self.issiso and self.ntraces == 1 and x.ndim == 3 and \
              self.squeeze is not False:
             # Single-input, single-output system with single trace
-            x = self.x[:, 0, :]
+            x = x[:, 0, :]
 
-        else:
-            # Return the full set of data
-            x = self.x
-
-        # Transpose processing
-        if self.transpose:
-            x = np.transpose(x, np.roll(range(x.ndim), 1))
-
-        return x
+        return NamedSignal(x, self.state_labels, self.input_labels)
 
     # Getter for inputs (implements squeeze processing)
     @property
@@ -621,6 +633,10 @@ class TimeResponseData:
         the two.  If a 3D vector is passed, then it represents a multi-trace,
         multi-input signal, indexed by input, trace, and time.
 
+        Input and output signal names can be used to index the data in
+        place of integer offsets, with the input signal names being used to
+        access multi-input data.
+
         See :attr:`TimeResponseData.squeeze` for a description of how the
         dimensions of the input vector can be modified using the `squeeze`
         keyword.
@@ -628,15 +644,17 @@ class TimeResponseData:
         :type: 1D or 2D array
 
         """
+        # TODO: move to __init__ to avoid recomputing each time?
         if self.u is None:
             return None
 
-        t, u = _process_time_response(
-            self.t, self.u, issiso=self.issiso,
+        u = _process_time_response(
+            self.u, issiso=self.issiso,
             transpose=self.transpose, squeeze=self.squeeze)
-        return u
+        return NamedSignal(u, self.input_labels, self.input_labels)
 
     # Getter for legacy state (implements non-standard squeeze processing)
+    # TODO: remove when no longer needed
     @property
     def _legacy_states(self):
         """Time response state vector (legacy version).
@@ -1265,7 +1283,7 @@ def forced_response(sysdata, T=None, U=0., X0=0., transpose=False, params=None,
 
 # Process time responses in a uniform way
 def _process_time_response(
-        tout, yout, issiso=False, transpose=None, squeeze=None):
+        signal, issiso=False, transpose=None, squeeze=None):
     """Process time response signals.
 
     This function processes the outputs (or inputs) of time response
@@ -1273,43 +1291,36 @@ def _process_time_response(
 
     Parameters
     ----------
-    T : 1D array
-        Time values of the output.  Ignored if None.
-
-    yout : ndarray
-        Response of the system.  This can either be a 1D array indexed by time
-        (for SISO systems), a 2D array indexed by output and time (for MIMO
-        systems with no input indexing, such as initial_response or forced
-        response) or a 3D array indexed by output, input, and time.
+    signal : ndarray
+        Data to be processed.  This can either be a 1D array indexed by
+        time (for SISO systems), a 2D array indexed by output and time (for
+        MIMO systems with no input indexing, such as initial_response or
+        forced response) or a 3D array indexed by output, input, and time.
 
     issiso : bool, optional
         If ``True``, process data as single-input, single-output data.
         Default is ``False``.
 
     transpose : bool, optional
-        If True, transpose all input and output arrays (for backward
-        compatibility with MATLAB and :func:`scipy.signal.lsim`).  Default
-        value is False.
+        If True, transpose data (for backward compatibility with MATLAB and
+        :func:`scipy.signal.lsim`).  Default value is False.
 
     squeeze : bool, optional
         By default, if a system is single-input, single-output (SISO) then the
-        output response is returned as a 1D array (indexed by time).  If
+        signals are returned as a 1D array (indexed by time).  If
         squeeze=True, remove single-dimensional entries from the shape of the
-        output even if the system is not SISO. If squeeze=False, keep the
-        output as a 3D array (indexed by the output, input, and time) even if
+        signal even if the system is not SISO. If squeeze=False, keep the
+        signal as a 3D array (indexed by the output, input, and time) even if
         the system is SISO. The default value can be set using
         config.defaults['control.squeeze_time_response'].
 
     Returns
     -------
-    T : 1D array
-        Time values of the output.
-
-    yout : ndarray
-        Response of the system.  If the system is SISO and squeeze is not
-        True, the array is 1D (indexed by time).  If the system is not SISO or
-        squeeze is False, the array is either 2D (indexed by output and time)
-        or 3D (indexed by input, output, and time).
+    output : ndarray
+        Processed signal.  If the system is SISO and squeeze is not True,
+        the array is 1D (indexed by time).  If the system is not SISO or
+        squeeze is False, the array is either 2D (indexed by output and
+        time) or 3D (indexed by input, output, and time).
 
     """
     # If squeeze was not specified, figure out the default (might remain None)
@@ -1317,29 +1328,26 @@ def _process_time_response(
         squeeze = config.defaults['control.squeeze_time_response']
 
     # Figure out whether and how to squeeze output data
-    if squeeze is True:         # squeeze all dimensions
-        yout = np.squeeze(yout)
-    elif squeeze is False:      # squeeze no dimensions
+    if squeeze is True:                 # squeeze all dimensions
+        signal = np.squeeze(signal)
+    elif squeeze is False:              # squeeze no dimensions
         pass
-    elif squeeze is None:       # squeeze signals if SISO
+    elif squeeze is None:               # squeeze signals if SISO
         if issiso:
-            if yout.ndim == 3:
-                yout = yout[0][0]       # remove input and output
+            if signal.ndim == 3:
+                signal = signal[0][0]   # remove input and output
             else:
-                yout = yout[0]          # remove input
+                signal = signal[0]      # remove input
     else:
         raise ValueError("Unknown squeeze value")
 
     # See if we need to transpose the data back into MATLAB form
     if transpose:
-        # Transpose time vector in case we are using np.matrix
-        tout = np.transpose(tout)
-
         # For signals, put the last index (time) into the first slot
-        yout = np.transpose(yout, np.roll(range(yout.ndim), 1))
+        signal = np.transpose(signal, np.roll(range(signal.ndim), 1))
 
-    # Return time, output, and (optionally) state
-    return tout, yout
+    # Return output
+    return signal
 
 
 def step_response(
diff --git a/control/xferfcn.py b/control/xferfcn.py
index 499359cb..56ec7395 100644
--- a/control/xferfcn.py
+++ b/control/xferfcn.py
@@ -48,25 +48,26 @@ $Id$
 """
 
 from collections.abc import Iterable
+from copy import deepcopy
+from itertools import chain
+from re import sub
+from warnings import warn
 
 # External function declarations
 import numpy as np
-from numpy import angle, array, empty, finfo, ndarray, ones, \
-    polyadd, polymul, polyval, roots, sqrt, zeros, squeeze, exp, pi, \
-    where, delete, real, poly, nonzero
 import scipy as sp
-from scipy.signal import tf2zpk, zpk2tf, cont2discrete
+from numpy import angle, array, delete, empty, exp, finfo, ndarray, nonzero, \
+    ones, pi, poly, polyadd, polymul, polyval, real, roots, sqrt, squeeze, \
+    where, zeros
 from scipy.signal import TransferFunction as signalTransferFunction
-from copy import deepcopy
-from warnings import warn
-from itertools import chain
-from re import sub
-from .lti import LTI, _process_frequency_response
-from .iosys import InputOutputSystem, common_timebase, isdtime, \
-    _process_iosys_keywords
+from scipy.signal import cont2discrete, tf2zpk, zpk2tf
+
+from . import config
 from .exception import ControlMIMONotImplemented
 from .frdata import FrequencyResponseData
-from . import config
+from .iosys import InputOutputSystem, NamedSignal, _process_iosys_keywords, \
+    _process_subsys_index, common_timebase, isdtime
+from .lti import LTI, _process_frequency_response
 
 __all__ = ['TransferFunction', 'tf', 'zpk', 'ss2tf', 'tfdata']
 
@@ -146,6 +147,16 @@ class TransferFunction(LTI):
     function evaluated at a point in the complex plane.  See
     :meth:`~control.TransferFunction.__call__` for a more detailed description.
 
+    Subsystems corresponding to selected input/output pairs can be
+    created by indexing the transfer function::
+
+        subsys = sys[output_spec, input_spec]
+
+    The input and output specifications can be single integers, lists of
+    integers, or slices.  In addition, the strings representing the names
+    of the signals can be used and will be replaced with the equivalent
+    signal offsets.
+
     The TransferFunction class defines two constants ``s`` and ``z`` that
     represent the differentiation and delay operators in continuous and
     discrete time.  These can be used to create variables that allow algebraic
@@ -761,48 +772,30 @@ class TransferFunction(LTI):
 
     def __getitem__(self, key):
         if not isinstance(key, Iterable) or len(key) != 2:
-            raise IOError('must provide indices of length 2 for transfer functions')
+            raise IOError(
+                "must provide indices of length 2 for transfer functions")
+
+        # Convert signal names to integer offsets (via NamedSignal object)
+        iomap = NamedSignal(
+            np.empty((self.noutputs, self.ninputs)),
+            self.output_labels, self.input_labels)
+        indices = iomap._parse_key(key, level=1)  # ignore index checks
+        outdx, outputs = _process_subsys_index(
+            indices[0], self.output_labels, slice_to_list=True)
+        inpdx, inputs = _process_subsys_index(
+            indices[1], self.input_labels, slice_to_list=True)
         
-        key1, key2 = key
-        if not isinstance(key1, (int, slice)) or not isinstance(key2, (int, slice)):
-            raise TypeError(f"system indices must be integers or slices")
-
-        # pre-process
-        if isinstance(key1, int):
-            key1 = slice(key1, key1 + 1, 1)
-        if isinstance(key2, int):
-            key2 = slice(key2, key2 + 1, 1)
-        # dim1
-        start1, stop1, step1 = key1.start, key1.stop, key1.step
-        if step1 is None:
-            step1 = 1
-        if start1 is None:
-            start1 = 0
-        if stop1 is None:
-            stop1 = len(self.num)
-        # dim1
-        start2, stop2, step2 = key2.start, key2.stop, key2.step
-        if step2 is None:
-            step2 = 1
-        if start2 is None:
-            start2 = 0
-        if stop2 is None:
-            stop2 = len(self.num[0])
-
+        # Construct the transfer function for the subsyste
         num, den = [], []
-        for i in range(start1, stop1, step1):
+        for i in outdx:
             num_i = []
             den_i = []
-            for j in range(start2, stop2, step2):
+            for j in inpdx:
                 num_i.append(self.num[i][j])
                 den_i.append(self.den[i][j])
             num.append(num_i)
             den.append(den_i)
 
-        # Save the label names
-        outputs = [self.output_labels[i] for i in range(start1, stop1, step1)]
-        inputs = [self.input_labels[j] for j in range(start2, stop2, step2)]
-
         # Create the system name
         sysname = config.defaults['iosys.indexed_system_name_prefix'] + \
             self.name + config.defaults['iosys.indexed_system_name_suffix']
diff --git a/doc/conventions.rst b/doc/conventions.rst
index fb1f0715..d2394e04 100644
--- a/doc/conventions.rst
+++ b/doc/conventions.rst
@@ -98,6 +98,20 @@ argument::
 
     mag, phase, omega = response(squeeze=False)
 
+Frequency response objects are also available as named properties of the
+``response`` object: ``response.magnitude``, ``response.phase``, and
+``response.response`` (for the complex response).  For MIMO systems, these
+elements of the frequency response can be accessed using the names of the
+inputs and outputs::
+
+  response.magnitude['y[0]', 'u[1]']
+
+where the signal names are based on the system that generated the frequency
+response.
+
+Note: The ``fresp`` data member is stored as a NumPy array and cannot be
+accessed with signal names.  Use ``response.response`` to access the
+complex frequency response using signal names.
 
 Discrete time systems
 ---------------------
@@ -132,6 +146,21 @@ constructor for the desired data type using the original system as the sole
 argument or using the explicit conversion functions :func:`ss2tf` and
 :func:`tf2ss`.
 
+Subsystems
+----------
+Subsets of input/output pairs for LTI systems can be obtained by indexing
+the system using either numerical indices (including slices) or signal
+names::
+
+    subsys = sys[[0, 2], 0:2]
+    subsys = sys[['y[0]', 'y[2]'], ['u[0]', 'u[1]']]
+
+Signal names for an indexed subsystem are preserved from the original
+system and the subsystem name is set according to the values of
+``control.config.defaults['iosys.indexed_system_name_prefix']`` and
+``control.config.defaults['iosys.indexed_system_name_suffix']``.  The default
+subsystem name is the original system name with '$indexed' appended.
+
 Simulating LTI systems
 ======================
 
@@ -233,7 +262,7 @@ properties::
 
     sys = ct.rss(4, 1, 1)
     response = ct.step_response(sys)
-    plot(response.time, response.outputs)
+    plt.plot(response.time, response.outputs)
 
 The dimensions of the response properties depend on the function being
 called and whether the system is SISO or MIMO.  In addition, some time
@@ -242,6 +271,17 @@ such as the :func:`step_response` function applied to a MIMO system,
 which will compute the step response for each input/output pair.  See
 :class:`TimeResponseData` for more details.
 
+The input, output, and state elements of the response can be accessed using
+signal names in place of integer offsets::
+
+    plt.plot(response. time, response.states['x[1]']
+
+For multi-trace systems generated by :func:`step_response` and
+:func:`impulse_response`, the input name used to generate the trace can be
+used to access the appropriate input output pair::
+
+    plt.plot(response.time, response.outputs['y[0]', 'u[1]'])
+
 The time response functions can also be assigned to a tuple, which extracts
 the time and output (and optionally the state, if the `return_x` keyword is
 used).  This allows simple commands for plotting::
diff --git a/doc/plotting.rst b/doc/plotting.rst
index 367e2c34..cc292bdb 100644
--- a/doc/plotting.rst
+++ b/doc/plotting.rst
@@ -79,6 +79,11 @@ the data from the simulation::
       for j in range(2):
           axs[i, j].plot(time, outputs[i, j])
 
+In addition to accessing time response data via integer indices, signal
+names can allow be used::
+
+  plt.plot(response.time, response.outputs['y[0]', 'u[1]'])
+
 A number of options are available in the `plot` method to customize
 the appearance of input output data.  For data produced by the
 :func:`~control.impulse_response` and :func:`~control.step_response`
@@ -278,6 +283,19 @@ maximum frequencies in the (log-spaced) frequency range::
 The number of (log-spaced) points in the frequency can be specified using
 the ``omega_num`` keyword parameter.
 
+Frequency response data can also be accessed directly and plotted manually::
+
+  sys = ct.rss(4, 2, 2, strictly_proper=True)  # 2x2 MIMO system
+  fresp = ct.frequency_response(sys)
+  plt.loglog(fresp.omega, fresp.magnitude['y[1]', 'u[0]'])
+
+Access to frequency response data is available via the attributes
+``omega``, ``magnitude``,` `phase``, and ``response``, where ``response``
+represents the complex value of the frequency response at each frequency.
+The ``magnitude``, ``phase``, and ``response`` arrays can be indexed using
+either input/output indices or signal names, with the first index
+corresponding to the output signal and the second input corresponding to
+the input signal.
 
 Pole/zero data
 ==============


Test patch:
diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py
index bae0ec47..c2a29ee2 100644
--- a/control/tests/frd_test.py
+++ b/control/tests/frd_test.py
@@ -609,3 +609,49 @@ def test_frequency_response():
     assert mag_nosq_sq.shape == mag_default.shape
     assert phase_nosq_sq.shape == phase_default.shape
     assert omega_nosq_sq.shape == omega_default.shape
+
+
+def test_signal_labels():
+    # Create a system response for a SISO system
+    sys = ct.rss(4, 1, 1)
+    fresp = ct.frequency_response(sys)
+
+    # Make sure access via strings works
+    np.testing.assert_equal(
+        fresp.magnitude['y[0]'], fresp.magnitude[0])
+    np.testing.assert_equal(
+        fresp.phase['y[0]'], fresp.phase[0])
+
+    # Make sure errors are generated if key is unknown
+    with pytest.raises(ValueError, match="unknown signal name 'bad'"):
+        fresp.magnitude['bad']
+
+    # Create a system response for a MIMO system
+    sys = ct.rss(4, 2, 2)
+    fresp = ct.frequency_response(sys)
+
+    # Make sure access via strings works
+    np.testing.assert_equal(
+        fresp.magnitude['y[0]', 'u[1]'],
+        fresp.magnitude[0, 1])
+    np.testing.assert_equal(
+        fresp.phase['y[0]', 'u[1]'],
+        fresp.phase[0, 1])
+    np.testing.assert_equal(
+        fresp.response['y[0]', 'u[1]'],
+        fresp.response[0, 1])
+
+    # Make sure access via lists of strings works
+    np.testing.assert_equal(
+        fresp.response[['y[1]', 'y[0]'], 'u[0]'],
+        fresp.response[[1, 0], 0])
+
+    # Make sure errors are generated if key is unknown
+    with pytest.raises(ValueError, match="unknown signal name 'bad'"):
+        fresp.magnitude['bad']
+
+    with pytest.raises(ValueError, match="unknown signal name 'bad'"):
+        fresp.response[['y[1]', 'bad']]
+
+    with pytest.raises(ValueError, match=r"unknown signal name 'y\[0\]'"):
+        fresp.response['y[1]', 'y[0]']         # second index = input name
diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py
index baaee03f..0216235b 100644
--- a/control/tests/iosys_test.py
+++ b/control/tests/iosys_test.py
@@ -2263,3 +2263,24 @@ def test_update_names():
 
     with pytest.raises(TypeError, match=".* takes 1 positional argument"):
         sys.update_names(5)
+
+
+def test_signal_indexing():
+    # Response with two outputs, no traces
+    resp = ct.initial_response(ct.rss(4, 2, 1, strictly_proper=True))
+    assert resp.outputs['y[0]'].shape == resp.outputs.shape[1:]
+    assert resp.outputs[0, 0].item() == 0
+
+    # Implicitly squeezed response
+    resp = ct.step_response(ct.rss(4, 1, 1, strictly_proper=True))
+    for key in ['y[0]', ('y[0]', 'u[0]')]:
+        with pytest.raises(IndexError, match=r"signal name\(s\) not valid"):
+            resp.outputs.__getitem__(key)
+
+    # Explicitly squeezed response
+    resp = ct.step_response(
+        ct.rss(4, 2, 1, strictly_proper=True), squeeze=True)
+    assert resp.outputs['y[0]'].shape == resp.outputs.shape[1:]
+    with pytest.raises(IndexError, match=r"signal name\(s\) not valid"):
+        resp.outputs['y[0]', 'u[0]']
+
diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py
index 734bdb40..3f001c17 100644
--- a/control/tests/lti_test.py
+++ b/control/tests/lti_test.py
@@ -303,3 +303,50 @@ class TestLTI:
             sys([[0.1j, 1j], [1j, 10j]])
         with pytest.raises(ValueError, match="must be 1D"):
             evalfr(sys, [[0.1j, 1j], [1j, 10j]])
+
+
+@slycotonly
+@pytest.mark.parametrize(
+    "outdx, inpdx, key",
+    [('y[0]', 'u[1]', (0, 1)),
+     (['y[0]'], ['u[1]'], (0, 1)),
+     (slice(0, 1, 1), slice(1, 2, 1), (0, 1)),
+     (['y[0]', 'y[1]'], ['u[1]', 'u[2]'], ([0, 1], [1, 2])),
+     ([0, 'y[1]'], ['u[1]', 2], ([0, 1], [1, 2])),
+     (slice(0, 2, 1), slice(1, 3, 1), ([0, 1], [1, 2])),
+     (['y[2]', 'y[1]'], ['u[2]', 'u[0]'], ([2, 1], [2, 0])),
+    ])
+@pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd])
+def test_subsys_indexing(fcn, outdx, inpdx, key):
+    # Construct the base system and subsystem
+    sys = ct.rss(4, 3, 3)
+    subsys = sys[key]
+
+    # Construct the system to be tested
+    match fcn:
+        case ct.frd:
+            omega = np.logspace(-1, 1)
+            sys = fcn(sys, omega)
+            subsys_chk = fcn(subsys, omega)
+        case _:
+            sys = fcn(sys)
+            subsys_chk = fcn(subsys)
+
+    # Construct the subsystem
+    subsys_fcn = sys[outdx, inpdx]
+
+    # Check to make sure everythng matches up
+    match fcn:
+        case ct.frd:
+            np.testing.assert_almost_equal(
+                subsys_fcn.response, subsys_chk.response)
+        case ct.ss:
+            np.testing.assert_almost_equal(subsys_fcn.A, subsys_chk.A)
+            np.testing.assert_almost_equal(subsys_fcn.B, subsys_chk.B)
+            np.testing.assert_almost_equal(subsys_fcn.C, subsys_chk.C)
+            np.testing.assert_almost_equal(subsys_fcn.D, subsys_chk.D)
+        case ct.tf:
+            omega = np.logspace(-1, 1)
+            np.testing.assert_almost_equal(
+                subsys_fcn.frequency_response(omega).response,
+                subsys_chk.frequency_response(omega).response)
diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py
index 2829d698..cb200c4a 100644
--- a/control/tests/statesp_test.py
+++ b/control/tests/statesp_test.py
@@ -473,18 +473,22 @@ class TestStateSpace:
         with pytest.raises(IOError):
             sys1[0]
 
-    @pytest.mark.parametrize("outdx, inpdx", 
-                             [(0, 1),
-                              (slice(0, 1, 1), 1),
-                              (0, slice(1, 2, 1)),
-                              (slice(0, 1, 1), slice(1, 2, 1)),
-                              (slice(None, None, -1), 1),
-                              (0, slice(None, None, -1)),
-                              (slice(None, 2, None), 1),
-                              (slice(None, None, 1), slice(None, None, 2)),
-                              (0, slice(1, 2, 1)),
-                              (slice(0, 1, 1), slice(1, 2, 1))])
-    def test_array_access_ss(self, outdx, inpdx):
+    @pytest.mark.parametrize(
+        "outdx, inpdx",
+        [(0, 1),
+         (slice(0, 1, 1), 1),
+         (0, slice(1, 2, 1)),
+         (slice(0, 1, 1), slice(1, 2, 1)),
+         (slice(None, None, -1), 1),
+         (0, slice(None, None, -1)),
+         (slice(None, 2, None), 1),
+         (slice(None, None, 1), slice(None, None, 2)),
+         (0, slice(1, 2, 1)),
+         (slice(0, 1, 1), slice(1, 2, 1)),
+         # ([0, 1], [0]),         # lists of indices
+         ])
+    @pytest.mark.parametrize("named", [False, True])
+    def test_array_access_ss(self, outdx, inpdx, named):
         sys1 = StateSpace(
             [[1., 2.], [3., 4.]],
             [[5., 6.], [7., 8.]],
@@ -492,20 +496,22 @@ class TestStateSpace:
             [[13., 14.], [15., 16.]], 1,
             inputs=['u0', 'u1'], outputs=['y0', 'y1'])
 
-        sys1_01 = sys1[outdx, inpdx]
-        
+        if named:
+            # Use names instead of numbers (and re-convert in statesp)
+            outnames = sys1.output_labels[outdx]
+            inpnames = sys1.input_labels[inpdx]
+            sys1_01 = sys1[outnames, inpnames]
+        else:
+            sys1_01 = sys1[outdx, inpdx]
+
         # Convert int to slice to ensure that numpy doesn't drop the dimension
         if isinstance(outdx, int): outdx = slice(outdx, outdx+1, 1)
         if isinstance(inpdx, int): inpdx = slice(inpdx, inpdx+1, 1)
-        
-        np.testing.assert_array_almost_equal(sys1_01.A,
-                                             sys1.A)
-        np.testing.assert_array_almost_equal(sys1_01.B,
-                                             sys1.B[:, inpdx])
-        np.testing.assert_array_almost_equal(sys1_01.C,
-                                             sys1.C[outdx, :])
-        np.testing.assert_array_almost_equal(sys1_01.D,
-                                             sys1.D[outdx, inpdx])
+
+        np.testing.assert_array_almost_equal(sys1_01.A, sys1.A)
+        np.testing.assert_array_almost_equal(sys1_01.B, sys1.B[:, inpdx])
+        np.testing.assert_array_almost_equal(sys1_01.C, sys1.C[outdx, :])
+        np.testing.assert_array_almost_equal(sys1_01.D, sys1.D[outdx, inpdx])
 
         assert sys1.dt == sys1_01.dt
         assert sys1_01.input_labels == sys1.input_labels[inpdx]
diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py
index e2d93be0..e5e24b99 100644
--- a/control/tests/timeresp_test.py
+++ b/control/tests/timeresp_test.py
@@ -537,7 +537,7 @@ class TestTimeresp:
         sysdt = sys.sample(dt, 'impulse')
         np.testing.assert_array_almost_equal(impulse_response(sys, t)[1],
                                              impulse_response(sysdt, t)[1])
-        
+
     def test_discrete_time_impulse_input(self):
         # discrete time impulse input, Only one active input for each trace
         A = [[.5, 0.25],[.0, .5]]
@@ -1318,3 +1318,53 @@ def test_step_info_nonstep():
     assert step_info['Peak'] == 1
     assert step_info['PeakTime'] == 0
     assert isclose(step_info['SteadyStateValue'], 0.96)
+
+
+def test_signal_labels():
+    # Create a system response for a SISO system
+    sys = ct.rss(4, 1, 1)
+    response = ct.step_response(sys)
+
+    # Make sure access via strings works
+    np.testing.assert_equal(response.states['x[2]'], response.states[2])
+
+    # Make sure access via lists of strings works
+    np.testing.assert_equal(
+        response.states[['x[1]', 'x[2]']], response.states[[1, 2]])
+
+    # Make sure errors are generated if key is unknown
+    with pytest.raises(ValueError, match="unknown signal name 'bad'"):
+        response.inputs['bad']
+
+    with pytest.raises(ValueError, match="unknown signal name 'bad'"):
+        response.states[['x[1]', 'bad']]
+
+    # Create a system response for a MIMO system
+    sys = ct.rss(4, 2, 2)
+    response = ct.step_response(sys)
+
+    # Make sure access via strings works
+    np.testing.assert_equal(
+        response.outputs['y[0]', 'u[1]'],
+        response.outputs[0, 1])
+    np.testing.assert_equal(
+        response.states['x[2]', 'u[0]'], response.states[2, 0])
+
+    # Make sure access via lists of strings works
+    np.testing.assert_equal(
+        response.states[['x[1]', 'x[2]'], 'u[0]'],
+        response.states[[1, 2], 0])
+
+    np.testing.assert_equal(
+        response.outputs[['y[1]'], ['u[1]', 'u[0]']],
+        response.outputs[[1], [1, 0]])
+
+    # Make sure errors are generated if key is unknown
+    with pytest.raises(ValueError, match="unknown signal name 'bad'"):
+        response.inputs['bad']
+
+    with pytest.raises(ValueError, match="unknown signal name 'bad'"):
+        response.states[['x[1]', 'bad']]
+
+    with pytest.raises(ValueError, match=r"unknown signal name 'x\[2\]'"):
+        response.states['x[1]', 'x[2]']         # second index = input name
diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py
index 14a11b66..d480cef6 100644
--- a/control/tests/xferfcn_test.py
+++ b/control/tests/xferfcn_test.py
@@ -390,19 +390,20 @@ class TestXferFcn:
         with pytest.raises(ValueError):
             TransferFunction.__pow__(sys1, 0.5)
 
-    def test_slice(self):
+    @pytest.mark.parametrize("named", [False, True])
+    def test_slice(self, named):
         sys = TransferFunction(
             [ [   [1],    [2],    [3]], [   [3],    [4],    [5]] ],
             [ [[1, 2], [1, 3], [1, 4]], [[1, 4], [1, 5], [1, 6]] ],
             inputs=['u0', 'u1', 'u2'], outputs=['y0', 'y1'], name='sys')
 
-        sys1 = sys[1:, 1:]
+        sys1 = sys[1:, 1:] if not named else sys['y1', ['u1', 'u2']]
         assert (sys1.ninputs, sys1.noutputs) == (2, 1)
         assert sys1.input_labels == ['u1', 'u2']
         assert sys1.output_labels == ['y1']
         assert sys1.name == 'sys$indexed'
 
-        sys2 = sys[:2, :2]
+        sys2 = sys[:2, :2] if not named else sys[['y0', 'y1'], ['u0', 'u1']]
         assert (sys2.ninputs, sys2.noutputs) == (2, 2)
         assert sys2.input_labels == ['u0', 'u1']
         assert sys2.output_labels == ['y0', 'y1']
@@ -411,7 +412,7 @@ class TestXferFcn:
         sys = TransferFunction(
             [ [   [1],    [2],    [3]], [   [3],    [4],    [5]] ],
             [ [[1, 2], [1, 3], [1, 4]], [[1, 4], [1, 5], [1, 6]] ], 0.5)
-        sys1 = sys[1:, 1:]
+        sys1 = sys[1:, 1:] if not named else sys[['y[1]'], ['u[1]', 'u[2]']]
         assert (sys1.ninputs, sys1.noutputs) == (2, 1)
         assert sys1.dt == 0.5
         assert sys1.input_labels == ['u[1]', 'u[2]']