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: pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy