From ab59657202413e03072790787cc495a60330e083 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 21 Aug 2021 12:32:43 -0700 Subject: [PATCH 01/14] initial class definition (as passthru) --- control/timeresp.py | 108 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 15 deletions(-) diff --git a/control/timeresp.py b/control/timeresp.py index 989a832cb..ce6d3a323 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -83,6 +83,95 @@ 'impulse_response'] +class InputOutputResponse: + """Class for returning time responses + + This class maintains and manipulates the data corresponding to the + temporal response of an input/output system. It is used as the return + type for time domain simulations (step response, input/output response, + etc). + + Attributes + ---------- + t : array + Time values of the output. + + y : array + Response of the system, indexed by the output number and time. + + x : array + Time evolution of the state vector, indexed by state number and time. + + u : array + Input to the system, indexed by the input number and time. + + Methods + ------- + plot(**kwargs) + Plot the input/output response. Keywords are passed to matplotlib. + + 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: + + t, y = step_response(sys) + t, y, x = step_response(sys, return_x=True) + t, y, x, u = step_response(sys, return_x=True, return_u=True) + + 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: + + response[0]: returns the time vector + response[1]: returns the output vector + response[2]: returns the state vector + + If the index is two-dimensional, a new ``InputOutputResponse`` object + is returned that corresponds to the specified subset of input/output + responses. + + """ + + def __init__( + self, t, y, x, u, sys=None, dt=None, + return_x=False, squeeze=None # for legacy interface + ): + # Store response attributes + self.t, self.y, self.x = t, y, x + + # Store legacy keyword values (only used for legacy interface) + self.return_x, self.squeeze = return_x, squeeze + + # Implement iter to allow assigning to a tuple + def __iter__(self): + if not self.return_x: + return iter((self.t, self.y)) + return iter((self.t, self.y, self.x)) + + # Implement (thin) getitem to allow access via legacy indexing + def __getitem__(self, index): + # See if we were passed a slice + if isinstance(index, slice): + if (index.start is None or index.start == 0) and index.stop == 2: + return (self.t, self.y) + + # Otherwise assume we were passed a single index + if index == 0: + return self.t + if index == 1: + return self.y + if index == 2: + return self.x + raise IndexError + + # Implement (thin) len to emulate legacy testing interface + def __len__(self): + return 3 if self.return_x else 2 + + # Helper function for checking array-like parameters def _check_convert_array(in_obj, legal_shapes, err_msg_start, squeeze=False, transpose=False): @@ -534,21 +623,9 @@ def _process_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). + response: InputOutputResponse + The input/output response of the system. - xout : array, optional - Individual response of each x variable (if return_x is True). For a - SISO system (or if a single input is specified), xout is a 2D array - indexed by the state index and time. For a non-SISO system, xout is a - 3D array indexed by the state, the input, and time. The shape of xout - is not affected by the ``squeeze`` keyword. """ # If squeeze was not specified, figure out the default (might remain None) if squeeze is None: @@ -586,7 +663,8 @@ def _process_time_response( xout = np.transpose(xout, np.roll(range(xout.ndim), 1)) # Return time, output, and (optionally) state - return (tout, yout, xout) if return_x else (tout, yout) + return InputOutputResponse( + tout, yout, xout, None, return_x=return_x, squeeze=squeeze) def _get_ss_simo(sys, input=None, output=None, squeeze=None): From bb1259874545c84ae7ba82fc156bcfd0dd84483d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 21 Aug 2021 13:58:08 -0700 Subject: [PATCH 02/14] Update timeresp, iosys to return InputOutputResponse, with properties --- control/iosys.py | 8 +++-- control/timeresp.py | 82 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 479039c3d..251a9d2cb 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -32,7 +32,8 @@ from warnings import warn from .statesp import StateSpace, tf2ss, _convert_to_statespace -from .timeresp import _check_convert_array, _process_time_response +from .timeresp import _check_convert_array, _process_time_response, \ + InputOutputResponse from .lti import isctime, isdtime, common_timebase from . import config @@ -1666,8 +1667,9 @@ def ivp_rhs(t, x): else: # Neither ctime or dtime?? raise TypeError("Can't determine system type") - return _process_time_response(sys, soln.t, y, soln.y, transpose=transpose, - return_x=return_x, squeeze=squeeze) + return InputOutputResponse( + soln.t, y, soln.y, U, sys=sys, + transpose=transpose, return_x=return_x, squeeze=squeeze) def find_eqpt(sys, x0, u0=[], y0=None, t=0, params={}, diff --git a/control/timeresp.py b/control/timeresp.py index ce6d3a323..e111678f5 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -137,13 +137,62 @@ class InputOutputResponse: def __init__( self, t, y, x, u, sys=None, dt=None, - return_x=False, squeeze=None # for legacy interface + transpose=False, return_x=False, squeeze=None ): - # Store response attributes - self.t, self.y, self.x = t, y, x + # + # Process and store the basic input/output elements + # + t, y, x = _process_time_response( + sys, t, y, x, + transpose=transpose, return_x=True, squeeze=squeeze) + + # Time vector + self.t = np.atleast_1d(t) + if len(self.t.shape) != 1: + raise ValueError("Time vector must be 1D array") + + # Output vector + self.yout = np.array(y) + self.noutputs = 1 if len(self.yout.shape) < 2 else self.yout.shape[0] + self.ninputs = 1 if len(self.yout.shape) < 3 else self.yout.shape[-2] + # TODO: Check to make sure time points match + + # State vector + self.xout = np.array(x) + self.nstates = self.xout.shape[0] + # TODO: Check to make sure time points match + + # Input vector + self.uout = np.array(u) + # TODO: Check to make sure input shape is OK + # TODO: Check to make sure time points match + + # If the system was specified, make sure it is compatible + if sys is not None: + if sys.ninputs != self.ninputs: + ValueError("System inputs do not match response data") + if sys.noutputs != self.noutputs: + ValueError("System outputs do not match response data") + if sys.nstates != self.nstates: + ValueError("System states do not match response data") + self.sys = sys + + # Keep track of whether to squeeze inputs, outputs, and states + self.squeeze = squeeze # Store legacy keyword values (only used for legacy interface) - self.return_x, self.squeeze = return_x, squeeze + self.transpose = transpose + self.return_x = return_x + + # Getter for output (implements squeeze processing) + @property + def y(self): + return self.yout + + # Getter for state (implements squeeze processing) + @property + def x(self): + return self.xout # Implement iter to allow assigning to a tuple def __iter__(self): @@ -565,8 +614,9 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, xout = np.transpose(xout) yout = np.transpose(yout) - return _process_time_response(sys, tout, yout, xout, transpose=transpose, - return_x=return_x, squeeze=squeeze) + return InputOutputResponse( + tout, yout, xout, U, sys=sys, + transpose=transpose, return_x=return_x, squeeze=squeeze) # Process time responses in a uniform way @@ -623,8 +673,21 @@ def _process_time_response( Returns ------- - response: InputOutputResponse - The input/output response of the system. + 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). + + xout : array, optional + Individual response of each x variable (if return_x is True). For a + SISO system (or if a single input is specified), xout is a 2D array + indexed by the state index and time. For a non-SISO system, xout is a + 3D array indexed by the state, the input, and time. The shape of xout + is not affected by the ``squeeze`` keyword. """ # If squeeze was not specified, figure out the default (might remain None) @@ -663,8 +726,7 @@ def _process_time_response( xout = np.transpose(xout, np.roll(range(xout.ndim), 1)) # Return time, output, and (optionally) state - return InputOutputResponse( - tout, yout, xout, None, return_x=return_x, squeeze=squeeze) + return (tout, yout, xout) if return_x else (tout, yout) def _get_ss_simo(sys, input=None, output=None, squeeze=None): From 724d1dfebc09b5768ddb52482bc1c88795d70f0a Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 21 Aug 2021 15:52:40 -0700 Subject: [PATCH 03/14] all time response functions return InputOutput response object --- control/iosys.py | 4 +- control/timeresp.py | 104 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 251a9d2cb..c36fa41ef 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1571,8 +1571,8 @@ def input_output_response( for i in range(len(T)): u = U[i] if len(U.shape) == 1 else U[:, i] y[:, i] = sys._out(T[i], [], u) - return _process_time_response( - sys, T, y, np.array((0, 0, np.asarray(T).size)), + return InputOutputResponse( + T, y, np.array((0, 0, np.asarray(T).size)), None, sys=sys, transpose=transpose, return_x=return_x, squeeze=squeeze) # create X0 if not given, test if X0 has correct shape diff --git a/control/timeresp.py b/control/timeresp.py index e111678f5..f1bca27cf 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -91,25 +91,50 @@ class InputOutputResponse: type for time domain simulations (step response, input/output response, etc). + Input/output responses can be stored for multiple input signals, with + the output and state indexed by the input number. This allows for + input/output response matrices, which is mainly useful for impulse and + step responses for linear systems. For mulit-input responses, the same + time vector must be used for all inputs. + Attributes ---------- t : array - Time values of the output. + Time values of the input/output response(s). y : array - Response of the system, indexed by the output number and time. + Output response of the system, indexed by either the output and time + (if only a single input is given) or the output, input, and time + (for muitiple inputs). x : array - Time evolution of the state vector, indexed by state number and time. + Time evolution of the state vector, indexed indexed by either the + state and time (if only a single input is given) or the state, + input, and time (for muitiple inputs). - u : array - Input to the system, indexed by the input number and time. + u : 1D or 2D array + Input(s) to the system, indexed by input (optional) and time. If a + 1D vector is passed, the output and state responses should be 2D + arrays. If a 2D array is passed, then the state and output vectors + should be 3D (indexed by input). Methods ------- plot(**kwargs) Plot the input/output response. Keywords are passed to matplotlib. + Examples + -------- + >>> sys = ct.rss(4, 2, 2) + >>> response = ct.step_response(sys) + >>> response.plot() # 2x2 matrix of step responses + >>> response.plot(output=1, input=0) # First input to second output + + >>> T = np.linspace(0, 10, 100) + >>> U = np.sin(np.linspace(T)) + >>> response = ct.forced_response(sys, T, U) + >>> t, y = response.t, response.y + Notes ----- 1. For backward compatibility with earlier versions of python-control, @@ -137,14 +162,64 @@ class InputOutputResponse: def __init__( self, t, y, x, u, sys=None, dt=None, - transpose=False, return_x=False, squeeze=None + transpose=False, return_x=False, squeeze=None, + input=None, output=None ): + """Create an input/output time response object. + + Parameters + ---------- + sys : LTI or InputOutputSystem + System that generated the data (used to check if SISO/MIMO). + + 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. + + xout : array, optional + Individual response of each x variable (if return_x is + True). For a SISO system (or if a single input is specified), + this should be a 2D array indexed by the state index and time + (for single input systems) or a 3D array indexed by state, + input, and time. Ignored if None. + + 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. + + return_x : bool, optional + If True, return the state vector (default = 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 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 the system is SISO. The default value can be set + using config.defaults['control.squeeze_time_response']. + + input : int, optional + If present, the response represents only the listed input. + + output : int, optional + If present, the response represents only the listed output. + + """ # # Process and store the basic input/output elements # t, y, x = _process_time_response( sys, t, y, x, - transpose=transpose, return_x=True, squeeze=squeeze) + transpose=transpose, return_x=True, squeeze=squeeze, + input=input, output=output) # Time vector self.t = np.atleast_1d(t) @@ -180,9 +255,10 @@ def __init__( # Keep track of whether to squeeze inputs, outputs, and states self.squeeze = squeeze - # Store legacy keyword values (only used for legacy interface) + # Store legacy keyword values (only needed for legacy interface) self.transpose = transpose self.return_x = return_x + self.input, self.output = input, output # Getter for output (implements squeeze processing) @property @@ -898,9 +974,9 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, if return_x: xout[:, i, :] = out[2] - return _process_time_response( - sys, out[0], yout, xout, transpose=transpose, return_x=return_x, - squeeze=squeeze, input=input, output=output) + return InputOutputResponse( + out[0], yout, xout, None, sys=sys, transpose=transpose, + return_x=return_x, squeeze=squeeze, input=input, output=output) def step_info(sysdata, T=None, T_num=None, yfinal=None, @@ -1370,9 +1446,9 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, if return_x: xout[:, i, :] = out[2] - return _process_time_response( - sys, out[0], yout, xout, transpose=transpose, return_x=return_x, - squeeze=squeeze, input=input, output=output) + return InputOutputResponse( + out[0], yout, xout, None, sys=sys, transpose=transpose, + return_x=return_x, squeeze=squeeze, input=input, output=output) # utility function to find time period and time increment using pole locations From 85231b59b0f66ef48d0abc5b38b780b680386c50 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 21 Aug 2021 22:50:13 -0700 Subject: [PATCH 04/14] move I/O processing to property functions --- control/iosys.py | 4 +- control/timeresp.py | 123 ++++++++++++++++++++++++++++---------------- 2 files changed, 81 insertions(+), 46 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index c36fa41ef..18e7165dc 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -879,7 +879,7 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, def __call__(sys, u, params=None, squeeze=None): """Evaluate a (static) nonlinearity at a given input value - If a nonlinear I/O system has not internal state, then evaluating the + If a nonlinear I/O system has no internal state, then evaluating the system at an input `u` gives the output `y = F(u)`, determined by the output function. @@ -1572,7 +1572,7 @@ def input_output_response( u = U[i] if len(U.shape) == 1 else U[:, i] y[:, i] = sys._out(T[i], [], u) return InputOutputResponse( - T, y, np.array((0, 0, np.asarray(T).size)), None, sys=sys, + T, y, np.zeros((0, 0, np.asarray(T).size)), None, sys=sys, transpose=transpose, return_x=return_x, squeeze=squeeze) # create X0 if not given, test if X0 has correct shape diff --git a/control/timeresp.py b/control/timeresp.py index f1bca27cf..9169d880c 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -120,9 +120,12 @@ class InputOutputResponse: Methods ------- - plot(**kwargs) + plot(**kwargs) [NOT IMPLEMENTED] Plot the input/output response. Keywords are passed to matplotlib. + set_defaults(**kwargs) [NOT IMPLEMENTED] + Set the default values for accessing the input/output data. + Examples -------- >>> sys = ct.rss(4, 2, 2) @@ -144,7 +147,6 @@ class InputOutputResponse: t, y = step_response(sys) t, y, x = step_response(sys, return_x=True) - t, y, x, u = step_response(sys, return_x=True, return_u=True) 2. For backward compatibility with earlier version of python-control, this class has ``__getitem__`` and ``__len__`` methods that allow the @@ -154,9 +156,9 @@ class InputOutputResponse: response[1]: returns the output vector response[2]: returns the state vector - If the index is two-dimensional, a new ``InputOutputResponse`` object - is returned that corresponds to the specified subset of input/output - responses. + 3. If a response is indexed using a two-dimensional tuple, a new + ``InputOutputResponse`` object is returned that corresponds to the + specified subset of input/output responses. [NOT IMPLEMENTED] """ @@ -169,26 +171,46 @@ def __init__( Parameters ---------- - sys : LTI or InputOutputSystem - System that generated the data (used to check if SISO/MIMO). - - T : 1D array + 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. + y : ndarray + Output response of the system. This can either be a 1D array + indexed by time (for SISO systems or MISO systems with a specified + input), 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. + + x : array, optional + Individual response of each state variable. This should be a 2D + array indexed by the state index and time (for single input + systems) or a 3D array indexed by state, input, and time. + + u : array, optional + Inputs used to generate the output. This can either be a 1D array + indexed by time (for SISO systems or MISO/MIMO systems with a + specified input) or a 2D array indexed by input and time. - xout : array, optional - Individual response of each x variable (if return_x is - True). For a SISO system (or if a single input is specified), - this should be a 2D array indexed by the state index and time - (for single input systems) or a 3D array indexed by state, - input, and time. Ignored if None. + sys : LTI or InputOutputSystem, optional + System that generated the data. If desired, the system used to + generate the data can be stored along with the data. + squeeze : bool, optional + By default, if a system is single-input, single-output (SISO) then + the inputs and outputs are returned as a 1D array (indexed by + time) and if a system is multi-input or multi-output, the the + inputs are returned as a 2D array (indexed by input and time) and + the outputs are returned as a 3D array (indexed by output, input, + and time). 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, keep the + input as a 2D array (indexed by the input and time) and the output + 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']. + + Additional parameters + --------------------- transpose : bool, optional If True, transpose all input and output arrays (for backward compatibility with MATLAB and :func:`scipy.signal.lsim`). @@ -197,15 +219,6 @@ def __init__( return_x : bool, optional If True, return the state vector (default = 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 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 the system is SISO. The default value can be set - using config.defaults['control.squeeze_time_response']. - input : int, optional If present, the response represents only the listed input. @@ -216,10 +229,6 @@ def __init__( # # Process and store the basic input/output elements # - t, y, x = _process_time_response( - sys, t, y, x, - transpose=transpose, return_x=True, squeeze=squeeze, - input=input, output=output) # Time vector self.t = np.atleast_1d(t) @@ -229,23 +238,27 @@ def __init__( # Output vector self.yout = np.array(y) self.noutputs = 1 if len(self.yout.shape) < 2 else self.yout.shape[0] - self.ninputs = 1 if len(self.yout.shape) < 3 else self.yout.shape[-2] - # TODO: Check to make sure time points match + if self.t.shape[-1] != self.yout.shape[-1]: + raise ValueError("Output vector does not match time vector") # State vector self.xout = np.array(x) - self.nstates = self.xout.shape[0] - # TODO: Check to make sure time points match + self.nstates = 0 if self.xout is None else self.xout.shape[0] + if self.t.shape[-1] != self.xout.shape[-1]: + raise ValueError("State vector does not match time vector") # Input vector self.uout = np.array(u) - # TODO: Check to make sure input shape is OK - # TODO: Check to make sure time points match + if len(self.uout.shape) != 0: + self.ninputs = 1 if len(self.uout.shape) < 2 \ + else self.uout.shape[-2] + if self.t.shape[-1] != self.uout.shape[-1]: + raise ValueError("Input vector does not match time vector") + else: + self.ninputs = 0 # If the system was specified, make sure it is compatible if sys is not None: - if sys.ninputs != self.ninputs: - ValueError("System inputs do not match response data") if sys.noutputs != self.noutputs: ValueError("System outputs do not match response data") if sys.nstates != self.nstates: @@ -253,6 +266,8 @@ def __init__( self.sys = sys # Keep track of whether to squeeze inputs, outputs, and states + if not (squeeze is True or squeeze is None or squeeze is False): + raise ValueError("unknown squeeze value") self.squeeze = squeeze # Store legacy keyword values (only needed for legacy interface) @@ -263,12 +278,29 @@ def __init__( # Getter for output (implements squeeze processing) @property def y(self): - return self.yout + t, y = _process_time_response( + self.sys, self.t, self.yout, None, + transpose=self.transpose, return_x=False, squeeze=self.squeeze, + input=self.input, output=self.output) + return y # Getter for state (implements squeeze processing) @property def x(self): - return self.xout + t, y, x = _process_time_response( + self.sys, self.t, self.yout, self.xout, + transpose=self.transpose, return_x=True, squeeze=self.squeeze, + input=self.input, output=self.output) + return x + + # Getter for state (implements squeeze processing) + @property + def u(self): + t, y = _process_time_response( + self.sys, self.t, self.uout, None, + transpose=self.transpose, return_x=False, squeeze=self.squeeze, + input=self.input, output=self.output) + return x # Implement iter to allow assigning to a tuple def __iter__(self): @@ -685,6 +717,9 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, tout = T # Return exact list of time steps yout = yout[::inc, :] xout = xout[::inc, :] + else: + # Interpolate the input to get the right number of points + U = sp.interpolate.interp1d(T, U)(tout) # Transpose the output and state vectors to match local convention xout = np.transpose(xout) From 3bc9871c2196a3399c87d05962cc8df64f3b9edb Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 22 Aug 2021 22:47:24 -0700 Subject: [PATCH 05/14] update naming conventions + initial unit tests --- control/tests/timeresp_return_test.py | 42 +++++ control/timeresp.py | 211 +++++++++++++++++--------- 2 files changed, 178 insertions(+), 75 deletions(-) create mode 100644 control/tests/timeresp_return_test.py diff --git a/control/tests/timeresp_return_test.py b/control/tests/timeresp_return_test.py new file mode 100644 index 000000000..aca18287f --- /dev/null +++ b/control/tests/timeresp_return_test.py @@ -0,0 +1,42 @@ +"""timeresp_return_test.py - test return values from time response functions + +RMM, 22 Aug 2021 + +This set of unit tests covers checks to make sure that the various time +response functions are returning the right sets of objects in the (new) +InputOutputResponse class. + +""" + +import pytest + +import numpy as np +import control as ct + + +def test_ioresponse_retvals(): + # SISO, single trace + sys = ct.rss(4, 1, 1) + T = np.linspace(0, 1, 10) + U = np.sin(T) + X0 = np.ones((sys.nstates,)) + + # Initial response + res = ct.initial_response(sys, X0=X0) + assert res.outputs.shape == (res.time.shape[0],) + assert res.states.shape == (sys.nstates, res.time.shape[0]) + np.testing.assert_equal(res.inputs, np.zeros((res.time.shape[0],))) + + # Impulse response + res = ct.impulse_response(sys) + assert res.outputs.shape == (res.time.shape[0],) + assert res.states.shape == (sys.nstates, res.time.shape[0]) + assert res.inputs.shape == (res.time.shape[0],) + np.testing.assert_equal(res.inputs, None) + + # Step response + res = ct.step_response(sys) + assert res.outputs.shape == (res.time.shape[0],) + assert res.states.shape == (sys.nstates, res.time.shape[0]) + assert res.inputs.shape == (res.time.shape[0],) + diff --git a/control/timeresp.py b/control/timeresp.py index 9169d880c..c6751c748 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -64,6 +64,9 @@ Modified by Ilhan Polat to improve automatic time vector creation Date: August 17, 2020 +Modified by Richard Murray to add InputOutputResponse class +Date: August 2021 + $Id$ """ @@ -79,8 +82,8 @@ from .statesp import StateSpace, _convert_to_statespace, _mimo2simo, _mimo2siso from .xferfcn import TransferFunction -__all__ = ['forced_response', 'step_response', 'step_info', 'initial_response', - 'impulse_response'] +__all__ = ['forced_response', 'step_response', 'step_info', + 'initial_response', 'impulse_response', 'InputOutputResponse'] class InputOutputResponse: @@ -91,32 +94,53 @@ class InputOutputResponse: type for time domain simulations (step response, input/output response, etc). - Input/output responses can be stored for multiple input signals, with - the output and state indexed by the input number. This allows for - input/output response matrices, which is mainly useful for impulse and - step responses for linear systems. For mulit-input responses, the same - time vector must be used for all inputs. + Input/output responses can be stored for multiple input signals (called + a trace), with the output and state indexed by the trace number. This + allows for input/output response matrices, which is mainly useful for + impulse and step responses for linear systems. For multi-trace + responses, the same time vector must be used for all traces. Attributes ---------- - t : array + time : array Time values of the input/output response(s). - y : array + outputs : 1D, 2D, or 3D array Output response of the system, indexed by either the output and time - (if only a single input is given) or the output, input, and time - (for muitiple inputs). + (if only a single input is given) or the output, trace, and time + (for multiple traces). - x : array + states : 2D or 3D array Time evolution of the state vector, indexed indexed by either the - state and time (if only a single input is given) or the state, - input, and time (for muitiple inputs). + state and time (if only a single trace is given) or the state, + trace, and time (for multiple traces). + + inputs : 1D or 2D array + Input(s) to the system, indexed by input (optiona), trace (optional), + and time. If a 1D vector is passed, the input corresponds to a + scalar-valued input. If a 2D vector is passed, then it can either + represent multiple single-input traces or a single multi-input trace. + The optional ``multi_trace`` keyword should be used to disambiguate + the two. If a 3D vector is passed, then it represents a multi-trace, + multi-input signal, indexed by input, trace, and time. + + sys : InputOutputSystem or LTI, optional + If present, stores the system used to generate the response. + + ninputs, noutputs, nstates : int + Number of inputs, outputs, and states of the underlying system. - u : 1D or 2D array - Input(s) to the system, indexed by input (optional) and time. If a - 1D vector is passed, the output and state responses should be 2D - arrays. If a 2D array is passed, then the state and output vectors - should be 3D (indexed by input). + ntraces : int + Number of independent traces represented in the input/output response. + + input_index : int, optional + If set to an integer, represents the input index for the input signal. + Default is ``None``, in which case all inputs should be given. + + output_index : int, optional + If set to an integer, represents the output index for the output + response. Default is ``None``, in which case all outputs should be + given. Methods ------- @@ -163,50 +187,57 @@ class InputOutputResponse: """ def __init__( - self, t, y, x, u, sys=None, dt=None, + self, time, outputs, states, inputs, sys=None, dt=None, transpose=False, return_x=False, squeeze=None, - input=None, output=None + multi_trace=False, input_index=None, output_index=None ): """Create an input/output time response object. Parameters ---------- - t : 1D array + time : 1D array Time values of the output. Ignored if None. - y : ndarray + outputs : ndarray Output response of the system. This can either be a 1D array indexed by time (for SISO systems or MISO systems with a specified input), 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. + response) or trace and time (for SISO systems with multiple + traces), or a 3D array indexed by output, trace, and time (for + multi-trace input/output responses). - x : array, optional + states : array, optional Individual response of each state variable. This should be a 2D array indexed by the state index and time (for single input - systems) or a 3D array indexed by state, input, and time. + systems) or a 3D array indexed by state, trace, and time. - u : array, optional - Inputs used to generate the output. This can either be a 1D array - indexed by time (for SISO systems or MISO/MIMO systems with a - specified input) or a 2D array indexed by input and time. + inputs : array, optional + Inputs used to generate the output. This can either be a 1D + array indexed by time (for SISO systems or MISO/MIMO systems + with a specified input), a 2D array indexed either by input and + time (for a multi-input system) or trace and time (for a + single-input, multi-trace response), or a 3D array indexed by + input, trace, and time. sys : LTI or InputOutputSystem, optional System that generated the data. If desired, the system used to generate the data can be stored along with the data. squeeze : bool, optional - By default, if a system is single-input, single-output (SISO) then - the inputs and outputs are returned as a 1D array (indexed by - time) and if a system is multi-input or multi-output, the the - inputs are returned as a 2D array (indexed by input and time) and - the outputs are returned as a 3D array (indexed by output, input, - and time). 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, keep the - input as a 2D array (indexed by the input and time) and the output - as a 3D array (indexed by the output, input, and time) even if the - system is SISO. The default value can be set using + By default, if a system is single-input, single-output (SISO) + then the inputs and outputs are returned as a 1D array (indexed + by time) and if a system is multi-input or multi-output, then + the inputs are returned as a 2D array (indexed by input and + time) and the outputs are returned as either a 2D array (indexed + by output and time) or a 3D array (indexed by output, trace, and + time). 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, + keep the input as a 2D or 3D array (indexed by the input (if + multi-input), trace (if single input) and time) and the output + as a 3D array (indexed by the output, trace, and time) even if + the system is SISO. The default value can be set using config.defaults['control.squeeze_time_response']. Additional parameters @@ -219,10 +250,15 @@ def __init__( return_x : bool, optional If True, return the state vector (default = False). - input : int, optional + multi_trace : bool, optional + If ``True``, then 2D input array represents multiple traces. For + a MIMO system, the ``input`` attribute should then be set to + indicate which input is being specified. Default is ``False``. + + input_index : int, optional If present, the response represents only the listed input. - output : int, optional + output_index : int, optional If present, the response represents only the listed output. """ @@ -231,24 +267,41 @@ def __init__( # # Time vector - self.t = np.atleast_1d(t) + self.t = np.atleast_1d(time) if len(self.t.shape) != 1: raise ValueError("Time vector must be 1D array") - # Output vector - self.yout = np.array(y) - self.noutputs = 1 if len(self.yout.shape) < 2 else self.yout.shape[0] + # Output vector (and number of traces) + self.yout = np.array(outputs) + if multi_trace or len(self.yout.shape) == 3: + if len(self.yout.shape) < 2: + raise ValueError("Output vector is the wrong shape") + self.ntraces = self.yout.shape[-2] + self.noutputs = 1 if len(self.yout.shape) < 2 else \ + self.yout.shape[0] + else: + self.ntraces = 1 + self.noutputs = 1 if len(self.yout.shape) < 2 else \ + self.yout.shape[0] + + # Make sure time dimension of output is OK if self.t.shape[-1] != self.yout.shape[-1]: raise ValueError("Output vector does not match time vector") # State vector - self.xout = np.array(x) + self.xout = np.array(states) self.nstates = 0 if self.xout is None else self.xout.shape[0] if self.t.shape[-1] != self.xout.shape[-1]: raise ValueError("State vector does not match time vector") # Input vector - self.uout = np.array(u) + # If no input is present, return an empty array + if inputs is None: + self.uout = np.empty( + (sys.ninputs, self.ntraces, self.time.shape[0])) + else: + self.uout = np.array(inputs) + if len(self.uout.shape) != 0: self.ninputs = 1 if len(self.uout.shape) < 2 \ else self.uout.shape[-2] @@ -273,55 +326,59 @@ def __init__( # Store legacy keyword values (only needed for legacy interface) self.transpose = transpose self.return_x = return_x - self.input, self.output = input, output + self.input_index, self.output_index = input_index, output_index + + @property + def time(self): + return self.t # Getter for output (implements squeeze processing) @property - def y(self): + def outputs(self): t, y = _process_time_response( self.sys, self.t, self.yout, None, transpose=self.transpose, return_x=False, squeeze=self.squeeze, - input=self.input, output=self.output) + input=self.input_index, output=self.output_index) return y # Getter for state (implements squeeze processing) @property - def x(self): + def states(self): t, y, x = _process_time_response( self.sys, self.t, self.yout, self.xout, transpose=self.transpose, return_x=True, squeeze=self.squeeze, - input=self.input, output=self.output) + input=self.input_index, output=self.output_index) return x # Getter for state (implements squeeze processing) @property - def u(self): - t, y = _process_time_response( + def inputs(self): + t, u = _process_time_response( self.sys, self.t, self.uout, None, transpose=self.transpose, return_x=False, squeeze=self.squeeze, - input=self.input, output=self.output) - return x + input=self.input_index, output=self.output_index) + return u # Implement iter to allow assigning to a tuple def __iter__(self): if not self.return_x: - return iter((self.t, self.y)) - return iter((self.t, self.y, self.x)) + return iter((self.time, self.outputs)) + return iter((self.time, self.outputs, self.states)) # Implement (thin) getitem to allow access via legacy indexing def __getitem__(self, index): # See if we were passed a slice if isinstance(index, slice): if (index.start is None or index.start == 0) and index.stop == 2: - return (self.t, self.y) + return (self.time, self.outputs) # Otherwise assume we were passed a single index if index == 0: - return self.t + return self.time if index == 1: - return self.y + return self.outputs if index == 2: - return self.x + return self.states raise IndexError # Implement (thin) len to emulate legacy testing interface @@ -913,7 +970,8 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, input : int, optional Only compute the step response for the listed input. If not - specified, the step responses for each independent input are computed. + specified, the step responses for each independent input are + computed (as separate traces). output : int, optional Only report the step response for the listed output. If not @@ -948,7 +1006,7 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, 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 3D (indexed by the input, output, and + squeeze is False, the array is 3D (indexed by the output, trace, and time). xout : array, optional @@ -992,6 +1050,7 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, noutputs = sys.noutputs if output is None else 1 yout = np.empty((noutputs, ninputs, np.asarray(T).size)) xout = np.empty((sys.nstates, ninputs, np.asarray(T).size)) + uout = np.empty((ninputs, ninputs, np.asarray(T).size)) # Simulate the response for each input for i in range(sys.ninputs): @@ -1006,12 +1065,13 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, return_x=return_x, squeeze=True) inpidx = i if input is None else 0 yout[:, inpidx, :] = out[1] - if return_x: - xout[:, i, :] = out[2] + xout[:, inpidx, :] = out[2] + uout[:, inpidx, :] = U return InputOutputResponse( - out[0], yout, xout, None, sys=sys, transpose=transpose, - return_x=return_x, squeeze=squeeze, input=input, output=output) + out[0], yout, xout, uout, sys=sys, transpose=transpose, + return_x=return_x, squeeze=squeeze, + input_index=input, output_index=output) def step_info(sysdata, T=None, T_num=None, yfinal=None, @@ -1447,6 +1507,7 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, noutputs = sys.noutputs if output is None else 1 yout = np.empty((noutputs, ninputs, np.asarray(T).size)) xout = np.empty((sys.nstates, ninputs, np.asarray(T).size)) + uout = np.full((ninputs, ninputs, np.asarray(T).size), None) # Simulate the response for each input for i in range(sys.ninputs): @@ -1473,17 +1534,17 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, # Simulate the impulse response fo this input out = forced_response(simo, T, U, new_X0, transpose=False, - return_x=return_x, squeeze=squeeze) + return_x=True, squeeze=squeeze) # Store the output (and states) inpidx = i if input is None else 0 yout[:, inpidx, :] = out[1] - if return_x: - xout[:, i, :] = out[2] + xout[:, inpidx, :] = out[2] return InputOutputResponse( - out[0], yout, xout, None, sys=sys, transpose=transpose, - return_x=return_x, squeeze=squeeze, input=input, output=output) + out[0], yout, xout, uout, sys=sys, transpose=transpose, + return_x=return_x, squeeze=squeeze, + input_index=input, output_index=output) # utility function to find time period and time increment using pole locations From 44274c3250fd9b42aca29f71abcbd9c94dfdd94d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 25 Aug 2021 07:04:57 -0700 Subject: [PATCH 06/14] update names and clean up zero input/state and single trace processing --- control/iosys.py | 6 +- control/statesp.py | 4 +- control/tests/timeresp_return_test.py | 42 ------- control/tests/trdata_test.py | 121 ++++++++++++++++++++ control/timeresp.py | 157 +++++++++++++++----------- 5 files changed, 215 insertions(+), 115 deletions(-) delete mode 100644 control/tests/timeresp_return_test.py create mode 100644 control/tests/trdata_test.py diff --git a/control/iosys.py b/control/iosys.py index 18e7165dc..6e86612e0 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -33,7 +33,7 @@ from .statesp import StateSpace, tf2ss, _convert_to_statespace from .timeresp import _check_convert_array, _process_time_response, \ - InputOutputResponse + TimeResponseData from .lti import isctime, isdtime, common_timebase from . import config @@ -1571,7 +1571,7 @@ def input_output_response( for i in range(len(T)): u = U[i] if len(U.shape) == 1 else U[:, i] y[:, i] = sys._out(T[i], [], u) - return InputOutputResponse( + return TimeResponseData( T, y, np.zeros((0, 0, np.asarray(T).size)), None, sys=sys, transpose=transpose, return_x=return_x, squeeze=squeeze) @@ -1667,7 +1667,7 @@ def ivp_rhs(t, x): else: # Neither ctime or dtime?? raise TypeError("Can't determine system type") - return InputOutputResponse( + return TimeResponseData( soln.t, y, soln.y, U, sys=sys, transpose=transpose, return_x=return_x, squeeze=squeeze) diff --git a/control/statesp.py b/control/statesp.py index 6b3a1dff3..6a46a26e0 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1932,10 +1932,10 @@ def rss(states=1, outputs=1, inputs=1, strictly_proper=False): ---------- states : int Number of state variables - inputs : int - Number of system inputs outputs : int Number of system outputs + inputs : int + Number of system inputs strictly_proper : bool, optional If set to 'True', returns a proper system (no direct term). diff --git a/control/tests/timeresp_return_test.py b/control/tests/timeresp_return_test.py deleted file mode 100644 index aca18287f..000000000 --- a/control/tests/timeresp_return_test.py +++ /dev/null @@ -1,42 +0,0 @@ -"""timeresp_return_test.py - test return values from time response functions - -RMM, 22 Aug 2021 - -This set of unit tests covers checks to make sure that the various time -response functions are returning the right sets of objects in the (new) -InputOutputResponse class. - -""" - -import pytest - -import numpy as np -import control as ct - - -def test_ioresponse_retvals(): - # SISO, single trace - sys = ct.rss(4, 1, 1) - T = np.linspace(0, 1, 10) - U = np.sin(T) - X0 = np.ones((sys.nstates,)) - - # Initial response - res = ct.initial_response(sys, X0=X0) - assert res.outputs.shape == (res.time.shape[0],) - assert res.states.shape == (sys.nstates, res.time.shape[0]) - np.testing.assert_equal(res.inputs, np.zeros((res.time.shape[0],))) - - # Impulse response - res = ct.impulse_response(sys) - assert res.outputs.shape == (res.time.shape[0],) - assert res.states.shape == (sys.nstates, res.time.shape[0]) - assert res.inputs.shape == (res.time.shape[0],) - np.testing.assert_equal(res.inputs, None) - - # Step response - res = ct.step_response(sys) - assert res.outputs.shape == (res.time.shape[0],) - assert res.states.shape == (sys.nstates, res.time.shape[0]) - assert res.inputs.shape == (res.time.shape[0],) - diff --git a/control/tests/trdata_test.py b/control/tests/trdata_test.py new file mode 100644 index 000000000..73cf79974 --- /dev/null +++ b/control/tests/trdata_test.py @@ -0,0 +1,121 @@ +"""trdata_test.py - test return values from time response functions + +RMM, 22 Aug 2021 + +This set of unit tests covers checks to make sure that the various time +response functions are returning the right sets of objects in the (new) +InputOutputResponse class. + +""" + +import pytest + +import numpy as np +import control as ct + + +@pytest.mark.parametrize( + "nout, nin, squeeze", [ + [1, 1, None], + [1, 1, True], + [1, 1, False], + [1, 2, None], + [1, 2, True], + [1, 2, False], + [2, 1, None], + [2, 1, True], + [2, 1, False], + [2, 2, None], + [2, 2, True], + [2, 2, False], +]) +def test_trdata_shapes(nin, nout, squeeze): + # SISO, single trace + sys = ct.rss(4, nout, nin, strictly_proper=True) + T = np.linspace(0, 1, 10) + U = np.outer(np.ones(nin), np.sin(T) ) + X0 = np.ones(sys.nstates) + + # + # Initial response + # + res = ct.initial_response(sys, X0=X0) + ntimes = res.time.shape[0] + + # Check shape of class members + assert len(res.time.shape) == 1 + assert res.y.shape == (sys.noutputs, ntimes) + assert res.x.shape == (sys.nstates, ntimes) + assert res.u is None + + # Check shape of class properties + if sys.issiso(): + assert res.outputs.shape == (ntimes,) + assert res.states.shape == (sys.nstates, ntimes) + assert res.inputs is None + elif res.squeeze is True: + assert res.outputs.shape == (ntimes, ) + assert res.states.shape == (sys.nstates, ntimes) + assert res.inputs is None + else: + assert res.outputs.shape == (sys.noutputs, ntimes) + assert res.states.shape == (sys.nstates, ntimes) + assert res.inputs is None + + # + # Impulse and step response + # + for fcn in (ct.impulse_response, ct.step_response): + res = fcn(sys, squeeze=squeeze) + ntimes = res.time.shape[0] + + # Check shape of class members + assert len(res.time.shape) == 1 + assert res.y.shape == (sys.noutputs, sys.ninputs, ntimes) + assert res.x.shape == (sys.nstates, sys.ninputs, ntimes) + assert res.u.shape == (sys.ninputs, sys.ninputs, ntimes) + + # Check shape of inputs and outputs + if sys.issiso() and squeeze is not False: + assert res.outputs.shape == (ntimes, ) + assert res.inputs.shape == (ntimes, ) + elif res.squeeze is True: + assert res.outputs.shape == \ + np.empty((sys.noutputs, sys.ninputs, ntimes)).squeeze().shape + assert res.inputs.shape == \ + np.empty((sys.ninputs, sys.ninputs, ntimes)).squeeze().shape + else: + assert res.outputs.shape == (sys.noutputs, sys.ninputs, ntimes) + assert res.inputs.shape == (sys.ninputs, sys.ninputs, ntimes) + + # Check state space dimensions (not affected by squeeze) + if sys.issiso(): + assert res.states.shape == (sys.nstates, ntimes) + else: + assert res.states.shape == (sys.nstates, sys.ninputs, ntimes) + + # + # Forced response + # + res = ct.forced_response(sys, T, U, X0, squeeze=squeeze) + ntimes = res.time.shape[0] + + assert len(res.time.shape) == 1 + assert res.y.shape == (sys.noutputs, ntimes) + assert res.x.shape == (sys.nstates, ntimes) + assert res.u.shape == (sys.ninputs, ntimes) + + if sys.issiso() and squeeze is not False: + assert res.outputs.shape == (ntimes,) + assert res.inputs.shape == (ntimes,) + elif squeeze is True: + assert res.outputs.shape == \ + np.empty((sys.noutputs, 1, ntimes)).squeeze().shape + assert res.inputs.shape == \ + np.empty((sys.ninputs, 1, ntimes)).squeeze().shape + else: # MIMO or squeeze is False + assert res.outputs.shape == (sys.noutputs, ntimes) + assert res.inputs.shape == (sys.ninputs, ntimes) + + # Check state space dimensions (not affected by squeeze) + assert res.states.shape == (sys.nstates, ntimes) diff --git a/control/timeresp.py b/control/timeresp.py index c6751c748..0857bcf89 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -64,7 +64,7 @@ Modified by Ilhan Polat to improve automatic time vector creation Date: August 17, 2020 -Modified by Richard Murray to add InputOutputResponse class +Modified by Richard Murray to add TimeResponseData class Date: August 2021 $Id$ @@ -83,10 +83,10 @@ from .xferfcn import TransferFunction __all__ = ['forced_response', 'step_response', 'step_info', - 'initial_response', 'impulse_response', 'InputOutputResponse'] + 'initial_response', 'impulse_response', 'TimeResponseData'] -class InputOutputResponse: +class TimeResponseData: """Class for returning time responses This class maintains and manipulates the data corresponding to the @@ -94,13 +94,24 @@ class InputOutputResponse: type for time domain simulations (step response, input/output response, etc). - Input/output responses can be stored for multiple input signals (called + A time response consists of a time vector, an output vector, and + optionally an input vector and/or state vector. Inputs and outputs can + be 1D (scalar input/output) or 2D (vector input/output). + + A time response can be stored for multiple input signals (called a trace), with the output and state indexed by the trace number. This allows for input/output response matrices, which is mainly useful for impulse and step responses for linear systems. For multi-trace responses, the same time vector must be used for all traces. - Attributes + Time responses are access through either the raw data, stored as ``t``, + ``y``, ``x``, ``u``, or using a set of properties ``time``, ``outputs``, + ``states``, ``inputs``. When access time responses via their + properties, squeeze processing is applied so that (by default) + single-input, single-output systems will have the output and input + indices supressed. This behavior is set using the ``squeeze`` keyword. + + Properties ---------- time : array Time values of the input/output response(s). @@ -124,6 +135,27 @@ class InputOutputResponse: the two. If a 3D vector is passed, then it represents a multi-trace, multi-input signal, indexed by input, trace, and time. + squeeze : bool, optional + By default, if a system is single-input, single-output (SISO) then + the inputs and outputs are returned as a 1D array (indexed by time) + and if a system is multi-input or multi-output, then the inputs are + returned as a 2D array (indexed by input and time) and the outputs + are returned as either a 2D array (indexed by output and time) or a + 3D array (indexed by output, trace, and time). 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 input is returned as a 2D or 3D + array (indexed by the input [if multi-input], trace [if + multi-trace] and time) and the output as a 2D or 3D array (indexed + by the output, trace [if multi-trace], and time) even if the system + is SISO. The default value can be set using + config.defaults['control.squeeze_time_response']. + + 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. + sys : InputOutputSystem or LTI, optional If present, stores the system used to generate the response. @@ -131,7 +163,9 @@ class InputOutputResponse: Number of inputs, outputs, and states of the underlying system. ntraces : int - Number of independent traces represented in the input/output response. + Number of independent traces represented in the input/output + response. If ntraces is 0 then the data represents a single trace + with the trace index surpressed in the data. input_index : int, optional If set to an integer, represents the input index for the input signal. @@ -142,26 +176,6 @@ class InputOutputResponse: response. Default is ``None``, in which case all outputs should be given. - Methods - ------- - plot(**kwargs) [NOT IMPLEMENTED] - Plot the input/output response. Keywords are passed to matplotlib. - - set_defaults(**kwargs) [NOT IMPLEMENTED] - Set the default values for accessing the input/output data. - - Examples - -------- - >>> sys = ct.rss(4, 2, 2) - >>> response = ct.step_response(sys) - >>> response.plot() # 2x2 matrix of step responses - >>> response.plot(output=1, input=0) # First input to second output - - >>> T = np.linspace(0, 10, 100) - >>> U = np.sin(np.linspace(T)) - >>> response = ct.forced_response(sys, T, U) - >>> t, y = response.t, response.y - Notes ----- 1. For backward compatibility with earlier versions of python-control, @@ -180,14 +194,10 @@ class InputOutputResponse: response[1]: returns the output vector response[2]: returns the state vector - 3. If a response is indexed using a two-dimensional tuple, a new - ``InputOutputResponse`` object is returned that corresponds to the - specified subset of input/output responses. [NOT IMPLEMENTED] - """ def __init__( - self, time, outputs, states, inputs, sys=None, dt=None, + self, time, outputs, states=None, inputs=None, sys=None, dt=None, transpose=False, return_x=False, squeeze=None, multi_trace=False, input_index=None, output_index=None ): @@ -253,7 +263,7 @@ def __init__( multi_trace : bool, optional If ``True``, then 2D input array represents multiple traces. For a MIMO system, the ``input`` attribute should then be set to - indicate which input is being specified. Default is ``False``. + indicate which trace is being specified. Default is ``False``. input_index : int, optional If present, the response represents only the listed input. @@ -272,40 +282,39 @@ def __init__( raise ValueError("Time vector must be 1D array") # Output vector (and number of traces) - self.yout = np.array(outputs) - if multi_trace or len(self.yout.shape) == 3: - if len(self.yout.shape) < 2: + self.y = np.array(outputs) + if multi_trace or len(self.y.shape) == 3: + if len(self.y.shape) < 2: raise ValueError("Output vector is the wrong shape") - self.ntraces = self.yout.shape[-2] - self.noutputs = 1 if len(self.yout.shape) < 2 else \ - self.yout.shape[0] + self.ntraces = self.y.shape[-2] + self.noutputs = 1 if len(self.y.shape) < 2 else \ + self.y.shape[0] else: self.ntraces = 1 - self.noutputs = 1 if len(self.yout.shape) < 2 else \ - self.yout.shape[0] + self.noutputs = 1 if len(self.y.shape) < 2 else \ + self.y.shape[0] # Make sure time dimension of output is OK - if self.t.shape[-1] != self.yout.shape[-1]: + if self.t.shape[-1] != self.y.shape[-1]: raise ValueError("Output vector does not match time vector") # State vector - self.xout = np.array(states) - self.nstates = 0 if self.xout is None else self.xout.shape[0] - if self.t.shape[-1] != self.xout.shape[-1]: + self.x = np.array(states) + self.nstates = 0 if self.x is None else self.x.shape[0] + if self.t.shape[-1] != self.x.shape[-1]: raise ValueError("State vector does not match time vector") # Input vector # If no input is present, return an empty array if inputs is None: - self.uout = np.empty( - (sys.ninputs, self.ntraces, self.time.shape[0])) + self.u = None else: - self.uout = np.array(inputs) + self.u = np.array(inputs) - if len(self.uout.shape) != 0: - self.ninputs = 1 if len(self.uout.shape) < 2 \ - else self.uout.shape[-2] - if self.t.shape[-1] != self.uout.shape[-1]: + if self.u is not None: + self.ninputs = 1 if len(self.u.shape) < 2 \ + else self.u.shape[-2] + if self.t.shape[-1] != self.u.shape[-1]: raise ValueError("Input vector does not match time vector") else: self.ninputs = 0 @@ -336,7 +345,7 @@ def time(self): @property def outputs(self): t, y = _process_time_response( - self.sys, self.t, self.yout, None, + self.sys, self.t, self.y, None, transpose=self.transpose, return_x=False, squeeze=self.squeeze, input=self.input_index, output=self.output_index) return y @@ -344,8 +353,11 @@ def outputs(self): # Getter for state (implements squeeze processing) @property def states(self): + if self.x is None: + return None + t, y, x = _process_time_response( - self.sys, self.t, self.yout, self.xout, + self.sys, self.t, self.y, self.x, transpose=self.transpose, return_x=True, squeeze=self.squeeze, input=self.input_index, output=self.output_index) return x @@ -353,8 +365,11 @@ def states(self): # Getter for state (implements squeeze processing) @property def inputs(self): + if self.u is None: + return None + t, u = _process_time_response( - self.sys, self.t, self.uout, None, + self.sys, self.t, self.u, None, transpose=self.transpose, return_x=False, squeeze=self.squeeze, input=self.input_index, output=self.output_index) return u @@ -671,6 +686,13 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, X0 = _check_convert_array(X0, [(n_states,), (n_states, 1)], 'Parameter ``X0``: ', squeeze=True) + # Test if U has correct shape and type + legal_shapes = [(n_steps,), (1, n_steps)] if n_inputs == 1 else \ + [(n_inputs, n_steps)] + U = _check_convert_array(U, legal_shapes, + 'Parameter ``U``: ', squeeze=False, + transpose=transpose) + xout = np.zeros((n_states, n_steps)) xout[:, 0] = X0 yout = np.zeros((n_outputs, n_steps)) @@ -691,17 +713,11 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, # General algorithm that interpolates U in between output points else: - # Test if U has correct shape and type - legal_shapes = [(n_steps,), (1, n_steps)] if n_inputs == 1 else \ - [(n_inputs, n_steps)] - U = _check_convert_array(U, legal_shapes, - 'Parameter ``U``: ', squeeze=False, - transpose=transpose) - # convert 1D array to 2D array with only one row + # convert input from 1D array to 2D array with only one row if len(U.shape) == 1: U = U.reshape(1, -1) # pylint: disable=E1103 - # Algorithm: to integrate from time 0 to time dt, with linear + # Algorithm: to integrate from time 0 to time dt, with linear # interpolation between inputs u(0) = u0 and u(dt) = u1, we solve # xdot = A x + B u, x(0) = x0 # udot = (u1 - u0) / dt, u(0) = u0. @@ -782,7 +798,7 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, xout = np.transpose(xout) yout = np.transpose(yout) - return InputOutputResponse( + return TimeResponseData( tout, yout, xout, U, sys=sys, transpose=transpose, return_x=return_x, squeeze=squeeze) @@ -1068,7 +1084,7 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, xout[:, inpidx, :] = out[2] uout[:, inpidx, :] = U - return InputOutputResponse( + return TimeResponseData( out[0], yout, xout, uout, sys=sys, transpose=transpose, return_x=return_x, squeeze=squeeze, input_index=input, output_index=output) @@ -1384,10 +1400,15 @@ def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None, # The initial vector X0 is created in forced_response(...) if necessary if T is None or np.asarray(T).size == 1: T = _default_time_vector(sys, N=T_num, tfinal=T, is_step=False) - U = np.zeros_like(T) - return forced_response(sys, T, U, X0, transpose=transpose, - return_x=return_x, squeeze=squeeze) + # Compute the forced response + res = forced_response(sys, T, 0, X0, transpose=transpose, + return_x=return_x, squeeze=squeeze) + + # Store the response without an input + return TimeResponseData( + res.t, res.y, res.x, None, sys=sys, + transpose=transpose, return_x=return_x, squeeze=squeeze) def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, @@ -1541,7 +1562,7 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, yout[:, inpidx, :] = out[1] xout[:, inpidx, :] = out[2] - return InputOutputResponse( + return TimeResponseData( out[0], yout, xout, uout, sys=sys, transpose=transpose, return_x=return_x, squeeze=squeeze, input_index=input, output_index=output) From 97ae02b27be12748c5ef64a249f13890e814d7f8 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 25 Aug 2021 08:05:19 -0700 Subject: [PATCH 07/14] clean up trace processing + shape checks --- control/iosys.py | 2 +- control/timeresp.py | 99 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 6e86612e0..a35fae598 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1572,7 +1572,7 @@ def input_output_response( u = U[i] if len(U.shape) == 1 else U[:, i] y[:, i] = sys._out(T[i], [], u) return TimeResponseData( - T, y, np.zeros((0, 0, np.asarray(T).size)), None, sys=sys, + T, y, None, None, sys=sys, transpose=transpose, return_x=return_x, squeeze=squeeze) # create X0 if not given, test if X0 has correct shape diff --git a/control/timeresp.py b/control/timeresp.py index 0857bcf89..e02717e27 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -219,7 +219,7 @@ def __init__( states : array, optional Individual response of each state variable. This should be a 2D - array indexed by the state index and time (for single input + array indexed by the state index and time (for single trace systems) or a 3D array indexed by state, trace, and time. inputs : array, optional @@ -281,50 +281,101 @@ def __init__( if len(self.t.shape) != 1: raise ValueError("Time vector must be 1D array") + # # Output vector (and number of traces) + # self.y = np.array(outputs) - if multi_trace or len(self.y.shape) == 3: - if len(self.y.shape) < 2: - raise ValueError("Output vector is the wrong shape") - self.ntraces = self.y.shape[-2] - self.noutputs = 1 if len(self.y.shape) < 2 else \ - self.y.shape[0] - else: + + if len(self.y.shape) == 3: + multi_trace = True + self.noutputs = self.y.shape[0] + self.ntraces = self.y.shape[1] + + elif multi_trace and len(self.y.shape) == 2: + self.noutputs = 1 + self.ntraces = self.y.shape[0] + + elif not multi_trace and len(self.y.shape) == 2: + self.noutputs = self.y.shape[0] + self.ntraces = 1 + + elif not multi_trace and len(self.y.shape) == 1: + self.nouptuts = 1 self.ntraces = 1 - self.noutputs = 1 if len(self.y.shape) < 2 else \ - self.y.shape[0] - # Make sure time dimension of output is OK + else: + raise ValueError("Output vector is the wrong shape") + + # Make sure time dimension of output is the right length if self.t.shape[-1] != self.y.shape[-1]: raise ValueError("Output vector does not match time vector") - # State vector - self.x = np.array(states) - self.nstates = 0 if self.x is None else self.x.shape[0] - if self.t.shape[-1] != self.x.shape[-1]: - raise ValueError("State vector does not match time vector") + # + # State vector (optional) + # + # If present, the shape of the state vector should be consistent + # with the multi-trace nature of the data. + # + if states is None: + self.x = None + self.nstates = 0 + else: + self.x = np.array(states) + self.nstates = self.x.shape[0] + + # Make sure the shape is OK + if multi_trace and len(self.x.shape) != 3 or \ + not multi_trace and len(self.x.shape) != 2: + raise ValueError("State vector is the wrong shape") - # Input vector - # If no input is present, return an empty array + # Make sure time dimension of state is the right length + if self.t.shape[-1] != self.x.shape[-1]: + raise ValueError("State vector does not match time vector") + + # + # Input vector (optional) + # + # If present, the shape and dimensions of the input vector should be + # consistent with the trace count computed above. + # if inputs is None: self.u = None + self.ninputs = 0 + else: self.u = np.array(inputs) - if self.u is not None: - self.ninputs = 1 if len(self.u.shape) < 2 \ - else self.u.shape[-2] + # Make sure the shape is OK and figure out the nuumber of inputs + if multi_trace and len(self.u.shape) == 3 and \ + self.u.shape[1] == self.ntraces: + self.ninputs = self.u.shape[0] + + elif multi_trace and len(self.u.shape) == 2 and \ + self.u.shape[0] == self.ntraces: + self.ninputs = 1 + + elif not multi_trace and len(self.u.shape) == 2 and \ + self.ntraces == 1: + self.ninputs = self.u.shape[0] + + elif not multi_trace and len(self.u.shape) == 1: + self.ninputs = 1 + + else: + raise ValueError("Input vector is the wrong shape") + + # Make sure time dimension of output is the right length if self.t.shape[-1] != self.u.shape[-1]: raise ValueError("Input vector does not match time vector") - else: - self.ninputs = 0 # If the system was specified, make sure it is compatible if sys is not None: if sys.noutputs != self.noutputs: ValueError("System outputs do not match response data") - if sys.nstates != self.nstates: + if self.x is not None and sys.nstates != self.nstates: ValueError("System states do not match response data") + if self.u is not None and sys.ninputs != self.ninputs: + ValueError("System inputs do not match response data") self.sys = sys # Keep track of whether to squeeze inputs, outputs, and states From bab117dbc63597f8ea8098ea785d3da365b28c99 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 25 Aug 2021 08:40:23 -0700 Subject: [PATCH 08/14] clean up _process_time_response + use ndim --- control/optimal.py | 13 +++---- control/timeresp.py | 84 +++++++++++++++++++-------------------------- 2 files changed, 43 insertions(+), 54 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index b88513f69..c8b4379f4 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -16,7 +16,7 @@ import logging import time -from .timeresp import _process_time_response +from .timeresp import TimeResponseData __all__ = ['find_optimal_input'] @@ -826,13 +826,14 @@ def __init__( else: states = None - retval = _process_time_response( - ocp.system, ocp.timepts, inputs, states, + # Process data as a time response (with "outputs" = inputs) + response = TimeResponseData( + ocp.timepts, inputs, states, sys=ocp.system, transpose=transpose, return_x=return_states, squeeze=squeeze) - self.time = retval[0] - self.inputs = retval[1] - self.states = None if states is None else retval[2] + self.time = response.time + self.inputs = response.outputs + self.states = response.states # Compute the input for a nonlinear, (constrained) optimal control problem diff --git a/control/timeresp.py b/control/timeresp.py index e02717e27..111c3f937 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -10,7 +10,8 @@ general function for simulating LTI systems the :func:`forced_response` function, which has the form:: - t, y = forced_response(sys, T, U, X0) + response = forced_response(sys, T, U, X0) + t, y = response.time, response.outputs where `T` is a vector of times at which the response should be evaluated, `U` is a vector of inputs (one for each time point) and @@ -106,7 +107,7 @@ class TimeResponseData: Time responses are access through either the raw data, stored as ``t``, ``y``, ``x``, ``u``, or using a set of properties ``time``, ``outputs``, - ``states``, ``inputs``. When access time responses via their + ``states``, ``inputs``. When accessing time responses via their properties, squeeze processing is applied so that (by default) single-input, single-output systems will have the output and input indices supressed. This behavior is set using the ``squeeze`` keyword. @@ -278,7 +279,7 @@ def __init__( # Time vector self.t = np.atleast_1d(time) - if len(self.t.shape) != 1: + if self.t.ndim != 1: raise ValueError("Time vector must be 1D array") # @@ -286,20 +287,20 @@ def __init__( # self.y = np.array(outputs) - if len(self.y.shape) == 3: + if self.y.ndim == 3: multi_trace = True self.noutputs = self.y.shape[0] self.ntraces = self.y.shape[1] - elif multi_trace and len(self.y.shape) == 2: + elif multi_trace and self.y.ndim == 2: self.noutputs = 1 self.ntraces = self.y.shape[0] - elif not multi_trace and len(self.y.shape) == 2: + elif not multi_trace and self.y.ndim == 2: self.noutputs = self.y.shape[0] self.ntraces = 1 - elif not multi_trace and len(self.y.shape) == 1: + elif not multi_trace and self.y.ndim == 1: self.nouptuts = 1 self.ntraces = 1 @@ -324,8 +325,8 @@ def __init__( self.nstates = self.x.shape[0] # Make sure the shape is OK - if multi_trace and len(self.x.shape) != 3 or \ - not multi_trace and len(self.x.shape) != 2: + if multi_trace and self.x.ndim != 3 or \ + not multi_trace and self.x.ndim != 2: raise ValueError("State vector is the wrong shape") # Make sure time dimension of state is the right length @@ -346,19 +347,19 @@ def __init__( self.u = np.array(inputs) # Make sure the shape is OK and figure out the nuumber of inputs - if multi_trace and len(self.u.shape) == 3 and \ + if multi_trace and self.u.ndim == 3 and \ self.u.shape[1] == self.ntraces: self.ninputs = self.u.shape[0] - elif multi_trace and len(self.u.shape) == 2 and \ + elif multi_trace and self.u.ndim == 2 and \ self.u.shape[0] == self.ntraces: self.ninputs = 1 - elif not multi_trace and len(self.u.shape) == 2 and \ + elif not multi_trace and self.u.ndim == 2 and \ self.ntraces == 1: self.ninputs = self.u.shape[0] - elif not multi_trace and len(self.u.shape) == 1: + elif not multi_trace and self.u.ndim == 1: self.ninputs = 1 else: @@ -396,21 +397,30 @@ def time(self): @property def outputs(self): t, y = _process_time_response( - self.sys, self.t, self.y, None, - transpose=self.transpose, return_x=False, squeeze=self.squeeze, + self.sys, self.t, self.y, + transpose=self.transpose, squeeze=self.squeeze, input=self.input_index, output=self.output_index) return y - # Getter for state (implements squeeze processing) + # Getter for state (implements non-standard squeeze processing) @property def states(self): if self.x is None: return None - t, y, x = _process_time_response( - self.sys, self.t, self.y, self.x, - transpose=self.transpose, return_x=True, squeeze=self.squeeze, - input=self.input_index, output=self.output_index) + elif self.ninputs == 1 and self.noutputs == 1 and \ + self.ntraces == 1 and self.x.ndim == 3: + # 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)) + return x # Getter for state (implements squeeze processing) @@ -420,8 +430,8 @@ def inputs(self): return None t, u = _process_time_response( - self.sys, self.t, self.u, None, - transpose=self.transpose, return_x=False, squeeze=self.squeeze, + self.sys, self.t, self.u, + transpose=self.transpose, squeeze=self.squeeze, input=self.input_index, output=self.output_index) return u @@ -765,7 +775,7 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, # General algorithm that interpolates U in between output points else: # convert input from 1D array to 2D array with only one row - if len(U.shape) == 1: + if U.ndim == 1: U = U.reshape(1, -1) # pylint: disable=E1103 # Algorithm: to integrate from time 0 to time dt, with linear @@ -856,7 +866,7 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, # Process time responses in a uniform way def _process_time_response( - sys, tout, yout, xout, transpose=None, return_x=False, + sys, tout, yout, transpose=None, squeeze=None, input=None, output=None): """Process time response signals. @@ -877,20 +887,11 @@ def _process_time_response( systems with no input indexing, such as initial_response or forced response) or a 3D array indexed by output, input, and time. - xout : array, optional - Individual response of each x variable (if return_x is True). For a - SISO system (or if a single input is specified), this should be a 2D - array indexed by the state index and time (for single input systems) - or a 3D array indexed by state, input, and time. Ignored if None. - 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. - return_x : bool, optional - If True, return the state vector (default = 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 @@ -917,13 +918,6 @@ def _process_time_response( squeeze is False, the array is either 2D (indexed by output and time) or 3D (indexed by input, output, and time). - xout : array, optional - Individual response of each x variable (if return_x is True). For a - SISO system (or if a single input is specified), xout is a 2D array - indexed by the state index and time. For a non-SISO system, xout is a - 3D array indexed by the state, the input, and time. The shape of xout - is not affected by the ``squeeze`` keyword. - """ # If squeeze was not specified, figure out the default (might remain None) if squeeze is None: @@ -939,17 +933,13 @@ def _process_time_response( pass elif squeeze is None: # squeeze signals if SISO if issiso: - if len(yout.shape) == 3: + if yout.ndim == 3: yout = yout[0][0] # remove input and output else: yout = yout[0] # remove input else: raise ValueError("unknown squeeze value") - # Figure out whether and how to squeeze the state data - if issiso and xout is not None and len(xout.shape) > 2: - xout = xout[:, 0, :] # remove input - # See if we need to transpose the data back into MATLAB form if transpose: # Transpose time vector in case we are using np.matrix @@ -957,11 +947,9 @@ def _process_time_response( # For signals, put the last index (time) into the first slot yout = np.transpose(yout, np.roll(range(yout.ndim), 1)) - if xout is not None: - xout = np.transpose(xout, np.roll(range(xout.ndim), 1)) # Return time, output, and (optionally) state - return (tout, yout, xout) if return_x else (tout, yout) + return tout, yout def _get_ss_simo(sys, input=None, output=None, squeeze=None): From 7e116f048ad6fa3e6b7985be3e2302982551d2eb Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 25 Aug 2021 10:13:36 -0700 Subject: [PATCH 09/14] clean up siso processing, remove internal property calls --- control/iosys.py | 7 ++-- control/optimal.py | 2 +- control/timeresp.py | 97 +++++++++++++++++++++------------------------ 3 files changed, 51 insertions(+), 55 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index a35fae598..8ea7742d6 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -908,7 +908,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(sys, None, out, None, squeeze=squeeze) + _, out = _process_time_response( + None, out, issiso=sys.issiso(), squeeze=squeeze) return out def _update_params(self, params, warning=False): @@ -1572,7 +1573,7 @@ def input_output_response( u = U[i] if len(U.shape) == 1 else U[:, i] y[:, i] = sys._out(T[i], [], u) return TimeResponseData( - T, y, None, None, sys=sys, + T, y, None, None, issiso=sys.issiso(), transpose=transpose, return_x=return_x, squeeze=squeeze) # create X0 if not given, test if X0 has correct shape @@ -1668,7 +1669,7 @@ def ivp_rhs(t, x): raise TypeError("Can't determine system type") return TimeResponseData( - soln.t, y, soln.y, U, sys=sys, + soln.t, y, soln.y, U, issiso=sys.issiso(), transpose=transpose, return_x=return_x, squeeze=squeeze) diff --git a/control/optimal.py b/control/optimal.py index c8b4379f4..76e9a2d31 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -828,7 +828,7 @@ def __init__( # Process data as a time response (with "outputs" = inputs) response = TimeResponseData( - ocp.timepts, inputs, states, sys=ocp.system, + ocp.timepts, inputs, states, issiso=ocp.system.issiso(), transpose=transpose, return_x=return_states, squeeze=squeeze) self.time = response.time diff --git a/control/timeresp.py b/control/timeresp.py index 111c3f937..612d6b83e 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -157,8 +157,10 @@ class TimeResponseData: compatibility with MATLAB and :func:`scipy.signal.lsim`). Default value is False. - sys : InputOutputSystem or LTI, optional - If present, stores the system used to generate the response. + issiso : bool, optional + Set to ``True`` if the system generating the data is single-input, + single-output. If passed as ``None`` (default), the input data + will be used to set the value. ninputs, noutputs, nstates : int Number of inputs, outputs, and states of the underlying system. @@ -198,7 +200,7 @@ class TimeResponseData: """ def __init__( - self, time, outputs, states=None, inputs=None, sys=None, dt=None, + self, time, outputs, states=None, inputs=None, issiso=None, transpose=False, return_x=False, squeeze=None, multi_trace=False, input_index=None, output_index=None ): @@ -369,15 +371,22 @@ def __init__( if self.t.shape[-1] != self.u.shape[-1]: raise ValueError("Input vector does not match time vector") - # If the system was specified, make sure it is compatible - if sys is not None: - if sys.noutputs != self.noutputs: - ValueError("System outputs do not match response data") - if self.x is not None and sys.nstates != self.nstates: - ValueError("System states do not match response data") - if self.u is not None and sys.ninputs != self.ninputs: - ValueError("System inputs do not match response data") - self.sys = sys + # Figure out if the system is SISO + if issiso is None: + # Figure out based on the data + if self.ninputs == 1: + issiso = self.noutputs == 1 + elif self.niinputs > 1: + issiso = False + else: + # Missing input data => can't resolve + raise ValueError("Can't determine if system is SISO") + elif issiso is True and (self.ninputs > 1 or self.noutputs > 1): + raise ValueError("Keyword `issiso` does not match data") + + # Set the value to be used for future processing + self.issiso = issiso or \ + (input_index is not None and output_index is not None) # Keep track of whether to squeeze inputs, outputs, and states if not (squeeze is True or squeeze is None or squeeze is False): @@ -397,9 +406,8 @@ def time(self): @property def outputs(self): t, y = _process_time_response( - self.sys, self.t, self.y, - transpose=self.transpose, squeeze=self.squeeze, - input=self.input_index, output=self.output_index) + self.t, self.y, issiso=self.issiso, + transpose=self.transpose, squeeze=self.squeeze) return y # Getter for state (implements non-standard squeeze processing) @@ -430,9 +438,8 @@ def inputs(self): return None t, u = _process_time_response( - self.sys, self.t, self.u, - transpose=self.transpose, squeeze=self.squeeze, - input=self.input_index, output=self.output_index) + self.t, self.u, issiso=self.issiso, + transpose=self.transpose, squeeze=self.squeeze) return u # Implement iter to allow assigning to a tuple @@ -571,7 +578,7 @@ def shape_matches(s_legal, s_actual): # Forced response of a linear system -def forced_response(sys, T=None, U=0., X0=0., transpose=False, +def forced_response(sys, T=None, U=0., X0=0., issiso=False, transpose=False, interpolate=False, return_x=None, squeeze=None): """Simulate the output of a linear system. @@ -860,24 +867,20 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, yout = np.transpose(yout) return TimeResponseData( - tout, yout, xout, U, sys=sys, + tout, yout, xout, U, issiso=sys.issiso(), transpose=transpose, return_x=return_x, squeeze=squeeze) # Process time responses in a uniform way def _process_time_response( - sys, tout, yout, transpose=None, - squeeze=None, input=None, output=None): + tout, yout, issiso=False, transpose=None, squeeze=None): """Process time response signals. - This function processes the outputs of the time response functions and - processes the transpose and squeeze keywords. + This function processes the outputs (or inputs) of time response + functions and processes the transpose and squeeze keywords. Parameters ---------- - sys : LTI or InputOutputSystem - System that generated the data (used to check if SISO/MIMO). - T : 1D array Time values of the output. Ignored if None. @@ -887,6 +890,10 @@ def _process_time_response( 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 @@ -901,12 +908,6 @@ def _process_time_response( the system is SISO. The default value can be set using config.defaults['control.squeeze_time_response']. - input : int, optional - If present, the response represents only the listed input. - - output : int, optional - If present, the response represents only the listed output. - Returns ------- T : 1D array @@ -923,9 +924,6 @@ def _process_time_response( if squeeze is None: squeeze = config.defaults['control.squeeze_time_response'] - # Determine if the system is SISO - issiso = sys.issiso() or (input is not None and output is not None) - # Figure out whether and how to squeeze output data if squeeze is True: # squeeze all dimensions yout = np.squeeze(yout) @@ -1116,16 +1114,15 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, # Create a set of single inputs system for simulation squeeze, simo = _get_ss_simo(sys, i, output, squeeze=squeeze) - out = forced_response(simo, T, U, X0, transpose=False, - return_x=return_x, squeeze=True) + response = forced_response(simo, T, U, X0, squeeze=True) inpidx = i if input is None else 0 - yout[:, inpidx, :] = out[1] - xout[:, inpidx, :] = out[2] + yout[:, inpidx, :] = response.y + xout[:, inpidx, :] = response.x uout[:, inpidx, :] = U return TimeResponseData( - out[0], yout, xout, uout, sys=sys, transpose=transpose, - return_x=return_x, squeeze=squeeze, + response.time, yout, xout, uout, issiso=sys.issiso(), + transpose=transpose, return_x=return_x, squeeze=squeeze, input_index=input, output_index=output) @@ -1441,12 +1438,11 @@ def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None, T = _default_time_vector(sys, N=T_num, tfinal=T, is_step=False) # Compute the forced response - res = forced_response(sys, T, 0, X0, transpose=transpose, - return_x=return_x, squeeze=squeeze) + response = forced_response(sys, T, 0, X0) # Store the response without an input return TimeResponseData( - res.t, res.y, res.x, None, sys=sys, + response.t, response.y, response.x, None, issiso=sys.issiso(), transpose=transpose, return_x=return_x, squeeze=squeeze) @@ -1593,17 +1589,16 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, U[0] = 1./simo.dt # unit area impulse # Simulate the impulse response fo this input - out = forced_response(simo, T, U, new_X0, transpose=False, - return_x=True, squeeze=squeeze) + response = forced_response(simo, T, U, new_X0) # Store the output (and states) inpidx = i if input is None else 0 - yout[:, inpidx, :] = out[1] - xout[:, inpidx, :] = out[2] + yout[:, inpidx, :] = response.y + xout[:, inpidx, :] = response.x return TimeResponseData( - out[0], yout, xout, uout, sys=sys, transpose=transpose, - return_x=return_x, squeeze=squeeze, + response.time, yout, xout, uout, issiso=sys.issiso(), + transpose=transpose, return_x=return_x, squeeze=squeeze, input_index=input, output_index=output) From ea45b032c77615403b8d827e275942ec6a903e4d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 25 Aug 2021 17:54:02 -0700 Subject: [PATCH 10/14] documentation cleanup/additions + PEP8 --- control/iosys.py | 35 ++++-- control/timeresp.py | 300 ++++++++++++++++++++++++++++---------------- doc/classes.rst | 1 + doc/control.rst | 1 + doc/conventions.rst | 34 +++-- 5 files changed, 244 insertions(+), 127 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 8ea7742d6..2c87c69d2 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1486,14 +1486,21 @@ def input_output_response( ---------- sys : InputOutputSystem Input/output system to simulate. + T : array-like Time steps at which the input is defined; values must be evenly spaced. + U : array-like or number, optional Input array giving input at each time `T` (default = 0). + X0 : array-like or number, optional Initial condition (default = 0). + return_x : bool, optional + If True, return the state vector when assigning to a tuple (default = + False). See :func:`forced_response` for more details. If True, return the values of the state at each time (default = False). + squeeze : bool, optional If True and if the system has a single output, return the system output as a 1D array rather than a 2D array. If False, return the @@ -1502,15 +1509,25 @@ def input_output_response( Returns ------- - T : array - Time values of the output. - yout : array - 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 2D (indexed by the output number and - time). - xout : array - Time evolution of the state vector (if return_x=True). + results : TimeResponseData + Time response represented as a :class:`TimeResponseData` object + containing the following properties: + + * time (array): Time values of the output. + + * outputs (array): 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 2D (indexed + by output and time). + + * states (array): Time evolution of the state vector, represented as + a 2D array indexed by state and time. + + * inputs (array): Input(s) to the system, indexed by input and time. + + The return value of the system can also be accessed by assigning the + function to a tuple of length 2 (time, output) or of length 3 (time, + output, state) if ``return_x`` is ``True``. Other parameters ---------------- diff --git a/control/timeresp.py b/control/timeresp.py index 612d6b83e..f624f5ed2 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -10,8 +10,7 @@ general function for simulating LTI systems the :func:`forced_response` function, which has the form:: - response = forced_response(sys, T, U, X0) - t, y = response.time, response.outputs + t, y = forced_response(sys, T, U, X0) where `T` is a vector of times at which the response should be evaluated, `U` is a vector of inputs (one for each time point) and @@ -87,8 +86,8 @@ 'initial_response', 'impulse_response', 'TimeResponseData'] -class TimeResponseData: - """Class for returning time responses +class TimeResponseData(): + """Class for returning time responses. This class maintains and manipulates the data corresponding to the temporal response of an input/output system. It is used as the return @@ -99,42 +98,45 @@ class TimeResponseData: optionally an input vector and/or state vector. Inputs and outputs can be 1D (scalar input/output) or 2D (vector input/output). - A time response can be stored for multiple input signals (called - a trace), with the output and state indexed by the trace number. This - allows for input/output response matrices, which is mainly useful for - impulse and step responses for linear systems. For multi-trace - responses, the same time vector must be used for all traces. - - Time responses are access through either the raw data, stored as ``t``, - ``y``, ``x``, ``u``, or using a set of properties ``time``, ``outputs``, - ``states``, ``inputs``. When accessing time responses via their - properties, squeeze processing is applied so that (by default) - single-input, single-output systems will have the output and input - indices supressed. This behavior is set using the ``squeeze`` keyword. - - Properties + A time response can be stored for multiple input signals (called traces), + with the output and state indexed by the trace number. This allows for + input/output response matrices, which is mainly useful for impulse and + step responses for linear systems. For multi-trace responses, the same + time vector must be used for all traces. + + Time responses are access through either the raw data, stored as + :attr:`t`, :attr:`y`, :attr:`x`, :attr:`u`, or using a set of properties + :attr:`time`, :attr:`outputs`, :attr:`states`, :attr:`inputs`. When + accessing time responses via their properties, squeeze processing is + applied so that (by default) single-input, single-output systems will have + the output and input indices supressed. This behavior is set using the + ``squeeze`` keyword. + + Attributes ---------- - time : array - Time values of the input/output response(s). - - outputs : 1D, 2D, or 3D array - Output response of the system, indexed by either the output and time - (if only a single input is given) or the output, trace, and time - (for multiple traces). - - states : 2D or 3D array - Time evolution of the state vector, indexed indexed by either the - state and time (if only a single trace is given) or the state, - trace, and time (for multiple traces). - - inputs : 1D or 2D array - Input(s) to the system, indexed by input (optiona), trace (optional), - and time. If a 1D vector is passed, the input corresponds to a - scalar-valued input. If a 2D vector is passed, then it can either - represent multiple single-input traces or a single multi-input trace. - The optional ``multi_trace`` keyword should be used to disambiguate - the two. If a 3D vector is passed, then it represents a multi-trace, - multi-input signal, indexed by input, trace, and time. + t : 1D array + Time values of the input/output response(s). This attribute is + normally accessed via the :attr:`time` property. + + y : 2D or 3D array + Output response data, indexed either by output index and time (for + single trace responses) or output, trace, and time (for multi-trace + responses). These data are normally accessed via the :attr:`outputs` + property, which performs squeeze processing. + + x : 2D or 3D array, or None + State space data, indexed either by output number and time (for single + trace responses) or output, trace, and time (for multi-trace + responses). If no state data are present, value is ``None``. These + data are normally accessed via the :attr:`states` property, which + performs squeeze processing. + + u : 2D or 3D array, or None + Input signal data, indexed either by input index and time (for single + trace responses) or input, trace, and time (for multi-trace + responses). If no input data are present, value is ``None``. These + data are normally accessed via the :attr:`inputs` property, which + performs squeeze processing. squeeze : bool, optional By default, if a system is single-input, single-output (SISO) then @@ -261,7 +263,8 @@ def __init__( Default value is False. return_x : bool, optional - If True, return the state vector (default = False). + If True, return the state vector when enumerating result by + assigning to a tuple (default = False). multi_trace : bool, optional If ``True``, then 2D input array represents multiple traces. For @@ -306,6 +309,9 @@ def __init__( self.nouptuts = 1 self.ntraces = 1 + # Reshape the data to be 2D for consistency + self.y = self.y.reshape(self.noutputs, -1) + else: raise ValueError("Output vector is the wrong shape") @@ -364,6 +370,9 @@ def __init__( elif not multi_trace and self.u.ndim == 1: self.ninputs = 1 + # Reshape the data to be 2D for consistency + self.u = self.u.reshape(self.ninputs, -1) + else: raise ValueError("Input vector is the wrong shape") @@ -375,7 +384,7 @@ def __init__( if issiso is None: # Figure out based on the data if self.ninputs == 1: - issiso = self.noutputs == 1 + issiso = (self.noutputs == 1) elif self.niinputs > 1: issiso = False else: @@ -400,11 +409,24 @@ def __init__( @property def time(self): + """Time vector. + + Time values of the input/output response(s). + + :type: 1D array""" return self.t # Getter for output (implements squeeze processing) @property def outputs(self): + """Time response output vector. + + Output response of the system, indexed by either the output and time + (if only a single input is given) or the output, trace, and time + (for multiple traces). + + :type: 1D, 2D, or 3D array + """ t, y = _process_time_response( self.t, self.y, issiso=self.issiso, transpose=self.transpose, squeeze=self.squeeze) @@ -413,6 +435,15 @@ def outputs(self): # Getter for state (implements non-standard squeeze processing) @property def states(self): + """Time response state vector. + + Time evolution of the state vector, indexed indexed by either the + state and time (if only a single trace is given) or the state, + trace, and time (for multiple traces). + + :type: 2D or 3D array + """ + if self.x is None: return None @@ -434,6 +465,18 @@ def states(self): # Getter for state (implements squeeze processing) @property def inputs(self): + """Time response input vector. + + Input(s) to the system, indexed by input (optiona), trace (optional), + and time. If a 1D vector is passed, the input corresponds to a + scalar-valued input. If a 2D vector is passed, then it can either + represent multiple single-input traces or a single multi-input trace. + The optional ``multi_trace`` keyword should be used to disambiguate + the two. If a 3D vector is passed, then it represents a multi-trace, + multi-input signal, indexed by input, trace, and time. + + :type: 1D or 2D array + """ if self.u is None: return None @@ -623,9 +666,13 @@ def forced_response(sys, T=None, U=0., X0=0., issiso=False, transpose=False, time simulations. return_x : bool, default=None - - If False, return only the time and output vectors. - - If True, also return the the state vector. - - If None, determine the returned variables by + Used if the time response data is assigned to a tuple: + + * If False, return only the time and output vectors. + + * If True, also return the the state vector. + + * If None, determine the returned variables by config.defaults['forced_response.return_x'], which was True before version 0.9 and is False since then. @@ -640,19 +687,25 @@ def forced_response(sys, T=None, U=0., X0=0., issiso=False, transpose=False, Returns ------- - T : array - Time values of the output. + results : TimeResponseData + Time response represented as a :class:`TimeResponseData` object + containing the following properties: - yout : array - 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 2D (indexed by the output number and - time). + * time (array): Time values of the output. + + * outputs (array): 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 2D (indexed + by output and time). + + * states (array): Time evolution of the state vector, represented as + a 2D array indexed by state and time. + + * inputs (array): Input(s) to the system, indexed by input and time. - xout : array - Time evolution of the state vector. Not affected by `squeeze`. Only - returned if `return_x` is True, or `return_x` is None and - config.defaults['forced_response.return_x'] is True. + The return value of the system can also be accessed by assigning the + function to a tuple of length 2 (time, output) or of length 3 (time, + output, state) if ``return_x`` is ``True``. See Also -------- @@ -911,7 +964,7 @@ def _process_time_response( Returns ------- T : 1D array - Time values of the output + Time values of the output. yout : ndarray Response of the system. If the system is SISO and squeeze is not @@ -970,7 +1023,7 @@ def _get_ss_simo(sys, input=None, output=None, squeeze=None): sys_ss = _convert_to_statespace(sys) if sys_ss.issiso(): return squeeze, sys_ss - elif squeeze == None and (input is None or output is None): + elif squeeze is None and (input is None or output is None): # Don't squeeze outputs if resulting system turns out to be siso # Note: if we expand input to allow a tuple, need to update this check squeeze = False @@ -1040,7 +1093,8 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, value is False. return_x : bool, optional - If True, return the state vector (default = False). + If True, return the state vector when assigning to a tuple (default = + False). See :func:`forced_response` for more details. squeeze : bool, optional By default, if a system is single-input, single-output (SISO) then the @@ -1053,21 +1107,27 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, Returns ------- - T : 1D array - Time values of the output + results : TimeResponseData + Time response represented as a :class:`TimeResponseData` object + containing the following properties: - 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 3D (indexed by the output, trace, and - time). + * time (array): Time values of the output. - xout : array, optional - Individual response of each x variable (if return_x is True). For a - SISO system (or if a single input is specified), xout is a 2D array - indexed by the state index and time. For a non-SISO system, xout is a - 3D array indexed by the state, the input, and time. The shape of xout - is not affected by the ``squeeze`` keyword. + * outputs (array): 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 3D (indexed + by the output, trace, and time). + + * states (array): Time evolution of the state vector, represented as + either a 2D array indexed by state and time (if SISO) or a 3D array + indexed by state, trace, and time. Not affected by ``squeeze``. + + * inputs (array): Input(s) to the system, indexed in the same manner + as ``outputs``. + + The return value of the system can also be accessed by assigning the + function to a tuple of length 2 (time, output) or of length 3 (time, + output, state) if ``return_x`` is ``True``. See Also -------- @@ -1348,6 +1408,7 @@ def step_info(sysdata, T=None, T_num=None, yfinal=None, return ret[0][0] if retsiso else ret + def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None, transpose=False, return_x=False, squeeze=None): # pylint: disable=W0622 @@ -1391,7 +1452,8 @@ def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None, value is False. return_x : bool, optional - If True, return the state vector (default = False). + If True, return the state vector when assigning to a tuple (default = + False). See :func:`forced_response` for more details. squeeze : bool, optional By default, if a system is single-input, single-output (SISO) then the @@ -1404,17 +1466,24 @@ def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None, Returns ------- - T : array - Time values of the output + results : TimeResponseData + Time response represented as a :class:`TimeResponseData` object + containing the following properties: - yout : array - 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 2D (indexed by the output number and - time). + * time (array): Time values of the output. + + * outputs (array): 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 2D (indexed + by the output and time). - xout : array, optional - Individual response of each x variable (if return_x is True). + * states (array): Time evolution of the state vector, represented as + either a 2D array indexed by state and time (if SISO). Not affected + by ``squeeze``. + + The return value of the system can also be accessed by assigning the + function to a tuple of length 2 (time, output) or of length 3 (time, + output, state) if ``return_x`` is ``True``. See Also -------- @@ -1493,7 +1562,8 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, value is False. return_x : bool, optional - If True, return the state vector (default = False). + If True, return the state vector when assigning to a tuple (default = + False). See :func:`forced_response` for more details. squeeze : bool, optional By default, if a system is single-input, single-output (SISO) then the @@ -1506,21 +1576,24 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, Returns ------- - T : array - Time values of the output + results : TimeResponseData + Impulse response represented as a :class:`TimeResponseData` object + containing the following properties: - yout : array - 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 2D (indexed by the output number and - time). + * time (array): Time values of the output. + + * outputs (array): 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 3D (indexed + by the output, trace, and time). - xout : array, optional - Individual response of each x variable (if return_x is True). For a - SISO system (or if a single input is specified), xout is a 2D array - indexed by the state index and time. For a non-SISO system, xout is a - 3D array indexed by the state, the input, and time. The shape of xout - is not affected by the ``squeeze`` keyword. + * states (array): Time evolution of the state vector, represented as + either a 2D array indexed by state and time (if SISO) or a 3D array + indexed by state, trace, and time. Not affected by ``squeeze``. + + The return value of the system can also be accessed by assigning the + function to a tuple of length 2 (time, output) or of length 3 (time, + output, state) if ``return_x`` is ``True``. See Also -------- @@ -1586,7 +1659,7 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, new_X0 = B + X0 else: new_X0 = X0 - U[0] = 1./simo.dt # unit area impulse + U[0] = 1./simo.dt # unit area impulse # Simulate the impulse response fo this input response = forced_response(simo, T, U, new_X0) @@ -1650,11 +1723,11 @@ def _ideal_tfinal_and_dt(sys, is_step=True): """ sqrt_eps = np.sqrt(np.spacing(1.)) - default_tfinal = 5 # Default simulation horizon + default_tfinal = 5 # Default simulation horizon default_dt = 0.1 - total_cycles = 5 # number of cycles for oscillating modes - pts_per_cycle = 25 # Number of points divide a period of oscillation - log_decay_percent = np.log(1000) # Factor of reduction for real pole decays + total_cycles = 5 # Number cycles for oscillating modes + pts_per_cycle = 25 # Number points divide period of osc + log_decay_percent = np.log(1000) # Reduction factor for real pole decays if sys._isstatic(): tfinal = default_tfinal @@ -1700,13 +1773,15 @@ def _ideal_tfinal_and_dt(sys, is_step=True): if p_int.size > 0: tfinal = tfinal * 5 - else: # cont time + else: # cont time sys_ss = _convert_to_statespace(sys) # Improve conditioning via balancing and zeroing tiny entries - # See for [[1,2,0], [9,1,0.01], [1,2,10*np.pi]] before/after balance + # See for [[1,2,0], [9,1,0.01], [1,2,10*np.pi]] + # before/after balance b, (sca, perm) = matrix_balance(sys_ss.A, separate=True) p, l, r = eig(b, left=True, right=True) - # Reciprocal of inner product for each eigval, (bound the ~infs by 1e12) + # Reciprocal of inner product for each eigval, (bound the + # ~infs by 1e12) # G = Transfer([1], [1,0,1]) gives zero sensitivity (bound by 1e-12) eig_sens = np.reciprocal(maximum(1e-12, einsum('ij,ij->j', l, r).real)) eig_sens = minimum(1e12, eig_sens) @@ -1726,9 +1801,9 @@ def _ideal_tfinal_and_dt(sys, is_step=True): dc = np.zeros_like(p, dtype=float) # well-conditioned nonzero poles, np.abs just in case ok = np.abs(eig_sens) <= 1/sqrt_eps - # the averaged t->inf response of each simple eigval on each i/o channel - # See, A = [[-1, k], [0, -2]], response sizes are k-dependent (that is - # R/L eigenvector dependent) + # the averaged t->inf response of each simple eigval on each i/o + # channel. See, A = [[-1, k], [0, -2]], response sizes are + # k-dependent (that is R/L eigenvector dependent) dc[ok] = norm(v[ok, :], axis=1)*norm(w[:, ok], axis=0)*eig_sens[ok] dc[wn != 0.] /= wn[wn != 0] if is_step else 1. dc[wn == 0.] = 0. @@ -1751,8 +1826,10 @@ def _ideal_tfinal_and_dt(sys, is_step=True): # The rest ~ts = log(%ss value) / exp(Re(eigval)t) texp_mode = log_decay_percent / np.abs(psub[~iw & ~ints].real) tfinal += texp_mode.tolist() - dt += minimum(texp_mode / 50, - (2 * np.pi / pts_per_cycle / wnsub[~iw & ~ints])).tolist() + dt += minimum( + texp_mode / 50, + (2 * np.pi / pts_per_cycle / wnsub[~iw & ~ints]) + ).tolist() # All integrators? if len(tfinal) == 0: @@ -1763,13 +1840,14 @@ def _ideal_tfinal_and_dt(sys, is_step=True): return tfinal, dt + def _default_time_vector(sys, N=None, tfinal=None, is_step=True): """Returns a time vector that has a reasonable number of points. if system is discrete-time, N is ignored """ N_max = 5000 - N_min_ct = 100 # min points for cont time systems - N_min_dt = 20 # more common to see just a few samples in discrete-time + N_min_ct = 100 # min points for cont time systems + N_min_dt = 20 # more common to see just a few samples in discrete time ideal_tfinal, ideal_dt = _ideal_tfinal_and_dt(sys, is_step=is_step) @@ -1782,7 +1860,7 @@ def _default_time_vector(sys, N=None, tfinal=None, is_step=True): tfinal = sys.dt * (N-1) else: N = int(np.ceil(tfinal/sys.dt)) + 1 - tfinal = sys.dt * (N-1) # make tfinal an integer multiple of sys.dt + tfinal = sys.dt * (N-1) # make tfinal integer multiple of sys.dt else: if tfinal is None: # for continuous time, simulate to ideal_tfinal but limit N diff --git a/doc/classes.rst b/doc/classes.rst index b80b7dd54..0a937cecf 100644 --- a/doc/classes.rst +++ b/doc/classes.rst @@ -47,3 +47,4 @@ Additional classes flatsys.SystemTrajectory optimal.OptimalControlProblem optimal.OptimalControlResult + TimeResponseData diff --git a/doc/control.rst b/doc/control.rst index a3e28881b..2ec93ed48 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -70,6 +70,7 @@ Time domain simulation input_output_response step_response phase_plot + TimeResponseData Block diagram algebra ===================== diff --git a/doc/conventions.rst b/doc/conventions.rst index 63f3fac2c..e6cf0fd36 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -161,23 +161,43 @@ The initial conditions are either 1D, or 2D with shape (j, 1):: ... [xj]] -As all simulation functions return *arrays*, plotting is convenient:: +Functions that return time responses (e.g., :func:`forced_response`, +:func:`impulse_response`, :func:`input_output_response`, +:func:`initial_response`, and :func:`step_response`) return a +:class:`TimeResponseData` object that contains the data for the time +response. These data can be accessed via the ``time``, ``outputs``, +``states`` and ``inputs`` properties:: + + sys = rss(4, 1, 1) + response = step_response(sys) + 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 +response function can return multiple "traces" (input/output pairs), +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 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:: t, y = step_response(sys) plot(t, y) The output of a MIMO system can be plotted like this:: - t, y = forced_response(sys, u, t) + t, y = forced_response(sys, t, u) plot(t, y[0], label='y_0') plot(t, y[1], label='y_1') -The convention also works well with the state space form of linear systems. If -``D`` is the feedthrough *matrix* of a linear system, and ``U`` is its input -(*matrix* or *array*), then the feedthrough part of the system's response, -can be computed like this:: +The convention also works well with the state space form of linear +systems. If ``D`` is the feedthrough matrix (2D array) of a linear system, +and ``U`` is its input (array), then the feedthrough part of the system's +response, can be computed like this:: - ft = D * U + ft = D @ U .. currentmodule:: control From 03f0e28b804c004512f149d0aceb7efecbd9cf75 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 25 Aug 2021 21:54:03 -0700 Subject: [PATCH 11/14] docstring and signature tweaks --- control/timeresp.py | 2 +- doc/conventions.rst | 32 ++++++++++++++++++-------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/control/timeresp.py b/control/timeresp.py index f624f5ed2..1ef3a3699 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -621,7 +621,7 @@ def shape_matches(s_legal, s_actual): # Forced response of a linear system -def forced_response(sys, T=None, U=0., X0=0., issiso=False, transpose=False, +def forced_response(sys, T=None, U=0., X0=0., transpose=False, interpolate=False, return_x=None, squeeze=None): """Simulate the output of a linear system. diff --git a/doc/conventions.rst b/doc/conventions.rst index e6cf0fd36..462a71408 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -134,13 +134,12 @@ Types: * **Arguments** can be **arrays**, **matrices**, or **nested lists**. * **Return values** are **arrays** (not matrices). -The time vector is either 1D, or 2D with shape (1, n):: +The time vector is a 1D array with shape (n, ):: - T = [[t1, t2, t3, ..., tn ]] + T = [t1, t2, t3, ..., tn ] Input, state, and output all follow the same convention. Columns are different -points in time, rows are different components. When there is only one row, a -1D object is accepted or returned, which adds convenience for SISO systems:: +points in time, rows are different components:: U = [[u1(t1), u1(t2), u1(t3), ..., u1(tn)] [u2(t1), u2(t2), u2(t3), ..., u2(tn)] @@ -153,6 +152,9 @@ points in time, rows are different components. When there is only one row, a So, U[:,2] is the system's input at the third point in time; and U[1] or U[1,:] is the sequence of values for the system's second input. +When there is only one row, a 1D object is accepted or returned, which adds +convenience for SISO systems: + The initial conditions are either 1D, or 2D with shape (j, 1):: X0 = [[x1] @@ -230,27 +232,29 @@ on standard configurations. Selected variables that can be configured, along with their default values: - * freqplot.dB (False): Bode plot magnitude plotted in dB (otherwise powers of 10) + * freqplot.dB (False): Bode plot magnitude plotted in dB (otherwise powers + of 10) * freqplot.deg (True): Bode plot phase plotted in degrees (otherwise radians) - * freqplot.Hz (False): Bode plot frequency plotted in Hertz (otherwise rad/sec) + * freqplot.Hz (False): Bode plot frequency plotted in Hertz (otherwise + rad/sec) * freqplot.grid (True): Include grids for magnitude and phase plots * freqplot.number_of_samples (1000): Number of frequency points in Bode plots - * freqplot.feature_periphery_decade (1.0): How many decades to include in the - frequency range on both sides of features (poles, zeros). + * freqplot.feature_periphery_decade (1.0): How many decades to include in + the frequency range on both sides of features (poles, zeros). - * statesp.use_numpy_matrix (True): set the return type for state space matrices to - `numpy.matrix` (verus numpy.ndarray) + * statesp.use_numpy_matrix (True): set the return type for state space + matrices to `numpy.matrix` (verus numpy.ndarray) - * statesp.default_dt and xferfcn.default_dt (None): set the default value of dt when - constructing new LTI systems + * statesp.default_dt and xferfcn.default_dt (None): set the default value + of dt when constructing new LTI systems - * statesp.remove_useless_states (True): remove states that have no effect on the - input-output dynamics of the system + * statesp.remove_useless_states (True): remove states that have no effect + on the input-output dynamics of the system Additional parameter variables are documented in individual functions From 8aa68ebd92bb104e85d9baf1c3c54eed760b8172 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 26 Aug 2021 07:49:17 -0700 Subject: [PATCH 12/14] move input/output processing and add __call__ to change keywords --- control/tests/trdata_test.py | 36 +++++++++++++++ control/timeresp.py | 90 +++++++++++++++++++++++++----------- 2 files changed, 99 insertions(+), 27 deletions(-) diff --git a/control/tests/trdata_test.py b/control/tests/trdata_test.py index 73cf79974..36dc0215c 100644 --- a/control/tests/trdata_test.py +++ b/control/tests/trdata_test.py @@ -119,3 +119,39 @@ def test_trdata_shapes(nin, nout, squeeze): # Check state space dimensions (not affected by squeeze) assert res.states.shape == (sys.nstates, ntimes) + + +def test_response_copy(): + # Generate some initial data to use + sys_siso = ct.rss(4, 1, 1) + response_siso = ct.step_response(sys_siso) + siso_ntimes = response_siso.time.size + + sys_mimo = ct.rss(4, 2, 1) + response_mimo = ct.step_response(sys_mimo) + mimo_ntimes = response_mimo.time.size + + # Transpose + response_mimo_transpose = response_mimo(transpose=True) + assert response_mimo.outputs.shape == (2, 1, mimo_ntimes) + assert response_mimo_transpose.outputs.shape == (mimo_ntimes, 2, 1) + assert response_mimo.states.shape == (4, 1, mimo_ntimes) + assert response_mimo_transpose.states.shape == (mimo_ntimes, 4, 1) + + # Squeeze + response_siso_as_mimo = response_siso(squeeze=False) + assert response_siso_as_mimo.outputs.shape == (1, 1, siso_ntimes) + assert response_siso_as_mimo.states.shape == (4, siso_ntimes) + + response_mimo_squeezed = response_mimo(squeeze=True) + assert response_mimo_squeezed.outputs.shape == (2, mimo_ntimes) + assert response_mimo_squeezed.states.shape == (4, 1, mimo_ntimes) + + # Squeeze and transpose + response_mimo_sqtr = response_mimo(squeeze=True, transpose=True) + assert response_mimo_sqtr.outputs.shape == (mimo_ntimes, 2) + assert response_mimo_sqtr.states.shape == (mimo_ntimes, 4, 1) + + # Unknown keyword + with pytest.raises(ValueError, match="unknown"): + response_bad_kw = response_mimo(input=0) diff --git a/control/timeresp.py b/control/timeresp.py index 1ef3a3699..dd90b56ca 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -76,6 +76,7 @@ import scipy as sp from numpy import einsum, maximum, minimum from scipy.linalg import eig, eigvals, matrix_balance, norm +from copy import copy from . import config from .lti import isctime, isdtime @@ -172,15 +173,6 @@ class TimeResponseData(): response. If ntraces is 0 then the data represents a single trace with the trace index surpressed in the data. - input_index : int, optional - If set to an integer, represents the input index for the input signal. - Default is ``None``, in which case all inputs should be given. - - output_index : int, optional - If set to an integer, represents the output index for the output - response. Default is ``None``, in which case all outputs should be - given. - Notes ----- 1. For backward compatibility with earlier versions of python-control, @@ -199,12 +191,16 @@ class TimeResponseData(): response[1]: returns the output vector response[2]: returns the state vector + 3. 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 + """ def __init__( self, time, outputs, states=None, inputs=None, issiso=None, - transpose=False, return_x=False, squeeze=None, - multi_trace=False, input_index=None, output_index=None + transpose=False, return_x=False, squeeze=None, multi_trace=False ): """Create an input/output time response object. @@ -271,12 +267,6 @@ def __init__( a MIMO system, the ``input`` attribute should then be set to indicate which trace is being specified. Default is ``False``. - input_index : int, optional - If present, the response represents only the listed input. - - output_index : int, optional - If present, the response represents only the listed output. - """ # # Process and store the basic input/output elements @@ -394,8 +384,7 @@ def __init__( raise ValueError("Keyword `issiso` does not match data") # Set the value to be used for future processing - self.issiso = issiso or \ - (input_index is not None and output_index is not None) + self.issiso = issiso # Keep track of whether to squeeze inputs, outputs, and states if not (squeeze is True or squeeze is None or squeeze is False): @@ -405,10 +394,50 @@ def __init__( # Store legacy keyword values (only needed for legacy interface) self.transpose = transpose self.return_x = return_x - self.input_index, self.output_index = input_index, output_index + + def __call__(self, **kwargs): + """Change value of processing keywords. + + Calling the time response object will create a copy of the object and + change the values of the keywords used to control the ``outputs``, + ``states``, and ``inputs`` properties. + + Parameters + ---------- + squeeze : bool, optional + 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, + keep the input as a 2D or 3D array (indexed by the input (if + multi-input), trace (if single input) and time) and the output + as a 3D array (indexed by the output, trace, and time) even if + the system is SISO. + + 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. + + return_x : bool, optional + If True, return the state vector when enumerating result by + assigning to a tuple (default = False). + """ + # Make a copy of the object + response = copy(self) + + # Update any keywords that we were passed + response.transpose = kwargs.pop('transpose', self.transpose) + response.squeeze = kwargs.pop('squeeze', self.squeeze) + + # Make sure no unknown keywords were passed + if len(kwargs) != 0: + raise ValueError("unknown parameter(s) %s" % kwargs) + + return response @property def time(self): + """Time vector. Time values of the input/output response(s). @@ -1180,10 +1209,12 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, xout[:, inpidx, :] = response.x uout[:, inpidx, :] = U + # Figure out if the system is SISO or not + issiso = sys.issiso() or (input is not None and output is not None) + return TimeResponseData( - response.time, yout, xout, uout, issiso=sys.issiso(), - transpose=transpose, return_x=return_x, squeeze=squeeze, - input_index=input, output_index=output) + response.time, yout, xout, uout, issiso=issiso, + transpose=transpose, return_x=return_x, squeeze=squeeze) def step_info(sysdata, T=None, T_num=None, yfinal=None, @@ -1509,9 +1540,12 @@ def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None, # Compute the forced response response = forced_response(sys, T, 0, X0) + # Figure out if the system is SISO or not + issiso = sys.issiso() or (input is not None and output is not None) + # Store the response without an input return TimeResponseData( - response.t, response.y, response.x, None, issiso=sys.issiso(), + response.t, response.y, response.x, None, issiso=issiso, transpose=transpose, return_x=return_x, squeeze=squeeze) @@ -1669,10 +1703,12 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, yout[:, inpidx, :] = response.y xout[:, inpidx, :] = response.x + # Figure out if the system is SISO or not + issiso = sys.issiso() or (input is not None and output is not None) + return TimeResponseData( - response.time, yout, xout, uout, issiso=sys.issiso(), - transpose=transpose, return_x=return_x, squeeze=squeeze, - input_index=input, output_index=output) + response.time, yout, xout, uout, issiso=issiso, + transpose=transpose, return_x=return_x, squeeze=squeeze) # utility function to find time period and time increment using pole locations From ce5a95c317382c95baedec1517ef3cb2db8709c0 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 26 Aug 2021 15:30:10 -0700 Subject: [PATCH 13/14] consistent squeezing for state property + legacy interface + doc updates --- control/tests/trdata_test.py | 36 ++++++++-- control/timeresp.py | 125 +++++++++++++++++++++++++---------- doc/classes.rst | 2 +- doc/control.rst | 1 - 4 files changed, 122 insertions(+), 42 deletions(-) diff --git a/control/tests/trdata_test.py b/control/tests/trdata_test.py index 36dc0215c..bf1639187 100644 --- a/control/tests/trdata_test.py +++ b/control/tests/trdata_test.py @@ -51,14 +51,17 @@ def test_trdata_shapes(nin, nout, squeeze): # Check shape of class properties if sys.issiso(): assert res.outputs.shape == (ntimes,) + assert res._legacy_states.shape == (sys.nstates, ntimes) assert res.states.shape == (sys.nstates, ntimes) assert res.inputs is None elif res.squeeze is True: assert res.outputs.shape == (ntimes, ) + assert res._legacy_states.shape == (sys.nstates, ntimes) assert res.states.shape == (sys.nstates, ntimes) assert res.inputs is None else: assert res.outputs.shape == (sys.noutputs, ntimes) + assert res._legacy_states.shape == (sys.nstates, ntimes) assert res.states.shape == (sys.nstates, ntimes) assert res.inputs is None @@ -78,21 +81,26 @@ def test_trdata_shapes(nin, nout, squeeze): # Check shape of inputs and outputs if sys.issiso() and squeeze is not False: assert res.outputs.shape == (ntimes, ) + assert res.states.shape == (sys.nstates, ntimes) assert res.inputs.shape == (ntimes, ) elif res.squeeze is True: assert res.outputs.shape == \ np.empty((sys.noutputs, sys.ninputs, ntimes)).squeeze().shape + assert res.states.shape == \ + np.empty((sys.nstates, sys.ninputs, ntimes)).squeeze().shape assert res.inputs.shape == \ np.empty((sys.ninputs, sys.ninputs, ntimes)).squeeze().shape else: assert res.outputs.shape == (sys.noutputs, sys.ninputs, ntimes) + assert res.states.shape == (sys.nstates, sys.ninputs, ntimes) assert res.inputs.shape == (sys.ninputs, sys.ninputs, ntimes) - # Check state space dimensions (not affected by squeeze) + # Check legacy state space dimensions (not affected by squeeze) if sys.issiso(): - assert res.states.shape == (sys.nstates, ntimes) + assert res._legacy_states.shape == (sys.nstates, ntimes) else: - assert res.states.shape == (sys.nstates, sys.ninputs, ntimes) + assert res._legacy_states.shape == \ + (sys.nstates, sys.ninputs, ntimes) # # Forced response @@ -107,14 +115,18 @@ def test_trdata_shapes(nin, nout, squeeze): if sys.issiso() and squeeze is not False: assert res.outputs.shape == (ntimes,) + assert res.states.shape == (sys.nstates, ntimes) assert res.inputs.shape == (ntimes,) elif squeeze is True: assert res.outputs.shape == \ np.empty((sys.noutputs, 1, ntimes)).squeeze().shape + assert res.states.shape == \ + np.empty((sys.nstates, 1, ntimes)).squeeze().shape assert res.inputs.shape == \ np.empty((sys.ninputs, 1, ntimes)).squeeze().shape else: # MIMO or squeeze is False assert res.outputs.shape == (sys.noutputs, ntimes) + assert res.states.shape == (sys.nstates, ntimes) assert res.inputs.shape == (sys.ninputs, ntimes) # Check state space dimensions (not affected by squeeze) @@ -141,16 +153,28 @@ def test_response_copy(): # Squeeze response_siso_as_mimo = response_siso(squeeze=False) assert response_siso_as_mimo.outputs.shape == (1, 1, siso_ntimes) - assert response_siso_as_mimo.states.shape == (4, siso_ntimes) + assert response_siso_as_mimo.states.shape == (4, 1, siso_ntimes) + assert response_siso_as_mimo._legacy_states.shape == (4, siso_ntimes) response_mimo_squeezed = response_mimo(squeeze=True) assert response_mimo_squeezed.outputs.shape == (2, mimo_ntimes) - assert response_mimo_squeezed.states.shape == (4, 1, mimo_ntimes) + assert response_mimo_squeezed.states.shape == (4, mimo_ntimes) + assert response_mimo_squeezed._legacy_states.shape == (4, 1, mimo_ntimes) # Squeeze and transpose response_mimo_sqtr = response_mimo(squeeze=True, transpose=True) assert response_mimo_sqtr.outputs.shape == (mimo_ntimes, 2) - assert response_mimo_sqtr.states.shape == (mimo_ntimes, 4, 1) + assert response_mimo_sqtr.states.shape == (mimo_ntimes, 4) + assert response_mimo_sqtr._legacy_states.shape == (mimo_ntimes, 4, 1) + + # Return_x + t, y = response_mimo + t, y = response_mimo() + t, y, x = response_mimo(return_x=True) + with pytest.raises(ValueError, match="too many"): + t, y = response_mimo(return_x=True) + with pytest.raises(ValueError, match="not enough"): + t, y, x = response_mimo # Unknown keyword with pytest.raises(ValueError, match="unknown"): diff --git a/control/timeresp.py b/control/timeresp.py index dd90b56ca..70f52e7e0 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -88,7 +88,7 @@ class TimeResponseData(): - """Class for returning time responses. + """A class for returning time responses. This class maintains and manipulates the data corresponding to the temporal response of an input/output system. It is used as the return @@ -140,20 +140,18 @@ class TimeResponseData(): performs squeeze processing. squeeze : bool, optional - By default, if a system is single-input, single-output (SISO) then - the inputs and outputs are returned as a 1D array (indexed by time) - and if a system is multi-input or multi-output, then the inputs are - returned as a 2D array (indexed by input and time) and the outputs - are returned as either a 2D array (indexed by output and time) or a - 3D array (indexed by output, trace, and time). 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 input is returned as a 2D or 3D - array (indexed by the input [if multi-input], trace [if - multi-trace] and time) and the output as a 2D or 3D array (indexed - by the output, trace [if multi-trace], and time) even if the system - is SISO. The default value can be set using - config.defaults['control.squeeze_time_response']. + By default, if a system is single-input, single-output (SISO) + then the outputs (and inputs) are returned as a 1D array + (indexed by time) and if a system is multi-input or + multi-output, then the outputs are returned as a 2D array + (indexed by output and time) or a 3D array (indexed by output, + trace, and time). 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 2D or 3D array + (indexed by the output [if multi-input], trace [if multi-trace] + and time) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_time_response']. transpose : bool, optional If True, transpose all input and output arrays (for backward @@ -183,6 +181,9 @@ 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. + 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: @@ -191,11 +192,16 @@ class TimeResponseData(): 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. + 3. 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. + """ def __init__( @@ -251,8 +257,8 @@ def __init__( the system is SISO. The default value can be set using config.defaults['control.squeeze_time_response']. - Additional parameters - --------------------- + Other parameters + ---------------- transpose : bool, optional If True, transpose all input and output arrays (for backward compatibility with MATLAB and :func:`scipy.signal.lsim`). @@ -391,8 +397,10 @@ def __init__( raise ValueError("unknown squeeze value") self.squeeze = squeeze - # Store legacy keyword values (only needed for legacy interface) + # Keep track of whether to transpose for MATLAB/scipy.signal self.transpose = transpose + + # Store legacy keyword values (only needed for legacy interface) self.return_x = return_x def __call__(self, **kwargs): @@ -405,13 +413,13 @@ def __call__(self, **kwargs): Parameters ---------- squeeze : bool, optional - 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, - keep the input as a 2D or 3D array (indexed by the input (if - multi-input), trace (if single input) and time) and the output - as a 3D array (indexed by the output, trace, and time) even if - the system is SISO. + If squeeze=True, access to the output response will remove + single-dimensional entries from the shape of the inputs, outputs, + and states even if the system is not SISO. If squeeze=False, keep + the input as a 2D or 3D array (indexed by the input (if + multi-input), trace (if single input) and time) and the output and + states as a 3D array (indexed by the output/state, trace, and + time) even if the system is SISO. transpose : bool, optional If True, transpose all input and output arrays (for backward @@ -421,6 +429,7 @@ def __call__(self, **kwargs): return_x : bool, optional If True, return the state vector when enumerating result by assigning to a tuple (default = False). + """ # Make a copy of the object response = copy(self) @@ -428,6 +437,7 @@ def __call__(self, **kwargs): # Update any keywords that we were passed response.transpose = kwargs.pop('transpose', self.transpose) response.squeeze = kwargs.pop('squeeze', self.squeeze) + response.return_x = kwargs.pop('return_x', self.squeeze) # Make sure no unknown keywords were passed if len(kwargs) != 0: @@ -452,32 +462,40 @@ def outputs(self): Output response of the system, indexed by either the output and time (if only a single input is given) or the output, trace, and time - (for multiple traces). + (for multiple traces). See :attr:`TimeResponseData.squeeze` for a + description of how this can be modified using the `squeeze` keyword. :type: 1D, 2D, or 3D array + """ t, y = _process_time_response( self.t, self.y, issiso=self.issiso, transpose=self.transpose, squeeze=self.squeeze) return y - # Getter for state (implements non-standard squeeze processing) + # Getter for states (implements squeeze processing) @property def states(self): """Time response state vector. Time evolution of the state vector, indexed indexed by either the - state and time (if only a single trace is given) or the state, - trace, and time (for multiple traces). + state and time (if only a single trace is given) or the state, trace, + and time (for multiple traces). See :attr:`TimeResponseData.squeeze` + for a description of how this can be modified using the `squeeze` + keyword. :type: 2D or 3D array - """ + """ if self.x is None: return None + 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: + self.ntraces == 1 and self.x.ndim == 3 and \ + self.squeeze is not False: # Single-input, single-output system with single trace x = self.x[:, 0, :] @@ -491,7 +509,7 @@ def states(self): return x - # Getter for state (implements squeeze processing) + # Getter for inputs (implements squeeze processing) @property def inputs(self): """Time response input vector. @@ -504,7 +522,12 @@ 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. + See :attr:`TimeResponseData.squeeze` for a description of how the + dimensions of the input vector can be modified using the `squeeze` + keyword. + :type: 1D or 2D array + """ if self.u is None: return None @@ -514,11 +537,45 @@ def inputs(self): transpose=self.transpose, squeeze=self.squeeze) return u + # Getter for legacy state (implements non-standard squeeze processing) + @property + def _legacy_states(self): + """Time response state vector (legacy version). + + Time evolution of the state vector, indexed indexed by either the + state and time (if only a single trace is given) or the state, + trace, and time (for multiple traces). + + The `legacy_states` property is not affected by the `squeeze` keyword + and hence it will always have these dimensions. + + :type: 2D or 3D array + + """ + + if self.x is None: + return None + + elif self.ninputs == 1 and self.noutputs == 1 and \ + self.ntraces == 1 and self.x.ndim == 3: + # 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)) + + return x + # Implement iter to allow assigning to a tuple def __iter__(self): if not self.return_x: return iter((self.time, self.outputs)) - return iter((self.time, self.outputs, self.states)) + return iter((self.time, self.outputs, self._legacy_states)) # Implement (thin) getitem to allow access via legacy indexing def __getitem__(self, index): @@ -533,7 +590,7 @@ def __getitem__(self, index): if index == 1: return self.outputs if index == 2: - return self.states + return self._legacy_states raise IndexError # Implement (thin) len to emulate legacy testing interface diff --git a/doc/classes.rst b/doc/classes.rst index 0a937cecf..0753271c4 100644 --- a/doc/classes.rst +++ b/doc/classes.rst @@ -17,6 +17,7 @@ these directly. TransferFunction StateSpace FrequencyResponseData + TimeResponseData Input/output system subclasses ============================== @@ -47,4 +48,3 @@ Additional classes flatsys.SystemTrajectory optimal.OptimalControlProblem optimal.OptimalControlResult - TimeResponseData diff --git a/doc/control.rst b/doc/control.rst index 2ec93ed48..a3e28881b 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -70,7 +70,6 @@ Time domain simulation input_output_response step_response phase_plot - TimeResponseData Block diagram algebra ===================== From 136d6f4fe5e3ff4634dd5a8701d02f6ea744422f Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 28 Aug 2021 07:29:05 -0700 Subject: [PATCH 14/14] add signal labels + more unit tests/coverage + docstring tweaks --- control/iosys.py | 9 +- control/tests/timeresp_test.py | 2 +- control/tests/trdata_test.py | 189 ++++++++++++++++++++++++++++++++- control/timeresp.py | 114 +++++++++++++++++--- 4 files changed, 295 insertions(+), 19 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 2c87c69d2..1b55053e3 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1527,7 +1527,9 @@ def input_output_response( The return value of the system can also be accessed by assigning the function to a tuple of length 2 (time, output) or of length 3 (time, - output, state) if ``return_x`` is ``True``. + output, state) if ``return_x`` is ``True``. If the input/output + system signals are named, these names will be used as labels for the + time response. Other parameters ---------------- @@ -1590,7 +1592,8 @@ def input_output_response( u = U[i] if len(U.shape) == 1 else U[:, i] y[:, i] = sys._out(T[i], [], u) return TimeResponseData( - T, y, None, None, issiso=sys.issiso(), + T, y, None, U, issiso=sys.issiso(), + output_labels=sys.output_index, input_labels=sys.input_index, transpose=transpose, return_x=return_x, squeeze=squeeze) # create X0 if not given, test if X0 has correct shape @@ -1687,6 +1690,8 @@ def ivp_rhs(t, x): return TimeResponseData( soln.t, y, soln.y, U, issiso=sys.issiso(), + output_labels=sys.output_index, input_labels=sys.input_index, + state_labels=sys.state_index, transpose=transpose, return_x=return_x, squeeze=squeeze) diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py index c74c0c06d..435d8a60c 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -1117,7 +1117,7 @@ def test_squeeze(self, fcn, nstate, nout, ninp, squeeze, shape1, shape2): @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.ss2io]) def test_squeeze_exception(self, fcn): sys = fcn(ct.rss(2, 1, 1)) - with pytest.raises(ValueError, match="unknown squeeze value"): + with pytest.raises(ValueError, match="Unknown squeeze value"): step_response(sys, squeeze=1) @pytest.mark.usefixtures("editsdefaults") diff --git a/control/tests/trdata_test.py b/control/tests/trdata_test.py index bf1639187..fcd8676e9 100644 --- a/control/tests/trdata_test.py +++ b/control/tests/trdata_test.py @@ -25,9 +25,9 @@ [2, 1, None], [2, 1, True], [2, 1, False], - [2, 2, None], - [2, 2, True], - [2, 2, False], + [2, 3, None], + [2, 3, True], + [2, 3, False], ]) def test_trdata_shapes(nin, nout, squeeze): # SISO, single trace @@ -48,6 +48,12 @@ def test_trdata_shapes(nin, nout, squeeze): assert res.x.shape == (sys.nstates, ntimes) assert res.u is None + # Check dimensions of the response + assert res.ntraces == 0 # single trace + assert res.ninputs == 0 # no input for initial response + assert res.noutputs == sys.noutputs + assert res.nstates == sys.nstates + # Check shape of class properties if sys.issiso(): assert res.outputs.shape == (ntimes,) @@ -78,6 +84,12 @@ def test_trdata_shapes(nin, nout, squeeze): assert res.x.shape == (sys.nstates, sys.ninputs, ntimes) assert res.u.shape == (sys.ninputs, sys.ninputs, ntimes) + # Check shape of class members + assert res.ntraces == sys.ninputs + assert res.ninputs == sys.ninputs + assert res.noutputs == sys.noutputs + assert res.nstates == sys.nstates + # Check shape of inputs and outputs if sys.issiso() and squeeze is not False: assert res.outputs.shape == (ntimes, ) @@ -108,11 +120,19 @@ def test_trdata_shapes(nin, nout, squeeze): res = ct.forced_response(sys, T, U, X0, squeeze=squeeze) ntimes = res.time.shape[0] + # Check shape of class members assert len(res.time.shape) == 1 assert res.y.shape == (sys.noutputs, ntimes) assert res.x.shape == (sys.nstates, ntimes) assert res.u.shape == (sys.ninputs, ntimes) + # Check dimensions of the response + assert res.ntraces == 0 # single trace + assert res.ninputs == sys.ninputs + assert res.noutputs == sys.noutputs + assert res.nstates == sys.nstates + + # Check shape of inputs and outputs if sys.issiso() and squeeze is not False: assert res.outputs.shape == (ntimes,) assert res.states.shape == (sys.nstates, ntimes) @@ -176,6 +196,167 @@ def test_response_copy(): with pytest.raises(ValueError, match="not enough"): t, y, x = response_mimo + # Labels + assert response_mimo.output_labels is None + assert response_mimo.state_labels is None + assert response_mimo.input_labels is None + response = response_mimo( + output_labels=['y1', 'y2'], input_labels='u', + state_labels=["x[%d]" % i for i in range(4)]) + assert response.output_labels == ['y1', 'y2'] + assert response.state_labels == ['x[0]', 'x[1]', 'x[2]', 'x[3]'] + assert response.input_labels == ['u'] + # Unknown keyword - with pytest.raises(ValueError, match="unknown"): + with pytest.raises(ValueError, match="Unknown parameter(s)*"): response_bad_kw = response_mimo(input=0) + + +def test_trdata_labels(): + # Create an I/O system with labels + sys = ct.rss(4, 3, 2) + iosys = ct.LinearIOSystem(sys) + + T = np.linspace(1, 10, 10) + U = [np.sin(T), np.cos(T)] + + # Create a response + response = ct.input_output_response(iosys, T, U) + + # Make sure the labels got created + np.testing.assert_equal( + response.output_labels, ["y[%d]" % i for i in range(sys.noutputs)]) + np.testing.assert_equal( + response.state_labels, ["x[%d]" % i for i in range(sys.nstates)]) + np.testing.assert_equal( + response.input_labels, ["u[%d]" % i for i in range(sys.ninputs)]) + + +def test_trdata_multitrace(): + # + # Output signal processing + # + + # Proper call of multi-trace data w/ ambiguous 2D output + response = ct.TimeResponseData( + np.zeros(5), np.ones((2, 5)), np.zeros((3, 2, 5)), + np.ones((4, 2, 5)), multi_trace=True) + assert response.ntraces == 2 + assert response.noutputs == 1 + assert response.nstates == 3 + assert response.ninputs == 4 + + # Proper call of single trace w/ ambiguous 2D output + response = ct.TimeResponseData( + np.zeros(5), np.ones((2, 5)), np.zeros((3, 5)), + np.ones((4, 5)), multi_trace=False) + assert response.ntraces == 0 + assert response.noutputs == 2 + assert response.nstates == 3 + assert response.ninputs == 4 + + # Proper call of multi-trace data w/ ambiguous 1D output + response = ct.TimeResponseData( + np.zeros(5), np.ones(5), np.zeros((3, 5)), + np.ones((4, 5)), multi_trace=False) + assert response.ntraces == 0 + assert response.noutputs == 1 + assert response.nstates == 3 + assert response.ninputs == 4 + assert response.y.shape == (1, 5) # Make sure reshape occured + + # Output vector not the right shape + with pytest.raises(ValueError, match="Output vector is the wrong shape"): + response = ct.TimeResponseData( + np.zeros(5), np.ones((1, 2, 3, 5)), None, None) + + # Inconsistent output vector: different number of time points + with pytest.raises(ValueError, match="Output vector does not match time"): + response = ct.TimeResponseData( + np.zeros(5), np.ones(6), np.zeros(5), np.zeros(5)) + + # + # State signal processing + # + + # For multi-trace, state must be 3D + with pytest.raises(ValueError, match="State vector is the wrong shape"): + response = ct.TimeResponseData( + np.zeros(5), np.ones((1, 5)), np.zeros((3, 5)), multi_trace=True) + + # If not multi-trace, state must be 2D + with pytest.raises(ValueError, match="State vector is the wrong shape"): + response = ct.TimeResponseData( + np.zeros(5), np.ones(5), np.zeros((3, 1, 5)), multi_trace=False) + + # State vector in the wrong shape + with pytest.raises(ValueError, match="State vector is the wrong shape"): + response = ct.TimeResponseData( + np.zeros(5), np.ones((1, 2, 5)), np.zeros((2, 1, 5))) + + # Inconsistent state vector: different number of time points + with pytest.raises(ValueError, match="State vector does not match time"): + response = ct.TimeResponseData( + np.zeros(5), np.ones(5), np.zeros((1, 6)), np.zeros(5)) + + # + # Input signal processing + # + + # Proper call of multi-trace data with 2D input + response = ct.TimeResponseData( + np.zeros(5), np.ones((2, 5)), np.zeros((3, 2, 5)), + np.ones((2, 5)), multi_trace=True) + assert response.ntraces == 2 + assert response.noutputs == 1 + assert response.nstates == 3 + assert response.ninputs == 1 + + # Input vector in the wrong shape + with pytest.raises(ValueError, match="Input vector is the wrong shape"): + response = ct.TimeResponseData( + np.zeros(5), np.ones((1, 2, 5)), None, np.zeros((2, 1, 5))) + + # Inconsistent input vector: different number of time points + with pytest.raises(ValueError, match="Input vector does not match time"): + response = ct.TimeResponseData( + np.zeros(5), np.ones(5), np.zeros((1, 5)), np.zeros(6)) + + +def test_trdata_exceptions(): + # Incorrect dimension for time vector + with pytest.raises(ValueError, match="Time vector must be 1D"): + ct.TimeResponseData(np.zeros((2,2)), np.zeros(2), None) + + # Infer SISO system from inputs and outputs + response = ct.TimeResponseData( + np.zeros(5), np.ones(5), None, np.ones(5)) + assert response.issiso + + response = ct.TimeResponseData( + np.zeros(5), np.ones((1, 5)), None, np.ones((1, 5))) + assert response.issiso + + response = ct.TimeResponseData( + np.zeros(5), np.ones((1, 2, 5)), None, np.ones((1, 2, 5))) + assert response.issiso + + # Not enough input to infer whether SISO + with pytest.raises(ValueError, match="Can't determine if system is SISO"): + response = ct.TimeResponseData( + np.zeros(5), np.ones((1, 2, 5)), np.ones((4, 2, 5)), None) + + # Not enough input to infer whether SISO + with pytest.raises(ValueError, match="Keyword `issiso` does not match"): + response = ct.TimeResponseData( + np.zeros(5), np.ones((2, 5)), None, np.ones((1, 5)), issiso=True) + + # Unknown squeeze keyword value + with pytest.raises(ValueError, match="Unknown squeeze value"): + response=ct.TimeResponseData( + np.zeros(5), np.ones(5), None, np.ones(5), squeeze=1) + + # Legacy interface index error + response[0], response[1], response[2] + with pytest.raises(IndexError): + response[3] diff --git a/control/timeresp.py b/control/timeresp.py index 70f52e7e0..75e1dcf0b 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -105,7 +105,7 @@ class TimeResponseData(): step responses for linear systems. For multi-trace responses, the same time vector must be used for all traces. - Time responses are access through either the raw data, stored as + Time responses are accessed through either the raw data, stored as :attr:`t`, :attr:`y`, :attr:`x`, :attr:`u`, or using a set of properties :attr:`time`, :attr:`outputs`, :attr:`states`, :attr:`inputs`. When accessing time responses via their properties, squeeze processing is @@ -166,6 +166,9 @@ class TimeResponseData(): ninputs, noutputs, nstates : int Number of inputs, outputs, and states of the underlying system. + input_labels, output_labels, state_labels : array of str + Names for the input, output, and state variables. + ntraces : int Number of independent traces represented in the input/output response. If ntraces is 0 then the data represents a single trace @@ -206,6 +209,7 @@ class TimeResponseData(): def __init__( self, time, outputs, states=None, inputs=None, issiso=None, + output_labels=None, state_labels=None, input_labels=None, transpose=False, return_x=False, squeeze=None, multi_trace=False ): """Create an input/output time response object. @@ -259,6 +263,10 @@ def __init__( Other parameters ---------------- + input_labels, output_labels, state_labels: array of str, optional + Optional labels for the inputs, outputs, and states, given as a + list of strings matching the appropriate signal dimension. + transpose : bool, optional If True, transpose all input and output arrays (for backward compatibility with MATLAB and :func:`scipy.signal.lsim`). @@ -299,11 +307,11 @@ def __init__( elif not multi_trace and self.y.ndim == 2: self.noutputs = self.y.shape[0] - self.ntraces = 1 + self.ntraces = 0 elif not multi_trace and self.y.ndim == 1: - self.nouptuts = 1 - self.ntraces = 1 + self.noutputs = 1 + self.ntraces = 0 # Reshape the data to be 2D for consistency self.y = self.y.reshape(self.noutputs, -1) @@ -311,6 +319,10 @@ def __init__( else: raise ValueError("Output vector is the wrong shape") + # Check and store labels, if present + self.output_labels = _process_labels( + output_labels, "output", self.noutputs) + # Make sure time dimension of output is the right length if self.t.shape[-1] != self.y.shape[-1]: raise ValueError("Output vector does not match time vector") @@ -329,14 +341,19 @@ def __init__( self.nstates = self.x.shape[0] # Make sure the shape is OK - if multi_trace and self.x.ndim != 3 or \ - not multi_trace and self.x.ndim != 2: + if multi_trace and \ + (self.x.ndim != 3 or self.x.shape[1] != self.ntraces) or \ + not multi_trace and self.x.ndim != 2 : raise ValueError("State vector is the wrong shape") # Make sure time dimension of state is the right length if self.t.shape[-1] != self.x.shape[-1]: raise ValueError("State vector does not match time vector") + # Check and store labels, if present + self.state_labels = _process_labels( + state_labels, "state", self.nstates) + # # Input vector (optional) # @@ -360,7 +377,7 @@ def __init__( self.ninputs = 1 elif not multi_trace and self.u.ndim == 2 and \ - self.ntraces == 1: + self.ntraces == 0: self.ninputs = self.u.shape[0] elif not multi_trace and self.u.ndim == 1: @@ -376,12 +393,16 @@ def __init__( if self.t.shape[-1] != self.u.shape[-1]: raise ValueError("Input vector does not match time vector") + # Check and store labels, if present + self.input_labels = _process_labels( + input_labels, "input", self.ninputs) + # Figure out if the system is SISO if issiso is None: # Figure out based on the data if self.ninputs == 1: issiso = (self.noutputs == 1) - elif self.niinputs > 1: + elif self.ninputs > 1: issiso = False else: # Missing input data => can't resolve @@ -394,7 +415,7 @@ def __init__( # Keep track of whether to squeeze inputs, outputs, and states if not (squeeze is True or squeeze is None or squeeze is False): - raise ValueError("unknown squeeze value") + raise ValueError("Unknown squeeze value") self.squeeze = squeeze # Keep track of whether to transpose for MATLAB/scipy.signal @@ -430,6 +451,10 @@ def __call__(self, **kwargs): If True, return the state vector when enumerating result by assigning to a tuple (default = False). + input_labels, output_labels, state_labels: array of str + Labels for the inputs, outputs, and states, given as a + list of strings matching the appropriate signal dimension. + """ # Make a copy of the object response = copy(self) @@ -439,9 +464,25 @@ def __call__(self, **kwargs): response.squeeze = kwargs.pop('squeeze', self.squeeze) response.return_x = kwargs.pop('return_x', self.squeeze) + # Check for new labels + input_labels = kwargs.pop('input_labels', None) + if input_labels is not None: + response.input_labels = _process_labels( + input_labels, "input", response.ninputs) + + output_labels = kwargs.pop('output_labels', None) + if output_labels is not None: + response.output_labels = _process_labels( + output_labels, "output", response.noutputs) + + state_labels = kwargs.pop('state_labels', None) + if state_labels is not None: + response.state_labels = _process_labels( + state_labels, "state", response.nstates) + # Make sure no unknown keywords were passed if len(kwargs) != 0: - raise ValueError("unknown parameter(s) %s" % kwargs) + raise ValueError("Unknown parameter(s) %s" % kwargs) return response @@ -598,9 +639,58 @@ def __len__(self): return 3 if self.return_x else 2 +# Process signal labels +def _process_labels(labels, signal, length): + """Process time response signal labels. + + Parameters + ---------- + labels : list of str or dict + Description of the labels for the signal. This can be a list of + strings or a dict giving the index of each signal (used in iosys). + + signal : str + Name of the signal being processed (for error messages). + + length : int + Number of labels required. + + Returns + ------- + labels : list of str + List of labels. + + """ + if labels is None or len(labels) == 0: + return None + + # See if we got passed a dictionary (from iosys) + if isinstance(labels, dict): + # Form inverse dictionary + ivd = {v: k for k, v in labels.items()} + + try: + # Turn into a list + labels = [ivd[n] for n in range(len(labels))] + except KeyError: + raise ValueError("Name dictionary for %s is incomplete" % signal) + + # Convert labels to a list + labels = list(labels) + + # Make sure the signal list is the right length and type + if len(labels) != length: + raise ValueError("List of %s labels is the wrong length" % signal) + elif not all([isinstance(label, str) for label in labels]): + raise ValueError("List of %s labels must all be strings" % signal) + + return labels + + # Helper function for checking array-like parameters def _check_convert_array(in_obj, legal_shapes, err_msg_start, squeeze=False, transpose=False): + """Helper function for checking array_like parameters. * Check type and shape of ``in_obj``. @@ -867,7 +957,7 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, # Make sure the input vector and time vector have same length if (U.ndim == 1 and U.shape[0] != T.shape[0]) or \ (U.ndim > 1 and U.shape[1] != T.shape[0]): - raise ValueError('Pamameter ``T`` must have same elements as' + raise ValueError('Parameter ``T`` must have same elements as' ' the number of columns in input array ``U``') if U.ndim == 0: U = np.full((n_inputs, T.shape[0]), U) @@ -1075,7 +1165,7 @@ def _process_time_response( else: yout = yout[0] # remove input else: - raise ValueError("unknown squeeze value") + raise ValueError("Unknown squeeze value") # See if we need to transpose the data back into MATLAB form if transpose: