From 5d7fb42449a3c446e8031efb8a1268ef3fc4c2db Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 30 Nov 2024 08:53:37 -0800 Subject: [PATCH 01/12] clean up _process_time_response --- control/nlsys.py | 4 +-- control/timeresp.py | 70 +++++++++++++++++++-------------------------- 2 files changed, 32 insertions(+), 42 deletions(-) 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/timeresp.py b/control/timeresp.py index 5813c166d..2d45a6e4a 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -567,8 +567,8 @@ def outputs(self): :type: 1D, 2D, or 3D array """ - t, y = _process_time_response( - self.t, self.y, issiso=self.issiso, + y = _process_time_response( + self.y, issiso=self.issiso, transpose=self.transpose, squeeze=self.squeeze) return y @@ -631,8 +631,8 @@ def inputs(self): 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 @@ -1265,7 +1265,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 +1273,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 + Processd 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 +1310,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( From 5df6fb724ff75c40d1ade7ee7eed8da3ef0d9bba Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 30 Nov 2024 10:16:45 -0800 Subject: [PATCH 02/12] implement NamedSignal's for time responses --- control/iosys.py | 49 +++++++++++++++++++++++++++++-- control/tests/timeresp_test.py | 53 +++++++++++++++++++++++++++++++++- control/timeresp.py | 12 +++++--- 3 files changed, 107 insertions(+), 7 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 9092b672b..080a67268 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -14,8 +14,8 @@ from . import config -__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 +33,51 @@ } +# 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 + 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) + + def _parse_key(self, key, labels=None): + if labels is None: + labels = self.signal_labels + try: + if isinstance(key, str): + key = labels.index(item := key) + 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)) + key = keylist + elif isinstance(key, tuple): + keylist = [] + keylist.append( + self._parse_key(item := key[0], labels=self.signal_labels)) + if len(key) > 1: + keylist.append( + self._parse_key( + item := key[1], labels=self.trace_labels)) + 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}'") + return key + + def __getitem__(self, key): + return super().__getitem__(self._parse_key(key)) + + class InputOutputSystem(object): """A class for representing input/output systems. diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py index e2d93be0e..08da12fd2 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,54 @@ 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.inputs['u[0]'], response.inputs[0]) + 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/timeresp.py b/control/timeresp.py index 2d45a6e4a..fa8971345 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', @@ -567,10 +567,11 @@ def outputs(self): :type: 1D, 2D, or 3D array """ + # 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 @@ -586,6 +587,7 @@ def states(self): :type: 2D or 3D array """ + # TODO: move to __init__ to avoid recomputing each time? if self.x is None: return None @@ -606,7 +608,7 @@ def states(self): 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 @@ -628,15 +630,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 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). From b382abf664036b462daa9eab3e4114e50a237da6 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 30 Nov 2024 10:29:15 -0800 Subject: [PATCH 03/12] implement NamedSignal's for frequency responses --- control/frdata.py | 12 ++++++---- control/tests/frd_test.py | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 42ecee0d9..204bb20fe 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -20,7 +20,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, \ + common_timebase from .lti import LTI, _process_frequency_response __all__ = ['FrequencyResponseData', 'FRD', 'frd'] @@ -243,11 +244,13 @@ def __init__(self, *args, **kwargs): @property def magnitude(self): - return np.abs(self.fresp) + return NamedSignal( + np.abs(self.fresp), self.output_labels, self.input_labels) @property def phase(self): - return np.angle(self.fresp) + return NamedSignal( + np.angle(self.fresp), self.output_labels, self.input_labels) @property def frequency(self): @@ -255,7 +258,8 @@ def frequency(self): @property def response(self): - return self.fresp + return NamedSignal( + self.fresp, self.output_labels, self.input_labels) def __str__(self): """String representation of the transfer function.""" 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 From 58922cea3439d14135f62879334f18c20d02e93b Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 30 Nov 2024 21:42:33 -0800 Subject: [PATCH 04/12] add string-based indexing of state space systems --- control/iosys.py | 27 ++++++++++++++++++ control/statesp.py | 38 ++++++++++++------------- control/tests/statesp_test.py | 52 +++++++++++++++++++---------------- 3 files changed, 75 insertions(+), 42 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 080a67268..f7b265243 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1010,3 +1010,30 @@ 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(f"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/statesp.py b/control/statesp.py index aa1c7221b..f209aa688 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 @@ -1214,25 +1214,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) + 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/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] From e52fca6104e07a747450204f232992d176474f1a Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 30 Nov 2024 21:52:56 -0800 Subject: [PATCH 05/12] add string-based indexing of transfer functions --- control/tests/xferfcn_test.py | 9 +++-- control/xferfcn.py | 73 ++++++++++++++--------------------- 2 files changed, 33 insertions(+), 49 deletions(-) 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/xferfcn.py b/control/xferfcn.py index 499359cbc..cd83153d0 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'] @@ -761,48 +762,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) + 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'] From cca4d13ef7ed1f286e60f054b23a19abca5b9d90 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 30 Nov 2024 21:54:54 -0800 Subject: [PATCH 06/12] add string-based indexing of FRD systems --- control/frdata.py | 25 +++++++++++++++++---- control/tests/lti_test.py | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 204bb20fe..47b77c510 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 @@ -21,7 +22,7 @@ from . import config from .exception import pandas_check from .iosys import InputOutputSystem, NamedSignal, _process_iosys_keywords, \ - common_timebase + _process_subsys_index, common_timebase from .lti import LTI, _process_frequency_response __all__ = ['FrequencyResponseData', 'FRD', 'frd'] @@ -597,9 +598,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) + 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/tests/lti_test.py b/control/tests/lti_test.py index 734bdb40b..6ef75df64 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -303,3 +303,49 @@ 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]]) + + +@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 test + 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) From 944b6a20496c0087f418a6575d316b8d92451938 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 30 Nov 2024 22:41:22 -0800 Subject: [PATCH 07/12] fix NamedSignal.__getitem__ for new versions of NumPy + unit tests/slycot --- control/iosys.py | 2 +- control/tests/lti_test.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/control/iosys.py b/control/iosys.py index f7b265243..3096d28dc 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -59,7 +59,7 @@ def _parse_key(self, key, labels=None): for item in key: # use for loop to save item for error keylist.append(self._parse_key(item, labels=labels)) key = keylist - elif isinstance(key, tuple): + elif isinstance(key, tuple) and len(key) > 0: keylist = [] keylist.append( self._parse_key(item := key[0], labels=self.signal_labels)) diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index 6ef75df64..d63e5a2c1 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -305,6 +305,7 @@ def test_squeeze_exceptions(self, fcn): evalfr(sys, [[0.1j, 1j], [1j, 10j]]) +@slycotonly @pytest.mark.parametrize( "outdx, inpdx, key", [('y[0]', 'u[1]', (0, 1)), From cd3d315071463bb589b5f0f74f6c82eae57386e9 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 1 Dec 2024 11:07:16 -0800 Subject: [PATCH 08/12] update index checking + docstrings --- control/exception.py | 4 +++ control/frdata.py | 16 +++++++++- control/iosys.py | 26 ++++++++++++--- control/statesp.py | 13 +++++++- control/tests/iosys_test.py | 21 ++++++++++++ control/tests/timeresp_test.py | 1 - control/timeresp.py | 58 ++++++++++++++++++++++------------ control/xferfcn.py | 12 ++++++- 8 files changed, 122 insertions(+), 29 deletions(-) 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 47b77c510..ee58792fd 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -91,6 +91,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 @@ -606,7 +620,7 @@ def __getitem__(self, 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) + 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) diff --git a/control/iosys.py b/control/iosys.py index 3096d28dc..1645872d6 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -13,6 +13,7 @@ import numpy as np from . import config +from .exception import ControlIndexError __all__ = ['InputOutputSystem', 'NamedSignal', 'issiso', 'timebase', 'common_timebase', 'isdtime', 'isctime'] @@ -40,6 +41,7 @@ def __new__(cls, input_array, signal_labels=None, trace_labels=None): 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): @@ -47,31 +49,47 @@ def __array_finalize__(self, obj): 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): + 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)) + 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)) + 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)) + 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): diff --git a/control/statesp.py b/control/statesp.py index f209aa688..bfe5f996b 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -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 @@ -1221,7 +1232,7 @@ def __getitem__(self, key): # Convert signal names to integer offsets iomap = NamedSignal(self.D, self.output_labels, self.input_labels) - indices = iomap._parse_key(key) + 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( 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/timeresp_test.py b/control/tests/timeresp_test.py index 08da12fd2..e5e24b990 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -1326,7 +1326,6 @@ def test_signal_labels(): response = ct.step_response(sys) # Make sure access via strings works - np.testing.assert_equal(response.inputs['u[0]'], response.inputs[0]) np.testing.assert_equal(response.states['x[2]'], response.states[2]) # Make sure access via lists of strings works diff --git a/control/timeresp.py b/control/timeresp.py index fa8971345..5436e7a44 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -204,34 +204,50 @@ 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: + When using this (legacy) interface, the state vector is not affected + by the `squeeze` parameter. + + For backward compatibility with earlier version of python-control, this + 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. """ @@ -1302,8 +1318,8 @@ def _process_time_response( Returns ------- - output: ndarray - Processd signal. If the system is SISO and squeeze is not True, + 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). diff --git a/control/xferfcn.py b/control/xferfcn.py index cd83153d0..56ec7395f 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -147,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 @@ -769,7 +779,7 @@ def __getitem__(self, key): iomap = NamedSignal( np.empty((self.noutputs, self.ninputs)), self.output_labels, self.input_labels) - indices = iomap._parse_key(key) + 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( From df330028059ce0cdcba3b0d0917e12852a665f66 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 1 Dec 2024 11:37:52 -0800 Subject: [PATCH 09/12] add documentation for indexing by name + update docstrings --- control/frdata.py | 74 +++++++++++++++++++++++++++++++++++++++++++-- control/timeresp.py | 26 ++++++++++------ doc/conventions.rst | 42 ++++++++++++++++++++++++- doc/plotting.rst | 18 +++++++++++ 4 files changed, 148 insertions(+), 12 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index ee58792fd..1bdf28528 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -35,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 ---------- @@ -67,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 -------- @@ -259,24 +281,72 @@ def __init__(self, *args, **kwargs): @property def magnitude(self): + """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): + """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): + """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 diff --git a/control/timeresp.py b/control/timeresp.py index 5436e7a44..cce1fe44f 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -228,16 +228,12 @@ class TimeResponseData: t, y = step_response(sys) t, y, x = step_response(sys, return_x=True) - When using this (legacy) interface, the state vector is not affected - by the `squeeze` parameter. + Similarly, the class has ``__getitem__`` and ``__len__`` methods that + allow the return value to be indexed: - For backward compatibility with earlier version of python-control, this - 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 + * 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. @@ -580,6 +576,10 @@ 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 """ @@ -600,6 +600,10 @@ 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 """ @@ -639,6 +643,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. diff --git a/doc/conventions.rst b/doc/conventions.rst index fb1f0715f..4c31f6122 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 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]']) + 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..b8391f006 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 ============== From 1903055403e80a86aecdbae93421bf3eba9f0065 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 1 Dec 2024 12:28:06 -0800 Subject: [PATCH 10/12] use common processing for state response --- control/timeresp.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/control/timeresp.py b/control/timeresp.py index cce1fe44f..77efc1a6b 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -608,25 +608,15 @@ def states(self): """ # TODO: move to __init__ to avoid recomputing each time? - if self.x is None: - return None + x = _process_time_response( + self.x, transpose=self.transpose, + squeeze=self.squeeze, issiso=False) - elif self.squeeze is True: - x = self.x.squeeze() - - 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, :] - - 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)) + x = x[:, 0, :] return NamedSignal(x, self.state_labels, self.input_labels) From fba414de4e86c8dbc86342752aff6d578997ce89 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 1 Dec 2024 12:31:37 -0800 Subject: [PATCH 11/12] TRV: typo in documentation --- doc/conventions.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/conventions.rst b/doc/conventions.rst index 4c31f6122..c601db17b 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -157,8 +157,8 @@ names:: 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 +`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 From 7ef09a0edbb94aeaa4c87226fe778efef1918fdf Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Tue, 3 Dec 2024 20:28:11 -0800 Subject: [PATCH 12/12] address initial @slivingston comments --- control/iosys.py | 8 +++++--- control/tests/lti_test.py | 2 +- control/timeresp.py | 2 +- doc/conventions.rst | 16 ++++++++-------- doc/plotting.rst | 2 +- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 1645872d6..dd1566eb9 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -46,7 +46,8 @@ def __new__(cls, input_array, signal_labels=None, trace_labels=None): def __array_finalize__(self, obj): # See https://numpy.org/doc/stable/user/basics.subclassing.html - if obj is None: return + 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) @@ -1038,14 +1039,15 @@ def _parse_spec(syslist, spec, signame, dictname=None): # def _process_subsys_index(idx, sys_labels, slice_to_list=False): if not isinstance(idx, (slice, list, int)): - raise TypeError(f"system indices must be integers, slices, or lists") + 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) + 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) \ diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index d63e5a2c1..3f001c17b 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -322,7 +322,7 @@ def test_subsys_indexing(fcn, outdx, inpdx, key): sys = ct.rss(4, 3, 3) subsys = sys[key] - # Construct the system to be test + # Construct the system to be tested match fcn: case ct.frd: omega = np.logspace(-1, 1) diff --git a/control/timeresp.py b/control/timeresp.py index 77efc1a6b..072db60de 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -243,7 +243,7 @@ class TimeResponseData: response(tranpose=True).input - See :meth:`TimeResponseData.__call__` for more information. + See :meth:`TimeResponseData.__call__` for more information. """ diff --git a/doc/conventions.rst b/doc/conventions.rst index c601db17b..d2394e040 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -99,8 +99,8 @@ 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 +``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:: @@ -109,9 +109,9 @@ inputs and outputs:: 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. +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 --------------------- @@ -157,8 +157,8 @@ names:: 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 +``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 @@ -271,7 +271,7 @@ 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 access using +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]'] diff --git a/doc/plotting.rst b/doc/plotting.rst index b8391f006..cc292bdbe 100644 --- a/doc/plotting.rst +++ b/doc/plotting.rst @@ -292,7 +292,7 @@ Frequency response data can also be accessed directly and plotted manually:: 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 +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.