diff --git a/control/exception.py b/control/exception.py index e4758cc49..add5d01ae 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 42ecee0d9..1bdf28528 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -10,6 +10,7 @@ FRD data. """ +from collections.abc import Iterable from copy import copy from warnings import warn @@ -20,7 +21,8 @@ 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 constructor, using the :func:~~control.frd` factory function 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 constructor, using the :func:~~control.frd` factory function 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 @@ def __init__(self, *args, **kwargs): @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 @@ def __iter__(self): 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 9092b672b..dd1566eb9 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -13,9 +13,10 @@ 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 @@ } +# 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 835c16ef6..62e4bf78e 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -193,8 +193,8 @@ def __call__(sys, u, params=None, squeeze=None): # 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 aa1c7221b..bfe5f996b 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -48,15 +48,15 @@ """ 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 . 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 @@ def append(self, other): 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/tests/frd_test.py b/control/tests/frd_test.py index bae0ec47b..c2a29ee2e 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 baaee03f6..0216235b5 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 734bdb40b..3f001c17b 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -303,3 +303,50 @@ def test_squeeze_exceptions(self, fcn): 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 2829d6988..cb200c4ab 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -473,18 +473,22 @@ def test_array_access_ss_failure(self): 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 @@ def test_array_access_ss(self, outdx, inpdx): [[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 e2d93be0e..e5e24b990 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -537,7 +537,7 @@ def test_discrete_time_impulse(self, tsystem): 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 14a11b669..d480cef6e 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -390,19 +390,20 @@ def test_pow(self): 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 @@ def test_slice(self): 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]'] diff --git a/control/timeresp.py b/control/timeresp.py index 5813c166d..072db60de 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -80,7 +80,7 @@ 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 @@ def outputs(self): (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 @@ def states(self): 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 @@ def inputs(self): 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 @@ def inputs(self): :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 499359cbc..56ec7395f 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -48,25 +48,26 @@ """ 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 @@ def __pow__(self, other): 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 fb1f0715f..d2394e040 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 367e2c349..cc292bdbe 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 ==============