diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index 10cf2d1a9..0744906a7 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -4,7 +4,7 @@ on: [push, pull_request] jobs: test-linux: - name: Python ${{ matrix.python-version }}${{ matrix.slycot && format(' with Slycot from {0}', matrix.slycot) || ' without Slycot' }}${{ matrix.array-and-matrix == 1 && ', array and matrix' || '' }} + name: Python ${{ matrix.python-version }}${{ matrix.slycot && format(' with Slycot from {0}', matrix.slycot) || ' without Slycot' }}${{ matrix.pandas && ', with pandas' || '' }}${{ matrix.array-and-matrix == 1 && ', array and matrix' || '' }} runs-on: ubuntu-latest strategy: @@ -12,10 +12,12 @@ jobs: matrix: python-version: [3.7, 3.9] slycot: ["", "conda"] + pandas: [""] array-and-matrix: [0] include: - python-version: 3.9 slycot: conda + pandas: conda array-and-matrix: 1 steps: @@ -36,11 +38,13 @@ jobs: pip install coveralls # Install python-control dependencies - # use conda-forge until https://github.com/numpy/numpy/issues/20233 is resolved - conda install -c conda-forge numpy matplotlib scipy + conda install numpy matplotlib scipy if [[ '${{matrix.slycot}}' == 'conda' ]]; then conda install -c conda-forge slycot fi + if [[ '${{matrix.pandas}}' == 'conda' ]]; then + conda install pandas + fi - name: Test with pytest env: diff --git a/.gitignore b/.gitignore index 9f0a11c21..3ac21ae97 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,12 @@ TAGS # Files created by Spyder .spyproject/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ diff --git a/README.rst b/README.rst index 4010ecffe..f1feda7c5 100644 --- a/README.rst +++ b/README.rst @@ -22,6 +22,18 @@ Python Control Systems Library The Python Control Systems Library is a Python module that implements basic operations for analysis and design of feedback control systems. + +Have a go now! +============== +Try out the examples in the examples folder using the binder service. + +.. image:: https://mybinder.org/badge_logo.svg + :target: https://mybinder.org/v2/gh/python-control/python-control/HEAD + + + + + Features -------- diff --git a/control/__init__.py b/control/__init__.py index 57f2d2690..ad2685273 100644 --- a/control/__init__.py +++ b/control/__init__.py @@ -55,12 +55,14 @@ from .margins import * from .mateqn import * from .modelsimp import * +from .namedio import * from .nichols import * from .phaseplot import * from .pzmap import * from .rlocus import * from .statefbk import * from .statesp import * +from .stochsys import * from .timeresp import * from .xferfcn import * from .ctrlutil import * diff --git a/control/canonical.py b/control/canonical.py index 7b2b58ef7..e714e5b8d 100644 --- a/control/canonical.py +++ b/control/canonical.py @@ -2,7 +2,7 @@ # RMM, 10 Nov 2012 from .exception import ControlNotImplemented, ControlSlycot -from .lti import issiso +from .namedio import issiso from .statesp import StateSpace, _convert_to_statespace from .statefbk import ctrb, obsv diff --git a/control/config.py b/control/config.py index afd7615ca..32f5f2eef 100644 --- a/control/config.py +++ b/control/config.py @@ -73,6 +73,9 @@ def set_defaults(module, **keywords): if not isinstance(module, str): raise ValueError("module must be a string") for key, val in keywords.items(): + keyname = module + '.' + key + if keyname not in defaults and f"deprecated.{keyname}" not in defaults: + raise TypeError(f"unrecognized keyword: {key}") defaults[module + '.' + key] = val @@ -103,6 +106,9 @@ def reset_defaults(): from .iosys import _iosys_defaults defaults.update(_iosys_defaults) + from .optimal import _optimal_defaults + defaults.update(_optimal_defaults) + def _get_param(module, param, argval=None, defval=None, pop=False, last=False): """Return the default value for a configuration option. @@ -261,6 +267,15 @@ def use_legacy_defaults(version): # reset_defaults() # start from a clean slate + # Version 0.9.2: + if major == 0 and minor < 9 or (minor == 9 and patch < 2): + from math import inf + + # Reset Nyquist defaults + set_defaults('nyquist', indent_radius=0.1, max_curve_magnitude=inf, + max_curve_offset=0, primary_style=['-', '-'], + mirror_style=['--', '--'], start_marker_size=0) + # Version 0.9.0: if major == 0 and minor < 9: # switched to 'array' as default for state space objects @@ -286,6 +301,6 @@ def use_legacy_defaults(version): set_defaults('control', squeeze_time_response=True) # switched mirror_style of nyquist from '-' to '--' - set_defaults('nyqist', mirror_style='-') + set_defaults('nyquist', mirror_style='-') return (major, minor, patch) diff --git a/control/descfcn.py b/control/descfcn.py index 2ebb18569..149db1bd2 100644 --- a/control/descfcn.py +++ b/control/descfcn.py @@ -199,7 +199,8 @@ def describing_function( def describing_function_plot( - H, F, A, omega=None, refine=True, label="%5.2g @ %-5.2g", **kwargs): + H, F, A, omega=None, refine=True, label="%5.2g @ %-5.2g", + warn=None, **kwargs): """Plot a Nyquist plot with a describing function for a nonlinear system. This function generates a Nyquist plot for a closed loop system consisting @@ -220,6 +221,10 @@ def describing_function_plot( label : str, optional Formatting string used to label intersection points on the Nyquist plot. Defaults to "%5.2g @ %-5.2g". Set to `None` to omit labels. + warn : bool, optional + Set to True to turn on warnings generated by `nyquist_plot` or False + to turn off warnings. If not set (or set to None), warnings are + turned off if omega is specified, otherwise they are turned on. Returns ------- @@ -240,9 +245,15 @@ def describing_function_plot( [(3.344008947853124, 1.414213099755523)] """ + # Decide whether to turn on warnings or not + if warn is None: + # Turn warnings on unless omega was specified + warn = omega is None + # Start by drawing a Nyquist curve count, contour = nyquist_plot( - H, omega, plot=True, return_contour=True, **kwargs) + H, omega, plot=True, return_contour=True, + warn_encirclements=warn, warn_nyquist=warn, **kwargs) H_omega, H_vals = contour.imag, H(contour) # Compute the describing function diff --git a/control/dtime.py b/control/dtime.py index c60778d00..b05d22b96 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -47,7 +47,7 @@ """ -from .lti import isctime +from .namedio import isctime from .statesp import StateSpace __all__ = ['sample_system', 'c2d'] diff --git a/control/exception.py b/control/exception.py index e28ba8609..f66eb7f30 100644 --- a/control/exception.py +++ b/control/exception.py @@ -71,3 +71,16 @@ def slycot_check(): except: slycot_installed = False return slycot_installed + + +# Utility function to see if pandas is installed +pandas_installed = None +def pandas_check(): + global pandas_installed + if pandas_installed is None: + try: + import pandas + pandas_installed = True + except: + pandas_installed = False + return pandas_installed diff --git a/control/flatsys/bezier.py b/control/flatsys/bezier.py index 45a28995f..7e41c546e 100644 --- a/control/flatsys/bezier.py +++ b/control/flatsys/bezier.py @@ -55,7 +55,7 @@ class BezierFamily(BasisFamily): """ def __init__(self, N, T=1): """Create a polynomial basis of order N.""" - self.N = N # save number of basis functions + super(BezierFamily, self).__init__(N) self.T = T # save end of time interval # Compute the kth derivative of the ith basis function at time t diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index 9ea40f2fb..c01eb9127 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -108,7 +108,7 @@ class FlatSystem(NonlinearIOSystem): ----- The class must implement two functions: - zflag = flatsys.foward(x, u) + zflag = flatsys.foward(x, u, params) This function computes the flag (derivatives) of the flat output. The inputs to this function are the state 'x' and inputs 'u' (both 1D arrays). The output should be a 2D array with the first @@ -116,7 +116,7 @@ class FlatSystem(NonlinearIOSystem): dimension of the length required to represent the full system dynamics (typically the number of states) - x, u = flatsys.reverse(zflag) + x, u = flatsys.reverse(zflag, params) This function system state and inputs give the the flag (derivatives) of the flat output. The input to this function is an 2D array whose first dimension is equal to the number of system inputs and whose @@ -156,6 +156,11 @@ def __init__(self, # Save the length of the flat flag + def __str__(self): + return f"{NonlinearIOSystem.__str__(self)}\n\n" \ + + f"Forward: {self.forward}\n" \ + + f"Reverse: {self.reverse}" + def forward(self, x, u, params={}): """Compute the flat flag given the states and input. @@ -216,7 +221,7 @@ def _flat_updfcn(self, t, x, u, params={}): def _flat_outfcn(self, t, x, u, params={}): # Return the flat output zflag = self.forward(x, u, params) - return np.array(zflag[:][0]) + return np.array([zflag[i][0] for i in range(len(zflag))]) # Utility function to compute flag matrix given a basis @@ -244,8 +249,8 @@ def _basis_flag_matrix(sys, basis, flag, t, params={}): # Solve a point to point trajectory generation problem for a flat system def point_to_point( - sys, timepts, x0=0, u0=0, xf=0, uf=0, T0=0, basis=None, cost=None, - constraints=None, initial_guess=None, minimize_kwargs={}, **kwargs): + sys, timepts, x0=0, u0=0, xf=0, uf=0, T0=0, cost=None, basis=None, + trajectory_constraints=None, initial_guess=None, params=None, **kwargs): """Compute trajectory between an initial and final conditions. Compute a feasible trajectory for a differentially flat system between an @@ -284,7 +289,7 @@ def point_to_point( Function that returns the integral cost given the current state and input. Called as `cost(x, u)`. - constraints : list of tuples, optional + trajectory_constraints : list of tuples, optional List of constraints that should hold at each point in the time vector. Each element of the list should consist of a tuple with first element given by :class:`scipy.optimize.LinearConstraint` or @@ -337,8 +342,15 @@ def point_to_point( T0 = timepts[0] if len(timepts) > 1 else T0 # Process keyword arguments + if trajectory_constraints is None: + # Backwards compatibility + trajectory_constraints = kwargs.pop('constraints', None) + + minimize_kwargs = {} minimize_kwargs['method'] = kwargs.pop('minimize_method', None) minimize_kwargs['options'] = kwargs.pop('minimize_options', {}) + minimize_kwargs.update(kwargs.pop('minimize_kwargs', {})) + if kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) @@ -353,11 +365,14 @@ def point_to_point( # Make sure we have enough basis functions to solve the problem if basis.N * sys.ninputs < 2 * (sys.nstates + sys.ninputs): raise ValueError("basis set is too small") - elif (cost is not None or constraints is not None) and \ + elif (cost is not None or trajectory_constraints is not None) and \ basis.N * sys.ninputs == 2 * (sys.nstates + sys.ninputs): warnings.warn("minimal basis specified; optimization not possible") cost = None - constraints = None + trajectory_constraints = None + + # Figure out the parameters to use, if any + params = sys.params if params is None else params # # Map the initial and final conditions to flat output conditions @@ -366,8 +381,8 @@ def point_to_point( # and then evaluate this at the initial and final condition. # - zflag_T0 = sys.forward(x0, u0) - zflag_Tf = sys.forward(xf, uf) + zflag_T0 = sys.forward(x0, u0, params) + zflag_Tf = sys.forward(xf, uf, params) # # Compute the matrix constraints for initial and final conditions @@ -400,7 +415,7 @@ def point_to_point( # Start by solving the least squares problem alpha, residuals, rank, s = np.linalg.lstsq(M, Z, rcond=None) - if cost is not None or constraints is not None: + if cost is not None or trajectory_constraints is not None: # Search over the null space to minimize cost/satisfy constraints N = sp.linalg.null_space(M) @@ -418,7 +433,7 @@ def traj_cost(null_coeffs): zflag = (M_t @ coeffs).reshape(sys.ninputs, -1) # Find states and inputs at the time points - x, u = sys.reverse(zflag) + x, u = sys.reverse(zflag, params) # Evaluate the cost at this time point costval += cost(x, u) @@ -429,13 +444,13 @@ def traj_cost(null_coeffs): traj_cost = lambda coeffs: coeffs @ coeffs # Process the constraints we were given - traj_constraints = constraints - if constraints is None: + traj_constraints = trajectory_constraints + if traj_constraints is None: traj_constraints = [] - elif isinstance(constraints, tuple): + elif isinstance(traj_constraints, tuple): # TODO: Check to make sure this is really a constraint - traj_constraints = [constraints] - elif not isinstance(constraints, list): + traj_constraints = [traj_constraints] + elif not isinstance(traj_constraints, list): raise TypeError("trajectory constraints must be a list") # Process constraints @@ -456,7 +471,7 @@ def traj_const(null_coeffs): zflag = (M_t @ coeffs).reshape(sys.ninputs, -1) # Find states and inputs at the time points - states, inputs = sys.reverse(zflag) + states, inputs = sys.reverse(zflag, params) # Evaluate the constraint function along the trajectory for type, fun, lb, ub in traj_constraints: @@ -507,8 +522,8 @@ def traj_const(null_coeffs): # Transform the trajectory from flat outputs to states and inputs # - # Createa trajectory object to store the resul - systraj = SystemTrajectory(sys, basis) + # Create a trajectory object to store the result + systraj = SystemTrajectory(sys, basis, params=params) # Store the flag lengths and coefficients # TODO: make this more pythonic diff --git a/control/flatsys/linflat.py b/control/flatsys/linflat.py index 931446ca8..e4a31c6de 100644 --- a/control/flatsys/linflat.py +++ b/control/flatsys/linflat.py @@ -113,7 +113,7 @@ def __init__(self, linsys, inputs=None, outputs=None, states=None, self.Cf = Cfz @ Tr # Compute the flat flag from the state (and input) - def forward(self, x, u): + def forward(self, x, u, params): """Compute the flat flag given the states and input. See :func:`control.flatsys.FlatSystem.forward` for more info. @@ -130,7 +130,7 @@ def forward(self, x, u): return zflag # Compute state and input from flat flag - def reverse(self, zflag): + def reverse(self, zflag, params): """Compute the states and input given the flat flag. See :func:`control.flatsys.FlatSystem.reverse` for more info. @@ -140,3 +140,13 @@ def reverse(self, zflag): x = self.Tinv @ z u = zflag[0][-1] - self.F @ z return np.reshape(x, self.nstates), np.reshape(u, self.ninputs) + + # Update function + def _rhs(self, t, x, u, params={}): + # Use LinearIOSystem._rhs instead of default (MRO) NonlinearIOSystem + return LinearIOSystem._rhs(self, t, x, u) + + # output function + def _out(self, t, x, u, params={}): + # Use LinearIOSystem._out instead of default (MRO) NonlinearIOSystem + return LinearIOSystem._out(self, t, x, u) diff --git a/control/flatsys/poly.py b/control/flatsys/poly.py index 2d9f62455..08dcfb1c9 100644 --- a/control/flatsys/poly.py +++ b/control/flatsys/poly.py @@ -52,7 +52,7 @@ class PolyFamily(BasisFamily): """ def __init__(self, N): """Create a polynomial basis of order N.""" - self.N = N # save number of basis functions + super(PolyFamily, self).__init__(N) # Compute the kth derivative of the ith basis function at time t def eval_deriv(self, i, k, t): diff --git a/control/flatsys/systraj.py b/control/flatsys/systraj.py index c6ffb0867..9d425295b 100644 --- a/control/flatsys/systraj.py +++ b/control/flatsys/systraj.py @@ -37,6 +37,7 @@ # SUCH DAMAGE. import numpy as np +from ..timeresp import TimeResponseData class SystemTrajectory: """Class representing a system trajectory. @@ -62,7 +63,7 @@ class SystemTrajectory: """ - def __init__(self, sys, basis, coeffs=[], flaglen=[]): + def __init__(self, sys, basis, coeffs=[], flaglen=[], params=None): """Initilize a system trajectory object.""" self.nstates = sys.nstates self.ninputs = sys.ninputs @@ -70,6 +71,7 @@ def __init__(self, sys, basis, coeffs=[], flaglen=[]): self.basis = basis self.coeffs = list(coeffs) self.flaglen = list(flaglen) + self.params = sys.params if params is None else params # Evaluate the trajectory over a list of time points def eval(self, tlist): @@ -112,6 +114,79 @@ def eval(self, tlist): # Now copy the states and inputs # TODO: revisit order of list arguments - xd[:,tind], ud[:,tind] = self.system.reverse(zflag) + xd[:,tind], ud[:,tind] = \ + self.system.reverse(zflag, self.params) return xd, ud + + # Return the system trajectory as a TimeResponseData object + def response(self, tlist, transpose=False, return_x=False, squeeze=None): + """Return the trajectory of a system as a TimeResponseData object + + Evaluate the trajectory at a list of time points, returning the state + and input vectors for the trajectory: + + response = traj.response(tlist) + time, yd, ud = response.time, response.outputs, response.inputs + + Parameters + ---------- + tlist : 1D array + List of times to evaluate the trajectory. + + 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 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 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']. + + Returns + ------- + 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 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``. + + """ + # Compute the state and input response using the eval function + sys = self.system + xout, uout = self.eval(tlist) + yout = np.array([ + sys.output(tlist[i], xout[:, i], uout[:, i]) + for i in range(len(tlist))]).transpose() + + return TimeResponseData( + tlist, yout, xout, uout, issiso=sys.issiso(), + input_labels=sys.input_labels, output_labels=sys.output_labels, + state_labels=sys.state_labels, + transpose=transpose, return_x=return_x, squeeze=squeeze) diff --git a/control/frdata.py b/control/frdata.py index a80208963..a33775afb 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -44,12 +44,17 @@ """ # External function declarations +from copy import copy from warnings import warn + import numpy as np from numpy import angle, array, empty, ones, \ real, imag, absolute, eye, linalg, where, sort from scipy.interpolate import splprep, splev + from .lti import LTI, _process_frequency_response +from .exception import pandas_check +from .namedio import NamedIOSystem, _process_namedio_keywords from . import config __all__ = ['FrequencyResponseData', 'FRD', 'frd'] @@ -112,7 +117,7 @@ class FrequencyResponseData(LTI): # Allow NDarray * StateSpace to give StateSpace._rmul_() priority # https://docs.scipy.org/doc/numpy/reference/arrays.classes.html - __array_priority__ = 11 # override ndarray and matrix types + __array_priority__ = 13 # override ndarray, StateSpace, I/O sys # # Class attributes @@ -139,19 +144,22 @@ def __init__(self, *args, **kwargs): The default constructor is FRD(d, w), where w is an iterable of frequency points, and d is the matching frequency data. - If d is a single list, 1d array, or tuple, a SISO system description + If d is a single list, 1D array, or tuple, a SISO system description is assumed. d can also be To call the copy constructor, call FRD(sys), where sys is a FRD object. To construct frequency response data for an existing LTI - object, other than an FRD, call FRD(sys, omega) + object, other than an FRD, call FRD(sys, omega). """ # TODO: discrete-time FRD systems? - smooth = kwargs.get('smooth', False) + smooth = kwargs.pop('smooth', False) + # + # Process positional arguments + # if len(args) == 2: if not isinstance(args[0], FRD) and isinstance(args[0], LTI): # not an FRD, but still a system, second argument should be @@ -168,13 +176,12 @@ def __init__(self, *args, **kwargs): else: # The user provided a response and a freq vector - self.fresp = array(args[0], dtype=complex) - if len(self.fresp.shape) == 1: - self.fresp = self.fresp.reshape(1, 1, len(args[0])) - self.omega = array(args[1], dtype=float) - if len(self.fresp.shape) != 3 or \ - self.fresp.shape[-1] != self.omega.shape[-1] or \ - len(self.omega.shape) != 1: + self.fresp = array(args[0], dtype=complex, ndmin=1) + if self.fresp.ndim == 1: + self.fresp = self.fresp.reshape(1, 1, -1) + self.omega = array(args[1], dtype=float, ndmin=1) + if self.fresp.ndim != 3 or self.omega.ndim != 1 or \ + self.fresp.shape[-1] != self.omega.shape[-1]: raise TypeError( "The frequency data constructor needs a 1-d or 3-d" " response data array and a matching frequency vector" @@ -192,6 +199,29 @@ def __init__(self, *args, **kwargs): raise ValueError( "Needs 1 or 2 arguments; received %i." % len(args)) + # + # Process key word arguments + # + # Keep track of return type + self.return_magphase=kwargs.pop('return_magphase', False) + if self.return_magphase not in (True, False): + raise ValueError("unknown return_magphase value") + + # Determine whether to squeeze the output + self.squeeze=kwargs.pop('squeeze', None) + if self.squeeze not in (None, True, False): + raise ValueError("unknown squeeze value") + + # Process namedio keywords + defaults = { + 'inputs': self.fresp.shape[1], 'outputs': self.fresp.shape[0]} + name, inputs, outputs, states, dt = _process_namedio_keywords( + kwargs, defaults, end=True) + + # Process signal names + NamedIOSystem.__init__( + self, name=name, inputs=inputs, outputs=outputs, dt=dt) + # create interpolation functions if smooth: self.ifunc = empty((self.fresp.shape[0], self.fresp.shape[1]), @@ -204,7 +234,29 @@ def __init__(self, *args, **kwargs): w=1.0/(absolute(self.fresp[i, j, :]) + 0.001), s=0.0) else: self.ifunc = None - LTI.__init__(self, self.fresp.shape[1], self.fresp.shape[0]) + + # + # Frequency response properties + # + # Different properties of the frequency response that can be used for + # analysis and characterization. + # + + @property + def magnitude(self): + return np.abs(self.fresp) + + @property + def phase(self): + return np.angle(self.fresp) + + @property + def frequency(self): + return self.omega + + @property + def response(self): + return self.fresp def __str__(self): """String representation of the transfer function.""" @@ -256,11 +308,13 @@ def __add__(self, other): # Check that the input-output sizes are consistent. if self.ninputs != other.ninputs: - raise ValueError("The first summand has %i input(s), but the \ -second has %i." % (self.ninputs, other.ninputs)) + raise ValueError( + "The first summand has %i input(s), but the " \ + "second has %i." % (self.ninputs, other.ninputs)) if self.noutputs != other.noutputs: - raise ValueError("The first summand has %i output(s), but the \ -second has %i." % (self.noutputs, other.noutputs)) + raise ValueError( + "The first summand has %i output(s), but the " \ + "second has %i." % (self.noutputs, other.noutputs)) return FRD(self.fresp + other.fresp, other.omega) @@ -456,7 +510,7 @@ def eval(self, omega, squeeze=None): return _process_frequency_response(self, omega, out, squeeze=squeeze) - def __call__(self, s, squeeze=None): + def __call__(self, s=None, squeeze=None, return_magphase=None): """Evaluate system's transfer function at complex frequencies. Returns the complex frequency response `sys(s)` of system `sys` with @@ -469,17 +523,31 @@ def __call__(self, s, squeeze=None): For a frequency response data object, the argument must be an imaginary number (since only the frequency response is defined). + If ``s`` is not given, this function creates a copy of a frequency + response data object with a different set of output settings. + Parameters ---------- s : complex scalar or 1D array_like - Complex frequencies - squeeze : bool, optional (default=True) + Complex frequencies. If not specified, return a copy of the + frequency response data object with updated settings for output + processing (``squeeze``, ``return_magphase``). + + squeeze : bool, optional If squeeze=True, remove single-dimensional entries from the shape of the output even if the system is not SISO. If squeeze=False, keep all indices (output, input and, if omega is array_like, frequency) even if the system is SISO. The default value can be set using config.defaults['control.squeeze_frequency_response']. + return_magphase : bool, optional + If True, then a frequency response data object will enumerate as a + tuple of the form (mag, phase, omega) where where ``mag`` is the + magnitude (absolute value, not dB or log10) of the system + frequency response, ``phase`` is the wrapped phase in radians of + the system frequency response, and ``omega`` is the (sorted) + frequencies at which the response was evaluated. + Returns ------- fresp : complex ndarray @@ -498,6 +566,17 @@ def __call__(self, s, squeeze=None): frequency values. """ + if s is None: + # Create a copy of the response with new keywords + response = copy(self) + + # Update any keywords that we were passed + response.squeeze = self.squeeze if squeeze is None else squeeze + response.return_magphase = self.return_magphase \ + if return_magphase is None else return_magphase + + return response + # Make sure that we are operating on a simple list if len(np.atleast_1d(s).shape) > 1: raise ValueError("input list must be 1D") @@ -512,6 +591,22 @@ def __call__(self, s, squeeze=None): else: return self.eval(complex(s).imag, squeeze=squeeze) + # Implement iter to allow assigning to a tuple + def __iter__(self): + fresp = _process_frequency_response( + self, self.omega, self.fresp, squeeze=self.squeeze) + if not self.return_magphase: + return iter((self.omega, fresp)) + return iter((np.abs(fresp), np.angle(fresp), self.omega)) + + # Implement (thin) getitem to allow access via legacy indexing + def __getitem__(self, index): + return list(self.__iter__())[index] + + # Implement (thin) len to emulate legacy testing interface + def __len__(self): + return 3 if self.return_magphase else 2 + def freqresp(self, omega): """(deprecated) Evaluate transfer function at complex frequencies. @@ -547,6 +642,22 @@ def feedback(self, other=1, sign=-1): return FRD(fresp, other.omega, smooth=(self.ifunc is not None)) + # Convert to pandas + def to_pandas(self): + if not pandas_check(): + ImportError('pandas not installed') + import pandas + + # Create a dict for setting up the data frame + data = {'omega': self.omega} + data.update( + {'H_{%s, %s}' % (out, inp): self.fresp[i, j] \ + for i, out in enumerate(self.output_labels) \ + for j, inp in enumerate(self.input_labels)}) + + return pandas.DataFrame(data) + + # # Allow FRD as an alias for the FrequencyResponseData class # @@ -557,8 +668,6 @@ def feedback(self, other=1, sign=-1): # FrequenceResponseData and then assigning FRD to point to the same object # fixes this problem. # - - FRD = FrequencyResponseData diff --git a/control/freqplot.py b/control/freqplot.py index 881ec93dd..05ae9da55 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -47,6 +47,7 @@ import matplotlib.pyplot as plt import numpy as np import warnings +from math import nan from .ctrlutil import unwrap from .bdalg import feedback @@ -148,12 +149,13 @@ def bode_plot(syslist, omega=None, the `deg` parameter. Default is -180 if wrap_phase is False, 0 if wrap_phase is True. wrap_phase : bool or float - If wrap_phase is `False`, then the phase will be unwrapped so that it - is continuously increasing or decreasing. If wrap_phase is `True` the - phase will be restricted to the range [-180, 180) (or [:math:`-\\pi`, - :math:`\\pi`) radians). If `wrap_phase` is specified as a float, the - phase will be offset by 360 degrees if it falls below the specified - value. Default to `False`, set by config.defaults['freqplot.wrap_phase']. + If wrap_phase is `False` (default), then the phase will be unwrapped + so that it is continuously increasing or decreasing. If wrap_phase is + `True` the phase will be restricted to the range [-180, 180) (or + [:math:`-\\pi`, :math:`\\pi`) radians). If `wrap_phase` is specified + as a float, the phase will be offset by 360 degrees if it falls below + the specified value. Default value is `False` and can be set using + config.defaults['freqplot.wrap_phase']. The default values for Bode plot configuration parameters can be reset using the `config.defaults` dictionary, with module name 'bode'. @@ -204,12 +206,13 @@ def bode_plot(syslist, omega=None, initial_phase = config._get_param( 'freqplot', 'initial_phase', kwargs, None, pop=True) omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) + # If argument was a singleton, turn it into a tuple - if not hasattr(syslist, '__iter__'): + if not isinstance(syslist, (list, tuple)): syslist = (syslist,) omega, omega_range_given = _determine_omega_vector( - syslist, omega, omega_limits, omega_num) + syslist, omega, omega_limits, omega_num, Hz=Hz) if plot: # Set up the axes with labels so that multiple calls to @@ -221,12 +224,10 @@ def bode_plot(syslist, omega=None, # Get the current figure if 'sisotool' in kwargs: - fig = kwargs['fig'] + fig = kwargs.pop('fig') ax_mag = fig.axes[0] ax_phase = fig.axes[2] - sisotool = kwargs['sisotool'] - del kwargs['fig'] - del kwargs['sisotool'] + sisotool = kwargs.pop('sisotool') else: fig = plt.gcf() ax_mag = None @@ -519,17 +520,25 @@ def gen_zero_centered_series(val_min, val_max, period): # Default values for module parameter variables _nyquist_defaults = { - 'nyquist.mirror_style': '--', - 'nyquist.arrows': 2, - 'nyquist.arrow_size': 8, - 'nyquist.indent_radius': 1e-1, - 'nyquist.indent_direction': 'right', + 'nyquist.primary_style': ['-', '-.'], # style for primary curve + 'nyquist.mirror_style': ['--', ':'], # style for mirror curve + 'nyquist.arrows': 2, # number of arrors around curve + 'nyquist.arrow_size': 8, # pixel size for arrows + 'nyquist.encirclement_threshold': 0.05, # warning threshold + 'nyquist.indent_radius': 1e-4, # indentation radius + 'nyquist.indent_direction': 'right', # indentation direction + 'nyquist.indent_points': 50, # number of points to insert + 'nyquist.max_curve_magnitude': 20, # clip large values + 'nyquist.max_curve_offset': 0.02, # offset of primary/mirror + 'nyquist.start_marker': 'o', # marker at start of curve + 'nyquist.start_marker_size': 4, # size of the maker } -def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, - omega_num=None, label_freq=0, color=None, - return_contour=False, warn_nyquist=True, *args, **kwargs): +def nyquist_plot( + syslist, omega=None, plot=True, omega_limits=None, omega_num=None, + label_freq=0, color=None, return_contour=False, + warn_encirclements=True, warn_nyquist=True, **kwargs): """Nyquist plot for a system Plots a Nyquist plot for the system over a (optional) frequency range. @@ -563,19 +572,26 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, color : string Used to specify the color of the line and arrowhead. - mirror_style : string or False - Linestyle for mirror image of the Nyquist curve. If `False` then - omit completely. Default linestyle ('--') is determined by - config.defaults['nyquist.mirror_style']. - - return_contour : bool + return_contour : bool, optional If 'True', return the contour used to evaluate the Nyquist plot. - label_freq : int - Label every nth frequency on the plot. If not specified, no labels - are generated. + **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional + Additional keywords (passed to `matplotlib`) + + Returns + ------- + count : int (or list of int if len(syslist) > 1) + Number of encirclements of the point -1 by the Nyquist curve. If + multiple systems are given, an array of counts is returned. + + contour : ndarray (or list of ndarray if len(syslist) > 1)), optional + The contour used to create the primary Nyquist curve segment, returned + if `return_contour` is Tue. To obtain the Nyquist curve values, + evaluate system(s) along contour. - arrows : int or 1D/2D array of floats + Additional Parameters + --------------------- + arrows : int or 1D/2D array of floats, optional Specify the number of arrows to plot on the Nyquist curve. If an integer is passed. that number of equally spaced arrows will be plotted on each of the primary segment and the mirror image. If a 1D @@ -585,39 +601,74 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, locations for the primary curve and the second row will be used for the mirror image. - arrow_size : float + arrow_size : float, optional Arrowhead width and length (in display coordinates). Default value is 8 and can be set using config.defaults['nyquist.arrow_size']. - arrow_style : matplotlib.patches.ArrowStyle + arrow_style : matplotlib.patches.ArrowStyle, optional Define style used for Nyquist curve arrows (overrides `arrow_size`). - indent_radius : float - Amount to indent the Nyquist contour around poles that are at or near - the imaginary axis. + encirclement_threshold : float, optional + Define the threshold for generating a warning if the number of net + encirclements is a non-integer value. Default value is 0.05 and can + be set using config.defaults['nyquist.encirclement_threshold']. - indent_direction : str + indent_direction : str, optional For poles on the imaginary axis, set the direction of indentation to be 'right' (default), 'left', or 'none'. - warn_nyquist : bool, optional - If set to 'False', turn off warnings about frequencies above Nyquist. + indent_points : int, optional + Number of points to insert in the Nyquist contour around poles that + are at or near the imaginary axis. - *args : :func:`matplotlib.pyplot.plot` positional properties, optional - Additional arguments for `matplotlib` plots (color, linestyle, etc) + indent_radius : float, optional + Amount to indent the Nyquist contour around poles on or near the + imaginary axis. Portions of the Nyquist plot corresponding to indented + portions of the contour are plotted using a different line style. - **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional - Additional keywords (passed to `matplotlib`) + label_freq : int, optiona + Label every nth frequency on the plot. If not specified, no labels + are generated. - Returns - ------- - count : int (or list of int if len(syslist) > 1) - Number of encirclements of the point -1 by the Nyquist curve. If - multiple systems are given, an array of counts is returned. + max_curve_magnitude : float, optional + Restrict the maximum magnitude of the Nyquist plot to this value. + Portions of the Nyquist plot whose magnitude is restricted are + plotted using a different line style. + + max_curve_offset : float, optional + When plotting scaled portion of the Nyquist plot, increase/decrease + the magnitude by this fraction of the max_curve_magnitude to allow + any overlaps between the primary and mirror curves to be avoided. + + mirror_style : [str, str] or False + Linestyles for mirror image of the Nyquist curve. The first element + is used for unscaled portions of the Nyquist curve, the second element + is used for portions that are scaled (using max_curve_magnitude). If + `False` then omit completely. Default linestyle (['--', ':']) is + determined by config.defaults['nyquist.mirror_style']. + + primary_style : [str, str], optional + Linestyles for primary image of the Nyquist curve. The first + element is used for unscaled portions of the Nyquist curve, + the second element is used for portions that are scaled (using + max_curve_magnitude). Default linestyle (['-', '-.']) is + determined by config.defaults['nyquist.mirror_style']. + + start_marker : str, optional + Matplotlib marker to use to mark the starting point of the Nyquist + plot. Defaults value is 'o' and can be set using + config.defaults['nyquist.start_marker']. + + start_marker_size : float, optional + Start marker size (in display coordinates). Default value is + 4 and can be set using config.defaults['nyquist.start_marker_size']. - contour : ndarray (or list of ndarray if len(syslist) > 1)), optional - The contour used to create the primary Nyquist curve segment. To - obtain the Nyquist curve values, evaluate system(s) along contour. + warn_nyquist : bool, optional + If set to 'False', turn off warnings about frequencies above Nyquist. + + warn_encirclements : bool, optional + If set to 'False', turn off warnings about number of encirclements not + meeting the Nyquist criterion. Notes ----- @@ -667,9 +718,8 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, kwargs.pop('arrow_length', False) # Get values for params (and pop from list to allow keyword use in plot) + omega_num_given = omega_num is not None omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) - mirror_style = config._get_param( - 'nyquist', 'mirror_style', kwargs, _nyquist_defaults, pop=True) arrows = config._get_param( 'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True) arrow_size = config._get_param( @@ -677,18 +727,58 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None) indent_radius = config._get_param( 'nyquist', 'indent_radius', kwargs, _nyquist_defaults, pop=True) + encirclement_threshold = config._get_param( + 'nyquist', 'encirclement_threshold', kwargs, + _nyquist_defaults, pop=True) indent_direction = config._get_param( 'nyquist', 'indent_direction', kwargs, _nyquist_defaults, pop=True) + indent_points = config._get_param( + 'nyquist', 'indent_points', kwargs, _nyquist_defaults, pop=True) + max_curve_magnitude = config._get_param( + 'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True) + max_curve_offset = config._get_param( + 'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True) + start_marker = config._get_param( + 'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True) + start_marker_size = config._get_param( + 'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True) + + # Set line styles for the curves + def _parse_linestyle(style_name, allow_false=False): + style = config._get_param( + 'nyquist', style_name, kwargs, _nyquist_defaults, pop=True) + if isinstance(style, str): + # Only one style provided, use the default for the other + style = [style, _nyquist_defaults['nyquist.' + style_name][1]] + warnings.warn( + "use of a single string for linestyle will be deprecated " + " in a future release", PendingDeprecationWarning) + if (allow_false and style is False) or \ + (isinstance(style, list) and len(style) == 2): + return style + else: + raise ValueError(f"invalid '{style_name}': {style}") - # If argument was a singleton, turn it into a list - if not hasattr(syslist, '__iter__'): + primary_style = _parse_linestyle('primary_style') + mirror_style = _parse_linestyle('mirror_style', allow_false=True) + + # If argument was a singleton, turn it into a tuple + if not isinstance(syslist, (list, tuple)): syslist = (syslist,) + # Determine the range of frequencies to use, based on args/features omega, omega_range_given = _determine_omega_vector( - syslist, omega, omega_limits, omega_num) + syslist, omega, omega_limits, omega_num, feature_periphery_decades=2) + + # If omega was not specified explicitly, start at omega = 0 if not omega_range_given: - # Start contour at zero frequency - omega[0] = 0. + if omega_num_given: + # Just reset the starting point + omega[0] = 0.0 + else: + # Insert points between the origin and the first frequency point + omega = np.concatenate(( + np.linspace(0, omega[0], indent_points), omega[1:])) # Go through each system and keep track of the results counts, contours = [], [] @@ -703,7 +793,7 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, # Determine the contour used to evaluate the Nyquist curve if sys.isdtime(strict=True): - # Transform frequencies in for discrete-time systems + # Restrict frequencies for discrete-time systems nyquistfrq = math.pi / sys.dt if not omega_range_given: # limit up to and including nyquist frequency @@ -723,38 +813,102 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, if isinstance(sys, (StateSpace, TransferFunction)) \ and indent_direction != 'none': if sys.isctime(): - splane_poles = sys.pole() + splane_poles = sys.poles() + splane_cl_poles = sys.feedback().poles() else: # map z-plane poles to s-plane, ignoring any at the origin # because we don't need to indent for them - zplane_poles = sys.pole() + zplane_poles = sys.poles() zplane_poles = zplane_poles[~np.isclose(abs(zplane_poles), 0.)] - splane_poles = np.log(zplane_poles)/sys.dt - - if splane_contour[1].imag > indent_radius \ - and np.any(np.isclose(abs(splane_poles), 0)) \ - and not omega_range_given: - # add some points for quarter circle around poles at origin - splane_contour = np.concatenate( - (1j * np.linspace(0., indent_radius, 50), - splane_contour[1:])) + splane_poles = np.log(zplane_poles) / sys.dt + + zplane_cl_poles = sys.feedback().poles() + zplane_cl_poles = zplane_cl_poles[ + ~np.isclose(abs(zplane_poles), 0.)] + splane_cl_poles = np.log(zplane_cl_poles) / sys.dt + + # + # Check to make sure indent radius is small enough + # + # If there is a closed loop pole that is near the imaginary access + # at a point that is near an open loop pole, it is possible that + # indentation might skip or create an extraneous encirclement. + # We check for that situation here and generate a warning if that + # could happen. + # + for p_cl in splane_cl_poles: + # See if any closed loop poles are near the imaginary axis + if abs(p_cl.real) <= indent_radius: + # See if any open loop poles are close to closed loop poles + p_ol = splane_poles[ + (np.abs(splane_poles - p_cl)).argmin()] + + if abs(p_ol - p_cl) <= indent_radius and \ + warn_encirclements: + warnings.warn( + "indented contour may miss closed loop pole; " + "consider reducing indent_radius to be less than " + f"{abs(p_ol - p_cl):5.2g}", stacklevel=2) + + # + # See if we should add some frequency points near imaginary poles + # + for p in splane_poles: + # See if we need to process this pole (skip if on the negative + # imaginary axis or not near imaginary axis + user override) + if p.imag < 0 or abs(p.real) > indent_radius or \ + omega_range_given: + continue + + # Find the frequencies before the pole frequency + below_points = np.argwhere( + splane_contour.imag - abs(p.imag) < -indent_radius) + if below_points.size > 0: + first_point = below_points[-1].item() + start_freq = p.imag - indent_radius + else: + # Add the points starting at the beginning of the contour + assert splane_contour[0] == 0 + first_point = 0 + start_freq = 0 + + # Find the frequencies after the pole frequency + above_points = np.argwhere( + splane_contour.imag - abs(p.imag) > indent_radius) + last_point = above_points[0].item() + + # Add points for half/quarter circle around pole frequency + # (these will get indented left or right below) + splane_contour = np.concatenate(( + splane_contour[0:first_point+1], + (1j * np.linspace( + start_freq, p.imag + indent_radius, indent_points)), + splane_contour[last_point:])) + + # Indent points that are too close to a pole for i, s in enumerate(splane_contour): # Find the nearest pole p = splane_poles[(np.abs(splane_poles - s)).argmin()] + # See if we need to indent around it if abs(s - p) < indent_radius: - if p.real < 0 or (np.isclose(p.real, 0) \ - and indent_direction == 'right'): + # Figure out how much to offset (simple trigonometry) + offset = np.sqrt(indent_radius ** 2 - (s - p).imag ** 2) \ + - (s - p).real + + # Figure out which way to offset the contour point + if p.real < 0 or (p.real == 0 and + indent_direction == 'right'): # Indent to the right - splane_contour[i] += \ - np.sqrt(indent_radius ** 2 - (s-p).imag ** 2) - elif p.real > 0 or (np.isclose(p.real, 0) \ - and indent_direction == 'left'): + splane_contour[i] += offset + + elif p.real > 0 or (p.real == 0 and + indent_direction == 'left'): # Indent to the left - splane_contour[i] -= \ - np.sqrt(indent_radius ** 2 - (s-p).imag ** 2) + splane_contour[i] -= offset + else: - ValueError("unknown value for indent_direction") + raise ValueError("unknown value for indent_direction") # change contour to z-plane if necessary if sys.isctime(): @@ -767,21 +921,65 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, # Compute CW encirclements of -1 by integrating the (unwrapped) angle phase = -unwrap(np.angle(resp + 1)) - count = int(np.round(np.sum(np.diff(phase)) / np.pi, 0)) + encirclements = np.sum(np.diff(phase)) / np.pi + count = int(np.round(encirclements, 0)) + + # Let the user know if the count might not make sense + if abs(encirclements - count) > encirclement_threshold and \ + warn_encirclements: + warnings.warn( + "number of encirclements was a non-integer value; this can" + " happen is contour is not closed, possibly based on a" + " frequency range that does not include zero.") + + # + # Make sure that the enciriclements match the Nyquist criterion + # + # If the user specifies the frequency points to use, it is possible + # to miss enciriclements, so we check here to make sure that the + # Nyquist criterion is actually satisfied. + # + if isinstance(sys, (StateSpace, TransferFunction)): + # Count the number of open/closed loop RHP poles + if sys.isctime(): + if indent_direction == 'right': + P = (sys.poles().real > 0).sum() + else: + P = (sys.poles().real >= 0).sum() + Z = (sys.feedback().poles().real >= 0).sum() + else: + if indent_direction == 'right': + P = (np.abs(sys.poles()) > 1).sum() + else: + P = (np.abs(sys.poles()) >= 1).sum() + Z = (np.abs(sys.feedback().poles()) >= 1).sum() + + # Check to make sure the results make sense; warn if not + if Z != count + P and warn_encirclements: + warnings.warn( + "number of encirclements does not match Nyquist criterion;" + " check frequency range and indent radius/direction", + UserWarning, stacklevel=2) + elif indent_direction == 'none' and any(sys.poles().real == 0) and \ + warn_encirclements: + warnings.warn( + "system has pure imaginary poles but indentation is" + " turned off; results may be meaningless", + RuntimeWarning, stacklevel=2) counts.append(count) contours.append(contour) if plot: # Parse the arrows keyword - if isinstance(arrows, int): + if not arrows: + arrow_pos = [] + elif isinstance(arrows, int): N = arrows # Space arrows out, starting midway along each "region" arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False) elif isinstance(arrows, (list, np.ndarray)): arrow_pos = np.sort(np.atleast_1d(arrows)) - elif not arrows: - arrow_pos = [] else: raise ValueError("unknown or unsupported arrow location") @@ -790,22 +988,77 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, arrow_style = mpl.patches.ArrowStyle( 'simple', head_width=arrow_size, head_length=arrow_size) - # Save the components of the response - x, y = resp.real, resp.imag - - # Plot the primary curve - p = plt.plot(x, y, '-', color=color, *args, **kwargs) + # Find the different portions of the curve (with scaled pts marked) + reg_mask = np.logical_or( + np.abs(resp) > max_curve_magnitude, + splane_contour.real != 0) + # reg_mask = np.logical_or( + # np.abs(resp.real) > max_curve_magnitude, + # np.abs(resp.imag) > max_curve_magnitude) + + scale_mask = ~reg_mask \ + & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \ + & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1])) + + # Rescale the points with large magnitude + rescale = np.logical_and( + reg_mask, abs(resp) > max_curve_magnitude) + resp[rescale] *= max_curve_magnitude / abs(resp[rescale]) + + # Plot the regular portions of the curve (and grab the color) + x_reg = np.ma.masked_where(reg_mask, resp.real) + y_reg = np.ma.masked_where(reg_mask, resp.imag) + p = plt.plot( + x_reg, y_reg, primary_style[0], color=color, **kwargs) c = p[0].get_color() + + # Figure out how much to offset the curve: the offset goes from + # zero at the start of the scaled section to max_curve_offset as + # we move along the curve + curve_offset = _compute_curve_offset( + resp, scale_mask, max_curve_offset) + + # Plot the scaled sections of the curve (changing linestyle) + x_scl = np.ma.masked_where(scale_mask, resp.real) + y_scl = np.ma.masked_where(scale_mask, resp.imag) + plt.plot( + x_scl * (1 + curve_offset), y_scl * (1 + curve_offset), + primary_style[1], color=c, **kwargs) + + # Plot the primary curve (invisible) for setting arrows + x, y = resp.real.copy(), resp.imag.copy() + x[reg_mask] *= (1 + curve_offset[reg_mask]) + y[reg_mask] *= (1 + curve_offset[reg_mask]) + p = plt.plot(x, y, linestyle='None', color=c, **kwargs) + + # Add arrows ax = plt.gca() _add_arrows_to_line2D( ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1) # Plot the mirror image if mirror_style is not False: - p = plt.plot(x, -y, mirror_style, color=c, *args, **kwargs) + # Plot the regular and scaled segments + plt.plot( + x_reg, -y_reg, mirror_style[0], color=c, **kwargs) + plt.plot( + x_scl * (1 - curve_offset), + -y_scl * (1 - curve_offset), + mirror_style[1], color=c, **kwargs) + + # Add the arrows (on top of an invisible contour) + x, y = resp.real.copy(), resp.imag.copy() + x[reg_mask] *= (1 - curve_offset[reg_mask]) + y[reg_mask] *= (1 - curve_offset[reg_mask]) + p = plt.plot(x, -y, linestyle='None', color=c, **kwargs) _add_arrows_to_line2D( ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1) + # Mark the start of the curve + if start_marker: + plt.plot(resp[0].real, resp[0].imag, start_marker, + color=c, markersize=start_marker_size) + # Mark the -1 point plt.plot([-1], [0], 'r+') @@ -921,9 +1174,65 @@ def _add_arrows_to_line2D( # -# Gang of Four plot +# Function to compute Nyquist curve offsets # +# This function computes a smoothly varying offset that starts and ends at +# zero at the ends of a scaled segment. +# +def _compute_curve_offset(resp, mask, max_offset): + # Compute the arc length along the curve + s_curve = np.cumsum( + np.sqrt(np.diff(resp.real) ** 2 + np.diff(resp.imag) ** 2)) + + # Initialize the offset + offset = np.zeros(resp.size) + arclen = np.zeros(resp.size) + + # Walk through the response and keep track of each continous component + i, nsegs = 0, 0 + while i < resp.size: + # Skip the regular segment + while i < resp.size and mask[i]: + i += 1 # Increment the counter + if i == resp.size: + break + # Keep track of the arclength + arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1]) + + nsegs += 0.5 + if i == resp.size: + break + + # Save the starting offset of this segment + seg_start = i + + # Walk through the scaled segment + while i < resp.size and not mask[i]: + i += 1 + if i == resp.size: # See if we are done with this segment + break + # Keep track of the arclength + arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1]) + + nsegs += 0.5 + if i == resp.size: + break + + # Save the ending offset of this segment + seg_end = i + + # Now compute the scaling for this segment + s_segment = arclen[seg_end-1] - arclen[seg_start] + offset[seg_start:seg_end] = max_offset * s_segment/s_curve[-1] * \ + np.sin(np.pi * (arclen[seg_start:seg_end] + - arclen[seg_start])/s_segment) + + return offset + +# +# Gang of Four plot +# # TODO: think about how (and whether) to handle lists of systems def gangof4_plot(P, C, omega=None, **kwargs): """Plot the "Gang of 4" transfer functions for a system @@ -965,7 +1274,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): # Select a default range if none is provided # TODO: This needs to be made more intelligent if omega is None: - omega = _default_frequency_range((P, C, S)) + omega = _default_frequency_range((P, C, S), Hz=Hz) # Set up the axes with labels so that multiple calls to # gangof4_plot will superimpose the data. See details in bode_plot. @@ -1047,7 +1356,8 @@ def singular_values_plot(syslist, omega=None, *args, **kwargs): """Singular value plot for a system - Plots a Singular Value plot for the system over a (optional) frequency range. + Plots a singular value plot for the system over a (optional) frequency + range. Parameters ---------- @@ -1061,11 +1371,11 @@ def singular_values_plot(syslist, omega=None, Limits of the frequency vector to generate. If Hz=True the limits are in Hz otherwise in rad/s. omega_num : int - Number of samples to plot. - Default value (1000) set by config.defaults['freqplot.number_of_samples']. + Number of samples to plot. Default value (1000) set by + config.defaults['freqplot.number_of_samples']. dB : bool - If True, plot result in dB. - Default value (False) set by config.defaults['freqplot.dB']. + If True, plot result in dB. Default value (False) set by + config.defaults['freqplot.dB']. Hz : bool If True, plot frequency in Hz (omega must be provided in rad/sec). Default value (False) set by config.defaults['freqplot.Hz'] @@ -1087,7 +1397,8 @@ def singular_values_plot(syslist, omega=None, -------- >>> import numpy as np >>> den = [75, 1] - >>> sys = TransferFunction([[[87.8], [-86.4]], [[108.2], [-109.6]]], [[den, den], [den, den]]) + >>> sys = TransferFunction( + [[[87.8], [-86.4]], [[108.2], [-109.6]]], [[den, den], [den, den]]) >>> omega = np.logspace(-4, 1, 1000) >>> sigma, omega = singular_values_plot(sys, plot=True) >>> singular_values_plot(sys, 0.0, plot=False) @@ -1111,11 +1422,11 @@ def singular_values_plot(syslist, omega=None, omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) # If argument was a singleton, turn it into a tuple - if not hasattr(syslist, '__iter__'): + if not isinstance(syslist, (list, tuple)): syslist = (syslist,) omega, omega_range_given = _determine_omega_vector( - syslist, omega, omega_limits, omega_num) + syslist, omega, omega_limits, omega_num, Hz=Hz) omega = np.atleast_1d(omega) @@ -1194,7 +1505,8 @@ def singular_values_plot(syslist, omega=None, # Add a grid to the plot + labeling if plot: ax_sigma.grid(grid, which='both') - ax_sigma.set_ylabel("Singular Values (dB)" if dB else "Singular Values") + ax_sigma.set_ylabel( + "Singular Values (dB)" if dB else "Singular Values") ax_sigma.set_xlabel("Frequency (Hz)" if Hz else "Frequency (rad/sec)") if len(syslist) == 1: @@ -1210,7 +1522,8 @@ def singular_values_plot(syslist, omega=None, # Determine the frequency range to be used -def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num): +def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num, + Hz=None, feature_periphery_decades=None): """Determine the frequency range for a frequency-domain plot according to a standard logic. @@ -1236,6 +1549,10 @@ def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num): omega_num : int Number of points to be used for the frequency range (if the frequency range is not user-specified) + Hz : bool, optional + If True, the limits (first and last value) of the frequencies + are set to full decades in Hz so it fits plotting with logarithmic + scale in Hz otherwise in rad/s. Omega is always returned in rad/sec. Returns ------- @@ -1252,8 +1569,9 @@ def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num): if omega_limits is None: omega_range_given = False # Select a default range if none is provided - omega_out = _default_frequency_range(syslist, - number_of_samples=omega_num) + omega_out = _default_frequency_range( + syslist, number_of_samples=omega_num, Hz=Hz, + feature_periphery_decades=feature_periphery_decades) else: omega_limits = np.asarray(omega_limits) if len(omega_limits) != 2: @@ -1263,6 +1581,7 @@ def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num): num=omega_num, endpoint=True) else: omega_out = np.copy(omega_in) + return omega_out, omega_range_given @@ -1280,7 +1599,7 @@ def _default_frequency_range(syslist, Hz=None, number_of_samples=None, ---------- syslist : list of LTI List of linear input/output systems (single system is OK) - Hz : bool + Hz : bool, optional If True, the limits (first and last value) of the frequencies are set to full decades in Hz so it fits plotting with logarithmic scale in Hz otherwise in rad/s. Omega is always returned in rad/sec. @@ -1323,10 +1642,10 @@ def _default_frequency_range(syslist, Hz=None, number_of_samples=None, try: # Add new features to the list if sys.isctime(): - features_ = np.concatenate((np.abs(sys.pole()), - np.abs(sys.zero()))) + features_ = np.concatenate( + (np.abs(sys.poles()), np.abs(sys.zeros()))) # Get rid of poles and zeros at the origin - toreplace = features_ == 0.0 + toreplace = np.isclose(features_, 0.0) if np.any(toreplace): features_ = features_[~toreplace] elif sys.isdtime(strict=True): @@ -1334,12 +1653,11 @@ def _default_frequency_range(syslist, Hz=None, number_of_samples=None, # TODO: What distance to the Nyquist frequency is appropriate? freq_interesting.append(fn * 0.9) - features_ = np.concatenate((sys.pole(), - sys.zero())) + features_ = np.concatenate((sys.poles(), sys.zeros())) # Get rid of poles and zeros on the real axis (imag==0) - # * origin and real < 0 + # * origin and real < 0 # * at 1.: would result in omega=0. (logaritmic plot!) - toreplace = (features_.imag == 0.0) & ( + toreplace = np.isclose(features_.imag, 0.0) & ( (features_.real <= 0.) | (np.abs(features_.real - 1.0) < 1.e-10)) if np.any(toreplace): @@ -1360,15 +1678,13 @@ def _default_frequency_range(syslist, Hz=None, number_of_samples=None, if Hz: features /= 2. * math.pi - features = np.log10(features) - lsp_min = np.floor(np.min(features) - feature_periphery_decades) - lsp_max = np.ceil(np.max(features) + feature_periphery_decades) + features = np.log10(features) + lsp_min = np.rint(np.min(features) - feature_periphery_decades) + lsp_max = np.rint(np.max(features) + feature_periphery_decades) + if Hz: lsp_min += np.log10(2. * math.pi) lsp_max += np.log10(2. * math.pi) - else: - features = np.log10(features) - lsp_min = np.floor(np.min(features) - feature_periphery_decades) - lsp_max = np.ceil(np.max(features) + feature_periphery_decades) + if freq_interesting: lsp_min = min(lsp_min, np.log10(min(freq_interesting))) lsp_max = max(lsp_max, np.log10(max(freq_interesting))) diff --git a/control/iosys.py b/control/iosys.py index c8e921c90..e3719614b 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -31,17 +31,20 @@ import copy from warnings import warn +from .lti import LTI +from .namedio import NamedIOSystem, _process_signal_list, \ + _process_namedio_keywords, isctime, isdtime, common_timebase from .statesp import StateSpace, tf2ss, _convert_to_statespace +from .statesp import _rss_generate from .xferfcn import TransferFunction from .timeresp import _check_convert_array, _process_time_response, \ TimeResponseData -from .lti import isctime, isdtime, common_timebase from . import config __all__ = ['InputOutputSystem', 'LinearIOSystem', 'NonlinearIOSystem', 'InterconnectedSystem', 'LinearICSystem', 'input_output_response', - 'find_eqpt', 'linearize', 'ss2io', 'tf2io', 'interconnect', - 'summing_junction'] + 'find_eqpt', 'linearize', 'ss', 'rss', 'drss', 'ss2io', 'tf2io', + 'interconnect', 'summing_junction'] # Define module default parameter values _iosys_defaults = { @@ -53,7 +56,7 @@ } -class InputOutputSystem(object): +class InputOutputSystem(NamedIOSystem): """A class for representing input/output systems. The InputOutputSystem class allows (possibly nonlinear) input/output @@ -66,7 +69,7 @@ class for a set of subclasses that are used to implement specific ---------- inputs : int, list of str, or None Description of the system inputs. This can be given as an integer - count or as a list of strings that name the individual signals. If an + count or a list of strings that name the individual signals. If an integer count is specified, the names of the signal will be of the form `s[i]` (where `s` is one of `u`, `y`, or `x`). If this parameter is not given or given as `None`, the relevant quantity will be @@ -77,17 +80,16 @@ class for a set of subclasses that are used to implement specific states : int, list of str, or None Description of the system states. Same format as `inputs`. dt : None, True or float, optional - System timebase. 0 (default) indicates continuous - time, True indicates discrete time with unspecified sampling - time, positive number is discrete time with specified - sampling time, None indicates unspecified timebase (either - continuous or discrete time). - params : dict, optional - Parameter values for the systems. Passed to the evaluation functions - for the system as default values, overriding internal defaults. + System timebase. 0 (default) indicates continuous time, True + indicates discrete time with unspecified sampling time, positive + number is discrete time with specified sampling time, None indicates + unspecified timebase (either continuous or discrete time). name : string, optional System name (used for specifying signals). If unspecified, a generic name is generated with a unique integer id. + params : dict, optional + Parameter values for the systems. Passed to the evaluation functions + for the system as default values, overriding internal defaults. Attributes ---------- @@ -124,16 +126,7 @@ class for a set of subclasses that are used to implement specific # Allow ndarray * InputOutputSystem to give IOSystem._rmul_() priority __array_priority__ = 12 # override ndarray, matrix, SS types - _idCounter = 0 - - def _name_or_default(self, name=None): - if name is None: - name = "sys[{}]".format(InputOutputSystem._idCounter) - InputOutputSystem._idCounter += 1 - return name - - def __init__(self, inputs=None, outputs=None, states=None, params={}, - name=None, **kwargs): + def __init__(self, params={}, **kwargs): """Create an input/output system. The InputOutputSystem constructor is used to create an input/output @@ -144,58 +137,18 @@ def __init__(self, inputs=None, outputs=None, states=None, params={}, :class:`~control.InterconnectedSystem`. """ - # Store the input arguments + # Store the system name, inputs, outputs, and states + name, inputs, outputs, states, dt = _process_namedio_keywords( + kwargs, end=True) + + # Initialize the data structure + # Note: don't use super() to override LinearIOSystem/StateSpace MRO + NamedIOSystem.__init__( + self, inputs=inputs, outputs=outputs, + states=states, name=name, dt=dt) # default parameters self.params = params.copy() - # timebase - self.dt = kwargs.get('dt', config.defaults['control.default_dt']) - # system name - self.name = self._name_or_default(name) - - # Parse and store the number of inputs, outputs, and states - self.set_inputs(inputs) - self.set_outputs(outputs) - self.set_states(states) - - # - # Class attributes - # - # These attributes are defined as class attributes so that they are - # documented properly. They are "overwritten" in __init__. - # - - #: Number of system inputs. - #: - #: :meta hide-value: - ninputs = 0 - - #: Number of system outputs. - #: - #: :meta hide-value: - noutputs = 0 - - #: Number of system states. - #: - #: :meta hide-value: - nstates = 0 - - def __repr__(self): - return self.name if self.name is not None else str(type(self)) - - def __str__(self): - """String representation of an input/output system""" - str = "System: " + (self.name if self.name else "(None)") + "\n" - str += "Inputs (%s): " % self.ninputs - for key in self.input_index: - str += key + ", " - str += "\nOutputs (%s): " % self.noutputs - for key in self.output_index: - str += key + ", " - str += "\nStates (%s): " % self.nstates - for key in self.state_index: - str += key + ", " - return str def __mul__(sys2, sys1): """Multiply two input/output systems (series interconnection)""" @@ -210,7 +163,8 @@ def __mul__(sys2, sys1): elif isinstance(sys1, np.ndarray): sys1 = LinearIOSystem(StateSpace([], [], [], sys1)) - elif isinstance(sys1, (StateSpace, TransferFunction)): + elif isinstance(sys1, (StateSpace, TransferFunction)) and \ + not isinstance(sys1, LinearIOSystem): sys1 = LinearIOSystem(sys1) elif not isinstance(sys1, InputOutputSystem): @@ -256,7 +210,8 @@ def __rmul__(sys1, sys2): elif isinstance(sys2, np.ndarray): sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) - elif isinstance(sys2, (StateSpace, TransferFunction)): + elif isinstance(sys2, (StateSpace, TransferFunction)) and \ + not isinstance(sys2, LinearIOSystem): sys2 = LinearIOSystem(sys2) elif not isinstance(sys2, InputOutputSystem): @@ -274,7 +229,8 @@ def __add__(sys1, sys2): elif isinstance(sys2, np.ndarray): sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) - elif isinstance(sys2, (StateSpace, TransferFunction)): + elif isinstance(sys2, (StateSpace, TransferFunction)) and \ + not isinstance(sys2, LinearIOSystem): sys2 = LinearIOSystem(sys2) elif not isinstance(sys2, InputOutputSystem): @@ -311,7 +267,8 @@ def __radd__(sys1, sys2): elif isinstance(sys2, np.ndarray): sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) - elif isinstance(sys2, (StateSpace, TransferFunction)): + elif isinstance(sys2, (StateSpace, TransferFunction)) and \ + not isinstance(sys2, LinearIOSystem): sys2 = LinearIOSystem(sys2) elif not isinstance(sys2, InputOutputSystem): @@ -329,7 +286,8 @@ def __sub__(sys1, sys2): elif isinstance(sys2, np.ndarray): sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) - elif isinstance(sys2, (StateSpace, TransferFunction)): + elif isinstance(sys2, (StateSpace, TransferFunction)) and \ + not isinstance(sys2, LinearIOSystem): sys2 = LinearIOSystem(sys2) elif not isinstance(sys2, InputOutputSystem): @@ -366,7 +324,8 @@ def __rsub__(sys1, sys2): elif isinstance(sys2, np.ndarray): sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) - elif isinstance(sys2, (StateSpace, TransferFunction)): + elif isinstance(sys2, (StateSpace, TransferFunction)) and \ + not isinstance(sys2, LinearIOSystem): sys2 = LinearIOSystem(sys2) elif not isinstance(sys2, InputOutputSystem): @@ -393,34 +352,6 @@ def __neg__(sys): # Return the newly created system return newsys - def _isstatic(self): - """Check to see if a system is a static system (no states)""" - return self.nstates == 0 - - # Utility function to parse a list of signals - def _process_signal_list(self, signals, prefix='s'): - if signals is None: - # No information provided; try and make it up later - return None, {} - - elif isinstance(signals, int): - # Number of signals given; make up the names - return signals, {'%s[%d]' % (prefix, i): i for i in range(signals)} - - elif isinstance(signals, str): - # Single string given => single signal with given name - return 1, {signals: 0} - - elif all(isinstance(s, str) for s in signals): - # Use the list of strings as the signal names - return len(signals), {signals[i]: i for i in range(len(signals))} - - else: - raise TypeError("Can't parse signal list %s" % str(signals)) - - # Find a signal by name - def _find_signal(self, name, sigdict): return sigdict.get(name, None) - # Update parameters used for _rhs, _out (used by subclasses) def _update_params(self, params, warning=False): if warning: @@ -508,82 +439,6 @@ def output(self, t, x, u): """ return self._out(t, x, u) - def set_inputs(self, inputs, prefix='u'): - """Set the number/names of the system inputs. - - Parameters - ---------- - inputs : int, list of str, or None - Description of the system inputs. This can be given as an integer - count or as a list of strings that name the individual signals. - If an integer count is specified, the names of the signal will be - of the form `u[i]` (where the prefix `u` can be changed using the - optional prefix parameter). - prefix : string, optional - If `inputs` is an integer, create the names of the states using - the given prefix (default = 'u'). The names of the input will be - of the form `prefix[i]`. - - """ - self.ninputs, self.input_index = \ - self._process_signal_list(inputs, prefix=prefix) - - def set_outputs(self, outputs, prefix='y'): - """Set the number/names of the system outputs. - - Parameters - ---------- - outputs : int, list of str, or None - Description of the system outputs. This can be given as an integer - count or as a list of strings that name the individual signals. - If an integer count is specified, the names of the signal will be - of the form `u[i]` (where the prefix `u` can be changed using the - optional prefix parameter). - prefix : string, optional - If `outputs` is an integer, create the names of the states using - the given prefix (default = 'y'). The names of the input will be - of the form `prefix[i]`. - - """ - self.noutputs, self.output_index = \ - self._process_signal_list(outputs, prefix=prefix) - - def set_states(self, states, prefix='x'): - """Set the number/names of the system states. - - Parameters - ---------- - states : int, list of str, or None - Description of the system states. This can be given as an integer - count or as a list of strings that name the individual signals. - If an integer count is specified, the names of the signal will be - of the form `u[i]` (where the prefix `u` can be changed using the - optional prefix parameter). - prefix : string, optional - If `states` is an integer, create the names of the states using - the given prefix (default = 'x'). The names of the input will be - of the form `prefix[i]`. - - """ - self.nstates, self.state_index = \ - self._process_signal_list(states, prefix=prefix) - - def find_input(self, name): - """Find the index for an input given its name (`None` if not found)""" - return self.input_index.get(name, None) - - def find_output(self, name): - """Find the index for an output given its name (`None` if not found)""" - return self.output_index.get(name, None) - - def find_state(self, name): - """Find the index for a state given its name (`None` if not found)""" - return self.state_index.get(name, None) - - def issiso(self): - """Check to see if a system is single input, single output""" - return self.ninputs == 1 and self.noutputs == 1 - def feedback(self, other=1, sign=-1, params={}): """Feedback interconnection between two input/output systems @@ -709,7 +564,7 @@ def linearize(self, x0, u0, t=0, params={}, eps=1e-6, # Create the state space system linsys = LinearIOSystem( - StateSpace(A, B, C, D, self.dt, remove_useless=False), + StateSpace(A, B, C, D, self.dt, remove_useless_states=False), name=name, **kwargs) # Set the names the system, inputs, outputs, and states @@ -728,15 +583,6 @@ def linearize(self, x0, u0, t=0, params={}, eps=1e-6, return linsys - def copy(self, newname=None): - """Make a copy of an input/output system.""" - dup_prefix = config.defaults['iosys.duplicate_system_name_prefix'] - dup_suffix = config.defaults['iosys.duplicate_system_name_suffix'] - newsys = copy.copy(self) - newsys.name = self._name_or_default( - dup_prefix + self.name + dup_suffix if not newname else newname) - return newsys - class LinearIOSystem(InputOutputSystem, StateSpace): """Input/output representation of a linear (state space) system. @@ -765,12 +611,12 @@ class LinearIOSystem(InputOutputSystem, StateSpace): discrete time with unspecified sampling time, positive number is discrete time with specified sampling time, None indicates unspecified timebase (either continuous or discrete time). - params : dict, optional - Parameter values for the systems. Passed to the evaluation functions - for the system as default values, overriding internal defaults. name : string, optional System name (used for specifying signals). If unspecified, a generic name is generated with a unique integer id. + params : dict, optional + Parameter values for the systems. Passed to the evaluation functions + for the system as default values, overriding internal defaults. Attributes ---------- @@ -781,8 +627,7 @@ class LinearIOSystem(InputOutputSystem, StateSpace): See :class:`~control.StateSpace` for inherited attributes. """ - def __init__(self, linsys, inputs=None, outputs=None, states=None, - name=None, **kwargs): + def __init__(self, linsys, **kwargs): """Create an I/O system from a state space linear system. Converts a :class:`~control.StateSpace` system into an @@ -798,32 +643,19 @@ def __init__(self, linsys, inputs=None, outputs=None, states=None, raise TypeError("Linear I/O system must be a state space " "or transfer function object") - # Look for 'input' and 'output' parameter name variants - inputs = _parse_signal_parameter(inputs, 'input', kwargs) - outputs = _parse_signal_parameter(outputs, 'output', kwargs, end=True) + # Process keyword arguments + name, inputs, outputs, states, dt = _process_namedio_keywords( + kwargs, linsys, end=True) # Create the I/O system object - super(LinearIOSystem, self).__init__( - inputs=linsys.ninputs, outputs=linsys.noutputs, - states=linsys.nstates, params={}, dt=linsys.dt, name=name) + # Note: don't use super() to override StateSpace MRO + InputOutputSystem.__init__( + self, inputs=inputs, outputs=outputs, states=states, + params={}, dt=dt, name=name) # Initalize additional state space variables - StateSpace.__init__(self, linsys, remove_useless=False) - - # Process input, output, state lists, if given - # Make sure they match the size of the linear system - ninputs, self.input_index = self._process_signal_list( - inputs if inputs is not None else linsys.ninputs, prefix='u') - if ninputs is not None and linsys.ninputs != ninputs: - raise ValueError("Wrong number/type of inputs given.") - noutputs, self.output_index = self._process_signal_list( - outputs if outputs is not None else linsys.noutputs, prefix='y') - if noutputs is not None and linsys.noutputs != noutputs: - raise ValueError("Wrong number/type of outputs given.") - nstates, self.state_index = self._process_signal_list( - states if states is not None else linsys.nstates, prefix='x') - if nstates is not None and linsys.nstates != nstates: - raise ValueError("Wrong number/type of states given.") + StateSpace.__init__( + self, linsys, remove_useless_states=False, init_namedio=False) # The following text needs to be replicated from StateSpace in order for # this entry to show up properly in sphinx doccumentation (not sure why, @@ -853,6 +685,14 @@ def _out(self, t, x, u): + self.D @ np.reshape(u, (-1, 1)) return np.array(y).reshape((-1,)) + def __repr__(self): + # Need to define so that I/O system gets used instead of StateSpace + return InputOutputSystem.__repr__(self) + + def __str__(self): + return InputOutputSystem.__str__(self) + "\n\n" \ + + StateSpace.__str__(self) + class NonlinearIOSystem(InputOutputSystem): """Nonlinear I/O system. @@ -896,11 +736,6 @@ class NonlinearIOSystem(InputOutputSystem): states : int, list of str, or None, optional Description of the system states. Same format as `inputs`. - params : dict, optional - Parameter values for the systems. Passed to the evaluation - functions for the system as default values, overriding internal - defaults. - dt : timebase, optional The timebase for the system, used to specify whether the system is operating in continuous or discrete time. It can have the @@ -915,28 +750,27 @@ class NonlinearIOSystem(InputOutputSystem): System name (used for specifying signals). If unspecified, a generic name is generated with a unique integer id. + params : dict, optional + Parameter values for the systems. Passed to the evaluation + functions for the system as default values, overriding internal + defaults. + """ - def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, - states=None, params={}, name=None, **kwargs): + def __init__(self, updfcn, outfcn=None, params={}, **kwargs): """Create a nonlinear I/O system given update and output functions.""" - # Look for 'input' and 'output' parameter name variants - inputs = _parse_signal_parameter(inputs, 'input', kwargs) - outputs = _parse_signal_parameter(outputs, 'output', kwargs) - - # Store the update and output functions - self.updfcn = updfcn - self.outfcn = outfcn + # Process keyword arguments + name, inputs, outputs, states, dt = _process_namedio_keywords( + kwargs, end=True) # Initialize the rest of the structure - dt = kwargs.pop('dt', config.defaults['control.default_dt']) - super(NonlinearIOSystem, self).__init__( + super().__init__( inputs=inputs, outputs=outputs, states=states, params=params, dt=dt, name=name ) - # Make sure all input arguments got parsed - if kwargs: - raise TypeError("unknown parameters %s" % kwargs) + # Store the update and output functions + self.updfcn = updfcn + self.outfcn = outfcn # Check to make sure arguments are consistent if updfcn is None: @@ -959,6 +793,11 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, # Initialize current parameters to default parameters self._current_params = params.copy() + def __str__(self): + return f"{InputOutputSystem.__str__(self)}\n\n" + \ + f"Update: {self.updfcn}\n" + \ + f"Output: {self.outfcn}" + # Return the value of a static nonlinear system def __call__(sys, u, params=None, squeeze=None): """Evaluate a (static) nonlinearity at a given input value @@ -1024,31 +863,33 @@ class InterconnectedSystem(InputOutputSystem): """ def __init__(self, syslist, connections=[], inplist=[], outlist=[], - inputs=None, outputs=None, states=None, - params={}, dt=None, name=None, **kwargs): + params={}, warn_duplicate=None, **kwargs): """Create an I/O system from a list of systems + connection info.""" - - # Look for 'input' and 'output' parameter name variants - inputs = _parse_signal_parameter(inputs, 'input', kwargs) - outputs = _parse_signal_parameter(outputs, 'output', kwargs, end=True) - # Convert input and output names to lists if they aren't already if not isinstance(inplist, (list, tuple)): inplist = [inplist] if not isinstance(outlist, (list, tuple)): outlist = [outlist] - # Check to make sure all systems are consistent + # Process keyword arguments + defaults = {'inputs': len(inplist), 'outputs': len(outlist)} + name, inputs, outputs, states, dt = _process_namedio_keywords( + kwargs, defaults, end=True) + + # Initialize the system list and index self.syslist = syslist self.syslist_index = {} - nstates = 0 - self.state_offset = [] - ninputs = 0 - self.input_offset = [] - noutputs = 0 - self.output_offset = [] + + # Initialize the input, output, and state counts, indices + nstates, self.state_offset = 0, [] + ninputs, self.input_offset = 0, [] + noutputs, self.output_offset = 0, [] + + # Keep track of system objects and names we have already seen sysobj_name_dct = {} sysname_count_dct = {} + + # Go through the system list and keep track of counts, offsets for sysidx, sys in enumerate(syslist): # Make sure time bases are consistent dt = common_timebase(dt, sys.dt) @@ -1073,17 +914,32 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], # Check for duplicate systems or duplicate names # Duplicates are renamed sysname_1, sysname_2, etc. if sys in sysobj_name_dct: - sys = sys.copy() - warn("Duplicate object found in system list: %s. " - "Making a copy" % str(sys.name)) + # Make a copy of the object using a new name + if warn_duplicate is None and sys._generic_name_check(): + # Make a copy w/out warning, using generic format + sys = sys.copy(use_prefix_suffix=False) + warn_flag = False + else: + sys = sys.copy() + warn_flag = warn_duplicate + + # Warn the user about the new object + if warn_flag is not False: + warn("duplicate object found in system list; " + "created copy: %s" % str(sys.name), stacklevel=2) + + # Check to see if the system name shows up more than once if sys.name is not None and sys.name in sysname_count_dct: count = sysname_count_dct[sys.name] sysname_count_dct[sys.name] += 1 sysname = sys.name + "_" + str(count) sysobj_name_dct[sys] = sysname self.syslist_index[sysname] = sysidx - warn("Duplicate name found in system list. " - "Renamed to {}".format(sysname)) + + if warn_duplicate is not False: + warn("duplicate name found in system list; " + "renamed to {}".format(sysname), stacklevel=2) + else: sysname_count_dct[sys.name] = 1 sysobj_name_dct[sys] = sys.name @@ -1096,23 +952,18 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], states += [sysname + state_name_delim + statename for statename in sys.state_index.keys()] + # Make sure we the state list is the right length (internal check) + if isinstance(states, list) and len(states) != nstates: + raise RuntimeError( + f"construction of state labels failed; found: " + f"{len(states)} labels; expecting {nstates}") + # Create the I/O system - super(InterconnectedSystem, self).__init__( - inputs=len(inplist), outputs=len(outlist), + # Note: don't use super() to override LinearICSystem/StateSpace MRO + InputOutputSystem.__init__( + self, inputs=inputs, outputs=outputs, states=states, params=params, dt=dt, name=name) - # If input or output list was specified, update it - if inputs is not None: - nsignals, self.input_index = \ - self._process_signal_list(inputs, prefix='u') - if nsignals is not None and len(inplist) != nsignals: - raise ValueError("Wrong number/type of inputs given.") - if outputs is not None: - nsignals, self.output_index = \ - self._process_signal_list(outputs, prefix='y') - if nsignals is not None and len(outlist) != nsignals: - raise ValueError("Wrong number/type of outputs given.") - # Convert the list of interconnections to a connection map (matrix) self.connect_map = np.zeros((ninputs, noutputs)) for connection in connections: @@ -1649,8 +1500,8 @@ class LinearICSystem(InterconnectedSystem, LinearIOSystem): :class:`StateSpace` class structure, allowing it to be passed to functions that expect a :class:`StateSpace` system. - This class is usually generated using :func:`~control.interconnect` and - not called directly + This class is generated using :func:`~control.interconnect` and + not called directly. """ @@ -1658,18 +1509,15 @@ def __init__(self, io_sys, ss_sys=None): if not isinstance(io_sys, InterconnectedSystem): raise TypeError("First argument must be an interconnected system.") - # Create the I/O system object + # Create the (essentially empty) I/O system object InputOutputSystem.__init__( self, name=io_sys.name, params=io_sys.params) - # Copy over the I/O systems attributes + # Copy over the named I/O system attributes self.syslist = io_sys.syslist - self.ninputs = io_sys.ninputs - self.noutputs = io_sys.noutputs - self.nstates = io_sys.nstates - self.input_index = io_sys.input_index - self.output_index = io_sys.output_index - self.state_index = io_sys.state_index + self.ninputs, self.input_index = io_sys.ninputs, io_sys.input_index + self.noutputs, self.output_index = io_sys.noutputs, io_sys.output_index + self.nstates, self.state_index = io_sys.nstates, io_sys.state_index self.dt = io_sys.dt # Copy over the attributes from the interconnected system @@ -1689,13 +1537,14 @@ def __init__(self, io_sys, ss_sys=None): # Initialize the state space attributes if isinstance(ss_sys, StateSpace): - # Make sure the dimension match + # Make sure the dimensions match if io_sys.ninputs != ss_sys.ninputs or \ io_sys.noutputs != ss_sys.noutputs or \ io_sys.nstates != ss_sys.nstates: raise ValueError("System dimensions for first and second " "arguments must match.") - StateSpace.__init__(self, ss_sys, remove_useless=False) + StateSpace.__init__( + self, ss_sys, remove_useless_states=False, init_namedio=False) else: raise TypeError("Second argument must be a state space system.") @@ -1715,7 +1564,7 @@ def __init__(self, io_sys, ss_sys=None): def input_output_response( sys, T, U=0., X0=0, params={}, transpose=False, return_x=False, squeeze=None, - solve_ivp_kwargs={}, **kwargs): + solve_ivp_kwargs={}, t_eval='T', **kwargs): """Compute the output response of a system to a given input. Simulate a dynamical system with a given input and return its output @@ -1729,11 +1578,17 @@ def input_output_response( 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). + U : array-like, list, or number, optional + Input array giving input at each time `T` (default = 0). If a list + is specified, each element in the list will be treated as a portion + of the input and broadcast (if necessary) to match the time vector. - X0 : array-like or number, optional - Initial condition (default = 0). + X0 : array-like, list, or number, optional + Initial condition (default = 0). If a list is given, each element + in the list will be flattened and stacked into the initial + condition. If a smaller number of elements are given that the + number of states in the system, the initial condition will be padded + with zeros. return_x : bool, optional If True, return the state vector when assigning to a tuple (default = @@ -1785,42 +1640,128 @@ def input_output_response( ValueError If time step does not match sampling time (for discrete time systems). + Notes + ----- + 1. If a smaller number of initial conditions are given than the number of + states in the system, the initial conditions will be padded with + zeros. This is often useful for interconnected control systems where + the process dynamics are the first system and all other components + start with zero initial condition since this can be specified as + [xsys_0, 0]. A warning is issued if the initial conditions are padded + and and the final listed initial state is not zero. + """ # # Process keyword arguments # - # Allow method as an alternative to solve_ivp_method - if kwargs.get('method', None): - solve_ivp_kwargs['method'] = kwargs.pop('method') - # Figure out the method to be used if kwargs.get('solve_ivp_method', None): if kwargs.get('method', None): raise ValueError("ivp_method specified more than once") - solve_ivp_kwargs['method'] = kwargs['solve_ivp_method'] + solve_ivp_kwargs['method'] = kwargs.pop('solve_ivp_method') + elif kwargs.get('method', None): + # Allow method as an alternative to solve_ivp_method + solve_ivp_kwargs['method'] = kwargs.pop('method') # Set the default method to 'RK45' if solve_ivp_kwargs.get('method', None) is None: solve_ivp_kwargs['method'] = 'RK45' + # Make sure there were no extraneous keywords + if kwargs: + raise TypeError("unrecognized keyword(s): ", str(kwargs)) + # Sanity checking on the input if not isinstance(sys, InputOutputSystem): raise TypeError("System of type ", type(sys), " not valid") # Compute the time interval and number of steps T0, Tf = T[0], T[-1] - n_steps = len(T) + ntimepts = len(T) + + # Figure out simulation times (t_eval) + if solve_ivp_kwargs.get('t_eval'): + if t_eval == 'T': + # Override the default with the solve_ivp keyword + t_eval = solve_ivp_kwargs.pop('t_eval') + else: + raise ValueError("t_eval specified more than once") + if isinstance(t_eval, str) and t_eval == 'T': + # Use the input time points as the output time points + t_eval = T + + # If we were passed a list of input, concatenate them (w/ broadcast) + if isinstance(U, (tuple, list)) and len(U) != ntimepts: + U_elements = [] + for i, u in enumerate(U): + u = np.array(u) # convert everyting to an array + # Process this input + if u.ndim == 0 or (u.ndim == 1 and u.shape[0] != T.shape[0]): + # Broadcast array to the length of the time input + u = np.outer(u, np.ones_like(T)) + + elif (u.ndim == 1 and u.shape[0] == T.shape[0]) or \ + (u.ndim == 2 and u.shape[1] == T.shape[0]): + # No processing necessary; just stack + pass - # Check and convert the input, if needed - # TODO: improve MIMO ninputs check (choose from U) + else: + raise ValueError(f"Input element {i} has inconsistent shape") + + # Append this input to our list + U_elements.append(u) + + # Save the newly created input vector + U = np.vstack(U_elements) + + # Make sure the input has the right shape if sys.ninputs is None or sys.ninputs == 1: - legal_shapes = [(n_steps,), (1, n_steps)] + legal_shapes = [(ntimepts,), (1, ntimepts)] else: - legal_shapes = [(sys.ninputs, n_steps)] + legal_shapes = [(sys.ninputs, ntimepts)] + U = _check_convert_array(U, legal_shapes, 'Parameter ``U``: ', squeeze=False) + # Always store the input as a 2D array + U = U.reshape(-1, ntimepts) + ninputs = U.shape[0] + + # If we were passed a list of initial states, concatenate them + if isinstance(X0, (tuple, list)): + X0_list = [] + for i, x0 in enumerate(X0): + x0 = np.array(x0).reshape(-1) # convert everyting to 1D array + X0_list += x0.tolist() # add elements to initial state + + # Save the newly created input vector + X0 = np.array(X0_list) + + # If the initial state is too short, make it longer (NB: sys.nstates + # could be None if nstates comes from size of initial condition) + if sys.nstates and isinstance(X0, np.ndarray) and X0.size < sys.nstates: + if X0[-1] != 0: + warn("initial state too short; padding with zeros") + X0 = np.hstack([X0, np.zeros(sys.nstates - X0.size)]) + + # If we were passed a list of initial states, concatenate them + if isinstance(X0, (tuple, list)): + X0_list = [] + for i, x0 in enumerate(X0): + x0 = np.array(x0).reshape(-1) # convert everyting to 1D array + X0_list += x0.tolist() # add elements to initial state + + # Save the newly created input vector + X0 = np.array(X0_list) + + # If the initial state is too short, make it longer (NB: sys.nstates + # could be None if nstates comes from size of initial condition) + if sys.nstates and isinstance(X0, np.ndarray) and X0.size < sys.nstates: + if X0[-1] != 0: + warn("initial state too short; padding with zeros") + X0 = np.hstack([X0, np.zeros(sys.nstates - X0.size)]) + # Check to make sure this is not a static function nstates = _find_size(sys.nstates, X0) if nstates == 0: @@ -1839,8 +1780,12 @@ def input_output_response( X0 = _check_convert_array(X0, [(nstates,), (nstates, 1)], 'Parameter ``X0``: ', squeeze=True) - # Update the parameter values - sys._update_params(params) + # Figure out the number of outputs + if sys.noutputs is None: + # Evaluate the output function to find number of outputs + noutputs = np.shape(sys._out(T[0], X0, U[:, 0]))[0] + else: + noutputs = sys.noutputs # # Define a function to evaluate the input at an arbitrary time @@ -1857,6 +1802,31 @@ def ufun(t): dt = (t - T[idx-1]) / (T[idx] - T[idx-1]) return U[..., idx-1] * (1. - dt) + U[..., idx] * dt + # Check to make sure this is not a static function + if nstates == 0: # No states => map input to output + # Make sure the user gave a time vector for evaluation (or 'T') + if t_eval is None: + # User overrode t_eval with None, but didn't give us the times... + warn("t_eval set to None, but no dynamics; using T instead") + t_eval = T + + # Allocate space for the inputs and outputs + u = np.zeros((ninputs, len(t_eval))) + y = np.zeros((noutputs, len(t_eval))) + + # Compute the input and output at each point in time + for i, t in enumerate(t_eval): + u[:, i] = ufun(t) + y[:, i] = sys._out(t, [], u[:, i]) + + return TimeResponseData( + t_eval, y, None, u, issiso=sys.issiso(), + output_labels=sys.output_index, input_labels=sys.input_index, + transpose=transpose, return_x=return_x, squeeze=squeeze) + + # Update the parameter values + sys._update_params(params) + # Create a lambda function for the right hand side def ivp_rhs(t, x): return sys._rhs(t, x, ufun(t)) @@ -1867,26 +1837,31 @@ def ivp_rhs(t, x): raise NameError("scipy.integrate.solve_ivp not found; " "use SciPy 1.0 or greater") soln = sp.integrate.solve_ivp( - ivp_rhs, (T0, Tf), X0, t_eval=T, + ivp_rhs, (T0, Tf), X0, t_eval=t_eval, vectorized=False, **solve_ivp_kwargs) + if not soln.success: + raise RuntimeError("solve_ivp failed: " + soln.message) - # Compute the output associated with the state (and use sys.out to - # figure out the number of outputs just in case it wasn't specified) - u = U[0] if len(U.shape) == 1 else U[:, 0] - y = np.zeros((np.shape(sys._out(T[0], X0, u))[0], len(T))) - for i in range(len(T)): - u = U[i] if len(U.shape) == 1 else U[:, i] - y[:, i] = sys._out(T[i], soln.y[:, i], u) + # Compute inputs and outputs for each time point + u = np.zeros((ninputs, len(soln.t))) + y = np.zeros((noutputs, len(soln.t))) + for i, t in enumerate(soln.t): + u[:, i] = ufun(t) + y[:, i] = sys._out(t, soln.y[:, i], u[:, i]) elif isdtime(sys): + # If t_eval was not specified, use the sampling time + if t_eval is None: + t_eval = np.arange(T[0], T[1] + sys.dt, sys.dt) + # Make sure the time vector is uniformly spaced - dt = T[1] - T[0] - if not np.allclose(T[1:] - T[:-1], dt): - raise ValueError("Parameter ``T``: time values must be " + dt = t_eval[1] - t_eval[0] + if not np.allclose(t_eval[1:] - t_eval[:-1], dt): + raise ValueError("Parameter ``t_eval``: time values must be " "equally spaced.") # Make sure the sample time matches the given time - if (sys.dt is not True): + if sys.dt is not True: # Make sure that the time increment is a multiple of sampling time # TODO: add back functionality for undersampling @@ -1902,21 +1877,23 @@ def ivp_rhs(t, x): # Compute the solution soln = sp.optimize.OptimizeResult() - soln.t = T # Store the time vector directly - x = [float(x0) for x0 in X0] # State vector (store as floats) + soln.t = t_eval # Store the time vector directly + x = np.array(X0) # State vector (store as floats) soln.y = [] # Solution, following scipy convention - y = [] # System output - for i in range(len(T)): - # Store the current state and output + u, y = [], [] # System input, output + for t in t_eval: + # Store the current input, state, and output soln.y.append(x) - y.append(sys._out(T[i], x, ufun(T[i]))) + u.append(ufun(t)) + y.append(sys._out(t, x, u[-1])) # Update the state for the next iteration - x = sys._rhs(T[i], x, ufun(T[i])) + x = sys._rhs(t, x, u[-1]) # Convert output to numpy arrays soln.y = np.transpose(np.array(soln.y)) y = np.transpose(np.array(y)) + u = np.transpose(np.array(u)) # Mark solution as successful soln.success = True # No way to fail @@ -1925,7 +1902,7 @@ def ivp_rhs(t, x): raise TypeError("Can't determine system type") return TimeResponseData( - soln.t, y, soln.y, U, issiso=sys.issiso(), + 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) @@ -1933,7 +1910,7 @@ def ivp_rhs(t, x): def find_eqpt(sys, x0, u0=[], y0=None, t=0, params={}, iu=None, iy=None, ix=None, idx=None, dx0=None, - return_y=False, return_result=False, **kw): + return_y=False, return_result=False): """Find the equilibrium point for an input/output system. Returns the value of an equilibrium point given the initial state and @@ -2037,7 +2014,7 @@ def find_eqpt(sys, x0, u0=[], y0=None, t=0, params={}, # Take u0 as fixed and minimize over x # TODO: update to allow discrete time systems def ode_rhs(z): return sys._rhs(t, z, u0) - result = root(ode_rhs, x0, **kw) + result = root(ode_rhs, x0) z = (result.x, u0, sys._out(t, result.x, u0)) else: # Take y0 as fixed and minimize over x and u @@ -2048,7 +2025,7 @@ def rootfun(z): return np.concatenate( (sys._rhs(t, x, u), sys._out(t, x, u) - y0), axis=0) z0 = np.concatenate((x0, u0), axis=0) # Put variables together - result = root(rootfun, z0, **kw) # Find the eq point + result = root(rootfun, z0) # Find the eq point x, u = np.split(result.x, [nstates]) # Split result back in two z = (x, u, sys._out(t, x, u)) @@ -2160,7 +2137,7 @@ def rootfun(z): z0 = np.concatenate((x[state_vars], u[input_vars]), axis=0) # Finally, call the root finding function - result = root(rootfun, z0, **kw) + result = root(rootfun, z0) # Extract out the results and insert into x and u x[state_vars] = result.x[:nstate_vars] @@ -2225,23 +2202,23 @@ def linearize(sys, xeq, ueq=[], t=0, params={}, **kw): The linearization of the system, as a :class:`~control.LinearIOSystem` object (which is also a :class:`~control.StateSpace` object. + Additional Parameters + --------------------- + inputs : int, list of str or None, optional + Description of the system inputs. If not specified, the origional + system inputs are used. See :class:`InputOutputSystem` for more + information. + outputs : int, list of str or None, optional + Description of the system outputs. Same format as `inputs`. + states : int, list of str, or None, optional + Description of the system states. Same format as `inputs`. + """ if not isinstance(sys, InputOutputSystem): raise TypeError("Can only linearize InputOutputSystem types") return sys.linearize(xeq, ueq, t=t, params=params, **kw) -# Utility function to parse a signal parameter -def _parse_signal_parameter(value, name, kwargs, end=False): - # Check kwargs for a variant of the parameter name - if value is None and name in kwargs: - value = kwargs.pop(name) - - if end and kwargs: - raise TypeError("unknown parameters %s" % kwargs) - return value - - def _find_size(sysval, vecval): """Utility function to find the size of a system parameter @@ -2261,6 +2238,220 @@ def _find_size(sysval, vecval): raise ValueError("Can't determine size of system component.") +# Define a state space object that is an I/O system +def ss(*args, **kwargs): + """ss(A, B, C, D[, dt]) + + Create a state space system. + + The function accepts either 1, 2, 4 or 5 parameters: + + ``ss(sys)`` + Convert a linear system into space system form. Always creates a + new system, even if sys is already a state space system. + + ``ss(updfcn, outfucn)``` + Create a nonlinear input/output system with update function ``updfcn`` + and output function ``outfcn``. See :class:`NonlinearIOSystem` for + more information. + + ``ss(A, B, C, D)`` + Create a state space system from the matrices of its state and + output equations: + + .. math:: + \\dot x = A \\cdot x + B \\cdot u + + y = C \\cdot x + D \\cdot u + + ``ss(A, B, C, D, dt)`` + Create a discrete-time state space system from the matrices of + its state and output equations: + + .. math:: + x[k+1] = A \\cdot x[k] + B \\cdot u[k] + + y[k] = C \\cdot x[k] + D \\cdot u[ki] + + The matrices can be given as *array like* data types or strings. + Everything that the constructor of :class:`numpy.matrix` accepts is + permissible here too. + + ``ss(args, inputs=['u1', ..., 'up'], outputs=['y1', ..., 'yq'], + states=['x1', ..., 'xn']) + Create a system with named input, output, and state signals. + + Parameters + ---------- + sys : StateSpace or TransferFunction + A linear system. + A, B, C, D : array_like or string + System, control, output, and feed forward matrices. + dt : None, True or float, optional + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling + time, positive number is discrete time with specified + sampling time, None indicates unspecified timebase (either + continuous or discrete time). + inputs, outputs, states : str, or list of str, optional + List of strings that name the individual signals. If this parameter + is not given or given as `None`, the signal names will be of the + form `s[i]` (where `s` is one of `u`, `y`, or `x`). See + :class:`InputOutputSystem` for more information. + name : string, optional + System name (used for specifying signals). If unspecified, a generic + name is generated with a unique integer id. + + Returns + ------- + out: :class:`LinearIOSystem` + Linear input/output system. + + Raises + ------ + ValueError + If matrix sizes are not self-consistent. + + See Also + -------- + tf + ss2tf + tf2ss + + Examples + -------- + >>> # Create a Linear I/O system object from from for matrices + >>> sys1 = ss([[1, -2], [3 -4]], [[5], [7]], [[6, 8]], [[9]]) + + >>> # Convert a TransferFunction to a StateSpace object. + >>> sys_tf = tf([2.], [1., 3]) + >>> sys2 = ss(sys_tf) + + """ + # See if this is a nonlinear I/O system + if len(args) > 0 and (hasattr(args[0], '__call__') or args[0] is None) \ + and not isinstance(args[0], (InputOutputSystem, LTI)): + # Function as first (or second) argument => assume nonlinear IO system + return NonlinearIOSystem(*args, **kwargs) + + elif len(args) == 4 or len(args) == 5: + # Create a state space function from A, B, C, D[, dt] + sys = LinearIOSystem(StateSpace(*args, **kwargs)) + + elif len(args) == 1: + sys = args[0] + if isinstance(sys, LTI): + # Check for system with no states and specified state names + if sys.nstates is None and 'states' in kwargs: + warn("state labels specified for " + "non-unique state space realization") + + # Create a state space system from an LTI system + sys = LinearIOSystem(_convert_to_statespace(sys), **kwargs) + else: + raise TypeError("ss(sys): sys must be a StateSpace or " + "TransferFunction object. It is %s." % type(sys)) + else: + raise TypeError( + "Needs 1, 4, or 5 arguments; received %i." % len(args)) + + return sys + + +def rss(states=1, outputs=1, inputs=1, strictly_proper=False, **kwargs): + """Create a stable random state space object. + + Parameters + ---------- + inputs : int, list of str, or None + Description of the system inputs. This can be given as an integer + count or as a list of strings that name the individual signals. If an + integer count is specified, the names of the signal will be of the + form `s[i]` (where `s` is one of `u`, `y`, or `x`). + outputs : int, list of str, or None + Description of the system outputs. Same format as `inputs`. + states : int, list of str, or None + Description of the system states. Same format as `inputs`. + strictly_proper : bool, optional + If set to 'True', returns a proper system (no direct term). + dt : None, True or float, optional + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling + time, positive number is discrete time with specified + sampling time, None indicates unspecified timebase (either + continuous or discrete time). + name : string, optional + System name (used for specifying signals). If unspecified, a generic + name is generated with a unique integer id. + + Returns + ------- + sys : StateSpace + The randomly created linear system + + Raises + ------ + ValueError + if any input is not a positive integer + + Notes + ----- + If the number of states, inputs, or outputs is not specified, then the + missing numbers are assumed to be 1. If dt is not specified or is given + as 0 or None, the poles of the returned system will always have a + negative real part. If dt is True or a postive float, the poles of the + returned system will have magnitude less than 1. + + """ + # Process keyword arguments + kwargs.update({'states': states, 'outputs': outputs, 'inputs': inputs}) + name, inputs, outputs, states, dt = _process_namedio_keywords( + kwargs, end=True) + + # Figure out the size of the sytem + nstates, _ = _process_signal_list(states) + ninputs, _ = _process_signal_list(inputs) + noutputs, _ = _process_signal_list(outputs) + + sys = _rss_generate( + nstates, ninputs, noutputs, 'c' if not dt else 'd', name=name, + strictly_proper=strictly_proper) + + return LinearIOSystem( + sys, name=name, states=states, inputs=inputs, outputs=outputs, dt=dt) + + +def drss(*args, **kwargs): + """Create a stable, discrete-time, random state space system + + Create a stable *discrete time* random state space object. This + function calls :func:`rss` using either the `dt` keyword provided by + the user or `dt=True` if not specified. + + """ + # Make sure the timebase makes sense + if 'dt' in kwargs: + dt = kwargs['dt'] + + if dt == 0: + raise ValueError("drss called with continuous timebase") + elif dt is None: + warn("drss called with unspecified timebase; " + "system may be interpreted as continuous time") + kwargs['dt'] = True # force rss to generate discrete time sys + else: + dt = True + kwargs['dt'] = True + + # Create the system + sys = rss(*args, **kwargs) + + # Reset the timebase (in case it was specified as None) + sys.dt = dt + + return sys + + # Convert a state space system into an input/output system (wrapper) def ss2io(*args, **kwargs): return LinearIOSystem(*args, **kwargs) @@ -2269,8 +2460,70 @@ def ss2io(*args, **kwargs): # Convert a transfer function into an input/output system (wrapper) def tf2io(*args, **kwargs): - """Convert a transfer function into an I/O system""" - # TODO: add remaining documentation + """tf2io(sys) + + Convert a transfer function into an I/O system + + The function accepts either 1 or 2 parameters: + + ``tf2io(sys)`` + Convert a linear system into space space form. Always creates + a new system, even if sys is already a StateSpace object. + + ``tf2io(num, den)`` + Create a linear I/O system from its numerator and denominator + polynomial coefficients. + + For details see: :func:`tf` + + Parameters + ---------- + sys : LTI (StateSpace or TransferFunction) + A linear system. + num : array_like, or list of list of array_like + Polynomial coefficients of the numerator. + den : array_like, or list of list of array_like + Polynomial coefficients of the denominator. + + Returns + ------- + out : LinearIOSystem + New I/O system (in state space form). + + Other Parameters + ---------------- + inputs, outputs : str, or list of str, optional + List of strings that name the individual signals of the transformed + system. If not given, the inputs and outputs are the same as the + original system. + name : string, optional + System name. If unspecified, a generic name is generated + with a unique integer id. + + Raises + ------ + ValueError + if `num` and `den` have invalid or unequal dimensions, or if an + invalid number of arguments is passed in. + TypeError + if `num` or `den` are of incorrect type, or if sys is not a + TransferFunction object. + + See Also + -------- + ss2io + tf2ss + + Examples + -------- + >>> num = [[[1., 2.], [3., 4.]], [[5., 6.], [7., 8.]]] + >>> den = [[[9., 8., 7.], [6., 5., 4.]], [[3., 2., 1.], [-1., -2., -3.]]] + >>> sys1 = tf2ss(num, den) + + >>> sys_tf = tf(num, den) + >>> sys2 = tf2ss(sys_tf) + + """ # Convert the system to a state space system linsys = tf2ss(*args) @@ -2279,11 +2532,9 @@ def tf2io(*args, **kwargs): # Function to create an interconnected system -def interconnect(syslist, connections=None, inplist=[], outlist=[], - inputs=None, outputs=None, states=None, - params={}, dt=None, name=None, +def interconnect(syslist, connections=None, inplist=[], outlist=[], params={}, check_unused=True, ignore_inputs=None, ignore_outputs=None, - **kwargs): + warn_duplicate=None, **kwargs): """Interconnect a set of input/output systems. This function creates a new system that is an interconnection of a set of @@ -2433,6 +2684,12 @@ def interconnect(syslist, connections=None, inplist=[], outlist=[], outputs from all sub-systems with that base name are considered ignored. + warn_duplicate : None, True, or False + Control how warnings are generated if duplicate objects or names are + detected. In `None` (default), then warnings are generated for + systems that have non-generic names. If `False`, warnings are not + generated and if `True` then warnings are always generated. + Example ------- @@ -2455,7 +2712,7 @@ def interconnect(syslist, connections=None, inplist=[], outlist=[], >>> P = control.tf2io(control.tf(1, [1, 0]), inputs='u', outputs='y') >>> C = control.tf2io(control.tf(10, [1, 1]), inputs='e', outputs='u') >>> sumblk = control.summing_junction(inputs=['r', '-y'], output='e') - >>> T = control.interconnect([P, C, sumblk], input='r', output='y') + >>> T = control.interconnect([P, C, sumblk], inputs='r', outputs='y') Notes ----- @@ -2484,17 +2741,16 @@ def interconnect(syslist, connections=None, inplist=[], outlist=[], `outputs`, for more natural naming of SISO systems. """ - # Look for 'input' and 'output' parameter name variants - inputs = _parse_signal_parameter(inputs, 'input', kwargs) - outputs = _parse_signal_parameter(outputs, 'output', kwargs, end=True) + dt = kwargs.pop('dt', None) # by pass normal 'dt' processing + name, inputs, outputs, states, _ = _process_namedio_keywords( + kwargs, end=True) if not check_unused and (ignore_inputs or ignore_outputs): raise ValueError('check_unused is False, but either ' + 'ignore_inputs or ignore_outputs non-empty') - if (connections is False - and not inplist and not outlist - and not inputs and not outputs): + if connections is False and not inplist and not outlist \ + and not inputs and not outputs: # user has disabled auto-connect, and supplied neither input # nor output mappings; assume they know what they're doing check_unused = False @@ -2504,10 +2760,10 @@ def interconnect(syslist, connections=None, inplist=[], outlist=[], # For each system input, look for outputs with the same name connections = [] for input_sys in syslist: - for input_name in input_sys.input_index.keys(): + for input_name in input_sys.input_labels: connect = [input_sys.name + "." + input_name] for output_sys in syslist: - if input_name in output_sys.output_index.keys(): + if input_name in output_sys.output_labels: connect.append(output_sys.name + "." + input_name) if len(connect) > 1: connections.append(connect) @@ -2584,11 +2840,12 @@ def interconnect(syslist, connections=None, inplist=[], outlist=[], newsys = InterconnectedSystem( syslist, connections=connections, inplist=inplist, outlist=outlist, inputs=inputs, outputs=outputs, states=states, - params=params, dt=dt, name=name) + params=params, dt=dt, name=name, warn_duplicate=warn_duplicate) # check for implicity dropped signals if check_unused: newsys.check_unused_signals(ignore_inputs, ignore_outputs) + # If all subsystems are linear systems, maintain linear structure if all([isinstance(sys, LinearIOSystem) for sys in syslist]): return LinearICSystem(newsys, None) @@ -2598,8 +2855,7 @@ def interconnect(syslist, connections=None, inplist=[], outlist=[], # Summing junction def summing_junction( - inputs=None, output=None, dimension=None, name=None, - prefix='u', **kwargs): + inputs=None, output=None, dimension=None, prefix='u', **kwargs): """Create a summing junction as an input/output system. This function creates a static input/output system that outputs the sum of @@ -2638,10 +2894,10 @@ def summing_junction( Example ------- - >>> P = control.tf2io(ct.tf(1, [1, 0]), input='u', output='y') - >>> C = control.tf2io(ct.tf(10, [1, 1]), input='e', output='u') + >>> P = control.tf2io(ct.tf(1, [1, 0]), inputs='u', outputs='y') + >>> C = control.tf2io(ct.tf(10, [1, 1]), inputs='e', outputs='u') >>> sumblk = control.summing_junction(inputs=['r', '-y'], output='e') - >>> T = control.interconnect((P, C, sumblk), input='r', output='y') + >>> T = control.interconnect((P, C, sumblk), inputs='r', outputs='y') """ # Utility function to parse input and output signal lists @@ -2674,15 +2930,15 @@ def _parse_list(signals, signame='input', prefix='u'): # Return the parsed list return nsignals, names, gains - # Look for 'input' and 'output' parameter name variants - inputs = _parse_signal_parameter(inputs, 'input', kwargs) - output = _parse_signal_parameter(output, 'outputs', kwargs, end=True) - - # Default values for inputs and output + # Parse system and signal names (with some minor pre-processing) + if input is not None: + kwargs['inputs'] = inputs # positional/keyword -> keyword + if output is not None: + kwargs['output'] = output # positional/keyword -> keyword + name, inputs, output, states, dt = _process_namedio_keywords( + kwargs, {'inputs': None, 'outputs': 'y'}, end=True) if inputs is None: raise TypeError("input specification is required") - if output is None: - output = 'y' # Read the input list ninputs, input_names, input_gains = _parse_list( diff --git a/control/lti.py b/control/lti.py index b56c2bb44..fdb4946cd 100644 --- a/control/lti.py +++ b/control/lti.py @@ -16,12 +16,12 @@ from numpy import absolute, real, angle, abs from warnings import warn from . import config +from .namedio import NamedIOSystem, isdtime -__all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', - 'isdtime', 'isctime', 'pole', 'zero', 'damp', 'evalfr', - 'freqresp', 'dcgain'] +__all__ = ['poles', 'zeros', 'damp', 'evalfr', 'frequency_response', + 'freqresp', 'dcgain', 'pole', 'zero'] -class LTI: +class LTI(NamedIOSystem): """LTI is a parent class to linear time-invariant (LTI) system objects. LTI is the parent to the StateSpace and TransferFunction child classes. It @@ -41,15 +41,13 @@ class LTI: with timebase None can be combined with a system having a specified timebase, and the result will have the timebase of the latter system. - """ + Note: dt processing has been moved to the NamedIOSystem class. - def __init__(self, inputs=1, outputs=1, dt=None): + """ + def __init__(self, inputs=1, outputs=1, states=None, name=None, **kwargs): """Assign the LTI object's numbers of inputs and ouputs.""" - - # Data members common to StateSpace and TransferFunction. - self.ninputs = inputs - self.noutputs = outputs - self.dt = dt + super().__init__( + name=name, inputs=inputs, outputs=outputs, states=states, **kwargs) # # Getter and setter functions for legacy state attributes @@ -77,9 +75,9 @@ def _set_inputs(self, value): """ Deprecated attribute; use :attr:`ninputs` instead. - The ``input`` attribute was used to store the number of system inputs. - It is no longer used. If you need access to the number of inputs for - an LTI system, use :attr:`ninputs`. + The ``inputs`` attribute was used to store the number of system + inputs. It is no longer used. If you need access to the number + of inputs for an LTI system, use :attr:`ninputs`. """) def _get_outputs(self): @@ -100,50 +98,11 @@ def _set_outputs(self, value): """ Deprecated attribute; use :attr:`noutputs` instead. - The ``output`` attribute was used to store the number of system + The ``outputs`` attribute was used to store the number of system outputs. It is no longer used. If you need access to the number of outputs for an LTI system, use :attr:`noutputs`. """) - def isdtime(self, strict=False): - """ - Check to see if a system is a discrete-time system - - Parameters - ---------- - strict: bool, optional - If strict is True, make sure that timebase is not None. Default - is False. - """ - - # If no timebase is given, answer depends on strict flag - if self.dt == None: - return True if not strict else False - - # Look for dt > 0 (also works if dt = True) - return self.dt > 0 - - def isctime(self, strict=False): - """ - Check to see if a system is a continuous-time system - - Parameters - ---------- - sys : LTI system - System to be checked - strict: bool, optional - If strict is True, make sure that timebase is not None. Default - is False. - """ - # If no timebase is given, answer depends on strict flag - if self.dt is None: - return True if not strict else False - return self.dt == 0 - - def issiso(self): - '''Check to see if a system is single input, single output''' - return self.ninputs == 1 and self.noutputs == 1 - def damp(self): '''Natural frequency, damping ratio of system poles @@ -156,9 +115,9 @@ def damp(self): poles : array Array of system poles ''' - poles = self.pole() + poles = self.poles() - if isdtime(self, strict=True): + if self.isdtime(strict=True): splane_poles = np.log(poles.astype(complex))/self.dt else: splane_poles = poles @@ -172,16 +131,16 @@ def frequency_response(self, omega, squeeze=None): Reports the frequency response of the system, - G(j*omega) = mag*exp(j*phase) + G(j*omega) = mag * exp(j*phase) - for continuous time systems. For discrete time systems, the response is - evaluated around the unit circle such that + for continuous time systems. For discrete time systems, the response + is evaluated around the unit circle such that - G(exp(j*omega*dt)) = mag*exp(j*phase). + G(exp(j*omega*dt)) = mag * exp(j*phase). In general the system may be multiple input, multiple output (MIMO), - where `m = self.ninputs` number of inputs and `p = self.noutputs` number - of outputs. + where `m = self.ninputs` number of inputs and `p = self.noutputs` + number of outputs. Parameters ---------- @@ -197,29 +156,37 @@ def frequency_response(self, omega, squeeze=None): Returns ------- - mag : ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. If the system is SISO and squeeze is not - True, the array is 1D, indexed by frequency. If the system is not - SISO or squeeze is False, the array is 3D, indexed by the output, + response : :class:`FrequencyReponseData` + Frequency response data object representing the frequency + response. This object can be assigned to a tuple using + + mag, phase, omega = response + + where ``mag`` is the magnitude (absolute value, not dB or + log10) of the system frequency response, ``phase`` is the wrapped + phase in radians of the system frequency response, and ``omega`` + is the (sorted) frequencies at which the response was evaluated. + If the system is SISO and squeeze is not True, ``magnitude`` and + ``phase`` are 1D, indexed by frequency. If the system is not SISO + or squeeze is False, the array is 3D, indexed by the output, input, and frequency. If ``squeeze`` is True then single-dimensional axes are removed. - phase : ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray - The (sorted) frequencies at which the response was evaluated. """ omega = np.sort(np.array(omega, ndmin=1)) - if isdtime(self, strict=True): + if self.isdtime(strict=True): # Convert the frequency to discrete time if np.any(omega * self.dt > np.pi): warn("__call__: evaluation above Nyquist frequency") s = np.exp(1j * omega * self.dt) else: s = 1j * omega - response = self.__call__(s, squeeze=squeeze) - return abs(response), angle(response), omega + + # Return the data as a frequency response data object + from .frdata import FrequencyResponseData + response = self.__call__(s) + return FrequencyResponseData( + response, omega, return_magphase=True, squeeze=squeeze) def dcgain(self): """Return the zero-frequency gain""" @@ -234,191 +201,22 @@ def _dcgain(self, warn_infinite): else: return zeroresp -# Test to see if a system is SISO -def issiso(sys, strict=False): - """ - Check to see if a system is single input, single output - - Parameters - ---------- - sys : LTI system - System to be checked - strict: bool (default = False) - If strict is True, do not treat scalars as SISO - """ - if isinstance(sys, (int, float, complex, np.number)) and not strict: - return True - elif not isinstance(sys, LTI): - raise ValueError("Object is not an LTI system") - - # Done with the tricky stuff... - return sys.issiso() - -# Return the timebase (with conversion if unspecified) -def timebase(sys, strict=True): - """Return the timebase for an LTI system - - dt = timebase(sys) - - returns the timebase for a system 'sys'. If the strict option is - set to False, dt = True will be returned as 1. - """ - # System needs to be either a constant or an LTI system - if isinstance(sys, (int, float, complex, np.number)): - return None - elif not isinstance(sys, LTI): - raise ValueError("Timebase not defined") - - # Return the sample time, with converstion to float if strict is false - if (sys.dt == None): - return None - elif (strict): - return float(sys.dt) - - return sys.dt - -def common_timebase(dt1, dt2): - """ - Find the common timebase when interconnecting systems - - Parameters - ---------- - dt1, dt2: number or system with a 'dt' attribute (e.g. TransferFunction - or StateSpace system) - - Returns - ------- - dt: number - The common timebase of dt1 and dt2, as specified in - :ref:`conventions-ref`. - - Raises - ------ - ValueError - when no compatible time base can be found - """ - # explanation: - # if either dt is None, they are compatible with anything - # if either dt is True (discrete with unspecified time base), - # use the timebase of the other, if it is also discrete - # otherwise both dts must be equal - if hasattr(dt1, 'dt'): - dt1 = dt1.dt - if hasattr(dt2, 'dt'): - dt2 = dt2.dt - - if dt1 is None: - return dt2 - elif dt2 is None: - return dt1 - elif dt1 is True: - if dt2 > 0: - return dt2 - else: - raise ValueError("Systems have incompatible timebases") - elif dt2 is True: - if dt1 > 0: - return dt1 - else: - raise ValueError("Systems have incompatible timebases") - elif np.isclose(dt1, dt2): - return dt1 - else: - raise ValueError("Systems have incompatible timebases") - -# Check to see if two timebases are equal -def timebaseEqual(sys1, sys2): - """ - Check to see if two systems have the same timebase - - timebaseEqual(sys1, sys2) - - returns True if the timebases for the two systems are compatible. By - default, systems with timebase 'None' are compatible with either - discrete or continuous timebase systems. If two systems have a discrete - timebase (dt > 0) then their timebases must be equal. - """ - warn("timebaseEqual will be deprecated in a future release of " - "python-control; use :func:`common_timebase` instead", - PendingDeprecationWarning) - - if (type(sys1.dt) == bool or type(sys2.dt) == bool): - # Make sure both are unspecified discrete timebases - return type(sys1.dt) == type(sys2.dt) and sys1.dt == sys2.dt - elif (sys1.dt is None or sys2.dt is None): - # One or the other is unspecified => the other can be anything - return True - else: - return sys1.dt == sys2.dt - - -# Check to see if a system is a discrete time system -def isdtime(sys, strict=False): - """ - Check to see if a system is a discrete time system - - Parameters - ---------- - sys : LTI system - System to be checked - strict: bool (default = False) - If strict is True, make sure that timebase is not None - """ - - # Check to see if this is a constant - if isinstance(sys, (int, float, complex, np.number)): - # OK as long as strict checking is off - return True if not strict else False - - # Check for a transfer function or state-space object - if isinstance(sys, LTI): - return sys.isdtime(strict) - - # Check to see if object has a dt object - if hasattr(sys, 'dt'): - # If no timebase is given, answer depends on strict flag - if sys.dt == None: - return True if not strict else False - - # Look for dt > 0 (also works if dt = True) - return sys.dt > 0 - - # Got passed something we don't recognize - return False - -# Check to see if a system is a continuous time system -def isctime(sys, strict=False): - """ - Check to see if a system is a continuous-time system - - Parameters - ---------- - sys : LTI system - System to be checked - strict: bool (default = False) - If strict is True, make sure that timebase is not None - """ - - # Check to see if this is a constant - if isinstance(sys, (int, float, complex, np.number)): - # OK as long as strict checking is off - return True if not strict else False + # + # Deprecated functions + # - # Check for a transfer function or state space object - if isinstance(sys, LTI): - return sys.isctime(strict) + def pole(self): + warn("pole() will be deprecated; use poles()", + PendingDeprecationWarning) + return self.poles() - # Check to see if object has a dt object - if hasattr(sys, 'dt'): - # If no timebase is given, answer depends on strict flag - if sys.dt is None: - return True if not strict else False - return sys.dt == 0 + def zero(self): + warn("zero() will be deprecated; use zeros()", + PendingDeprecationWarning) + return self.zeros() - # Got passed something we don't recognize - return False -def pole(sys): +def poles(sys): """ Compute system poles. @@ -432,23 +230,23 @@ def pole(sys): poles: ndarray Array that contains the system's poles. - Raises - ------ - NotImplementedError - when called on a TransferFunction object - See Also -------- - zero - TransferFunction.pole - StateSpace.pole + zeros + TransferFunction.poles + StateSpace.poles """ - return sys.pole() + return sys.poles() -def zero(sys): +def pole(sys): + warn("pole() will be deprecated; use poles()", PendingDeprecationWarning) + return poles(sys) + + +def zeros(sys): """ Compute system zeros. @@ -462,20 +260,21 @@ def zero(sys): zeros: ndarray Array that contains the system's zeros. - Raises - ------ - NotImplementedError - when called on a MIMO system - See Also -------- - pole - StateSpace.zero - TransferFunction.zero + poles + StateSpace.zeros + TransferFunction.zeros """ - return sys.zero() + return sys.zeros() + + +def zero(sys): + warn("zero() will be deprecated; use zeros()", PendingDeprecationWarning) + return zeros(sys) + def damp(sys, doprint=True): """ @@ -589,7 +388,7 @@ def evalfr(sys, x, squeeze=None): """ return sys.__call__(x, squeeze=squeeze) -def freqresp(sys, omega, squeeze=None): +def frequency_response(sys, omega, squeeze=None): """Frequency response of an LTI system at multiple angular frequencies. In general the system may be multiple input, multiple output (MIMO), where @@ -613,18 +412,21 @@ def freqresp(sys, omega, squeeze=None): Returns ------- - mag : ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. If the system is SISO and squeeze is not True, - the array is 1D, indexed by frequency. If the system is not SISO or - squeeze is False, the array is 3D, indexed by the output, input, and + response : FrequencyResponseData + Frequency response data object representing the frequency response. + This object can be assigned to a tuple using + + mag, phase, omega = response + + where ``mag`` is the magnitude (absolute value, not dB or log10) of + the system frequency response, ``phase`` is the wrapped phase in + radians of the system frequency response, and ``omega`` is the + (sorted) frequencies at which the response was evaluated. If the + system is SISO and squeeze is not True, ``magnitude`` and ``phase`` + are 1D, indexed by frequency. If the system is not SISO or squeeze + is False, the array is 3D, indexed by the output, input, and frequency. If ``squeeze`` is True then single-dimensional axes are removed. - phase : ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray - The list of sorted frequencies at which the response was - evaluated. See Also -------- @@ -663,6 +465,10 @@ def freqresp(sys, omega, squeeze=None): return sys.frequency_response(omega, squeeze=squeeze) +# Alternative name (legacy) +freqresp = frequency_response + + def dcgain(sys): """Return the zero-frequency (or DC) gain of the given system diff --git a/control/margins.py b/control/margins.py index 41739704e..662634086 100644 --- a/control/margins.py +++ b/control/margins.py @@ -52,7 +52,8 @@ import numpy as np import scipy as sp from . import xferfcn -from .lti import issiso, evalfr +from .lti import evalfr +from .namedio import issiso from . import frdata from . import freqplot from .exception import ControlMIMONotImplemented diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py index 196a4a6c8..80f2a0a65 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -62,8 +62,10 @@ # Control system library from ..statesp import * +from ..iosys import ss, rss, drss # moved from .statesp from ..xferfcn import * from ..lti import * +from ..namedio import * from ..frdata import * from ..dtime import * from ..exception import ControlArgument @@ -83,6 +85,9 @@ from ..dtime import c2d from ..sisotool import sisotool +# Functions that are renamed in MATLAB +pole, zero = poles, zeros + # Import functions specific to Matlab compatibility package from .timeresp import * from .wrappers import * diff --git a/control/matlab/wrappers.py b/control/matlab/wrappers.py index f7cbaea41..8eafdaad2 100644 --- a/control/matlab/wrappers.py +++ b/control/matlab/wrappers.py @@ -3,7 +3,7 @@ """ import numpy as np -from ..statesp import ss +from ..iosys import ss from ..xferfcn import tf from ..ctrlutil import issys from ..exception import ControlArgument diff --git a/control/modelsimp.py b/control/modelsimp.py index f43acc2fd..432b76b96 100644 --- a/control/modelsimp.py +++ b/control/modelsimp.py @@ -45,7 +45,7 @@ import warnings from .exception import ControlSlycot, ControlMIMONotImplemented, \ ControlDimension -from .lti import isdtime, isctime +from .namedio import isdtime, isctime from .statesp import StateSpace from .statefbk import gram @@ -354,7 +354,7 @@ def minreal(sys, tol=None, verbose=True): sysr = sys.minreal(tol) if verbose: print("{nstates} states have been removed from the model".format( - nstates=len(sys.pole()) - len(sysr.pole()))) + nstates=len(sys.poles()) - len(sysr.poles()))) return sysr diff --git a/control/namedio.py b/control/namedio.py new file mode 100644 index 000000000..254f310ff --- /dev/null +++ b/control/namedio.py @@ -0,0 +1,564 @@ +# namedio.py - named I/O system class and helper functions +# RMM, 13 Mar 2022 +# +# This file implements the NamedIOSystem class, which is used as a parent +# class for FrequencyResponseData, InputOutputSystem, LTI, TimeResponseData, +# and other similar classes to allow naming of signals. + +import numpy as np +from copy import copy +from warnings import warn +from . import config + +__all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', + 'isdtime', 'isctime'] + +class NamedIOSystem(object): + def __init__( + self, name=None, inputs=None, outputs=None, states=None, **kwargs): + + # system name + self.name = self._name_or_default(name) + + # Parse and store the number of inputs and outputs + self.set_inputs(inputs) + self.set_outputs(outputs) + self.set_states(states) + + # Process timebase: if not given use default, but allow None as value + self.dt = _process_dt_keyword(kwargs) + + # Make sure there were no other keywords + if kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) + + # + # Functions to manipulate the system name + # + _idCounter = 0 # Counter for creating generic system name + + # Return system name + def _name_or_default(self, name=None): + if name is None: + name = "sys[{}]".format(NamedIOSystem._idCounter) + NamedIOSystem._idCounter += 1 + return name + + # Check if system name is generic + def _generic_name_check(self): + import re + return re.match(r'^sys\[\d*\]$', self.name) is not None + + # + # Class attributes + # + # These attributes are defined as class attributes so that they are + # documented properly. They are "overwritten" in __init__. + # + + #: Number of system inputs. + #: + #: :meta hide-value: + ninputs = None + + #: Number of system outputs. + #: + #: :meta hide-value: + noutputs = None + + #: Number of system states. + #: + #: :meta hide-value: + nstates = None + + def __repr__(self): + return f'<{self.__class__.__name__}:{self.name}:' + \ + f'{list(self.input_labels)}->{list(self.output_labels)}>' + + def __str__(self): + """String representation of an input/output object""" + str = f"<{self.__class__.__name__}>: {self.name}\n" + str += f"Inputs ({self.ninputs}): {self.input_labels}\n" + str += f"Outputs ({self.noutputs}): {self.output_labels}\n" + if self.nstates is not None: + str += f"States ({self.nstates}): {self.state_labels}" + return str + + # Find a signal by name + def _find_signal(self, name, sigdict): + return sigdict.get(name, None) + + def copy(self, name=None, use_prefix_suffix=True): + """Make a copy of an input/output system + + A copy of the system is made, with a new name. The `name` keyword + can be used to specify a specific name for the system. If no name + is given and `use_prefix_suffix` is True, the name is constructed + by prepending config.defaults['iosys.duplicate_system_name_prefix'] + and appending config.defaults['iosys.duplicate_system_name_suffix']. + Otherwise, a generic system name of the form `sys[]` is used, + where `` is based on an internal counter. + + """ + # Create a copy of the system + newsys = copy(self) + + # Update the system name + if name is None and use_prefix_suffix: + # Get the default prefix and suffix to use + dup_prefix = config.defaults['iosys.duplicate_system_name_prefix'] + dup_suffix = config.defaults['iosys.duplicate_system_name_suffix'] + newsys.name = self._name_or_default( + dup_prefix + self.name + dup_suffix) + else: + newsys.name = self._name_or_default(name) + + return newsys + + def set_inputs(self, inputs, prefix='u'): + + """Set the number/names of the system inputs. + + Parameters + ---------- + inputs : int, list of str, or None + Description of the system inputs. This can be given as an integer + count or as a list of strings that name the individual signals. + If an integer count is specified, the names of the signal will be + of the form `u[i]` (where the prefix `u` can be changed using the + optional prefix parameter). + prefix : string, optional + If `inputs` is an integer, create the names of the states using + the given prefix (default = 'u'). The names of the input will be + of the form `prefix[i]`. + + """ + self.ninputs, self.input_index = \ + _process_signal_list(inputs, prefix=prefix) + + def find_input(self, name): + """Find the index for an input given its name (`None` if not found)""" + return self.input_index.get(name, None) + + # Property for getting and setting list of input signals + input_labels = property( + lambda self: list(self.input_index.keys()), # getter + set_inputs) # setter + + def set_outputs(self, outputs, prefix='y'): + """Set the number/names of the system outputs. + + Parameters + ---------- + outputs : int, list of str, or None + Description of the system outputs. This can be given as an integer + count or as a list of strings that name the individual signals. + If an integer count is specified, the names of the signal will be + of the form `u[i]` (where the prefix `u` can be changed using the + optional prefix parameter). + prefix : string, optional + If `outputs` is an integer, create the names of the states using + the given prefix (default = 'y'). The names of the input will be + of the form `prefix[i]`. + + """ + self.noutputs, self.output_index = \ + _process_signal_list(outputs, prefix=prefix) + + def find_output(self, name): + """Find the index for an output given its name (`None` if not found)""" + return self.output_index.get(name, None) + + # Property for getting and setting list of output signals + output_labels = property( + lambda self: list(self.output_index.keys()), # getter + set_outputs) # setter + + def set_states(self, states, prefix='x'): + """Set the number/names of the system states. + + Parameters + ---------- + states : int, list of str, or None + Description of the system states. This can be given as an integer + count or as a list of strings that name the individual signals. + If an integer count is specified, the names of the signal will be + of the form `u[i]` (where the prefix `u` can be changed using the + optional prefix parameter). + prefix : string, optional + If `states` is an integer, create the names of the states using + the given prefix (default = 'x'). The names of the input will be + of the form `prefix[i]`. + + """ + self.nstates, self.state_index = \ + _process_signal_list(states, prefix=prefix) + + def find_state(self, name): + """Find the index for a state given its name (`None` if not found)""" + return self.state_index.get(name, None) + + # Property for getting and setting list of state signals + state_labels = property( + lambda self: list(self.state_index.keys()), # getter + set_states) # setter + + def isctime(self, strict=False): + """ + Check to see if a system is a continuous-time system + + Parameters + ---------- + sys : Named I/O system + System to be checked + strict: bool, optional + If strict is True, make sure that timebase is not None. Default + is False. + """ + # If no timebase is given, answer depends on strict flag + if self.dt is None: + return True if not strict else False + return self.dt == 0 + + def isdtime(self, strict=False): + """ + Check to see if a system is a discrete-time system + + Parameters + ---------- + strict: bool, optional + If strict is True, make sure that timebase is not None. Default + is False. + """ + + # If no timebase is given, answer depends on strict flag + if self.dt == None: + return True if not strict else False + + # Look for dt > 0 (also works if dt = True) + return self.dt > 0 + + def issiso(self): + """Check to see if a system is single input, single output""" + return self.ninputs == 1 and self.noutputs == 1 + + def _isstatic(self): + """Check to see if a system is a static system (no states)""" + return self.nstates == 0 + + +# Test to see if a system is SISO +def issiso(sys, strict=False): + """ + Check to see if a system is single input, single output + + Parameters + ---------- + sys : I/O or LTI system + System to be checked + strict: bool (default = False) + If strict is True, do not treat scalars as SISO + """ + if isinstance(sys, (int, float, complex, np.number)) and not strict: + return True + elif not isinstance(sys, NamedIOSystem): + raise ValueError("Object is not an I/O or LTI system") + + # Done with the tricky stuff... + return sys.issiso() + +# Return the timebase (with conversion if unspecified) +def timebase(sys, strict=True): + """Return the timebase for a system + + dt = timebase(sys) + + returns the timebase for a system 'sys'. If the strict option is + set to False, dt = True will be returned as 1. + """ + # System needs to be either a constant or an I/O or LTI system + if isinstance(sys, (int, float, complex, np.number)): + return None + elif not isinstance(sys, NamedIOSystem): + raise ValueError("Timebase not defined") + + # Return the sample time, with converstion to float if strict is false + if (sys.dt == None): + return None + elif (strict): + return float(sys.dt) + + return sys.dt + +def common_timebase(dt1, dt2): + """ + Find the common timebase when interconnecting systems + + Parameters + ---------- + dt1, dt2: number or system with a 'dt' attribute (e.g. TransferFunction + or StateSpace system) + + Returns + ------- + dt: number + The common timebase of dt1 and dt2, as specified in + :ref:`conventions-ref`. + + Raises + ------ + ValueError + when no compatible time base can be found + """ + # explanation: + # if either dt is None, they are compatible with anything + # if either dt is True (discrete with unspecified time base), + # use the timebase of the other, if it is also discrete + # otherwise both dts must be equal + if hasattr(dt1, 'dt'): + dt1 = dt1.dt + if hasattr(dt2, 'dt'): + dt2 = dt2.dt + + if dt1 is None: + return dt2 + elif dt2 is None: + return dt1 + elif dt1 is True: + if dt2 > 0: + return dt2 + else: + raise ValueError("Systems have incompatible timebases") + elif dt2 is True: + if dt1 > 0: + return dt1 + else: + raise ValueError("Systems have incompatible timebases") + elif np.isclose(dt1, dt2): + return dt1 + else: + raise ValueError("Systems have incompatible timebases") + +# Check to see if two timebases are equal +def timebaseEqual(sys1, sys2): + """ + Check to see if two systems have the same timebase + + timebaseEqual(sys1, sys2) + + returns True if the timebases for the two systems are compatible. By + default, systems with timebase 'None' are compatible with either + discrete or continuous timebase systems. If two systems have a discrete + timebase (dt > 0) then their timebases must be equal. + """ + warn("timebaseEqual will be deprecated in a future release of " + "python-control; use :func:`common_timebase` instead", + PendingDeprecationWarning) + + if (type(sys1.dt) == bool or type(sys2.dt) == bool): + # Make sure both are unspecified discrete timebases + return type(sys1.dt) == type(sys2.dt) and sys1.dt == sys2.dt + elif (sys1.dt is None or sys2.dt is None): + # One or the other is unspecified => the other can be anything + return True + else: + return sys1.dt == sys2.dt + + +# Check to see if a system is a discrete time system +def isdtime(sys, strict=False): + """ + Check to see if a system is a discrete time system + + Parameters + ---------- + sys : I/O or LTI system + System to be checked + strict: bool (default = False) + If strict is True, make sure that timebase is not None + """ + + # Check to see if this is a constant + if isinstance(sys, (int, float, complex, np.number)): + # OK as long as strict checking is off + return True if not strict else False + + # Check for a transfer function or state-space object + if isinstance(sys, NamedIOSystem): + return sys.isdtime(strict) + + # Check to see if object has a dt object + if hasattr(sys, 'dt'): + # If no timebase is given, answer depends on strict flag + if sys.dt == None: + return True if not strict else False + + # Look for dt > 0 (also works if dt = True) + return sys.dt > 0 + + # Got passed something we don't recognize + return False + +# Check to see if a system is a continuous time system +def isctime(sys, strict=False): + """ + Check to see if a system is a continuous-time system + + Parameters + ---------- + sys : I/O or LTI system + System to be checked + strict: bool (default = False) + If strict is True, make sure that timebase is not None + """ + + # Check to see if this is a constant + if isinstance(sys, (int, float, complex, np.number)): + # OK as long as strict checking is off + return True if not strict else False + + # Check for a transfer function or state space object + if isinstance(sys, NamedIOSystem): + return sys.isctime(strict) + + # Check to see if object has a dt object + if hasattr(sys, 'dt'): + # If no timebase is given, answer depends on strict flag + if sys.dt is None: + return True if not strict else False + return sys.dt == 0 + + # Got passed something we don't recognize + return False + + +# Utility function to parse nameio keywords +def _process_namedio_keywords( + keywords={}, defaults={}, static=False, end=False): + """Process namedio specification + + This function processes the standard keywords used in initializing a named + I/O system. It first looks in the `keyword` dictionary to see if a value + is specified. If not, the `default` dictionary is used. The `default` + dictionary can also be set to a NamedIOSystem object, which is useful for + copy constructors that change system and signal names. + + If `end` is True, then generate an error if there are any remaining + keywords. + + """ + # If default is a system, redefine as a dictionary + if isinstance(defaults, NamedIOSystem): + sys = defaults + defaults = { + 'name': sys.name, 'inputs': sys.input_labels, + 'outputs': sys.output_labels, 'dt': sys.dt} + + if sys.nstates is not None: + defaults['states'] = sys.state_labels + + elif not isinstance(defaults, dict): + raise TypeError("default must be dict or sys") + + else: + sys = None + + # Sort out singular versus plural signal names + for singular in ['input', 'output', 'state']: + kw = singular + 's' + if singular in keywords and kw in keywords: + raise TypeError(f"conflicting keywords '{singular}' and '{kw}'") + + if singular in keywords: + keywords[kw] = keywords.pop(singular) + + # Utility function to get keyword with defaults, processing + def pop_with_default(kw, defval=None, return_list=True): + val = keywords.pop(kw, None) + if val is None: + val = defaults.get(kw, defval) + if return_list and isinstance(val, str): + val = [val] # make sure to return a list + return val + + # Process system and signal names + name = pop_with_default('name', return_list=False) + inputs = pop_with_default('inputs') + outputs = pop_with_default('outputs') + states = pop_with_default('states') + + # If we were given a system, make sure sizes match list lengths + if sys: + if isinstance(inputs, list) and sys.ninputs != len(inputs): + raise ValueError("Wrong number of input labels given.") + if isinstance(outputs, list) and sys.noutputs != len(outputs): + raise ValueError("Wrong number of output labels given.") + if sys.nstates is not None and \ + isinstance(states, list) and sys.nstates != len(states): + raise ValueError("Wrong number of state labels given.") + + # Process timebase: if not given use default, but allow None as value + dt = _process_dt_keyword(keywords, defaults, static=static) + + # If desired, make sure we processed all keywords + if end and keywords: + raise TypeError("unrecognized keywords: ", str(keywords)) + + # Return the processed keywords + return name, inputs, outputs, states, dt + +# +# Parse 'dt' in for named I/O system +# +# The 'dt' keyword is used to set the timebase for a system. Its +# processing is a bit unusual: if it is not specified at all, then the +# value is pulled from config.defaults['control.default_dt']. But +# since 'None' is an allowed value, we can't just use the default if +# dt is None. Instead, we have to look to see if it was listed as a +# variable keyword. +# +# In addition, if a system is static and dt is not specified, we set dt = +# None to allow static systems to be combined with either discrete-time or +# continuous-time systems. +# +# TODO: update all 'dt' processing to call this function, so that +# everything is done consistently. +# +def _process_dt_keyword(keywords, defaults={}, static=False): + if static and 'dt' not in keywords and 'dt' not in defaults: + dt = None + elif 'dt' in keywords: + dt = keywords.pop('dt') + elif 'dt' in defaults: + dt = defaults.pop('dt') + else: + dt = config.defaults['control.default_dt'] + + # Make sure that the value for dt is valid + if dt is not None and not isinstance(dt, (bool, int, float)) or \ + isinstance(dt, (bool, int, float)) and dt < 0: + raise ValueError(f"invalid timebase, dt = {dt}") + + return dt + + +# Utility function to parse a list of signals +def _process_signal_list(signals, prefix='s'): + if signals is None: + # No information provided; try and make it up later + return None, {} + + elif isinstance(signals, (int, np.integer)): + # Number of signals given; make up the names + return signals, {'%s[%d]' % (prefix, i): i for i in range(signals)} + + elif isinstance(signals, str): + # Single string given => single signal with given name + return 1, {signals: 0} + + elif all(isinstance(s, str) for s in signals): + # Use the list of strings as the signal names + return len(signals), {signals[i]: i for i in range(len(signals))} + + else: + raise TypeError("Can't parse signal list %s" % str(signals)) diff --git a/control/nichols.py b/control/nichols.py index a643d8580..69546678b 100644 --- a/control/nichols.py +++ b/control/nichols.py @@ -51,6 +51,8 @@ import numpy as np import matplotlib.pyplot as plt +import matplotlib.transforms + from .ctrlutil import unwrap from .freqplot import _default_frequency_range from . import config @@ -119,7 +121,18 @@ def nichols_plot(sys_list, omega=None, grid=None): nichols_grid() -def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'): +def _inner_extents(ax): + # intersection of data and view extents + # if intersection empty, return view extents + _inner = matplotlib.transforms.Bbox.intersection(ax.viewLim, ax.dataLim) + if _inner is None: + return ax.ViewLim.extents + else: + return _inner.extents + + +def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted', ax=None, + label_cl_phases=True): """Nichols chart grid Plots a Nichols chart grid on the current axis, or creates a new chart @@ -136,17 +149,36 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'): line_style : string, optional :doc:`Matplotlib linestyle \ ` + ax : matplotlib.axes.Axes, optional + Axes to add grid to. If ``None``, use ``plt.gca()``. + label_cl_phases: bool, optional + If True, closed-loop phase lines will be labelled. + Returns + ------- + cl_mag_lines: list of `matplotlib.line.Line2D` + The constant closed-loop gain contours + cl_phase_lines: list of `matplotlib.line.Line2D` + The constant closed-loop phase contours + cl_mag_labels: list of `matplotlib.text.Text` + mcontour labels; each entry corresponds to the respective entry + in ``cl_mag_lines`` + cl_phase_labels: list of `matplotlib.text.Text` + ncontour labels; each entry corresponds to the respective entry + in ``cl_phase_lines`` """ + if ax is None: + ax = plt.gca() + # Default chart size ol_phase_min = -359.99 ol_phase_max = 0.0 ol_mag_min = -40.0 ol_mag_max = default_ol_mag_max = 50.0 - # Find bounds of the current dataset, if there is one. - if plt.gcf().gca().has_data(): - ol_phase_min, ol_phase_max, ol_mag_min, ol_mag_max = plt.axis() + if ax.has_data(): + # Find extent of intersection the current dataset or view + ol_phase_min, ol_mag_min, ol_phase_max, ol_mag_max = _inner_extents(ax) # M-circle magnitudes. if cl_mags is None: @@ -165,19 +197,22 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'): ol_mag_min + cl_mag_step, cl_mag_step) cl_mags = np.concatenate((extended_cl_mags, key_cl_mags)) + # a minimum 360deg extent containing the phases + phase_round_max = 360.0*np.ceil(ol_phase_max/360.0) + phase_round_min = min(phase_round_max-360, + 360.0*np.floor(ol_phase_min/360.0)) + # N-circle phases (should be in the range -360 to 0) if cl_phases is None: - # Choose a reasonable set of default phases (denser if the open-loop - # data is restricted to a relatively small range of phases). - key_cl_phases = np.array([-0.25, -45.0, -90.0, -180.0, -270.0, - -325.0, -359.75]) - if np.abs(ol_phase_max - ol_phase_min) < 90.0: - other_cl_phases = np.arange(-10.0, -360.0, -10.0) - else: - other_cl_phases = np.arange(-10.0, -360.0, -20.0) - cl_phases = np.concatenate((key_cl_phases, other_cl_phases)) - else: - assert ((-360.0 < np.min(cl_phases)) and (np.max(cl_phases) < 0.0)) + # aim for 9 lines, but always show (-360+eps, -180, -eps) + # smallest spacing is 45, biggest is 180 + phase_span = phase_round_max - phase_round_min + spacing = np.clip(round(phase_span / 8 / 45) * 45, 45, 180) + key_cl_phases = np.array([-0.25, -359.75]) + other_cl_phases = np.arange(-spacing, -360.0, -spacing) + cl_phases = np.unique(np.concatenate((key_cl_phases, other_cl_phases))) + elif not ((-360 < np.min(cl_phases)) and (np.max(cl_phases) < 0.0)): + raise ValueError('cl_phases must between -360 and 0, exclusive') # Find the M-contours m = m_circles(cl_mags, phase_min=np.min(cl_phases), @@ -196,27 +231,57 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'): # over the range -360 < phase < 0. Given the range # the base chart is computed over, the phase offset should be 0 # for -360 < ol_phase_min < 0. - phase_offset_min = 360.0*np.ceil(ol_phase_min/360.0) - phase_offset_max = 360.0*np.ceil(ol_phase_max/360.0) + 360.0 - phase_offsets = np.arange(phase_offset_min, phase_offset_max, 360.0) + phase_offsets = 360 + np.arange(phase_round_min, phase_round_max, 360.0) + + cl_mag_lines = [] + cl_phase_lines = [] + cl_mag_labels = [] + cl_phase_labels = [] for phase_offset in phase_offsets: # Draw M and N contours - plt.plot(m_phase + phase_offset, m_mag, color='lightgray', - linestyle=line_style, zorder=0) - plt.plot(n_phase + phase_offset, n_mag, color='lightgray', - linestyle=line_style, zorder=0) + cl_mag_lines.extend( + ax.plot(m_phase + phase_offset, m_mag, color='lightgray', + linestyle=line_style, zorder=0)) + cl_phase_lines.extend( + ax.plot(n_phase + phase_offset, n_mag, color='lightgray', + linestyle=line_style, zorder=0)) # Add magnitude labels for x, y, m in zip(m_phase[:][-1] + phase_offset, m_mag[:][-1], cl_mags): align = 'right' if m < 0.0 else 'left' - plt.text(x, y, str(m) + ' dB', size='small', ha=align, - color='gray') + cl_mag_labels.append( + ax.text(x, y, str(m) + ' dB', size='small', ha=align, + color='gray', clip_on=True)) + + # phase labels + if label_cl_phases: + for x, y, p in zip(n_phase[:][0] + phase_offset, + n_mag[:][0], + cl_phases): + if p > -175: + align = 'right' + elif p > -185: + align = 'center' + else: + align = 'left' + cl_phase_labels.append( + ax.text(x, y, f'{round(p)}\N{DEGREE SIGN}', + size='small', + ha=align, + va='bottom', + color='gray', + clip_on=True)) + # Fit axes to generated chart - plt.axis([phase_offset_min - 360.0, phase_offset_max - 360.0, - np.min(cl_mags), np.max([ol_mag_max, default_ol_mag_max])]) + ax.axis([phase_round_min, + phase_round_max, + np.min(np.concatenate([cl_mags,[ol_mag_min]])), + np.max([ol_mag_max, default_ol_mag_max])]) + + return cl_mag_lines, cl_phase_lines, cl_mag_labels, cl_phase_labels # # Utility functions diff --git a/control/optimal.py b/control/optimal.py index dd09532c5..da1bdcb8e 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -16,10 +16,21 @@ import logging import time +from . import config +from .exception import ControlNotImplemented from .timeresp import TimeResponseData __all__ = ['find_optimal_input'] +# Define module default parameter values +_optimal_defaults = { + 'optimal.minimize_method': None, + 'optimal.minimize_options': {}, + 'optimal.minimize_kwargs': {}, + 'optimal.solve_ivp_method': None, + 'optimal.solve_ivp_options': {}, +} + class OptimalControlProblem(): """Description of a finite horizon, optimal control problem. @@ -58,6 +69,7 @@ class OptimalControlProblem(): extension of the time axis. log : bool, optional If `True`, turn on logging messages (using Python logging module). + Use ``logging.basicConfig`` to enable logging output (e.g., to a file). kwargs : dict, optional Additional parameters (passed to :func:`scipy.optimal.minimize`). @@ -95,7 +107,7 @@ class OptimalControlProblem(): trajectory generated by the proposed input. It does this by calling a user-defined function for the integral_cost given the current states and inputs at each point along the trajectory and then adding the value of a - user-defined terminal cost at the final pint in the trajectory. + user-defined terminal cost at the final point in the trajectory. The `_constraint_function` method evaluates the constraint functions along the trajectory generated by the proposed input. As in the case of the @@ -109,6 +121,10 @@ class OptimalControlProblem(): values of the input at the specified times (using linear interpolation for continuous systems). + The default values for ``minimize_method``, ``minimize_options``, + ``minimize_kwargs``, ``solve_ivp_method``, and ``solve_ivp_options`` can + be set using config.defaults['optimal.']. + """ def __init__( self, sys, timepts, integral_cost, trajectory_constraints=[], @@ -125,13 +141,22 @@ def __init__( # Process keyword arguments self.solve_ivp_kwargs = {} - self.solve_ivp_kwargs['method'] = kwargs.pop('solve_ivp_method', None) - self.solve_ivp_kwargs.update(kwargs.pop('solve_ivp_kwargs', {})) + self.solve_ivp_kwargs['method'] = kwargs.pop( + 'solve_ivp_method', config.defaults['optimal.solve_ivp_method']) + self.solve_ivp_kwargs.update(kwargs.pop( + 'solve_ivp_kwargs', config.defaults['optimal.solve_ivp_options'])) self.minimize_kwargs = {} - self.minimize_kwargs['method'] = kwargs.pop('minimize_method', None) - self.minimize_kwargs['options'] = kwargs.pop('minimize_options', {}) - self.minimize_kwargs.update(kwargs.pop('minimize_kwargs', {})) + self.minimize_kwargs['method'] = kwargs.pop( + 'minimize_method', config.defaults['optimal.minimize_method']) + self.minimize_kwargs['options'] = kwargs.pop( + 'minimize_options', config.defaults['optimal.minimize_options']) + self.minimize_kwargs.update(kwargs.pop( + 'minimize_kwargs', config.defaults['optimal.minimize_kwargs'])) + + # Make sure there were no extraneous keywords + if kwargs: + raise TypeError("unrecognized keyword(s): ", str(kwargs)) # Process trajectory constraints if isinstance(trajectory_constraints, tuple): @@ -141,6 +166,11 @@ def __init__( else: self.trajectory_constraints = trajectory_constraints + # Make sure that we recognize all of the constraint types + for ctype, fun, lb, ub in self.trajectory_constraints: + if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: + raise TypeError(f"unknown constraint type {ctype}") + # Process terminal constraints if isinstance(terminal_constraints, tuple): self.terminal_constraints = [terminal_constraints] @@ -149,6 +179,11 @@ def __init__( else: self.terminal_constraints = terminal_constraints + # Make sure that we recognize all of the constraint types + for ctype, fun, lb, ub in self.terminal_constraints: + if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: + raise TypeError(f"unknown constraint type {ctype}") + # # Compute and store constraints # @@ -256,9 +291,10 @@ def _cost_function(self, coeffs): logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) # Simulate the system to get the state + # TODO: try calling solve_ivp directly for better speed? _, _, states = ct.input_output_response( self.system, self.timepts, inputs, x, return_x=True, - solve_ivp_kwargs=self.solve_ivp_kwargs) + solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) self.system_simulations += 1 self.last_x = x self.last_coeffs = coeffs @@ -269,7 +305,6 @@ def _cost_function(self, coeffs): + str(states)) # Trajectory cost - # TODO: vectorize if ct.isctime(self.system): # Evaluate the costs costs = [self.integral_cost(states[:, i], inputs[:, i]) for @@ -279,6 +314,7 @@ def _cost_function(self, coeffs): dt = np.diff(self.timepts) # Integrate the cost + # TODO: vectorize cost = 0 for i in range(self.timepts.size-1): # Approximate the integral using trapezoidal rule @@ -288,8 +324,8 @@ def _cost_function(self, coeffs): # Sum the integral cost over the time (second) indices # cost += self.integral_cost(states[:,i], inputs[:,i]) cost = sum(map( - self.integral_cost, np.transpose(states), - np.transpose(inputs))) + self.integral_cost, np.transpose(states[:, :-1]), + np.transpose(inputs[:, :-1]))) # Terminal cost if self.terminal_cost is not None: @@ -378,7 +414,7 @@ def _constraint_function(self, coeffs): # Simulate the system to get the state _, _, states = ct.input_output_response( self.system, self.timepts, inputs, x, return_x=True, - solve_ivp_kwargs=self.solve_ivp_kwargs) + solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) self.system_simulations += 1 self.last_x = x self.last_coeffs = coeffs @@ -396,7 +432,8 @@ def _constraint_function(self, coeffs): value.append(fun @ np.hstack([states[:, i], inputs[:, i]])) elif ctype == opt.NonlinearConstraint: value.append(fun(states[:, i], inputs[:, i])) - else: + else: # pragma: no cover + # Checked above => we should never get here raise TypeError(f"unknown constraint type {ctype}") # Evaluate the terminal constraint functions @@ -408,7 +445,8 @@ def _constraint_function(self, coeffs): value.append(fun @ np.hstack([states[:, i], inputs[:, i]])) elif ctype == opt.NonlinearConstraint: value.append(fun(states[:, i], inputs[:, i])) - else: + else: # pragma: no cover + # Checked above => we should never get here raise TypeError(f"unknown constraint type {ctype}") # Update statistics @@ -458,7 +496,7 @@ def _eqconst_function(self, coeffs): # Simulate the system to get the state _, _, states = ct.input_output_response( self.system, self.timepts, inputs, x, return_x=True, - solve_ivp_kwargs=self.solve_ivp_kwargs) + solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) self.system_simulations += 1 self.last_x = x self.last_coeffs = coeffs @@ -480,7 +518,8 @@ def _eqconst_function(self, coeffs): value.append(fun @ np.hstack([states[:, i], inputs[:, i]])) elif ctype == opt.NonlinearConstraint: value.append(fun(states[:, i], inputs[:, i])) - else: + else: # pragma: no cover + # Checked above => we should never get here raise TypeError(f"unknown constraint type {ctype}") # Evaluate the terminal constraint functions @@ -492,7 +531,8 @@ def _eqconst_function(self, coeffs): value.append(fun @ np.hstack([states[:, i], inputs[:, i]])) elif ctype == opt.NonlinearConstraint: value.append(fun(states[:, i], inputs[:, i])) - else: + else: # pragma: no cover + # Checked above => we should never get here raise TypeError("unknown constraint type {ctype}") # Update statistics @@ -529,7 +569,7 @@ def _process_initial_guess(self, initial_guess): initial_guess = np.atleast_1d(initial_guess) # See whether we got entire guess or just first time point - if len(initial_guess.shape) == 1: + if initial_guess.ndim == 1: # Broadcast inputs to entire time vector try: initial_guess = np.broadcast_to( @@ -634,35 +674,9 @@ def _print_statistics(self, reset=True): if reset: self._reset_statistics(self.log) - # Create an input/output system implementing an MPC controller - def _create_mpc_iosystem(self, dt=True): - """Create an I/O system implementing an MPC controller""" - def _update(t, x, u, params={}): - coeffs = x.reshape((self.system.ninputs, -1)) - if self.basis: - # Keep the coeffecients unchanged - # TODO: could compute input vector, shift, and re-project (?) - self.initial_guess = coeffs - else: - # Shift the basis elements by one time step - self.initial_guess = np.hstack( - [coeffs[:, 1:], coeffs[:, -1:]]).reshape(-1) - res = self.compute_trajectory(u, print_summary=False) - return res.inputs.reshape(-1) - - def _output(t, x, u, params={}): - if self.basis: - # TODO: compute inputs from basis elements - raise NotImplementedError("basis elements not implemented") - else: - inputs = x.reshape((self.system.ninputs, -1)) - return inputs[:, 0] - - return ct.NonlinearIOSystem( - _update, _output, dt=dt, - inputs=self.system.nstates, outputs=self.system.ninputs, - states=self.system.ninputs * - (self.timepts.size if self.basis is None else self.basis.N)) + # + # Optimal control computations + # # Compute the optimal trajectory from the current state def compute_trajectory( @@ -755,7 +769,42 @@ def compute_mpc(self, x, squeeze=None): """ res = self.compute_trajectory(x, squeeze=squeeze) - return inputs[:, 0] if res.success else None + return res.inputs[:, 0] + + # Create an input/output system implementing an MPC controller + def create_mpc_iosystem(self): + """Create an I/O system implementing an MPC controller""" + # Check to make sure we are in discrete time + if self.system.dt == 0: + raise ct.ControlNotImplemented( + "MPC for continuous time systems not implemented") + + def _update(t, x, u, params={}): + coeffs = x.reshape((self.system.ninputs, -1)) + if self.basis: + # Keep the coeffecients unchanged + # TODO: could compute input vector, shift, and re-project (?) + self.initial_guess = coeffs + else: + # Shift the basis elements by one time step + self.initial_guess = np.hstack( + [coeffs[:, 1:], coeffs[:, -1:]]).reshape(-1) + res = self.compute_trajectory(u, print_summary=False) + + # New state is the new input vector + return res.inputs.reshape(-1) + + def _output(t, x, u, params={}): + # Start with initial guess and recompute based on input state (u) + self.initial_guess = x + res = self.compute_trajectory(u, print_summary=False) + return res.inputs[:, 0] + + return ct.NonlinearIOSystem( + _update, _output, dt=self.system.dt, + inputs=self.system.nstates, outputs=self.system.ninputs, + states=self.system.ninputs * \ + (self.timepts.size if self.basis is None else self.basis.N)) # Optimal control result @@ -776,6 +825,15 @@ class OptimalControlResult(sp.optimize.OptimizeResult): Whether or not the optimizer exited successful. problem : OptimalControlProblem Optimal control problem that generated this solution. + cost : float + Final cost of the return solution. + system_simulations, {cost, constraint, eqconst}_evaluations : int + Number of system simulations and evaluations of the cost function, + (inequality) constraint function, and equality constraint function + performed during the optimzation. + {cost, constraint, eqconst}_process_time : float + If logging was enabled, the amount of time spent evaluating the cost + and constraint functions. """ def __init__( @@ -805,15 +863,19 @@ def __init__( "unable to solve optimal control problem\n" "scipy.optimize.minimize returned " + res.message, UserWarning) + # Save the final cost + self.cost = res.fun + # Optionally print summary information if print_summary: ocp._print_statistics() + print("* Final cost:", self.cost) if return_states and inputs.shape[1] == ocp.timepts.shape[0]: # Simulate the system if we need the state back _, _, states = ct.input_output_response( ocp.system, ocp.timepts, inputs, ocp.x, return_x=True, - solve_ivp_kwargs=ocp.solve_ivp_kwargs) + solve_ivp_kwargs=ocp.solve_ivp_kwargs, t_eval=ocp.timepts) ocp.system_simulations += 1 else: states = None @@ -830,7 +892,7 @@ def __init__( # Compute the input for a nonlinear, (constrained) optimal control problem def solve_ocp( - sys, horizon, X0, cost, constraints=[], terminal_cost=None, + sys, horizon, X0, cost, trajectory_constraints=None, terminal_cost=None, terminal_constraints=[], initial_guess=None, basis=None, squeeze=None, transpose=None, return_states=False, log=False, **kwargs): @@ -851,7 +913,7 @@ def solve_ocp( Function that returns the integral cost given the current state and input. Called as `cost(x, u)`. - constraints : list of tuples, optional + trajectory_constraints : list of tuples, optional List of constraints that should hold at each point in the time vector. Each element of the list should consist of a tuple with first element given by :meth:`scipy.optimize.LinearConstraint` or @@ -929,13 +991,24 @@ def solve_ocp( :func:`OptimalControlProblem` for more information. """ + # Process keyword arguments + if trajectory_constraints is None: + # Backwards compatibility + trajectory_constraints = kwargs.pop('constraints', []) + # Allow 'return_x` as a synonym for 'return_states' return_states = ct.config._get_param( 'optimal', 'return_x', kwargs, return_states, pop=True) + # Process (legacy) method keyword + if kwargs.get('method'): + if kwargs.get('minimize_method'): + raise ValueError("'minimize_method' specified more than once") + kwargs['minimize_method'] = kwargs.pop('method') + # Set up the optimal control problem ocp = OptimalControlProblem( - sys, horizon, cost, trajectory_constraints=constraints, + sys, horizon, cost, trajectory_constraints=trajectory_constraints, terminal_cost=terminal_cost, terminal_constraints=terminal_constraints, initial_guess=initial_guess, basis=basis, log=log, **kwargs) @@ -947,7 +1020,7 @@ def solve_ocp( # Create a model predictive controller for an optimal control problem def create_mpc_iosystem( sys, horizon, cost, constraints=[], terminal_cost=None, - terminal_constraints=[], dt=True, log=False, **kwargs): + terminal_constraints=[], log=False, **kwargs): """Create a model predictive I/O control system This function creates an input/output system that implements a model @@ -996,7 +1069,6 @@ def create_mpc_iosystem( :func:`OptimalControlProblem` for more information. """ - # Set up the optimal control problem ocp = OptimalControlProblem( sys, horizon, cost, trajectory_constraints=constraints, @@ -1004,7 +1076,7 @@ def create_mpc_iosystem( log=log, **kwargs) # Return an I/O system implementing the model predictive controller - return ocp._create_mpc_iosystem(dt=dt) + return ocp.create_mpc_iosystem() # diff --git a/control/pzmap.py b/control/pzmap.py index ae8db1241..09f58b79c 100644 --- a/control/pzmap.py +++ b/control/pzmap.py @@ -41,7 +41,8 @@ from numpy import real, imag, linspace, exp, cos, sin, sqrt from math import pi -from .lti import LTI, isdtime, isctime +from .lti import LTI +from .namedio import isdtime, isctime from .grid import sgrid, zgrid, nogrid from . import config @@ -91,7 +92,11 @@ def pzmap(sys, plot=None, grid=None, title='Pole Zero Map', **kwargs): import warnings warnings.warn("'Plot' keyword is deprecated in pzmap; use 'plot'", FutureWarning) - plot = kwargs['Plot'] + plot = kwargs.pop('Plot') + + # Make sure there were no extraneous keywords + if kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) # Get parameter values plot = config._get_param('pzmap', 'plot', plot, True) @@ -100,8 +105,8 @@ def pzmap(sys, plot=None, grid=None, title='Pole Zero Map', **kwargs): if not isinstance(sys, LTI): raise TypeError('Argument ``sys``: must be a linear system.') - poles = sys.pole() - zeros = sys.zero() + poles = sys.poles() + zeros = sys.zeros() if (plot): import matplotlib.pyplot as plt diff --git a/control/rlocus.py b/control/rlocus.py index 23122fe72..9d531de94 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -55,7 +55,7 @@ import matplotlib.pyplot as plt from numpy import array, poly1d, row_stack, zeros_like, real, imag import scipy.signal # signal processing toolbox -from .lti import isdtime +from .namedio import isdtime from .xferfcn import _convert_to_transfer_function from .exception import ControlMIMONotImplemented from .sisotool import _SisotoolUpdate @@ -168,6 +168,10 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, # Check for sisotool mode sisotool = False if 'sisotool' not in kwargs else True + # Make sure there were no extraneous keywords + if not sisotool and kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) + # Create the Plot if plot: if sisotool: diff --git a/control/sisotool.py b/control/sisotool.py index e6343c91e..52c061249 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -3,14 +3,12 @@ from control.exception import ControlMIMONotImplemented from .freqplot import bode_plot from .timeresp import step_response -from .lti import issiso, isdtime +from .namedio import issiso, common_timebase, isctime, isdtime from .xferfcn import tf -from .statesp import ss +from .iosys import ss from .bdalg import append, connect from .iosys import tf2io, ss2io, summing_junction, interconnect from control.statesp import _convert_to_statespace, StateSpace -from control.lti import common_timebase, isctime -import matplotlib import matplotlib.pyplot as plt import warnings @@ -215,14 +213,14 @@ def rootlocus_pid_designer(plant, gain='P', sign=+1, input_signal='r', derivative terms are given instead by Kp, Ki*dt/2*(z+1)/(z-1), and Kd/dt*(z-1)/z, respectively. - ------> C_ff ------ d - | | | - r | e V V u y - ------->O---> C_f --->O--->O---> plant ---> - ^- ^- | - | | | - | ----- C_b <-------| - --------------------------------- + ------> C_ff ------ d + | | | + r | e V V u y + ------->O---> C_f --->O--->O---> plant ---> + ^- ^- | + | | | + | ----- C_b <-------| + --------------------------------- It is also possible to move the derivative term into the feedback path `C_b` using `derivative_in_feedback_path=True`. This may be desired to @@ -234,8 +232,8 @@ def rootlocus_pid_designer(plant, gain='P', sign=+1, input_signal='r', Remark: It may be helpful to zoom in using the magnifying glass on the plot. Just ake sure to deactivate magnification mode when you are done by - clicking the magnifying glass. Otherwise you will not be able to be able to choose - a gain on the root locus plot. + clicking the magnifying glass. Otherwise you will not be able to be able + to choose a gain on the root locus plot. Parameters ---------- @@ -269,6 +267,7 @@ def rootlocus_pid_designer(plant, gain='P', sign=+1, input_signal='r', ---------- closedloop : class:`StateSpace` system The closed-loop system using initial gains. + """ plant = _convert_to_statespace(plant) diff --git a/control/statefbk.py b/control/statefbk.py index ef16cbfff..97f314da5 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -45,7 +45,10 @@ from . import statesp from .mateqn import care, dare, _check_shape from .statesp import StateSpace, _ssmatrix, _convert_to_statespace -from .lti import LTI, isdtime, isctime +from .lti import LTI +from .namedio import isdtime, isctime +from .iosys import InputOutputSystem, NonlinearIOSystem, LinearIOSystem, \ + interconnect, ss from .exception import ControlSlycot, ControlArgument, ControlDimension, \ ControlNotImplemented @@ -68,8 +71,8 @@ def sb03md(n, C, A, U, dico, job='X',fact='N',trana='N',ldwork=None): sb03od = None -__all__ = ['ctrb', 'obsv', 'gram', 'place', 'place_varga', 'lqr', 'lqe', - 'dlqr', 'dlqe', 'acker'] +__all__ = ['ctrb', 'obsv', 'gram', 'place', 'place_varga', 'lqr', + 'dlqr', 'acker', 'create_statefbk_iosystem'] # Pole placement @@ -258,276 +261,6 @@ def place_varga(A, B, p, dtime=False, alpha=None): return _ssmatrix(-F) -# contributed by Sawyer B. Fuller -def lqe(*args, **keywords): - """lqe(A, G, C, QN, RN, [, NN]) - - Linear quadratic estimator design (Kalman filter) for continuous-time - systems. Given the system - - .. math:: - - x &= Ax + Bu + Gw \\\\ - y &= Cx + Du + v - - with unbiased process noise w and measurement noise v with covariances - - .. math:: E{ww'} = QN, E{vv'} = RN, E{wv'} = NN - - The lqe() function computes the observer gain matrix L such that the - stationary (non-time-varying) Kalman filter - - .. math:: x_e = A x_e + B u + L(y - C x_e - D u) - - produces a state estimate x_e that minimizes the expected squared error - using the sensor measurements y. The noise cross-correlation `NN` is - set to zero when omitted. - - The function can be called with either 3, 4, 5, or 6 arguments: - - * ``L, P, E = lqe(sys, QN, RN)`` - * ``L, P, E = lqe(sys, QN, RN, NN)`` - * ``L, P, E = lqe(A, G, C, QN, RN)`` - * ``L, P, E = lqe(A, G, C, QN, RN, NN)`` - - where `sys` is an `LTI` object, and `A`, `G`, `C`, `QN`, `RN`, and `NN` - are 2D arrays or matrices of appropriate dimension. - - Parameters - ---------- - A, G, C : 2D array_like - Dynamics, process noise (disturbance), and output matrices - sys : LTI (StateSpace or TransferFunction) - Linear I/O system, with the process noise input taken as the system - input. - QN, RN : 2D array_like - Process and sensor noise covariance matrices - NN : 2D array, optional - Cross covariance matrix. Not currently implemented. - method : str, optional - Set the method used for computing the result. Current methods are - 'slycot' and 'scipy'. If set to None (default), try 'slycot' first - and then 'scipy'. - - Returns - ------- - L : 2D array (or matrix) - Kalman estimator gain - P : 2D array (or matrix) - Solution to Riccati equation - - .. math:: - - A P + P A^T - (P C^T + G N) R^{-1} (C P + N^T G^T) + G Q G^T = 0 - - E : 1D array - Eigenvalues of estimator poles eig(A - L C) - - Notes - ----- - 1. If the first argument is an LTI object, then this object will be used - to define the dynamics, noise and output matrices. Furthermore, if - the LTI object corresponds to a discrete time system, the ``dlqe()`` - function will be called. - - 2. The return type for 2D arrays depends on the default class set for - state space operations. See :func:`~control.use_numpy_matrix`. - - Examples - -------- - >>> L, P, E = lqe(A, G, C, QN, RN) - >>> L, P, E = lqe(A, G, C, Q, RN, NN) - - See Also - -------- - lqr, dlqe, dlqr - - """ - - # TODO: incorporate cross-covariance NN, something like this, - # which doesn't work for some reason - # if NN is None: - # NN = np.zeros(QN.size(0),RN.size(1)) - # NG = G @ NN - - # - # Process the arguments and figure out what inputs we received - # - - # Get the method to use (if specified as a keyword) - method = keywords.get('method', None) - - # Get the system description - if (len(args) < 3): - raise ControlArgument("not enough input arguments") - - # If we were passed a discrete time system as the first arg, use dlqe() - if isinstance(args[0], LTI) and isdtime(args[0], strict=True): - # Call dlqe - return dlqe(*args, **keywords) - - # If we were passed a state space system, use that to get system matrices - if isinstance(args[0], StateSpace): - A = np.array(args[0].A, ndmin=2, dtype=float) - G = np.array(args[0].B, ndmin=2, dtype=float) - C = np.array(args[0].C, ndmin=2, dtype=float) - index = 1 - - elif isinstance(args[0], LTI): - # Don't allow other types of LTI systems - raise ControlArgument("LTI system must be in state space form") - - else: - # Arguments should be A and B matrices - A = np.array(args[0], ndmin=2, dtype=float) - G = np.array(args[1], ndmin=2, dtype=float) - C = np.array(args[2], ndmin=2, dtype=float) - index = 3 - - # Get the weighting matrices (converting to matrices, if needed) - QN = np.array(args[index], ndmin=2, dtype=float) - RN = np.array(args[index+1], ndmin=2, dtype=float) - - # Get the cross-covariance matrix, if given - if (len(args) > index + 2): - NN = np.array(args[index+2], ndmin=2, dtype=float) - raise ControlNotImplemented("cross-covariance not implemented") - - else: - # For future use (not currently used below) - NN = np.zeros((QN.shape[0], RN.shape[1])) - - # Check dimensions of G (needed before calling care()) - _check_shape("QN", QN, G.shape[1], G.shape[1]) - - # Compute the result (dimension and symmetry checking done in care()) - P, E, LT = care(A.T, C.T, G @ QN @ G.T, RN, method=method, - B_s="C", Q_s="QN", R_s="RN", S_s="NN") - return _ssmatrix(LT.T), _ssmatrix(P), E - - -# contributed by Sawyer B. Fuller -def dlqe(*args, **keywords): - """dlqe(A, G, C, QN, RN, [, N]) - - Linear quadratic estimator design (Kalman filter) for discrete-time - systems. Given the system - - .. math:: - - x[n+1] &= Ax[n] + Bu[n] + Gw[n] \\\\ - y[n] &= Cx[n] + Du[n] + v[n] - - with unbiased process noise w and measurement noise v with covariances - - .. math:: E{ww'} = QN, E{vv'} = RN, E{wv'} = NN - - The dlqe() function computes the observer gain matrix L such that the - stationary (non-time-varying) Kalman filter - - .. math:: x_e[n+1] = A x_e[n] + B u[n] + L(y[n] - C x_e[n] - D u[n]) - - produces a state estimate x_e[n] that minimizes the expected squared error - using the sensor measurements y. The noise cross-correlation `NN` is - set to zero when omitted. - - Parameters - ---------- - A, G : 2D array_like - Dynamics and noise input matrices - QN, RN : 2D array_like - Process and sensor noise covariance matrices - NN : 2D array, optional - Cross covariance matrix (not yet supported) - method : str, optional - Set the method used for computing the result. Current methods are - 'slycot' and 'scipy'. If set to None (default), try 'slycot' first - and then 'scipy'. - - Returns - ------- - L : 2D array (or matrix) - Kalman estimator gain - P : 2D array (or matrix) - Solution to Riccati equation - - .. math:: - - A P + P A^T - (P C^T + G N) R^{-1} (C P + N^T G^T) + G Q G^T = 0 - - E : 1D array - Eigenvalues of estimator poles eig(A - L C) - - Notes - ----- - The return type for 2D arrays depends on the default class set for - state space operations. See :func:`~control.use_numpy_matrix`. - - Examples - -------- - >>> L, P, E = dlqe(A, G, C, QN, RN) - >>> L, P, E = dlqe(A, G, C, QN, RN, NN) - - See Also - -------- - dlqr, lqe, lqr - - """ - - # - # Process the arguments and figure out what inputs we received - # - - # Get the method to use (if specified as a keyword) - method = keywords.get('method', None) - - # Get the system description - if (len(args) < 3): - raise ControlArgument("not enough input arguments") - - # If we were passed a continus time system as the first arg, raise error - if isinstance(args[0], LTI) and isctime(args[0], strict=True): - raise ControlArgument("dlqr() called with a continuous time system") - - # If we were passed a state space system, use that to get system matrices - if isinstance(args[0], StateSpace): - A = np.array(args[0].A, ndmin=2, dtype=float) - G = np.array(args[0].B, ndmin=2, dtype=float) - C = np.array(args[0].C, ndmin=2, dtype=float) - index = 1 - - elif isinstance(args[0], LTI): - # Don't allow other types of LTI systems - raise ControlArgument("LTI system must be in state space form") - - else: - # Arguments should be A and B matrices - A = np.array(args[0], ndmin=2, dtype=float) - G = np.array(args[1], ndmin=2, dtype=float) - C = np.array(args[2], ndmin=2, dtype=float) - index = 3 - - # Get the weighting matrices (converting to matrices, if needed) - QN = np.array(args[index], ndmin=2, dtype=float) - RN = np.array(args[index+1], ndmin=2, dtype=float) - - # TODO: incorporate cross-covariance NN, something like this, - # which doesn't work for some reason - # if NN is None: - # NN = np.zeros(QN.size(0),RN.size(1)) - # NG = G @ NN - if len(args) > index + 2: - NN = np.array(args[index+2], ndmin=2, dtype=float) - raise ControlNotImplemented("cross-covariance not yet implememented") - - # Check dimensions of G (needed before calling care()) - _check_shape("QN", QN, G.shape[1], G.shape[1]) - - # Compute the result (dimension and symmetry checking done in dare()) - P, E, LT = dare(A.T, C.T, G @ QN @ G.T, RN, method=method, - B_s="C", Q_s="QN", R_s="RN", S_s="NN") - return _ssmatrix(LT.T), _ssmatrix(P), E - # Contributed by Roberto Bucher def acker(A, B, poles): """Pole placement using Ackermann method @@ -576,7 +309,7 @@ def acker(A, B, poles): return _ssmatrix(K) -def lqr(*args, **keywords): +def lqr(*args, **kwargs): """lqr(A, B, Q, R[, N]) Linear quadratic regulator design @@ -606,6 +339,13 @@ def lqr(*args, **keywords): State and input weight matrices N : 2D array, optional Cross weight matrix + integral_action : ndarray, optional + If this keyword is specified, the controller includes integral action + in addition to state feedback. The value of the `integral_action`` + keyword should be an ndarray that will be multiplied by the current to + generate the error for the internal integrator states of the control + law. The number of outputs that are to be integrated must match the + number of additional rows and columns in the ``Q`` matrix. method : str, optional Set the method used for computing the result. Current methods are 'slycot' and 'scipy'. If set to None (default), try 'slycot' first @@ -644,18 +384,15 @@ def lqr(*args, **keywords): # Process the arguments and figure out what inputs we received # - # Get the method to use (if specified as a keyword) - method = keywords.get('method', None) + # If we were passed a discrete time system as the first arg, use dlqr() + if isinstance(args[0], LTI) and isdtime(args[0], strict=True): + # Call dlqr + return dlqr(*args, **kwargs) # Get the system description if (len(args) < 3): raise ControlArgument("not enough input arguments") - # If we were passed a discrete time system as the first arg, use dlqr() - if isinstance(args[0], LTI) and isdtime(args[0], strict=True): - # Call dlqr - return dlqr(*args, **keywords) - # If we were passed a state space system, use that to get system matrices if isinstance(args[0], StateSpace): A = np.array(args[0].A, ndmin=2, dtype=float) @@ -680,12 +417,47 @@ def lqr(*args, **keywords): else: N = None + # + # Process keywords + # + + # Get the method to use (if specified as a keyword) + method = kwargs.pop('method', None) + + # See if we should augment the controller with integral feedback + integral_action = kwargs.pop('integral_action', None) + if integral_action is not None: + # Figure out the size of the system + nstates = A.shape[0] + ninputs = B.shape[1] + + # Make sure that the integral action argument is the right type + if not isinstance(integral_action, np.ndarray): + raise ControlArgument("Integral action must pass an array") + elif integral_action.shape[1] != nstates: + raise ControlArgument( + "Integral gain size must match system state size") + + # Process the states to be integrated + nintegrators = integral_action.shape[0] + C = integral_action + + # Augment the system with integrators + A = np.block([ + [A, np.zeros((nstates, nintegrators))], + [C, np.zeros((nintegrators, nintegrators))] + ]) + B = np.vstack([B, np.zeros((nintegrators, ninputs))]) + + if kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) + # Compute the result (dimension and symmetry checking done in care()) X, L, G = care(A, B, Q, R, N, None, method=method, S_s="N") return G, X, L -def dlqr(*args, **keywords): +def dlqr(*args, **kwargs): """dlqr(A, B, Q, R[, N]) Discrete-time linear quadratic regulator design @@ -693,7 +465,7 @@ def dlqr(*args, **keywords): The dlqr() function computes the optimal state feedback controller u[n] = - K x[n] that minimizes the quadratic cost - .. math:: J = \\Sum_0^\\infty (x[n]' Q x[n] + u[n]' R u[n] + 2 x[n]' N u[n]) + .. math:: J = \\sum_0^\\infty (x[n]' Q x[n] + u[n]' R u[n] + 2 x[n]' N u[n]) The function can be called with either 3, 4, or 5 arguments: @@ -716,6 +488,17 @@ def dlqr(*args, **keywords): State and input weight matrices N : 2D array, optional Cross weight matrix + integral_action : ndarray, optional + If this keyword is specified, the controller includes integral action + in addition to state feedback. The value of the `integral_action`` + keyword should be an ndarray that will be multiplied by the current to + generate the error for the internal integrator states of the control + law. The number of outputs that are to be integrated must match the + number of additional rows and columns in the ``Q`` matrix. + method : str, optional + Set the method used for computing the result. Current methods are + 'slycot' and 'scipy'. If set to None (default), try 'slycot' first + and then 'scipy'. Returns ------- @@ -745,9 +528,6 @@ def dlqr(*args, **keywords): # Process the arguments and figure out what inputs we received # - # Get the method to use (if specified as a keyword) - method = keywords.get('method', None) - # Get the system description if (len(args) < 3): raise ControlArgument("not enough input arguments") @@ -780,11 +560,236 @@ def dlqr(*args, **keywords): else: N = np.zeros((Q.shape[0], R.shape[1])) + # + # Process keywords + # + + # Get the method to use (if specified as a keyword) + method = kwargs.pop('method', None) + + # See if we should augment the controller with integral feedback + integral_action = kwargs.pop('integral_action', None) + if integral_action is not None: + # Figure out the size of the system + nstates = A.shape[0] + ninputs = B.shape[1] + + if not isinstance(integral_action, np.ndarray): + raise ControlArgument("Integral action must pass an array") + elif integral_action.shape[1] != nstates: + raise ControlArgument( + "Integral gain size must match system state size") + else: + nintegrators = integral_action.shape[0] + C = integral_action + + # Augment the system with integrators + A = np.block([ + [A, np.zeros((nstates, nintegrators))], + [C, np.eye(nintegrators)] + ]) + B = np.vstack([B, np.zeros((nintegrators, ninputs))]) + + if kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) + # Compute the result (dimension and symmetry checking done in dare()) S, E, K = dare(A, B, Q, R, N, method=method, S_s="N") return _ssmatrix(K), _ssmatrix(S), E +# Function to create an I/O sytems representing a state feedback controller +def create_statefbk_iosystem( + sys, K, integral_action=None, xd_labels='xd[{i}]', ud_labels='ud[{i}]', + estimator=None, type='linear'): + """Create an I/O system using a (full) state feedback controller + + This function creates an input/output system that implements a + state feedback controller of the form + + u = ud - K_p (x - xd) - K_i integral(C x - C x_d) + + It can be called in the form + + ctrl, clsys = ct.create_statefbk_iosystem(sys, K) + + where ``sys`` is the process dynamics and ``K`` is the state (+ integral) + feedback gain (eg, from LQR). The function returns the controller + ``ctrl`` and the closed loop systems ``clsys``, both as I/O systems. + + Parameters + ---------- + sys : InputOutputSystem + The I/O system that represents the process dynamics. If no estimator + is given, the output of this system should represent the full state. + + K : ndarray + The state feedback gain. This matrix defines the gains to be + applied to the system. If ``integral_action`` is None, then the + dimensions of this array should be (sys.ninputs, sys.nstates). If + `integral action` is set to a matrix or a function, then additional + columns represent the gains of the integral states of the + controller. + + xd_labels, ud_labels : str or list of str, optional + Set the name of the signals to use for the desired state and inputs. + If a single string is specified, it should be a format string using + the variable ``i`` as an index. Otherwise, a list of strings matching + the size of xd and ud, respectively, should be used. Default is + ``'xd[{i}]'`` for xd_labels and ``'xd[{i}]'`` for ud_labels. + + integral_action : None, ndarray, or func, optional + If this keyword is specified, the controller can include integral + action in addition to state feedback. If ``integral_action`` is an + ndarray, it will be multiplied by the current and desired state to + generate the error for the internal integrator states of the control + law. If ``integral_action`` is a function ``h``, that function will + be called with the signature h(t, x, u, params) to obtain the + outputs that should be integrated. The number of outputs that are + to be integrated must match the number of additional columns in the + ``K`` matrix. + + estimator : InputOutputSystem, optional + If an estimator is provided, using the states of the estimator as + the system inputs for the controller. + + type : 'nonlinear' or 'linear', optional + Set the type of controller to create. The default is a linear + controller implementing the LQR regulator. If the type is 'nonlinear', + a :class:NonlinearIOSystem is created instead, with the gain ``K`` as + a parameter (allowing modifications of the gain at runtime). + + Returns + ------- + ctrl : InputOutputSystem + Input/output system representing the controller. This system takes + as inputs the desired state xd, the desired input ud, and the system + state x. It outputs the controller action u according to the + formula u = ud - K(x - xd). If the keyword `integral_action` is + specified, then an additional set of integrators is included in the + control system (with the gain matrix K having the integral gains + appended after the state gains). + + clsys : InputOutputSystem + Input/output system representing the closed loop system. This + systems takes as inputs the desired trajectory (xd, ud) and outputs + the system state x and the applied input u (vertically stacked). + + """ + # Make sure that we were passed an I/O system as an input + if not isinstance(sys, InputOutputSystem): + raise ControlArgument("Input system must be I/O system") + + # See whether we were give an estimator + if estimator is not None: + # Check to make sure the estimator is the right size + if estimator.noutputs != sys.nstates: + raise ControlArgument("Estimator output size must match state") + elif sys.noutputs != sys.nstates: + # If no estimator, make sure that the system has all states as outputs + # TODO: check to make sure output map is the identity + raise ControlArgument("System output must be the full state") + else: + # Use the system directly instead of an estimator + estimator = sys + + # See whether we should implement integral action + nintegrators = 0 + if integral_action is not None: + if not isinstance(integral_action, np.ndarray): + raise ControlArgument("Integral action must pass an array") + elif integral_action.shape[1] != sys.nstates: + raise ControlArgument( + "Integral gain size must match system state size") + else: + nintegrators = integral_action.shape[0] + C = integral_action + else: + # Create a C matrix with no outputs, just in case update gets called + C = np.zeros((0, sys.nstates)) + + # Check to make sure that state feedback has the right shape + if not isinstance(K, np.ndarray) or \ + K.shape != (sys.ninputs, estimator.noutputs + nintegrators): + raise ControlArgument( + f'Control gain must be an array of size {sys.ninputs}' + f'x {sys.nstates}' + + (f'+{nintegrators}' if nintegrators > 0 else '')) + + # Figure out the labels to use + if isinstance(xd_labels, str): + # Gnerate the list of labels using the argument as a format string + xd_labels = [xd_labels.format(i=i) for i in range(sys.nstates)] + + if isinstance(ud_labels, str): + # Gnerate the list of labels using the argument as a format string + ud_labels = [ud_labels.format(i=i) for i in range(sys.ninputs)] + + # Define the controller system + if type == 'nonlinear': + # Create an I/O system for the state feedback gains + def _control_update(t, x, inputs, params): + # Split input into desired state, nominal input, and current state + xd_vec = inputs[0:sys.nstates] + x_vec = inputs[-estimator.nstates:] + + # Compute the integral error in the xy coordinates + return C @ x_vec - C @ xd_vec + + def _control_output(t, e, z, params): + K = params.get('K') + + # Split input into desired state, nominal input, and current state + xd_vec = z[0:sys.nstates] + ud_vec = z[sys.nstates:sys.nstates + sys.ninputs] + x_vec = z[-sys.nstates:] + + # Compute the control law + u = ud_vec - K[:, 0:sys.nstates] @ (x_vec - xd_vec) + if nintegrators > 0: + u -= K[:, sys.nstates:] @ e + + return u + + ctrl = NonlinearIOSystem( + _control_update, _control_output, name='control', + inputs=xd_labels + ud_labels + estimator.output_labels, + outputs=list(sys.input_index.keys()), params={'K': K}, + states=nintegrators) + + elif type == 'linear' or type is None: + # Create the matrices implementing the controller + if isctime(sys): + # Continuous time: integrator + A_lqr = np.zeros((C.shape[0], C.shape[0])) + else: + # Discrete time: summer + A_lqr = np.eye(C.shape[0]) + B_lqr = np.hstack([-C, np.zeros((C.shape[0], sys.ninputs)), C]) + C_lqr = -K[:, sys.nstates:] + D_lqr = np.hstack([ + K[:, 0:sys.nstates], np.eye(sys.ninputs), -K[:, 0:sys.nstates] + ]) + + ctrl = ss( + A_lqr, B_lqr, C_lqr, D_lqr, dt=sys.dt, name='control', + inputs=xd_labels + ud_labels + estimator.output_labels, + outputs=list(sys.input_index.keys()), states=nintegrators) + + else: + raise ControlArgument(f"unknown type '{type}'") + + # Define the closed loop system + closed = interconnect( + [sys, ctrl] if estimator == sys else [sys, ctrl, estimator], + name=sys.name + "_" + ctrl.name, + inplist=xd_labels + ud_labels, inputs=xd_labels + ud_labels, + outlist=sys.output_labels + sys.input_labels, + outputs=sys.output_labels + sys.input_labels + ) + return ctrl, closed + + def ctrb(A, B): """Controllabilty matrix diff --git a/control/statesp.py b/control/statesp.py index 0f1c560e2..98d4a1633 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -55,15 +55,23 @@ from numpy.linalg import solve, eigvals, matrix_rank from numpy.linalg.linalg import LinAlgError import scipy as sp +import scipy.linalg from scipy.signal import cont2discrete from scipy.signal import StateSpace as signalStateSpace from warnings import warn -from .lti import LTI, common_timebase, isdtime, _process_frequency_response +from .frdata import FrequencyResponseData +from .lti import LTI, _process_frequency_response +from .namedio import common_timebase, isdtime +from .namedio import _process_namedio_keywords from . import config from copy import deepcopy -__all__ = ['StateSpace', 'ss', 'rss', 'drss', 'tf2ss', 'ssdata'] +try: + from slycot import ab13dd +except ImportError: + ab13dd = None +__all__ = ['StateSpace', 'tf2ss', 'ssdata', 'linfnorm'] # Define module default parameter values _statesp_defaults = { @@ -161,7 +169,9 @@ class StateSpace(LTI): The StateSpace class is used to represent state-space realizations of linear time-invariant (LTI) systems: + .. math:: dx/dt = A x + B u + y = C x + D u where u is the input, y is the output, and x is the state. @@ -217,6 +227,8 @@ class StateSpace(LTI): The default value of dt can be changed by changing the value of ``control.config.defaults['control.default_dt']``. + Note: timebase processing has moved to namedio. + A state space system is callable and returns the value of the transfer function evaluated at a point in the complex plane. See :meth:`~control.StateSpace.__call__` for a more detailed description. @@ -244,7 +256,7 @@ class StateSpace(LTI): # Allow ndarray * StateSpace to give StateSpace._rmul_() priority __array_priority__ = 11 # override ndarray and matrix types - def __init__(self, *args, **kwargs): + def __init__(self, *args, init_namedio=True, **kwargs): """StateSpace(A, B, C, D[, dt]) Construct a state space object. @@ -262,14 +274,27 @@ def __init__(self, *args, **kwargs): value is read from `config.defaults['statesp.remove_useless_states']` (default = False). + The `init_namedio` keyword can be used to turn off initialization of + system and signal names. This is used internally by the + :class:`LinearIOSystem` class to avoid renaming. + """ - # first get A, B, C, D matrices + # + # Process positional arguments + # if len(args) == 4: # The user provided A, B, C, and D matrices. (A, B, C, D) = args + elif len(args) == 5: # Discrete time system - (A, B, C, D, _) = args + (A, B, C, D, dt) = args + if 'dt' in kwargs: + warn("received multiple dt arguments, " + "using positional arg dt = %s" % dt) + kwargs['dt'] = dt + args = args[:-1] + elif len(args) == 1: # Use the copy constructor. if not isinstance(args[0], StateSpace): @@ -280,15 +305,11 @@ def __init__(self, *args, **kwargs): B = args[0].B C = args[0].C D = args[0].D + else: - raise ValueError( + raise TypeError( "Expected 1, 4, or 5 arguments; received %i." % len(args)) - # Process keyword arguments - remove_useless_states = kwargs.get( - 'remove_useless_states', - config.defaults['statesp.remove_useless_states']) - # Convert all matrices to standard form A = _ssmatrix(A) # if B is a 1D array, turn it into a column vector if it fits @@ -305,37 +326,38 @@ def __init__(self, *args, **kwargs): D = np.zeros((C.shape[0], B.shape[1])) D = _ssmatrix(D) - # TODO: use super here? - LTI.__init__(self, inputs=D.shape[1], outputs=D.shape[0]) + # Matrices definining the linear system self.A = A self.B = B self.C = C self.D = D - # now set dt - if len(args) == 4: - if 'dt' in kwargs: - dt = kwargs['dt'] - elif self._isstatic(): - dt = None - else: - dt = config.defaults['control.default_dt'] - elif len(args) == 5: - dt = args[4] - if 'dt' in kwargs: - warn("received multiple dt arguments, " - "using positional arg dt = %s" % dt) - elif len(args) == 1: - try: - dt = args[0].dt - except AttributeError: - if self._isstatic(): - dt = None - else: - dt = config.defaults['control.default_dt'] - self.dt = dt - self.nstates = A.shape[1] + # + # Process keyword arguments + # + + remove_useless_states = kwargs.pop( + 'remove_useless_states', + config.defaults['statesp.remove_useless_states']) + # Initialize the instance variables + if init_namedio: + # Process namedio keywords + defaults = args[0] if len(args) == 1 else \ + {'inputs': D.shape[1], 'outputs': D.shape[0], + 'states': A.shape[0]} + static = (A.size == 0) + name, inputs, outputs, states, dt = _process_namedio_keywords( + kwargs, defaults, static=static, end=True) + + # Initialize LTI (NamedIOSystem) object + super().__init__( + name=name, inputs=inputs, outputs=outputs, + states=states, dt=dt) + elif kwargs: + raise TypeError("unrecognized keyword(s): ", str(kwargs)) + + # Reset shapes (may not be needed once np.matrix support is removed) if 0 == self.nstates: # static gain # matrix's default "empty" shape is 1x0 @@ -343,8 +365,11 @@ def __init__(self, *args, **kwargs): B.shape = (0, self.ninputs) C.shape = (self.noutputs, 0) - # Check that the matrix sizes are consistent. - if self.nstates != A.shape[0]: + # + # Check to make sure everything is consistent + # + # Check that the matrix sizes are consistent + if A.shape[0] != A.shape[1] or self.nstates != A.shape[0]: raise ValueError("A must be square.") if self.nstates != B.shape[0]: raise ValueError("A and B must have the same number of rows.") @@ -355,7 +380,10 @@ def __init__(self, *args, **kwargs): if self.noutputs != C.shape[0]: raise ValueError("C and D must have the same number of rows.") - # Check for states that don't do anything, and remove them. + # + # Final processing + # + # Check for states that don't do anything, and remove them if remove_useless_states: self._remove_useless_states() @@ -456,9 +484,10 @@ def _remove_useless_states(self): self.B = delete(self.B, useless, 0) self.C = delete(self.C, useless, 1) - self.nstates = self.A.shape[0] - self.ninputs = self.B.shape[1] - self.noutputs = self.C.shape[0] + # Remove any state names that we don't need + self.set_states( + [self.state_labels[i] for i in range(self.nstates) + if i not in useless]) def __str__(self): """Return string representation of the state space system.""" @@ -647,6 +676,13 @@ def __add__(self, other): D = self.D + other dt = self.dt else: + # Check to see if the right operator has priority + if getattr(other, '__array_priority__', None) and \ + getattr(self, '__array_priority__', None) and \ + other.__array_priority__ > self.__array_priority__: + return other.__radd__(self) + + # Convert the other argument to state space other = _convert_to_statespace(other) # Check to make sure the dimensions are OK @@ -697,6 +733,13 @@ def __mul__(self, other): D = self.D * other dt = self.dt else: + # Check to see if the right operator has priority + if getattr(other, '__array_priority__', None) and \ + getattr(self, '__array_priority__', None) and \ + other.__array_priority__ > self.__array_priority__: + return other.__rmul__(self) + + # Convert the other argument to state space other = _convert_to_statespace(other) # Check to make sure the dimensions are OK @@ -925,7 +968,7 @@ def horner(self, x, warn_infinite=True): # Evaluating at a pole. Return value depends if there # is a zero at the same point or not. - if x_idx in self.zero(): + if x_idx in self.zeros(): out[:, :, idx] = complex(np.nan, np.nan) else: out[:, :, idx] = complex(np.inf, np.nan) @@ -947,12 +990,13 @@ def freqresp(self, omega): return self.frequency_response(omega) # Compute poles and zeros - def pole(self): + def poles(self): """Compute the poles of a state space system.""" - return eigvals(self.A) if self.nstates else np.array([]) + return eigvals(self.A).astype(complex) if self.nstates \ + else np.array([]) - def zero(self): + def zeros(self): """Compute the zeros of a state space system.""" if not self.nstates: @@ -969,14 +1013,15 @@ def zero(self): if nu == 0: return np.array([]) else: + # Use SciPy generalized eigenvalue fucntion return sp.linalg.eigvals(out[8][0:nu, 0:nu], - out[9][0:nu, 0:nu]) + out[9][0:nu, 0:nu]).astype(complex) except ImportError: # Slycot unavailable. Fall back to scipy. if self.C.shape[0] != self.D.shape[1]: - raise NotImplementedError("StateSpace.zero only supports " - "systems with the same number of " - "inputs as outputs.") + raise NotImplementedError( + "StateSpace.zero only supports systems with the same " + "number of inputs as outputs.") # This implements the QZ algorithm for finding transmission zeros # from @@ -994,7 +1039,7 @@ def zero(self): (0, self.B.shape[1])), "constant") return np.array([x for x in sp.linalg.eigvals(L, M, overwrite_a=True) - if not isinf(x)]) + if not isinf(x)], dtype=complex) # Feedback around a state space system def feedback(self, other=1, sign=-1): @@ -1361,7 +1406,7 @@ def dynamics(self, t, x, u=None): The first argument `t` is ignored because :class:`StateSpace` systems are time-invariant. It is included so that the dynamics can be passed - to most numerical integrators, such as :func:`scipy.integrate.solve_ivp` + to numerical integrators, such as :func:`scipy.integrate.solve_ivp` and for consistency with :class:`IOSystem` systems. Parameters @@ -1376,6 +1421,7 @@ def dynamics(self, t, x, u=None): Returns ------- dx/dt or x[t+dt] : ndarray + """ x = np.reshape(x, (-1, 1)) # force to a column in case matrix if np.size(x) != self.nstates: @@ -1439,7 +1485,7 @@ def _isstatic(self): # TODO: add discrete time check -def _convert_to_statespace(sys, **kw): +def _convert_to_statespace(sys): """Convert a system to state space form (if needed). If sys is already a state space, then it is returned. If sys is a @@ -1452,16 +1498,15 @@ def _convert_to_statespace(sys, **kw): In the latter example, A = B = C = 0 and D = [[1., 1., 1.] [1., 1., 1.]]. + + Note: no renaming of inputs and outputs is performed; this should be done + by the calling function. + """ from .xferfcn import TransferFunction import itertools if isinstance(sys, StateSpace): - if len(kw): - raise TypeError("If sys is a StateSpace, _convert_to_statespace " - "cannot take keywords.") - - # Already a state space system; just return it return sys elif isinstance(sys, TransferFunction): @@ -1470,11 +1515,9 @@ def _convert_to_statespace(sys, **kw): [[len(num) for num in col] for col in sys.den]): raise ValueError("Transfer function is non-proper; can't " "convert to StateSpace system.") + try: from slycot import td04ad - if len(kw): - raise TypeError("If sys is a TransferFunction, " - "_convert_to_statespace cannot take keywords.") # Change the numerator and denominator arrays so that the transfer # function matrix has a common denominator. @@ -1486,10 +1529,10 @@ def _convert_to_statespace(sys, **kw): denorder, den, num, tol=0) states = ssout[0] - return StateSpace(ssout[1][:states, :states], - ssout[2][:states, :sys.ninputs], - ssout[3][:sys.noutputs, :states], ssout[4], - sys.dt) + return StateSpace( + ssout[1][:states, :states], ssout[2][:states, :sys.ninputs], + ssout[3][:sys.noutputs, :states], ssout[4], sys.dt, + inputs=sys.input_labels, outputs=sys.output_labels) except ImportError: # No Slycot. Scipy tf->ss can't handle MIMO, but static # MIMO is an easy special case we can check for here @@ -1512,34 +1555,25 @@ def _convert_to_statespace(sys, **kw): # the squeeze A, B, C, D = \ sp.signal.tf2ss(squeeze(sys.num), squeeze(sys.den)) - return StateSpace(A, B, C, D, sys.dt) + return StateSpace( + A, B, C, D, sys.dt, inputs=sys.input_labels, + outputs=sys.output_labels) - elif isinstance(sys, (int, float, complex, np.number)): - if "inputs" in kw: - inputs = kw["inputs"] - else: - inputs = 1 - if "outputs" in kw: - outputs = kw["outputs"] - else: - outputs = 1 - - # Generate a simple state space system of the desired dimension - # The following Doesn't work due to inconsistencies in ltisys: - # return StateSpace([[]], [[]], [[]], eye(outputs, inputs)) - return StateSpace([], zeros((0, inputs)), zeros((outputs, 0)), - sys * ones((outputs, inputs))) + elif isinstance(sys, FrequencyResponseData): + raise TypeError("Can't convert FRD to StateSpace system.") # If this is a matrix, try to create a constant feedthrough try: - D = _ssmatrix(sys) - return StateSpace([], [], [], D) + D = _ssmatrix(np.atleast_2d(sys)) + return StateSpace([], [], [], D, dt=None) + except Exception: raise TypeError("Can't convert given type to StateSpace system.") # TODO: add discrete time option -def _rss_generate(states, inputs, outputs, cdtype, strictly_proper=False): +def _rss_generate( + states, inputs, outputs, cdtype, strictly_proper=False, name=None): """Generate a random state space. This does the actual random state space generation expected from rss and @@ -1657,7 +1691,7 @@ def _rss_generate(states, inputs, outputs, cdtype, strictly_proper=False): ss_args = (A, B, C, D) else: ss_args = (A, B, C, D, True) - return StateSpace(*ss_args) + return StateSpace(*ss_args, name=name) # Convert a MIMO system to a SISO system @@ -1768,99 +1802,7 @@ def _mimo2simo(sys, input, warn_conversion=False): return sys -def ss(*args, **kwargs): - """ss(A, B, C, D[, dt]) - - Create a state space system. - - The function accepts either 1, 4 or 5 parameters: - - ``ss(sys)`` - Convert a linear system into space system form. Always creates a - new system, even if sys is already a StateSpace object. - - ``ss(A, B, C, D)`` - Create a state space system from the matrices of its state and - output equations: - - .. math:: - \\dot x = A \\cdot x + B \\cdot u - - y = C \\cdot x + D \\cdot u - - ``ss(A, B, C, D, dt)`` - Create a discrete-time state space system from the matrices of - its state and output equations: - - .. math:: - x[k+1] = A \\cdot x[k] + B \\cdot u[k] - - y[k] = C \\cdot x[k] + D \\cdot u[ki] - - The matrices can be given as *array like* data types or strings. - Everything that the constructor of :class:`numpy.matrix` accepts is - permissible here too. - - Parameters - ---------- - sys: StateSpace or TransferFunction - A linear system - A: array_like or string - System matrix - B: array_like or string - Control matrix - C: array_like or string - Output matrix - D: array_like or string - Feed forward matrix - dt: If present, specifies the timebase of the system - - Returns - ------- - out: :class:`StateSpace` - The new linear system - - Raises - ------ - ValueError - if matrix sizes are not self-consistent - - See Also - -------- - StateSpace - tf - ss2tf - tf2ss - - Examples - -------- - >>> # Create a StateSpace object from four "matrices". - >>> sys1 = ss("1. -2; 3. -4", "5.; 7", "6. 8", "9.") - - >>> # Convert a TransferFunction to a StateSpace object. - >>> sys_tf = tf([2.], [1., 3]) - >>> sys2 = ss(sys_tf) - - """ - - if len(args) == 4 or len(args) == 5: - return StateSpace(*args, **kwargs) - elif len(args) == 1: - from .xferfcn import TransferFunction - sys = args[0] - if isinstance(sys, StateSpace): - return deepcopy(sys) - elif isinstance(sys, TransferFunction): - return tf2ss(sys) - else: - raise TypeError("ss(sys): sys must be a StateSpace or " - "TransferFunction object. It is %s." % type(sys)) - else: - raise ValueError( - "Needs 1, 4, or 5 arguments; received %i." % len(args)) - - -def tf2ss(*args): +def tf2ss(*args, **kwargs): """tf2ss(sys) Transform a transfer function to a state space system. @@ -1868,11 +1810,11 @@ def tf2ss(*args): The function accepts either 1 or 2 parameters: ``tf2ss(sys)`` - Convert a linear system into transfer function form. Always creates - a new system, even if sys is already a TransferFunction object. + Convert a linear system into space space form. Always creates + a new system, even if sys is already a StateSpace object. ``tf2ss(num, den)`` - Create a transfer function system from its numerator and denominator + Create a state space system from its numerator and denominator polynomial coefficients. For details see: :func:`tf` @@ -1891,6 +1833,16 @@ def tf2ss(*args): out : StateSpace New linear system in state space form + Other Parameters + ---------------- + inputs, outputs : str, or list of str, optional + List of strings that name the individual signals of the transformed + system. If not given, the inputs and outputs are the same as the + original system. + name : string, optional + System name. If unspecified, a generic name is generated + with a unique integer id. + Raises ------ ValueError @@ -1920,114 +1872,90 @@ def tf2ss(*args): from .xferfcn import TransferFunction if len(args) == 2 or len(args) == 3: # Assume we were given the num, den - return _convert_to_statespace(TransferFunction(*args)) + return StateSpace( + _convert_to_statespace(TransferFunction(*args)), **kwargs) elif len(args) == 1: sys = args[0] if not isinstance(sys, TransferFunction): raise TypeError("tf2ss(sys): sys must be a TransferFunction " "object.") - return _convert_to_statespace(sys) + return StateSpace(_convert_to_statespace(sys), **kwargs) else: raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) -def rss(states=1, outputs=1, inputs=1, strictly_proper=False): +def ssdata(sys): """ - Create a stable *continuous* random state space object. + Return state space data objects for a system Parameters ---------- - states : int - Number of state variables - 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). + sys : LTI (StateSpace, or TransferFunction) + LTI system whose data will be returned Returns ------- - sys : StateSpace - The randomly created linear system - - Raises - ------ - ValueError - if any input is not a positive integer - - See Also - -------- - drss - - Notes - ----- - If the number of states, inputs, or outputs is not specified, then the - missing numbers are assumed to be 1. The poles of the returned system - will always have a negative real part. - + (A, B, C, D): list of matrices + State space data for the system """ - - return _rss_generate(states, inputs, outputs, 'c', - strictly_proper=strictly_proper) + ss = _convert_to_statespace(sys) + return ss.A, ss.B, ss.C, ss.D -def drss(states=1, outputs=1, inputs=1, strictly_proper=False): - """ - Create a stable *discrete* random state space object. +def linfnorm(sys, tol=1e-10): + """L-infinity norm of a linear system Parameters ---------- - states : int - Number of state variables - inputs : integer - Number of system inputs - outputs : int - Number of system outputs - strictly_proper: bool, optional - If set to 'True', returns a proper system (no direct term). - + sys : LTI (StateSpace or TransferFunction) + system to evalute L-infinity norm of + tol : real scalar + tolerance on norm estimate Returns ------- - sys : StateSpace - The randomly created linear system + gpeak : non-negative scalar + L-infinity norm + fpeak : non-negative scalar + Frequency, in rad/s, at which gpeak occurs - Raises - ------ - ValueError - if any input is not a positive integer + For stable systems, the L-infinity and H-infinity norms are equal; + for unstable systems, the H-infinity norm is infinite, while the + L-infinity norm is finite if the system has no poles on the + imaginary axis. - See Also + See also -------- - rss + slycot.ab13dd : the Slycot routine linfnorm that does the calculation + """ - Notes - ----- - If the number of states, inputs, or outputs is not specified, then the - missing numbers are assumed to be 1. The poles of the returned system - will always have a magnitude less than 1. + if ab13dd is None: + raise ControlSlycot("Can't find slycot module 'ab13dd'") - """ + a, b, c, d = ssdata(_convert_to_statespace(sys)) + e = np.eye(a.shape[0]) - return _rss_generate(states, inputs, outputs, 'd', - strictly_proper=strictly_proper) + n = a.shape[0] + m = b.shape[1] + p = c.shape[0] + if n == 0: + # ab13dd doesn't accept empty A, B, C, D; + # static gain case is easy enough to compute + gpeak = scipy.linalg.svdvals(d)[0] + # max svd is constant with freq; arbitrarily choose 0 as peak + fpeak = 0 + return gpeak, fpeak -def ssdata(sys): - """ - Return state space data objects for a system + dico = 'C' if sys.isctime() else 'D' + jobe = 'I' + equil = 'S' + jobd = 'Z' if all(0 == d.flat) else 'D' - Parameters - ---------- - sys : LTI (StateSpace, or TransferFunction) - LTI system whose data will be returned + gpeak, fpeak = ab13dd(dico, jobe, equil, jobd, n, m, p, a, e, b, c, d, tol) - Returns - ------- - (A, B, C, D): list of matrices - State space data for the system - """ - ss = _convert_to_statespace(sys) - return ss.A, ss.B, ss.C, ss.D + if dico=='D': + fpeak /= sys.dt + + return gpeak, fpeak diff --git a/control/stochsys.py b/control/stochsys.py new file mode 100644 index 000000000..2b8233070 --- /dev/null +++ b/control/stochsys.py @@ -0,0 +1,585 @@ +# stochsys.py - stochastic systems module +# RMM, 16 Mar 2022 +# +# This module contains functions that are intended to be used for analysis +# and design of stochastic control systems, mainly involving Kalman +# filtering and its variants. +# + +"""The :mod:`~control.stochsys` module contains functions for analyzing and +designing stochastic (control) systems, including white noise processes and +Kalman filtering. + +""" + +__license__ = "BSD" +__maintainer__ = "Richard Murray" +__email__ = "murray@cds.caltech.edu" + +import numpy as np +import scipy as sp +from math import sqrt + +from .iosys import InputOutputSystem, NonlinearIOSystem +from .lti import LTI +from .namedio import isctime, isdtime +from .mateqn import care, dare, _check_shape +from .statesp import StateSpace, _ssmatrix +from .exception import ControlArgument, ControlNotImplemented + +__all__ = ['lqe', 'dlqe', 'create_estimator_iosystem', 'white_noise', + 'correlation'] + + +# contributed by Sawyer B. Fuller +def lqe(*args, **kwargs): + """lqe(A, G, C, QN, RN, [, NN]) + + Linear quadratic estimator design (Kalman filter) for continuous-time + systems. Given the system + + .. math:: + + x &= Ax + Bu + Gw \\\\ + y &= Cx + Du + v + + with unbiased process noise w and measurement noise v with covariances + + .. math:: E{ww'} = QN, E{vv'} = RN, E{wv'} = NN + + The lqe() function computes the observer gain matrix L such that the + stationary (non-time-varying) Kalman filter + + .. math:: x_e = A x_e + B u + L(y - C x_e - D u) + + produces a state estimate x_e that minimizes the expected squared error + using the sensor measurements y. The noise cross-correlation `NN` is + set to zero when omitted. + + The function can be called with either 3, 4, 5, or 6 arguments: + + * ``L, P, E = lqe(sys, QN, RN)`` + * ``L, P, E = lqe(sys, QN, RN, NN)`` + * ``L, P, E = lqe(A, G, C, QN, RN)`` + * ``L, P, E = lqe(A, G, C, QN, RN, NN)`` + + where `sys` is an `LTI` object, and `A`, `G`, `C`, `QN`, `RN`, and `NN` + are 2D arrays or matrices of appropriate dimension. + + Parameters + ---------- + A, G, C : 2D array_like + Dynamics, process noise (disturbance), and output matrices + sys : LTI (StateSpace or TransferFunction) + Linear I/O system, with the process noise input taken as the system + input. + QN, RN : 2D array_like + Process and sensor noise covariance matrices + NN : 2D array, optional + Cross covariance matrix. Not currently implemented. + method : str, optional + Set the method used for computing the result. Current methods are + 'slycot' and 'scipy'. If set to None (default), try 'slycot' first + and then 'scipy'. + + Returns + ------- + L : 2D array (or matrix) + Kalman estimator gain + P : 2D array (or matrix) + Solution to Riccati equation + + .. math:: + + A P + P A^T - (P C^T + G N) R^{-1} (C P + N^T G^T) + G Q G^T = 0 + + E : 1D array + Eigenvalues of estimator poles eig(A - L C) + + Notes + ----- + 1. If the first argument is an LTI object, then this object will be used + to define the dynamics, noise and output matrices. Furthermore, if + the LTI object corresponds to a discrete time system, the ``dlqe()`` + function will be called. + + 2. The return type for 2D arrays depends on the default class set for + state space operations. See :func:`~control.use_numpy_matrix`. + + Examples + -------- + >>> L, P, E = lqe(A, G, C, QN, RN) + >>> L, P, E = lqe(A, G, C, Q, RN, NN) + + See Also + -------- + lqr, dlqe, dlqr + + """ + + # TODO: incorporate cross-covariance NN, something like this, + # which doesn't work for some reason + # if NN is None: + # NN = np.zeros(QN.size(0),RN.size(1)) + # NG = G @ NN + + # + # Process the arguments and figure out what inputs we received + # + + # If we were passed a discrete time system as the first arg, use dlqe() + if isinstance(args[0], LTI) and isdtime(args[0], strict=True): + # Call dlqe + return dlqe(*args, **kwargs) + + # Get the method to use (if specified as a keyword) + method = kwargs.pop('method', None) + if kwargs: + raise TypeError("unrecognized keyword(s): ", str(kwargs)) + + # Get the system description + if (len(args) < 3): + raise ControlArgument("not enough input arguments") + + # If we were passed a state space system, use that to get system matrices + if isinstance(args[0], StateSpace): + A = np.array(args[0].A, ndmin=2, dtype=float) + G = np.array(args[0].B, ndmin=2, dtype=float) + C = np.array(args[0].C, ndmin=2, dtype=float) + index = 1 + + elif isinstance(args[0], LTI): + # Don't allow other types of LTI systems + raise ControlArgument("LTI system must be in state space form") + + else: + # Arguments should be A and B matrices + A = np.array(args[0], ndmin=2, dtype=float) + G = np.array(args[1], ndmin=2, dtype=float) + C = np.array(args[2], ndmin=2, dtype=float) + index = 3 + + # Get the weighting matrices (converting to matrices, if needed) + QN = np.array(args[index], ndmin=2, dtype=float) + RN = np.array(args[index+1], ndmin=2, dtype=float) + + # Get the cross-covariance matrix, if given + if (len(args) > index + 2): + NN = np.array(args[index+2], ndmin=2, dtype=float) + raise ControlNotImplemented("cross-covariance not implemented") + + else: + # For future use (not currently used below) + NN = np.zeros((QN.shape[0], RN.shape[1])) + + # Check dimensions of G (needed before calling care()) + _check_shape("QN", QN, G.shape[1], G.shape[1]) + + # Compute the result (dimension and symmetry checking done in care()) + P, E, LT = care(A.T, C.T, G @ QN @ G.T, RN, method=method, + B_s="C", Q_s="QN", R_s="RN", S_s="NN") + return _ssmatrix(LT.T), _ssmatrix(P), E + + +# contributed by Sawyer B. Fuller +def dlqe(*args, **kwargs): + """dlqe(A, G, C, QN, RN, [, N]) + + Linear quadratic estimator design (Kalman filter) for discrete-time + systems. Given the system + + .. math:: + + x[n+1] &= Ax[n] + Bu[n] + Gw[n] \\\\ + y[n] &= Cx[n] + Du[n] + v[n] + + with unbiased process noise w and measurement noise v with covariances + + .. math:: E{ww'} = QN, E{vv'} = RN, E{wv'} = NN + + The dlqe() function computes the observer gain matrix L such that the + stationary (non-time-varying) Kalman filter + + .. math:: x_e[n+1] = A x_e[n] + B u[n] + L(y[n] - C x_e[n] - D u[n]) + + produces a state estimate x_e[n] that minimizes the expected squared error + using the sensor measurements y. The noise cross-correlation `NN` is + set to zero when omitted. + + Parameters + ---------- + A, G : 2D array_like + Dynamics and noise input matrices + QN, RN : 2D array_like + Process and sensor noise covariance matrices + NN : 2D array, optional + Cross covariance matrix (not yet supported) + method : str, optional + Set the method used for computing the result. Current methods are + 'slycot' and 'scipy'. If set to None (default), try 'slycot' first + and then 'scipy'. + + Returns + ------- + L : 2D array (or matrix) + Kalman estimator gain + P : 2D array (or matrix) + Solution to Riccati equation + + .. math:: + + A P + P A^T - (P C^T + G N) R^{-1} (C P + N^T G^T) + G Q G^T = 0 + + E : 1D array + Eigenvalues of estimator poles eig(A - L C) + + Notes + ----- + The return type for 2D arrays depends on the default class set for + state space operations. See :func:`~control.use_numpy_matrix`. + + Examples + -------- + >>> L, P, E = dlqe(A, G, C, QN, RN) + >>> L, P, E = dlqe(A, G, C, QN, RN, NN) + + See Also + -------- + dlqr, lqe, lqr + + """ + + # + # Process the arguments and figure out what inputs we received + # + + # Get the method to use (if specified as a keyword) + method = kwargs.pop('method', None) + if kwargs: + raise TypeError("unrecognized keyword(s): ", str(kwargs)) + + # Get the system description + if (len(args) < 3): + raise ControlArgument("not enough input arguments") + + # If we were passed a continus time system as the first arg, raise error + if isinstance(args[0], LTI) and isctime(args[0], strict=True): + raise ControlArgument("dlqr() called with a continuous time system") + + # If we were passed a state space system, use that to get system matrices + if isinstance(args[0], StateSpace): + A = np.array(args[0].A, ndmin=2, dtype=float) + G = np.array(args[0].B, ndmin=2, dtype=float) + C = np.array(args[0].C, ndmin=2, dtype=float) + index = 1 + + elif isinstance(args[0], LTI): + # Don't allow other types of LTI systems + raise ControlArgument("LTI system must be in state space form") + + else: + # Arguments should be A and B matrices + A = np.array(args[0], ndmin=2, dtype=float) + G = np.array(args[1], ndmin=2, dtype=float) + C = np.array(args[2], ndmin=2, dtype=float) + index = 3 + + # Get the weighting matrices (converting to matrices, if needed) + QN = np.array(args[index], ndmin=2, dtype=float) + RN = np.array(args[index+1], ndmin=2, dtype=float) + + # TODO: incorporate cross-covariance NN, something like this, + # which doesn't work for some reason + # if NN is None: + # NN = np.zeros(QN.size(0),RN.size(1)) + # NG = G @ NN + if len(args) > index + 2: + NN = np.array(args[index+2], ndmin=2, dtype=float) + raise ControlNotImplemented("cross-covariance not yet implememented") + + # Check dimensions of G (needed before calling care()) + _check_shape("QN", QN, G.shape[1], G.shape[1]) + + # Compute the result (dimension and symmetry checking done in dare()) + P, E, LT = dare(A.T, C.T, G @ QN @ G.T, RN, method=method, + B_s="C", Q_s="QN", R_s="RN", S_s="NN") + return _ssmatrix(LT.T), _ssmatrix(P), E + + +# Function to create an estimator +def create_estimator_iosystem( + sys, QN, RN, P0=None, G=None, C=None, + state_labels='xhat[{i}]', output_labels='xhat[{i}]', + covariance_labels='P[{i},{j}]', sensor_labels=None): + """Create an I/O system implementing a linqear quadratic estimator + + This function creates an input/output system that implements a + state estimator of the form + + xhat[k + 1] = A x[k] + B u[k] - L (C xhat[k] - y[k]) + P[k + 1] = A P A^T + F QN F^T - A P C^T Reps^{-1} C P A + L = A P C^T Reps^{-1} + + where Reps = RN + C P C^T. It can be called in the form + + estim = ct.create_estimator_iosystem(sys, QN, RN) + + where ``sys`` is the process dynamics and QN and RN are the covariance + of the disturbance noise and sensor noise. The function returns the + estimator ``estim`` as I/O system with a parameter ``correct`` that can + be used to turn off the correction term in the estimation (for forward + predictions). + + Parameters + ---------- + sys : InputOutputSystem + The I/O system that represents the process dynamics. If no estimator + is given, the output of this system should represent the full state. + QN, RN : ndarray + Process and sensor noise covariance matrices. + P0 : ndarray, optional + Initial covariance matrix. If not specified, defaults to the steady + state covariance. + G : ndarray, optional + Disturbance matrix describing how the disturbances enters the + dynamics. Defaults to sys.B. + C : ndarray, optional + If the system has all full states output, define the measured values + to be used by the estimator. Otherwise, use the system output as the + measured values. + {state, covariance, sensor, output}_labels : str or list of str, optional + Set the name of the signals to use for the internal state, covariance, + sensors, and outputs (state estimate). If a single string is + specified, it should be a format string using the variable ``i`` as an + index (or ``i`` and ``j`` for covariance). Otherwise, a list of + strings matching the size of the respective signal should be used. + Default is ``'xhat[{i}]'`` for state and output labels, ``'y[{i}]'`` + for output labels and ``'P[{i},{j}]'`` for covariance labels. + + Returns + ------- + estim : InputOutputSystem + Input/output system representing the estimator. This system takes the + system input and output and generates the estimated state. + + Notes + ----- + This function can be used with the ``create_statefbk_iosystem()`` function + to create a closed loop, output-feedback, state space controller: + + K, _, _ = ct.lqr(sys, Q, R) + est = ct.create_estimator_iosystem(sys, QN, RN, P0) + ctrl, clsys = ct.create_statefbk_iosystem(sys, K, estimator=est) + + The estimator can also be run on its own to process a noisy signal: + + resp = ct.input_output_response(est, T, [Y, U], [X0, P0]) + + If desired, the ``correct`` parameter can be set to ``False`` to allow + prediction with no additional sensor information: + + resp = ct.input_output_response( + est, T, 0, [X0, P0], param={'correct': False) + + """ + + # Make sure that we were passed an I/O system as an input + if not isinstance(sys, InputOutputSystem): + raise ControlArgument("Input system must be I/O system") + + # Extract the matrices that we need for easy reference + A, B = sys.A, sys.B + + # Set the disturbance and output matrices + G = sys.B if G is None else G + if C is not None: + # Make sure that we have the full system output + if not np.array_equal(sys.C, np.eye(sys.nstates)): + raise ValueError("System output must be full state") + + # Make sure that the output matches the size of RN + if C.shape[0] != RN.shape[0]: + raise ValueError("System output is the wrong size for C") + else: + # Use the system outputs as the sensor outputs + C = sys.C + if sensor_labels is None: + sensor_labels = sys.output_labels + + # Initialize the covariance matrix + if P0 is None: + # Initalize P0 to the steady state value + _, P0, _ = lqe(A, G, C, QN, RN) + + # Figure out the labels to use + if isinstance(state_labels, str): + # Generate the list of labels using the argument as a format string + state_labels = [state_labels.format(i=i) for i in range(sys.nstates)] + + if isinstance(covariance_labels, str): + # Generate the list of labels using the argument as a format string + covariance_labels = [ + covariance_labels.format(i=i, j=j) \ + for i in range(sys.nstates) for j in range(sys.nstates)] + + if isinstance(output_labels, str): + # Generate the list of labels using the argument as a format string + output_labels = [output_labels.format(i=i) for i in range(sys.nstates)] + + sensor_labels = 'y[{i}]' if sensor_labels is None else sensor_labels + if isinstance(sensor_labels, str): + # Generate the list of labels using the argument as a format string + sensor_labels = [sensor_labels.format(i=i) for i in range(C.shape[0])] + + if isctime(sys): + raise NotImplementedError("continuous time not yet implemented") + + else: + # Create an I/O system for the state feedback gains + # Note: reshape vectors into column vectors for legacy np.matrix + def _estim_update(t, x, u, params): + # See if we are estimating or predicting + correct = params.get('correct', True) + + # Get the state of the estimator + xhat = x[0:sys.nstates].reshape(-1, 1) + P = x[sys.nstates:].reshape(sys.nstates, sys.nstates) + + # Extract the inputs to the estimator + y = u[0:C.shape[0]].reshape(-1, 1) + u = u[C.shape[0]:].reshape(-1, 1) + + # Compute the optimal again + Reps_inv = np.linalg.inv(RN + C @ P @ C.T) + L = A @ P @ C.T @ Reps_inv + + # Update the state estimate + dxhat = A @ xhat + B @ u # prediction + if correct: + dxhat -= L @ (C @ xhat - y) # correction + + # Update the covariance + dP = A @ P @ A.T + G @ QN @ G.T + if correct: + dP -= A @ P @ C.T @ Reps_inv @ C @ P @ A.T + + # Return the update + return np.hstack([dxhat.reshape(-1), dP.reshape(-1)]) + + def _estim_output(t, x, u, params): + return x[0:sys.nstates] + + # Define the estimator system + return NonlinearIOSystem( + _estim_update, _estim_output, states=state_labels + covariance_labels, + inputs=sensor_labels + sys.input_labels, outputs=output_labels, + dt=sys.dt) + + +def white_noise(T, Q, dt=0): + """Generate a white noise signal with specified intensity. + + This function generates a (multi-variable) white noise signal of + specified intensity as either a sampled continous time signal or a + discrete time signal. A white noise signal along a 1D array + of linearly spaced set of times T can be computing using + + V = ct.white_noise(T, Q, dt) + + where Q is a positive definite matrix providing the noise intensity. + + In continuous time, the white noise signal is scaled such that the + integral of the covariance over a sample period is Q, thus approximating + a white noise signal. In discrete time, the white noise signal has + covariance Q at each point in time (without any scaling based on the + sample time). + + """ + # Convert input arguments to arrays + T = np.atleast_1d(T) + Q = np.atleast_2d(Q) + + # Check the shape of the input arguments + if len(T.shape) != 1: + raise ValueError("Time vector T must be 1D") + if len(Q.shape) != 2 or Q.shape[0] != Q.shape[1]: + raise ValueError("Covariance matrix Q must be square") + + # Figure out the time increment + if dt != 0: + # Discrete time system => white noise is not scaled + dt = 1 + else: + dt = T[1] - T[0] + + # Make sure data points are equally spaced + if not np.allclose(np.diff(T), T[1] - T[0]): + raise ValueError("Time values must be equally spaced.") + + # Generate independent white noise sources for each input + W = np.array([ + np.random.normal(0, 1/sqrt(dt), T.size) for i in range(Q.shape[0])]) + + # Return a linear combination of the noise sources + return sp.linalg.sqrtm(Q) @ W + +def correlation(T, X, Y=None, squeeze=True): + """Compute the correlation of time signals. + + For a time series X(t) (and optionally Y(t)), the correlation() function + computes the correlation matrix E(X'(t+tau) X(t)) or the cross-correlation + matrix E(X'(t+tau) Y(t)]: + + tau, Rtau = correlation(T, X[, Y]) + + The signal X (and Y, if present) represent a continuous time signal + sampled at times T. The return value provides the correlation Rtau + between X(t+tau) and X(t) at a set of time offets tau. + + Parameters + ---------- + T : 1D array_like + Sample times for the signal(s). + X : 1D or 2D array_like + Values of the signal at each time in T. The signal can either be + scalar or vector values. + Y : 1D or 2D array_like, optional + If present, the signal with which to compute the correlation. + Defaults to X. + squeeze : bool, optional + If True, squeeze Rtau to remove extra dimensions (useful if the + signals are scalars). + + Returns + ------- + + """ + T = np.atleast_1d(T) + X = np.atleast_2d(X) + Y = np.atleast_2d(Y) if Y is not None else X + + # Check the shape of the input arguments + if len(T.shape) != 1: + raise ValueError("Time vector T must be 1D") + if len(X.shape) != 2 or len(Y.shape) != 2: + raise ValueError("Signals X and Y must be 2D arrays") + if T.shape[0] != X.shape[1] or T.shape[0] != Y.shape[1]: + raise ValueError("Signals X and Y must have same length as T") + + # Figure out the time increment + dt = T[1] - T[0] + + # Make sure data points are equally spaced + if not np.allclose(np.diff(T), T[1] - T[0]): + raise ValueError("Time values must be equally spaced.") + + # Compute the correlation matrix + R = np.array( + [[sp.signal.correlate(X[i], Y[j]) + for i in range(X.shape[0])] for j in range(Y.shape[0])] + ) * dt / (T[-1] - T[0]) + # From scipy.signal.correlation_lags (for use with older versions) + # tau = sp.signal.correlation_lags(len(X[0]), len(Y[0])) * dt + tau = np.arange(-len(Y[0]) + 1, len(X[0])) * dt + + return tau, R.squeeze() if squeeze else R diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 433a584cc..2f6b5523f 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -11,7 +11,7 @@ from control.xferfcn import TransferFunction from control.statesp import StateSpace from control.bdalg import feedback, append, connect -from control.lti import zero, pole +from control.lti import zeros, poles class TestFeedback: @@ -188,52 +188,52 @@ def testLists(self, tsys): # Series sys1_2 = ctrl.series(sys1, sys2) - np.testing.assert_array_almost_equal(sort(pole(sys1_2)), [-4., -2.]) - np.testing.assert_array_almost_equal(sort(zero(sys1_2)), [-3., -1.]) + np.testing.assert_array_almost_equal(sort(poles(sys1_2)), [-4., -2.]) + np.testing.assert_array_almost_equal(sort(zeros(sys1_2)), [-3., -1.]) sys1_3 = ctrl.series(sys1, sys2, sys3) - np.testing.assert_array_almost_equal(sort(pole(sys1_3)), + np.testing.assert_array_almost_equal(sort(poles(sys1_3)), [-6., -4., -2.]) - np.testing.assert_array_almost_equal(sort(zero(sys1_3)), + np.testing.assert_array_almost_equal(sort(zeros(sys1_3)), [-5., -3., -1.]) sys1_4 = ctrl.series(sys1, sys2, sys3, sys4) - np.testing.assert_array_almost_equal(sort(pole(sys1_4)), + np.testing.assert_array_almost_equal(sort(poles(sys1_4)), [-8., -6., -4., -2.]) - np.testing.assert_array_almost_equal(sort(zero(sys1_4)), + np.testing.assert_array_almost_equal(sort(zeros(sys1_4)), [-7., -5., -3., -1.]) sys1_5 = ctrl.series(sys1, sys2, sys3, sys4, sys5) - np.testing.assert_array_almost_equal(sort(pole(sys1_5)), + np.testing.assert_array_almost_equal(sort(poles(sys1_5)), [-8., -6., -4., -2., -0.]) - np.testing.assert_array_almost_equal(sort(zero(sys1_5)), + np.testing.assert_array_almost_equal(sort(zeros(sys1_5)), [-9., -7., -5., -3., -1.]) # Parallel sys1_2 = ctrl.parallel(sys1, sys2) - np.testing.assert_array_almost_equal(sort(pole(sys1_2)), [-4., -2.]) - np.testing.assert_array_almost_equal(sort(zero(sys1_2)), - sort(zero(sys1 + sys2))) + np.testing.assert_array_almost_equal(sort(poles(sys1_2)), [-4., -2.]) + np.testing.assert_array_almost_equal(sort(zeros(sys1_2)), + sort(zeros(sys1 + sys2))) sys1_3 = ctrl.parallel(sys1, sys2, sys3) - np.testing.assert_array_almost_equal(sort(pole(sys1_3)), + np.testing.assert_array_almost_equal(sort(poles(sys1_3)), [-6., -4., -2.]) - np.testing.assert_array_almost_equal(sort(zero(sys1_3)), - sort(zero(sys1 + sys2 + sys3))) + np.testing.assert_array_almost_equal(sort(zeros(sys1_3)), + sort(zeros(sys1 + sys2 + sys3))) sys1_4 = ctrl.parallel(sys1, sys2, sys3, sys4) - np.testing.assert_array_almost_equal(sort(pole(sys1_4)), + np.testing.assert_array_almost_equal(sort(poles(sys1_4)), [-8., -6., -4., -2.]) np.testing.assert_array_almost_equal( - sort(zero(sys1_4)), - sort(zero(sys1 + sys2 + sys3 + sys4))) + sort(zeros(sys1_4)), + sort(zeros(sys1 + sys2 + sys3 + sys4))) sys1_5 = ctrl.parallel(sys1, sys2, sys3, sys4, sys5) - np.testing.assert_array_almost_equal(sort(pole(sys1_5)), + np.testing.assert_array_almost_equal(sort(poles(sys1_5)), [-8., -6., -4., -2., -0.]) np.testing.assert_array_almost_equal( - sort(zero(sys1_5)), - sort(zero(sys1 + sys2 + sys3 + sys4 + sys5))) + sort(zeros(sys1_5)), + sort(zeros(sys1 + sys2 + sys3 + sys4 + sys5))) def testMimoSeries(self, tsys): """regression: bdalg.series reverses order of arguments""" diff --git a/control/tests/config_test.py b/control/tests/config_test.py index e198254bf..295c68bdd 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -23,10 +23,10 @@ class TestConfig: sys = ct.tf([10], [1, 2, 1]) def test_set_defaults(self): - ct.config.set_defaults('config', test1=1, test2=2, test3=None) - assert ct.config.defaults['config.test1'] == 1 - assert ct.config.defaults['config.test2'] == 2 - assert ct.config.defaults['config.test3'] is None + ct.config.set_defaults('freqplot', dB=1, deg=2, Hz=None) + assert ct.config.defaults['freqplot.dB'] == 1 + assert ct.config.defaults['freqplot.deg'] == 2 + assert ct.config.defaults['freqplot.Hz'] is None @mplcleanup def test_get_param(self): @@ -292,8 +292,12 @@ def test_change_default_dt_static(self): """Test that static gain systems always have dt=None""" ct.set_defaults('control', default_dt=0) assert ct.tf(1, 1).dt is None - assert ct.ss(0, 0, 0, 1).dt is None - # TODO: add in test for static gain iosys + assert ct.ss([], [], [], 1).dt is None + + # Make sure static gain is preserved for the I/O system + sys = ct.ss([], [], [], 1) + sys_io = ct.ss2io(sys) + assert sys_io.dt is None def test_get_param_last(self): """Test _get_param last keyword""" diff --git a/control/tests/convert_test.py b/control/tests/convert_test.py index 36eac223c..6c4586471 100644 --- a/control/tests/convert_test.py +++ b/control/tests/convert_test.py @@ -225,7 +225,7 @@ def testTf2SsDuplicatePoles(self): [[1], [1, 0]]] g = tf(num, den) s = ss(g) - np.testing.assert_allclose(g.pole(), s.pole()) + np.testing.assert_allclose(g.poles(), s.poles()) @slycotonly def test_tf2ss_robustness(self): @@ -241,10 +241,10 @@ def test_tf2ss_robustness(self): sys2ss = tf2ss(sys2tf) # Make sure that the poles match for StateSpace and TransferFunction - np.testing.assert_array_almost_equal(np.sort(sys1tf.pole()), - np.sort(sys1ss.pole())) - np.testing.assert_array_almost_equal(np.sort(sys2tf.pole()), - np.sort(sys2ss.pole())) + np.testing.assert_array_almost_equal(np.sort(sys1tf.poles()), + np.sort(sys1ss.poles())) + np.testing.assert_array_almost_equal(np.sort(sys2tf.poles()), + np.sort(sys2ss.poles())) def test_tf2ss_nonproper(self): """Unit tests for non-proper transfer functions""" diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 6f4ef7cef..a12852759 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -113,8 +113,10 @@ def test_kinematic_car(self, vehicle_flat, poly): np.testing.assert_array_almost_equal(uf, u[:, 1]) # Simulate the system and make sure we stay close to desired traj - T = np.linspace(0, Tf, 500) + T = np.linspace(0, Tf, 100) xd, ud = traj.eval(T) + resp = ct.input_output_response(vehicle_flat, T, ud, x0) + np.testing.assert_array_almost_equal(resp.states, xd, decimal=2) # For SciPy 1.0+, integrate equations and compare to desired if StrictVersion(sp.__version__) >= "1.0": @@ -122,6 +124,35 @@ def test_kinematic_car(self, vehicle_flat, poly): vehicle_flat, T, ud, x0, return_x=True) np.testing.assert_allclose(x, xd, atol=0.01, rtol=0.01) + def test_flat_default_output(self, vehicle_flat): + # Construct a flat system with the default outputs + flatsys = fs.FlatSystem( + vehicle_flat.forward, vehicle_flat.reverse, vehicle_flat.updfcn, + inputs=vehicle_flat.ninputs, outputs=vehicle_flat.ninputs, + states=vehicle_flat.nstates) + + # Define the endpoints of the trajectory + x0 = [0., -2., 0.]; u0 = [10., 0.] + xf = [100., 2., 0.]; uf = [10., 0.] + Tf = 10 + + # Find trajectory between initial and final conditions + poly = fs.PolyFamily(6) + traj1 = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=poly) + traj2 = fs.point_to_point(flatsys, Tf, x0, u0, xf, uf, basis=poly) + + # Verify that the trajectory computation is correct + T = np.linspace(0, Tf, 10) + x1, u1 = traj1.eval(T) + x2, u2 = traj2.eval(T) + np.testing.assert_array_almost_equal(x1, x2) + np.testing.assert_array_almost_equal(u1, u2) + + # Run a simulation and verify that the outputs are correct + resp1 = ct.input_output_response(vehicle_flat, T, u1, x0) + resp2 = ct.input_output_response(flatsys, T, u1, x0) + np.testing.assert_array_almost_equal(resp1.outputs[0:2], resp2.outputs) + def test_flat_cost_constr(self): # Double integrator system sys = ct.ss([[0, 1], [0, 0]], [[0], [1]], [[1, 0]], 0) @@ -347,3 +378,29 @@ def test_point_to_point_errors(self): with pytest.raises(TypeError, match="unrecognized keyword"): traj_method = fs.point_to_point( flat_sys, timepts, x0, u0, xf, uf, solve_ivp_method=None) + + @pytest.mark.parametrize( + "xf, uf, Tf", + [([1, 0], [0], 2), + ([0, 1], [0], 3), + ([1, 1], [1], 4)]) + def test_response(self, xf, uf, Tf): + # Define a second order integrator + sys = ct.StateSpace([[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], 0) + flatsys = fs.LinearFlatSystem(sys) + + # Define the basis set + poly = fs.PolyFamily(6) + + x1, u1, = [0, 0], [0] + traj = fs.point_to_point(flatsys, Tf, x1, u1, xf, uf, basis=poly) + + # Compute the response the regular way + T = np.linspace(0, Tf, 10) + x, u = traj.eval(T) + + # Recompute using response() + response = traj.response(T, squeeze=False) + np.testing.assert_equal(T, response.time) + np.testing.assert_equal(u, response.inputs) + np.testing.assert_equal(x, response.states) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index c63a4c02b..ff88c3dea 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -15,6 +15,7 @@ from control.frdata import FRD, _convert_to_FRD, FrequencyResponseData from control import bdalg, evalfr, freqplot from control.tests.conftest import slycotonly +from control.exception import pandas_check class TestFRD: @@ -472,3 +473,93 @@ def test_repr_str(self): 10.000 0.2 +4j 100.000 0.1 +6j""" assert str(sysm) == refm + + def test_unrecognized_keyword(self): + h = TransferFunction([1], [1, 2, 2]) + omega = np.logspace(-1, 2, 10) + with pytest.raises(TypeError, match="unrecognized keyword"): + frd = FRD(h, omega, unknown=None) + + +def test_named_signals(): + ct.namedio.NamedIOSystem._idCounter = 0 + h1 = TransferFunction([1], [1, 2, 2]) + h2 = TransferFunction([1], [0.1, 1]) + omega = np.logspace(-1, 2, 10) + f1 = FRD(h1, omega) + f2 = FRD(h2, omega) + + # Make sure that systems were properly named + assert f1.name == 'sys[2]' + assert f2.name == 'sys[3]' + assert f1.ninputs == 1 + assert f1.input_labels == ['u[0]'] + assert f1.noutputs == 1 + assert f1.output_labels == ['y[0]'] + + # Change names + f1 = FRD(h1, omega, name='mysys', inputs='u0', outputs='y0') + assert f1.name == 'mysys' + assert f1.ninputs == 1 + assert f1.input_labels == ['u0'] + assert f1.noutputs == 1 + assert f1.output_labels == ['y0'] + + +@pytest.mark.skipif(not pandas_check(), reason="pandas not installed") +def test_to_pandas(): + # Create a SISO frequency response + h1 = TransferFunction([1], [1, 2, 2]) + omega = np.logspace(-1, 2, 10) + resp = FRD(h1, omega) + + # Convert to pandas + df = resp.to_pandas() + + # Check to make sure the data make senses + np.testing.assert_equal(df['omega'], resp.omega) + np.testing.assert_equal(df['H_{y[0], u[0]}'], resp.fresp[0, 0]) + + +def test_frequency_response(): + # Create an SISO frequence response + sys = ct.rss(2, 2, 2) + omega = np.logspace(-2, 2, 20) + resp = ct.frequency_response(sys, omega) + eval = sys(omega*1j) + + # Make sure we get the right answers in various ways + np.testing.assert_equal(resp.magnitude, np.abs(eval)) + np.testing.assert_equal(resp.phase, np.angle(eval)) + np.testing.assert_equal(resp.omega, omega) + + # Make sure that we can change the properties of the response + sys = ct.rss(2, 1, 1) + resp_default = ct.frequency_response(sys, omega) + mag_default, phase_default, omega_default = resp_default + assert mag_default.ndim == 1 + assert phase_default.ndim == 1 + assert omega_default.ndim == 1 + assert mag_default.shape[0] == omega_default.shape[0] + assert phase_default.shape[0] == omega_default.shape[0] + + resp_nosqueeze = ct.frequency_response(sys, omega, squeeze=False) + mag_nosqueeze, phase_nosqueeze, omega_nosqueeze = resp_nosqueeze + assert mag_nosqueeze.ndim == 3 + assert phase_nosqueeze.ndim == 3 + assert omega_nosqueeze.ndim == 1 + assert mag_nosqueeze.shape[2] == omega_nosqueeze.shape[0] + assert phase_nosqueeze.shape[2] == omega_nosqueeze.shape[0] + + # Try changing the response + resp_def_nosq = resp_default(squeeze=False) + mag_def_nosq, phase_def_nosq, omega_def_nosq = resp_def_nosq + assert mag_def_nosq.shape == mag_nosqueeze.shape + assert phase_def_nosq.shape == phase_nosqueeze.shape + assert omega_def_nosq.shape == omega_nosqueeze.shape + + resp_nosq_sq = resp_nosqueeze(squeeze=True) + mag_nosq_sq, phase_nosq_sq, omega_nosq_sq = resp_nosq_sq + assert mag_nosq_sq.shape == mag_default.shape + assert phase_nosq_sq.shape == phase_default.shape + assert omega_nosq_sq.shape == omega_default.shape diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index 4d1ac55e0..573fd6359 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -81,8 +81,9 @@ def test_nyquist_basic(ss_siso): tf_siso, plot=False, return_contour=True, omega_num=20) assert len(contour) == 20 - count, contour = nyquist_plot( - tf_siso, plot=False, omega_limits=(1, 100), return_contour=True) + with pytest.warns(UserWarning, match="encirclements was a non-integer"): + count, contour = nyquist_plot( + tf_siso, plot=False, omega_limits=(1, 100), return_contour=True) assert_allclose(contour[0], 1j) assert_allclose(contour[-1], 100j) @@ -418,11 +419,11 @@ def test_dcgain_consistency(): """Test to make sure that DC gain is consistently evaluated""" # Set up transfer function with pole at the origin sys_tf = ctrl.tf([1], [1, 0]) - assert 0 in sys_tf.pole() + assert 0 in sys_tf.poles() # Set up state space system with pole at the origin sys_ss = ctrl.tf2ss(sys_tf) - assert 0 in sys_ss.pole() + assert 0 in sys_ss.poles() # Finite (real) numerator over 0 denominator => inf + nanj np.testing.assert_equal( @@ -440,8 +441,8 @@ def test_dcgain_consistency(): # Set up transfer function with pole, zero at the origin sys_tf = ctrl.tf([1, 0], [1, 0]) - assert 0 in sys_tf.pole() - assert 0 in sys_tf.zero() + assert 0 in sys_tf.poles() + assert 0 in sys_tf.zeros() # Pole and zero at the origin should give nan + nanj for the response np.testing.assert_equal( @@ -456,7 +457,7 @@ def test_dcgain_consistency(): ctrl.tf2ss(ctrl.tf([1], [1, 0])) # Different systems give different representations => test accordingly - if 0 in sys_ss.pole() and 0 in sys_ss.zero(): + if 0 in sys_ss.poles() and 0 in sys_ss.zeros(): # Pole and zero at the origin => should get (nan + nanj) np.testing.assert_equal( sys_ss(0, warn_infinite=False), complex(np.nan, np.nan)) @@ -464,7 +465,7 @@ def test_dcgain_consistency(): sys_ss(0j, warn_infinite=False), complex(np.nan, np.nan)) np.testing.assert_equal( sys_ss.dcgain(), np.nan) - elif 0 in sys_ss.pole(): + elif 0 in sys_ss.poles(): # Pole at the origin, but zero elsewhere => should get (inf + nanj) np.testing.assert_equal( sys_ss(0, warn_infinite=False), complex(np.inf, np.nan)) @@ -479,11 +480,11 @@ def test_dcgain_consistency(): # Pole with non-zero, complex numerator => inf + infj s = ctrl.tf('s') sys_tf = (s + 1) / (s**2 + 1) - assert 1j in sys_tf.pole() + assert 1j in sys_tf.poles() # Set up state space system with pole on imaginary axis sys_ss = ctrl.tf2ss(sys_tf) - assert 1j in sys_tf.pole() + assert 1j in sys_tf.poles() # Make sure we get correct response if evaluated at the pole np.testing.assert_equal( diff --git a/control/tests/interconnect_test.py b/control/tests/interconnect_test.py index c927bf0f6..3b99adc6e 100644 --- a/control/tests/interconnect_test.py +++ b/control/tests/interconnect_test.py @@ -188,19 +188,19 @@ def test_interconnect_exceptions(): # Unrecognized arguments # LinearIOSystem - with pytest.raises(TypeError, match="unknown parameter"): + with pytest.raises(TypeError, match="unrecognized keyword"): P = ct.LinearIOSystem(ct.rss(2, 1, 1), output_name='y') # Interconnect - with pytest.raises(TypeError, match="unknown parameter"): + with pytest.raises(TypeError, match="unrecognized keyword"): T = ct.interconnect((P, C, sumblk), input_name='r', output='y') # Interconnected system - with pytest.raises(TypeError, match="unknown parameter"): + with pytest.raises(TypeError, match="unrecognized keyword"): T = ct.InterconnectedSystem((P, C, sumblk), input_name='r', output='y') # NonlinearIOSytem - with pytest.raises(TypeError, match="unknown parameter"): + with pytest.raises(TypeError, match="unrecognized keyword"): nlios = ct.NonlinearIOSystem( None, lambda t, x, u, params: u*u, input_count=1, output_count=1) @@ -208,5 +208,25 @@ def test_interconnect_exceptions(): with pytest.raises(TypeError, match="input specification is required"): sumblk = ct.summing_junction() - with pytest.raises(TypeError, match="unknown parameter"): + with pytest.raises(TypeError, match="unrecognized keyword"): sumblk = ct.summing_junction(input_count=2, output_count=2) + + +def test_string_inputoutput(): + # regression test for gh-692 + P1 = ct.rss(2, 1, 1) + P1_iosys = ct.LinearIOSystem(P1, inputs='u1', outputs='y1') + P2 = ct.rss(2, 1, 1) + P2_iosys = ct.LinearIOSystem(P2, inputs='y1', outputs='y2') + + P_s1 = ct.interconnect([P1_iosys, P2_iosys], inputs='u1', outputs=['y2']) + assert P_s1.input_index == {'u1' : 0} + + P_s2 = ct.interconnect([P1_iosys, P2_iosys], input='u1', outputs=['y2']) + assert P_s2.input_index == {'u1' : 0} + + P_s1 = ct.interconnect([P1_iosys, P2_iosys], inputs=['u1'], outputs='y2') + assert P_s1.output_index == {'y2' : 0} + + P_s2 = ct.interconnect([P1_iosys, P2_iosys], inputs=['u1'], output='y2') + assert P_s2.output_index == {'y2' : 0} diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 5fd83e946..ecb30c316 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -130,7 +130,8 @@ def test_iosys_print(self, tsys, capsys): print(ios_linearized) @noscipy0 - def test_nonlinear_iosys(self, tsys): + @pytest.mark.parametrize("ss", [ios.NonlinearIOSystem, ct.ss]) + def test_nonlinear_iosys(self, tsys, ss): # Create a simple nonlinear I/O system nlsys = ios.NonlinearIOSystem(predprey) T = tsys.T @@ -239,9 +240,9 @@ def test_linearize_named_signals(self, kincar): def test_connect(self, tsys): # Define a couple of (linear) systems to interconnection linsys1 = tsys.siso_linsys - iosys1 = ios.LinearIOSystem(linsys1) + iosys1 = ios.LinearIOSystem(linsys1, name='iosys1') linsys2 = tsys.siso_linsys - iosys2 = ios.LinearIOSystem(linsys2) + iosys2 = ios.LinearIOSystem(linsys2, name='iosys2') # Connect systems in different ways and compare to StateSpace linsys_series = linsys2 * linsys1 @@ -407,8 +408,8 @@ def test_algebraic_loop(self, tsys): lnios = ios.LinearIOSystem(linsys) nlios = ios.NonlinearIOSystem(None, \ lambda t, x, u, params: u*u, inputs=1, outputs=1) - nlios1 = nlios.copy() - nlios2 = nlios.copy() + nlios1 = nlios.copy(name='nlios1') + nlios2 = nlios.copy(name='nlios2') # Set up parameters for simulation T, U, X0 = tsys.T, tsys.U, tsys.X0 @@ -473,8 +474,8 @@ def test_algebraic_loop(self, tsys): def test_summer(self, tsys): # Construct a MIMO system for testing linsys = tsys.mimo_linsys1 - linio1 = ios.LinearIOSystem(linsys) - linio2 = ios.LinearIOSystem(linsys) + linio1 = ios.LinearIOSystem(linsys, name='linio1') + linio2 = ios.LinearIOSystem(linsys, name='linio2') linsys_parallel = linsys + linsys iosys_parallel = linio1 + linio2 @@ -731,6 +732,32 @@ def test_discrete(self, tsys): np.testing.assert_allclose(ios_t, lin_t,atol=0.002,rtol=0.) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) + def test_discrete_iosys(self, tsys): + """Create a discrete time system from scratch""" + linsys = ct.StateSpace( + [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[0]], True) + + # Create nonlinear version of the same system + def nlsys_update(t, x, u, params): + A, B = params['A'], params['B'] + return A @ x + B @ u + def nlsys_output(t, x, u, params): + C = params['C'] + return C @ x + nlsys = ct.NonlinearIOSystem( + nlsys_update, nlsys_output, inputs=1, outputs=1, states=2, dt=True) + + # Set up parameters for simulation + T, U, X0 = tsys.T, tsys.U, tsys.X0 + + # Simulate and compare to LTI output + ios_t, ios_y = ios.input_output_response( + nlsys, T, U, X0, + params={'A': linsys.A, 'B': linsys.B, 'C': linsys.C}) + lin_t, lin_y = ct.forced_response(linsys, T, U, X0) + np.testing.assert_allclose(ios_t, lin_t,atol=0.002,rtol=0.) + np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) + def test_find_eqpts(self, tsys): """Test find_eqpt function""" # Simple equilibrium point with no inputs @@ -1016,8 +1043,12 @@ def test_sys_naming_convention(self, tsys): ct.config.use_legacy_defaults('0.8.4') # changed delims in 0.9.0 ct.config.use_numpy_matrix(False) # np.matrix deprecated - ct.InputOutputSystem._idCounter = 0 - sys = ct.LinearIOSystem(tsys.mimo_linsys1) + + # Create a system with a known ID + ct.namedio.NamedIOSystem._idCounter = 0 + sys = ct.ss( + tsys.mimo_linsys1.A, tsys.mimo_linsys1.B, + tsys.mimo_linsys1.C, tsys.mimo_linsys1.D) assert sys.name == "sys[0]" assert sys.copy().name == "copy of sys[0]" @@ -1067,7 +1098,7 @@ def test_sys_naming_convention(self, tsys): # Same system conflict with pytest.warns(UserWarning): - unnamedsys1 * unnamedsys1 + namedsys * namedsys @pytest.mark.usefixtures("editsdefaults") def test_signals_naming_convention_0_8_4(self, tsys): @@ -1080,8 +1111,13 @@ def test_signals_naming_convention_0_8_4(self, tsys): ct.config.use_legacy_defaults('0.8.4') # changed delims in 0.9.0 ct.config.use_numpy_matrix(False) # np.matrix deprecated - ct.InputOutputSystem._idCounter = 0 - sys = ct.LinearIOSystem(tsys.mimo_linsys1) + + # Create a system with a known ID + ct.namedio.NamedIOSystem._idCounter = 0 + sys = ct.ss( + tsys.mimo_linsys1.A, tsys.mimo_linsys1.B, + tsys.mimo_linsys1.C, tsys.mimo_linsys1.D) + for statename in ["x[0]", "x[1]"]: assert statename in sys.state_index for inputname in ["u[0]", "u[1]"]: @@ -1128,9 +1164,9 @@ def test_signals_naming_convention_0_8_4(self, tsys): # Same system conflict with pytest.warns(UserWarning): - same_name_series = unnamedsys * unnamedsys - assert "sys[1].x[0]" in same_name_series.state_index - assert "copy of sys[1].x[0]" in same_name_series.state_index + same_name_series = namedsys * namedsys + assert "namedsys.x0" in same_name_series.state_index + assert "copy of namedsys.x0" in same_name_series.state_index def test_named_signals_linearize_inconsistent(self, tsys): """Mare sure that providing inputs or outputs not consistent with @@ -1180,13 +1216,13 @@ def outfcn(t, x, u, params): def test_lineariosys_statespace(self, tsys): """Make sure that a LinearIOSystem is also a StateSpace object""" - iosys_siso = ct.LinearIOSystem(tsys.siso_linsys) - iosys_siso2 = ct.LinearIOSystem(tsys.siso_linsys) + iosys_siso = ct.LinearIOSystem(tsys.siso_linsys, name='siso') + iosys_siso2 = ct.LinearIOSystem(tsys.siso_linsys, name='siso2') assert isinstance(iosys_siso, ct.StateSpace) # Make sure that state space functions work for LinearIOSystems np.testing.assert_allclose( - iosys_siso.pole(), tsys.siso_linsys.pole()) + iosys_siso.poles(), tsys.siso_linsys.poles()) omega = np.logspace(.1, 10, 100) mag_io, phase_io, omega_io = iosys_siso.frequency_response(omega) mag_ss, phase_ss, omega_ss = tsys.siso_linsys.frequency_response(omega) @@ -1364,7 +1400,7 @@ def test_duplicates(self, tsys): name="sys") # Duplicate objects - with pytest.warns(UserWarning, match="Duplicate object"): + with pytest.warns(UserWarning, match="duplicate object"): ios_series = nlios * nlios # Nonduplicate objects @@ -1372,7 +1408,7 @@ def test_duplicates(self, tsys): ct.config.use_numpy_matrix(False) # np.matrix deprecated nlios1 = nlios.copy() nlios2 = nlios.copy() - with pytest.warns(UserWarning, match="Duplicate name"): + with pytest.warns(UserWarning, match="duplicate name"): ios_series = nlios1 * nlios2 assert "copy of sys_1.x[0]" in ios_series.state_index.keys() assert "copy of sys.x[0]" in ios_series.state_index.keys() @@ -1386,7 +1422,7 @@ def test_duplicates(self, tsys): lambda t, x, u, params: u * u, inputs=1, outputs=1, name="sys") - with pytest.warns(UserWarning, match="Duplicate name"): + with pytest.warns(UserWarning, match="duplicate name"): ct.InterconnectedSystem([nlios1, iosys_siso, nlios2], inputs=0, outputs=0, states=0) @@ -1526,7 +1562,6 @@ def secord_update(t, x, u, params={}): """Second order system dynamics""" omega0 = params.get('omega0', 1.) zeta = params.get('zeta', 0.5) - u = np.array(u, ndmin=1) return np.array([ x[1], -2 * zeta * omega0 * x[1] - omega0*omega0 * x[0] + u[0] @@ -1555,7 +1590,8 @@ def test_interconnect_unused_input(): outputs=['u'], name='k') - with pytest.warns(UserWarning, match=r"Unused input\(s\) in InterconnectedSystem"): + with pytest.warns( + UserWarning, match=r"Unused input\(s\) in InterconnectedSystem"): h = ct.interconnect([g,s,k], inputs=['r'], outputs=['y']) @@ -1586,13 +1622,19 @@ def test_interconnect_unused_input(): # warn if explicity ignored input in fact used - with pytest.warns(UserWarning, match=r"Input\(s\) specified as ignored is \(are\) used:") as record: + with pytest.warns( + UserWarning, + match=r"Input\(s\) specified as ignored is \(are\) used:") \ + as record: h = ct.interconnect([g,s,k], inputs=['r'], outputs=['y'], ignore_inputs=['u','n']) - with pytest.warns(UserWarning, match=r"Input\(s\) specified as ignored is \(are\) used:") as record: + with pytest.warns( + UserWarning, + match=r"Input\(s\) specified as ignored is \(are\) used:") \ + as record: h = ct.interconnect([g,s,k], inputs=['r'], outputs=['y'], @@ -1623,7 +1665,9 @@ def test_interconnect_unused_output(): outputs=['u'], name='k') - with pytest.warns(UserWarning, match=r"Unused output\(s\) in InterconnectedSystem:") as record: + with pytest.warns( + UserWarning, + match=r"Unused output\(s\) in InterconnectedSystem:") as record: h = ct.interconnect([g,s,k], inputs=['r'], outputs=['y']) @@ -1654,13 +1698,17 @@ def test_interconnect_unused_output(): pytest.fail(f'Unexpected warning: {r.message}') # warn if explicity ignored output in fact used - with pytest.warns(UserWarning, match=r"Output\(s\) specified as ignored is \(are\) used:"): + with pytest.warns( + UserWarning, + match=r"Output\(s\) specified as ignored is \(are\) used:"): h = ct.interconnect([g,s,k], inputs=['r'], outputs=['y'], ignore_outputs=['dy','u']) - with pytest.warns(UserWarning, match=r"Output\(s\) specified as ignored is \(are\) used:"): + with pytest.warns( + UserWarning, + match=r"Output\(s\) specified as ignored is \(are\) used:"): h = ct.interconnect([g,s,k], inputs=['r'], outputs=['y'], @@ -1672,3 +1720,142 @@ def test_interconnect_unused_output(): inputs=['r'], outputs=['y'], ignore_outputs=['v']) + + +def test_input_output_broadcasting(): + # Create a system, time vector, and noisy input + sys = ct.rss(6, 2, 3) + T = np.linspace(0, 10, 10) + U = np.zeros((sys.ninputs, T.size)) + U[0, :] = np.sin(T) + U[1, :] = np.zeros_like(U[1, :]) + U[2, :] = np.ones_like(U[2, :]) + X0 = np.array([1, 2]) + P0 = np.array([[3.11, 3.12], [3.21, 3.3]]) + + # Simulate the system with nominal input to establish baseline + resp_base = ct.input_output_response( + sys, T, U, np.hstack([X0, P0.reshape(-1)])) + + # Split up the inputs into two pieces + resp_inp1 = ct.input_output_response(sys, T, [U[:1], U[1:]], [X0, P0]) + np.testing.assert_equal(resp_base.states, resp_inp1.states) + + # Specify two of the inputs as constants + resp_inp2 = ct.input_output_response(sys, T, [U[0], 0, 1], [X0, P0]) + np.testing.assert_equal(resp_base.states, resp_inp2.states) + + # Specify two of the inputs as constant vector + resp_inp3 = ct.input_output_response(sys, T, [U[0], [0, 1]], [X0, P0]) + np.testing.assert_equal(resp_base.states, resp_inp3.states) + + # Specify only some of the initial conditions + resp_init = ct.input_output_response(sys, T, [U[0], [0, 1]], [X0, 0]) + resp_cov0 = ct.input_output_response(sys, T, U, [X0, P0 * 0]) + np.testing.assert_equal(resp_cov0.states, resp_init.states) + + # Specify only some of the initial conditions + with pytest.warns(UserWarning, match="initial state too short; padding"): + resp_short = ct.input_output_response(sys, T, [U[0], [0, 1]], [X0, 1]) + + # Make sure that inconsistent settings don't work + with pytest.raises(ValueError, match="inconsistent"): + resp_bad = ct.input_output_response( + sys, T, (U[0, :], U[:2, :-1]), [X0, P0]) + + +def test_nonuniform_timepts(): + """Test non-uniform time points for simulations""" + sys = ct.LinearIOSystem(ct.rss(2, 1, 1)) + + # Start with a uniform set of times + unifpts = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + uniform = [1, 2, 3, 2, 1, -1, -3, -5, -7, -3, 1] + t_unif, y_unif = ct.input_output_response(sys, unifpts, uniform) + + # Create a non-uniform set of inputs + noufpts = [0, 2, 4, 8, 10] + nonunif = [1, 3, 1, -7, 1] + t_nouf, y_nouf = ct.input_output_response(sys, noufpts, nonunif) + + # Make sure the outputs agree at common times + np.testing.assert_almost_equal(y_unif[noufpts], y_nouf, decimal=6) + + # Resimulate using a new set of evaluation points + t_even, y_even = ct.input_output_response( + sys, noufpts, nonunif, t_eval=unifpts) + np.testing.assert_almost_equal(y_unif, y_even, decimal=6) + + +def test_ss_nonlinear(): + """Test ss() for creating nonlinear systems""" + secord = ct.ss(secord_update, secord_output, inputs='u', outputs='y', + states = ['x1', 'x2'], name='secord') + assert secord.name == 'secord' + assert secord.input_labels == ['u'] + assert secord.output_labels == ['y'] + assert secord.state_labels == ['x1', 'x2'] + + # Make sure we get the same answer for simulations + T = np.linspace(0, 10, 100) + U = np.sin(T) + X0 = np.array([1, -1]) + secord_nlio = ct.NonlinearIOSystem( + secord_update, secord_output, inputs=1, outputs=1, states=2) + ss_response = ct.input_output_response(secord, T, U, X0) + io_response = ct.input_output_response(secord_nlio, T, U, X0) + np.testing.assert_almost_equal(ss_response.time, io_response.time) + np.testing.assert_almost_equal(ss_response.inputs, io_response.inputs) + np.testing.assert_almost_equal(ss_response.outputs, io_response.outputs) + + # Make sure that optional keywords are allowed + secord = ct.ss(secord_update, secord_output, dt=True) + assert ct.isdtime(secord) + + # Make sure that state space keywords are flagged + with pytest.raises(TypeError, match="unrecognized keyword"): + ct.ss(secord_update, remove_useless_states=True) + + +def test_rss(): + # Basic call, with no arguments + sys = ct.rss() + assert sys.ninputs == 1 + assert sys.noutputs == 1 + assert sys.nstates == 1 + assert sys.dt == 0 + assert np.all(np.real(sys.poles()) < 0) + + # Set the timebase explicitly + sys = ct.rss(inputs=2, outputs=3, states=4, dt=None, name='sys') + assert sys.name == 'sys' + assert sys.ninputs == 2 + assert sys.noutputs == 3 + assert sys.nstates == 4 + assert sys.dt == None + assert np.all(np.real(sys.poles()) < 0) + + # Discrete time + sys = ct.rss(inputs=['a', 'b'], outputs=1, states=1, dt=True) + assert sys.ninputs == 2 + assert sys.input_labels == ['a', 'b'] + assert sys.noutputs == 1 + assert sys.nstates == 1 + assert sys.dt == True + assert np.all(np.abs(sys.poles()) < 1) + + # Call drss directly + sys = ct.drss(inputs=['a', 'b'], outputs=1, states=1, dt=True) + assert sys.ninputs == 2 + assert sys.input_labels == ['a', 'b'] + assert sys.noutputs == 1 + assert sys.nstates == 1 + assert sys.dt == True + assert np.all(np.abs(sys.poles()) < 1) + + with pytest.raises(ValueError, match="continuous timebase"): + sys = ct.drss(2, 1, 1, dt=0) + + with pytest.warns(UserWarning, match="may be interpreted as continuous"): + sys = ct.drss(2, 1, 1, dt=None) + assert np.all(np.abs(sys.poles()) < 1) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py new file mode 100644 index 000000000..ada16a46a --- /dev/null +++ b/control/tests/kwargs_test.py @@ -0,0 +1,206 @@ +# kwargs_test.py - test for uncrecognized keywords +# RMM, 20 Mar 2022 +# +# Allowing unrecognized keywords to be passed to a function without +# generating and error message can generate annoying bugs, since you +# sometimes think you are telling the function to do something and actually +# you have a misspelling or other error and your input is being ignored. +# +# This unit test looks through all functions in the package for any that +# allow kwargs as part of the function signature and makes sure that there +# is a unit test that checks for unrecognized keywords. + +import inspect +import pytest +import warnings +import matplotlib.pyplot as plt + +import control +import control.flatsys + +# List of all of the test modules where kwarg unit tests are defined +import control.tests.flatsys_test as flatsys_test +import control.tests.frd_test as frd_test +import control.tests.interconnect_test as interconnect_test +import control.tests.statefbk_test as statefbk_test +import control.tests.trdata_test as trdata_test + + +@pytest.mark.parametrize("module, prefix", [ + (control, ""), (control.flatsys, "flatsys.") +]) +def test_kwarg_search(module, prefix): + # Look through every object in the package + for name, obj in inspect.getmembers(module): + # Skip anything that is outside of this module + if inspect.getmodule(obj) is not None and \ + not inspect.getmodule(obj).__name__.startswith('control'): + # Skip anything that isn't part of the control package + continue + + # Only look for functions with keyword arguments + if not inspect.isfunction(obj): + continue + + # Get the signature for the function + sig = inspect.signature(obj) + + # Skip anything that is inherited + if inspect.isclass(module) and obj.__name__ not in module.__dict__: + continue + + # See if there is a variable keyword argument + for argname, par in sig.parameters.items(): + if not par.kind == inspect.Parameter.VAR_KEYWORD: + continue + + # Make sure there is a unit test defined + assert prefix + name in kwarg_unittest + + # Make sure there is a unit test + if not hasattr(kwarg_unittest[prefix + name], '__call__'): + warnings.warn("No unit test defined for '%s'" % prefix + name) + source = None + else: + source = inspect.getsource(kwarg_unittest[prefix + name]) + + # Make sure the unit test looks for unrecognized keyword + if source and source.find('unrecognized keyword') < 0: + warnings.warn( + f"'unrecognized keyword' not found in unit test " + f"for {name}") + + # Look for classes and then check member functions + if inspect.isclass(obj): + test_kwarg_search(obj, prefix + obj.__name__ + '.') + + +@pytest.mark.usefixtures('editsdefaults') +def test_unrecognized_kwargs(): + # Create a SISO system for use in parameterized tests + sys = control.ss([[-1, 1], [0, -1]], [[0], [1]], [[1, 0]], 0, dt=None) + + table = [ + [control.dlqe, (sys, [[1]], [[1]]), {}], + [control.dlqr, (sys, [[1, 0], [0, 1]], [[1]]), {}], + [control.drss, (2, 1, 1), {}], + [control.input_output_response, (sys, [0, 1, 2], [1, 1, 1]), {}], + [control.lqe, (sys, [[1]], [[1]]), {}], + [control.lqr, (sys, [[1, 0], [0, 1]], [[1]]), {}], + [control.linearize, (sys, 0, 0), {}], + [control.pzmap, (sys,), {}], + [control.rlocus, (control.tf([1], [1, 1]), ), {}], + [control.root_locus, (control.tf([1], [1, 1]), ), {}], + [control.rss, (2, 1, 1), {}], + [control.set_defaults, ('control',), {'default_dt': True}], + [control.ss, (0, 0, 0, 0), {'dt': 1}], + [control.ss2io, (sys,), {}], + [control.ss2tf, (sys,), {}], + [control.summing_junction, (2,), {}], + [control.tf, ([1], [1, 1]), {}], + [control.tf2io, (control.tf([1], [1, 1]),), {}], + [control.tf2ss, (control.tf([1], [1, 1]),), {}], + [control.InputOutputSystem, (), + {'inputs': 1, 'outputs': 1, 'states': 1}], + [control.InputOutputSystem.linearize, (sys, 0, 0), {}], + [control.StateSpace, ([[-1, 0], [0, -1]], [[1], [1]], [[1, 1]], 0), {}], + [control.TransferFunction, ([1], [1, 1]), {}], + ] + + for function, args, kwargs in table: + # Call the function normally and make sure it works + function(*args, **kwargs) + + # Now add an unrecognized keyword and make sure there is an error + with pytest.raises(TypeError, match="unrecognized keyword"): + function(*args, **kwargs, unknown=None) + + # If we opened any figures, close them to avoid matplotlib warnings + if plt.gca(): + plt.close('all') + + +def test_matplotlib_kwargs(): + # Create a SISO system for use in parameterized tests + sys = control.ss([[-1, 1], [0, -1]], [[0], [1]], [[1, 0]], 0, dt=None) + ctl = control.ss([[-1, 1], [0, -1]], [[0], [1]], [[1, 0]], 0, dt=None) + + table = [ + [control.bode, (sys, ), {}], + [control.bode_plot, (sys, ), {}], + [control.describing_function_plot, + (sys, control.descfcn.saturation_nonlinearity(1), [1, 2, 3, 4]), {}], + [control.gangof4, (sys, ctl), {}], + [control.gangof4_plot, (sys, ctl), {}], + [control.nyquist, (sys, ), {}], + [control.nyquist_plot, (sys, ), {}], + [control.singular_values_plot, (sys, ), {}], + ] + + for function, args, kwargs in table: + # Call the function normally and make sure it works + function(*args, **kwargs) + + # Now add an unrecognized keyword and make sure there is an error + with pytest.raises(AttributeError, match="has no property"): + function(*args, **kwargs, unknown=None) + + # If we opened any figures, close them to avoid matplotlib warnings + if plt.gca(): + plt.close('all') + + +# +# List of all unit tests that check for unrecognized keywords +# +# Every function that accepts variable keyword arguments (**kwargs) should +# have an entry in this table, to make sure that nothing is missing. This +# will also force people who add new functions to put in an appropriate unit +# test. +# + +kwarg_unittest = { + 'bode': test_matplotlib_kwargs, + 'bode_plot': test_matplotlib_kwargs, + 'describing_function_plot': test_matplotlib_kwargs, + 'dlqe': test_unrecognized_kwargs, + 'dlqr': test_unrecognized_kwargs, + 'drss': test_unrecognized_kwargs, + 'gangof4': test_matplotlib_kwargs, + 'gangof4_plot': test_matplotlib_kwargs, + 'input_output_response': test_unrecognized_kwargs, + 'interconnect': interconnect_test.test_interconnect_exceptions, + 'linearize': test_unrecognized_kwargs, + 'lqe': test_unrecognized_kwargs, + 'lqr': test_unrecognized_kwargs, + 'nyquist': test_matplotlib_kwargs, + 'nyquist_plot': test_matplotlib_kwargs, + 'pzmap': test_unrecognized_kwargs, + 'rlocus': test_unrecognized_kwargs, + 'root_locus': test_unrecognized_kwargs, + 'rss': test_unrecognized_kwargs, + 'set_defaults': test_unrecognized_kwargs, + 'singular_values_plot': test_matplotlib_kwargs, + 'ss': test_unrecognized_kwargs, + 'ss2io': test_unrecognized_kwargs, + 'ss2tf': test_unrecognized_kwargs, + 'summing_junction': interconnect_test.test_interconnect_exceptions, + 'tf': test_unrecognized_kwargs, + 'tf2io' : test_unrecognized_kwargs, + 'tf2ss' : test_unrecognized_kwargs, + 'flatsys.point_to_point': + flatsys_test.TestFlatSys.test_point_to_point_errors, + 'FrequencyResponseData.__init__': + frd_test.TestFRD.test_unrecognized_keyword, + 'InputOutputSystem.__init__': test_unrecognized_kwargs, + 'InputOutputSystem.linearize': test_unrecognized_kwargs, + 'InterconnectedSystem.__init__': + interconnect_test.test_interconnect_exceptions, + 'LinearIOSystem.__init__': + interconnect_test.test_interconnect_exceptions, + 'NonlinearIOSystem.__init__': + interconnect_test.test_interconnect_exceptions, + 'StateSpace.__init__': test_unrecognized_kwargs, + 'TimeResponseData.__call__': trdata_test.test_response_copy, + 'TransferFunction.__init__': test_unrecognized_kwargs, +} diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index e2f7f2e03..8e45ea482 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -5,23 +5,44 @@ from .conftest import editsdefaults import control as ct -from control import c2d, tf, tf2ss, NonlinearIOSystem -from control.lti import (LTI, common_timebase, evalfr, damp, dcgain, isctime, - isdtime, issiso, pole, timebaseEqual, zero) +from control import c2d, tf, ss, tf2ss, NonlinearIOSystem +from control.lti import LTI, evalfr, damp, dcgain, zeros, poles +from control import common_timebase, isctime, isdtime, issiso, timebaseEqual from control.tests.conftest import slycotonly from control.exception import slycot_check class TestLTI: + @pytest.mark.parametrize("fun, args", [ + [tf, (126, [-1, 42])], + [ss, ([[42]], [[1]], [[1]], 0)] + ]) + def test_poles(self, fun, args): + sys = fun(*args) + np.testing.assert_allclose(sys.poles(), 42) + np.testing.assert_allclose(poles(sys), 42) + + with pytest.warns(PendingDeprecationWarning): + pole_list = sys.pole() + assert pole_list == sys.poles() + + with pytest.warns(PendingDeprecationWarning): + pole_list = ct.pole(sys) + assert pole_list == sys.poles() + + @pytest.mark.parametrize("fun, args", [ + [tf, (126, [-1, 42])], + [ss, ([[42]], [[1]], [[1]], 0)] + ]) + def test_zero(self, fun, args): + sys = fun(*args) + np.testing.assert_allclose(sys.zeros(), 42) + np.testing.assert_allclose(zeros(sys), 42) - def test_pole(self): - sys = tf(126, [-1, 42]) - np.testing.assert_allclose(sys.pole(), 42) - np.testing.assert_allclose(pole(sys), 42) + with pytest.warns(PendingDeprecationWarning): + sys.zero() - def test_zero(self): - sys = tf([-1, 42], [1, 10]) - np.testing.assert_allclose(sys.zero(), 42) - np.testing.assert_allclose(zero(sys), 42) + with pytest.warns(PendingDeprecationWarning): + ct.zero(sys) def test_issiso(self): assert issiso(1) @@ -267,7 +288,7 @@ def test_squeeze_exceptions(self, fcn): sys = fcn(ct.rss(2, 1, 1)) with pytest.raises(ValueError, match="unknown squeeze value"): - sys.frequency_response([1], squeeze=1) + resp = sys.frequency_response([1], squeeze='siso') with pytest.raises(ValueError, match="unknown squeeze value"): sys([1j], squeeze='siso') with pytest.raises(ValueError, match="unknown squeeze value"): diff --git a/control/tests/minreal_test.py b/control/tests/minreal_test.py index 466f9384d..10c56d4ca 100644 --- a/control/tests/minreal_test.py +++ b/control/tests/minreal_test.py @@ -7,7 +7,7 @@ from scipy.linalg import eigvals import pytest -from control import rss, ss, zero +from control import rss, ss, zeros from control.statesp import StateSpace from control.xferfcn import TransferFunction from itertools import permutations @@ -64,8 +64,8 @@ def testMinrealBrute(self): # Check that the zeros match # Note: sorting doesn't work => have to do the hard way - z1 = zero(s1) - z2 = zero(s2) + z1 = zeros(s1) + z2 = zeros(s2) # Start by making sure we have the same # of zeros assert len(z1) == len(z2) diff --git a/control/tests/namedio_test.py b/control/tests/namedio_test.py new file mode 100644 index 000000000..3a96203a8 --- /dev/null +++ b/control/tests/namedio_test.py @@ -0,0 +1,253 @@ +"""namedio_test.py - test named input/output object operations + +RMM, 13 Mar 2022 + +This test suite checks to make sure that named input/output class +operations are working. It doesn't do exhaustive testing of +operations on input/output objects. Separate unit tests should be +created for that purpose. +""" + +import re +from copy import copy + +import numpy as np +import control as ct +import pytest + + +def test_named_ss(): + # Create a system to play with + sys = ct.rss(2, 2, 2) + assert sys.input_labels == ['u[0]', 'u[1]'] + assert sys.output_labels == ['y[0]', 'y[1]'] + assert sys.state_labels == ['x[0]', 'x[1]'] + + # Get the state matrices for later use + A, B, C, D = sys.A, sys.B, sys.C, sys.D + + # Set up a named state space systems with default names + ct.namedio.NamedIOSystem._idCounter = 0 + sys = ct.ss(A, B, C, D) + assert sys.name == 'sys[0]' + assert sys.input_labels == ['u[0]', 'u[1]'] + assert sys.output_labels == ['y[0]', 'y[1]'] + assert sys.state_labels == ['x[0]', 'x[1]'] + assert repr(sys) == \ + "['y[0]', 'y[1]']>" + + # Pass the names as arguments + sys = ct.ss( + A, B, C, D, name='system', + inputs=['u1', 'u2'], outputs=['y1', 'y2'], states=['x1', 'x2']) + assert sys.name == 'system' + assert ct.namedio.NamedIOSystem._idCounter == 1 + assert sys.input_labels == ['u1', 'u2'] + assert sys.output_labels == ['y1', 'y2'] + assert sys.state_labels == ['x1', 'x2'] + assert repr(sys) == \ + "['y1', 'y2']>" + + # Do the same with rss + sys = ct.rss(['x1', 'x2', 'x3'], ['y1', 'y2'], 'u1', name='random') + assert sys.name == 'random' + assert ct.namedio.NamedIOSystem._idCounter == 1 + assert sys.input_labels == ['u1'] + assert sys.output_labels == ['y1', 'y2'] + assert sys.state_labels == ['x1', 'x2', 'x3'] + assert repr(sys) == \ + "['y1', 'y2']>" + + +# List of classes that are expected +fun_instance = { + ct.rss: (ct.InputOutputSystem, ct.LinearIOSystem, ct.StateSpace), + ct.drss: (ct.InputOutputSystem, ct.LinearIOSystem, ct.StateSpace), + ct.FRD: (ct.lti.LTI), + ct.NonlinearIOSystem: (ct.InputOutputSystem), + ct.ss: (ct.InputOutputSystem, ct.LinearIOSystem, ct.StateSpace), + ct.StateSpace: (ct.StateSpace), + ct.tf: (ct.TransferFunction), + ct.TransferFunction: (ct.TransferFunction), +} + +# List of classes that are not expected +fun_notinstance = { + ct.FRD: (ct.InputOutputSystem, ct.LinearIOSystem, ct.StateSpace), + ct.StateSpace: (ct.InputOutputSystem, ct.TransferFunction), + ct.TransferFunction: (ct.InputOutputSystem, ct.StateSpace), +} + + +@pytest.mark.parametrize("fun, args, kwargs", [ + [ct.rss, (4, 1, 1), {}], + [ct.rss, (3, 2, 1), {}], + [ct.drss, (4, 1, 1), {}], + [ct.drss, (3, 2, 1), {}], + [ct.FRD, ([1, 2, 3,], [1, 2, 3]), {}], + [ct.NonlinearIOSystem, + (lambda t, x, u, params: -x, None), + {'inputs': 2, 'outputs':2, 'states':2}], + [ct.ss, ([[1, 2], [3, 4]], [[0], [1]], [[1, 0]], 0), {}], + [ct.StateSpace, ([[1, 2], [3, 4]], [[0], [1]], [[1, 0]], 0), {}], + [ct.tf, ([1, 2], [3, 4, 5]), {}], + [ct.TransferFunction, ([1, 2], [3, 4, 5]), {}], +]) +def test_io_naming(fun, args, kwargs): + # Reset the ID counter to get uniform generic names + ct.namedio.NamedIOSystem._idCounter = 0 + + # Create the system w/out any names + sys_g = fun(*args, **kwargs) + + # Make sure the class are what we expect + if fun in fun_instance: + assert isinstance(sys_g, fun_instance[fun]) + + if fun in fun_notinstance: + assert not isinstance(sys_g, fun_notinstance[fun]) + + # Make sure the names make sense + assert sys_g.name == 'sys[0]' + assert sys_g.input_labels == [f'u[{i}]' for i in range(sys_g.ninputs)] + assert sys_g.output_labels == [f'y[{i}]' for i in range(sys_g.noutputs)] + if sys_g.nstates: + assert sys_g.state_labels == [f'x[{i}]' for i in range(sys_g.nstates)] + + # + # Reset the names to something else and make sure they stick + # + sys_r = copy(sys_g) + + input_labels = [f'u{i}' for i in range(sys_g.ninputs)] + sys_r.set_inputs(input_labels) + assert sys_r.input_labels == input_labels + + output_labels = [f'y{i}' for i in range(sys_g.noutputs)] + sys_r.set_outputs(output_labels) + assert sys_r.output_labels == output_labels + + if sys_g.nstates: + state_labels = [f'x{i}' for i in range(sys_g.nstates)] + sys_r.set_states(state_labels) + assert sys_r.state_labels == state_labels + + # + # Set names using keywords and make sure they stick + # + + # How the keywords are used depends on the type of system + if fun in (ct.rss, ct.drss): + # Pass the labels instead of the numbers + sys_k = fun(state_labels, output_labels, input_labels, name='mysys') + + elif sys_g.nstates is None: + # Don't pass state labels + sys_k = fun( + *args, inputs=input_labels, outputs=output_labels, name='mysys') + + else: + sys_k = fun( + *args, inputs=input_labels, outputs=output_labels, + states=state_labels, name='mysys') + + assert sys_k.name == 'mysys' + assert sys_k.input_labels == input_labels + assert sys_k.output_labels == output_labels + if sys_g.nstates: + assert sys_k.state_labels == state_labels + + # + # Convert the system to state space and make sure labels transfer + # + if ct.slycot_check() and not isinstance( + sys_r, (ct.FrequencyResponseData, ct.NonlinearIOSystem)): + sys_ss = ct.ss(sys_r) + assert sys_ss != sys_r + assert sys_ss.input_labels == input_labels + assert sys_ss.output_labels == output_labels + + # Reassign system and signal names + sys_ss = ct.ss( + sys_g, inputs=input_labels, outputs=output_labels, name='new') + assert sys_ss.name == 'new' + assert sys_ss.input_labels == input_labels + assert sys_ss.output_labels == output_labels + + # + # Convert the system to a transfer function and make sure labels transfer + # + if not isinstance( + sys_r, (ct.FrequencyResponseData, ct.NonlinearIOSystem)) and \ + ct.slycot_check(): + sys_tf = ct.tf(sys_r) + assert sys_tf != sys_r + assert sys_tf.input_labels == input_labels + assert sys_tf.output_labels == output_labels + + # Reassign system and signal names + sys_tf = ct.tf( + sys_g, inputs=input_labels, outputs=output_labels, name='new') + assert sys_tf.name == 'new' + assert sys_tf.input_labels == input_labels + assert sys_tf.output_labels == output_labels + + +# Internal testing of StateSpace initialization +def test_init_namedif(): + # Set up the initial system + sys = ct.rss(2, 1, 1) + + # Rename the system, inputs, and outouts + sys_new = sys.copy() + ct.StateSpace.__init__( + sys_new, sys, inputs='u', outputs='y', name='new') + assert sys_new.name == 'new' + assert sys_new.input_labels == ['u'] + assert sys_new.output_labels == ['y'] + + # Call constructor without re-initialization + sys_keep = sys.copy() + ct.StateSpace.__init__(sys_keep, sys, init_namedio=False) + assert sys_keep.name == sys_keep.name + assert sys_keep.input_labels == sys_keep.input_labels + assert sys_keep.output_labels == sys_keep.output_labels + + # Make sure that passing an unrecognized keyword generates an error + with pytest.raises(TypeError, match="unrecognized keyword"): + ct.StateSpace.__init__( + sys_keep, sys, inputs='u', outputs='y', init_namedio=False) + +# Test state space conversion +def test_convert_to_statespace(): + # Set up the initial system + sys = ct.tf(ct.rss(2, 1, 1)) + + # Make sure we can rename system name, inputs, outputs + sys_new = ct.ss(sys, inputs='u', outputs='y', name='new') + assert sys_new.name == 'new' + assert sys_new.input_labels == ['u'] + assert sys_new.output_labels == ['y'] + + # Try specifying the state names (via low level test) + with pytest.warns(UserWarning, match="non-unique state space realization"): + sys_new = ct.ss(sys, inputs='u', outputs='y', states=['x1', 'x2']) + assert sys_new.input_labels == ['u'] + assert sys_new.output_labels == ['y'] + assert sys_new.state_labels == ['x1', 'x2'] + + +# Duplicate name warnings +def test_duplicate_sysname(): + # Start with an unnamed system + sys = ct.rss(4, 1, 1) + + # No warnings should be generated if we reuse an an unnamed system + with pytest.warns(None) as record: + res = sys * sys + assert not any([type(msg) == UserWarning for msg in record]) + + # Generate a warning if the system is named + sys = ct.rss(4, 1, 1, name='sys') + with pytest.warns(UserWarning, match="duplicate object found"): + res = sys * sys diff --git a/control/tests/nichols_test.py b/control/tests/nichols_test.py index 4cdfcaa65..90ea74cf7 100644 --- a/control/tests/nichols_test.py +++ b/control/tests/nichols_test.py @@ -3,9 +3,11 @@ RMM, 31 Mar 2011 """ +import matplotlib.pyplot as plt + import pytest -from control import StateSpace, nichols_plot, nichols +from control import StateSpace, nichols_plot, nichols, nichols_grid, pade, tf @pytest.fixture() @@ -26,3 +28,68 @@ def test_nichols(tsys, mplcleanup): def test_nichols_alias(tsys, mplcleanup): """Test the control.nichols alias and the grid=False parameter""" nichols(tsys, grid=False) + + +@pytest.mark.usefixtures("mplcleanup") +class TestNicholsGrid: + def test_ax(self): + # check grid is plotted into gca, or specified axis + fig, axs = plt.subplots(2,2) + plt.sca(axs[0,1]) + + cl_mag_lines = nichols_grid()[1] + assert cl_mag_lines[0].axes is axs[0, 1] + + cl_mag_lines = nichols_grid(ax=axs[1,1])[1] + assert cl_mag_lines[0].axes is axs[1, 1] + # nichols_grid didn't change what the "current axes" are + assert plt.gca() is axs[0, 1] + + + def test_cl_phase_label_control(self): + # test label_cl_phases argument + cl_mag_lines, cl_phase_lines, cl_mag_labels, cl_phase_labels \ + = nichols_grid() + assert len(cl_phase_labels) > 0 + + cl_mag_lines, cl_phase_lines, cl_mag_labels, cl_phase_labels \ + = nichols_grid(label_cl_phases=False) + assert len(cl_phase_labels) == 0 + + + def test_labels_clipped(self): + # regression test: check that contour labels are clipped + mcontours, ncontours, mlabels, nlabels = nichols_grid() + assert all(ml.get_clip_on() for ml in mlabels) + assert all(nl.get_clip_on() for nl in nlabels) + + + def test_minimal_phase(self): + # regression test: phase extent is minimal + g = tf([1],[1,1]) * tf([1],[1/1, 2*0.1/1, 1]) + nichols(g) + ax = plt.gca() + assert ax.get_xlim()[1] <= 0 + + + def test_fixed_view(self): + # respect xlim, ylim set by user + g = (tf([1],[1/1, 2*0.01/1, 1]) + * tf([1],[1/100**2, 2*0.001/100, 1]) + * tf(*pade(0.01, 5))) + + # normally a broad axis + nichols(g) + + assert(plt.xlim()[0] == -1440) + assert(plt.ylim()[0] <= -240) + + nichols(g, grid=False) + + # zoom in + plt.axis([-360,0,-40,50]) + + # nichols_grid doesn't expand limits + nichols_grid() + assert(plt.xlim()[0] == -360) + assert(plt.ylim()[1] >= -40) diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py index 4667c6219..b1aa00577 100644 --- a/control/tests/nyquist_test.py +++ b/control/tests/nyquist_test.py @@ -19,11 +19,11 @@ # Utility function for counting unstable poles of open loop (P in FBS) def _P(sys, indent='right'): if indent == 'right': - return (sys.pole().real > 0).sum() + return (sys.poles().real > 0).sum() elif indent == 'left': - return (sys.pole().real >= 0).sum() + return (sys.poles().real >= 0).sum() elif indent == 'none': - if any(sys.pole().real == 0): + if any(sys.poles().real == 0): raise ValueError("indent must be left or right for imaginary pole") else: raise TypeError("unknown indent value") @@ -31,7 +31,7 @@ def _P(sys, indent='right'): # Utility function for counting unstable poles of closed loop (Z in FBS) def _Z(sys): - return (sys.feedback().pole().real >= 0).sum() + return (sys.feedback().poles().real >= 0).sum() # Basic tests @@ -41,6 +41,33 @@ def test_nyquist_basic(): N_sys = ct.nyquist_plot(sys) assert _Z(sys) == N_sys + _P(sys) + # Previously identified bug + # + # This example has an open loop pole at -0.06 and a closed loop pole at + # 0.06, so if you use an indent_radius of larger than 0.12, then the + # encirclements computed by nyquist_plot() will not properly predict + # stability. A new warning messages was added to catch this case. + # + A = np.array([ + [-3.56355873, -1.22980795, -1.5626527 , -0.4626829 , -0.16741484], + [-8.52361371, -3.60331459, -3.71574266, -0.43839201, 0.41893656], + [-2.50458726, -0.72361335, -1.77795489, -0.4038419 , 0.52451147], + [-0.281183 , 0.23391825, 0.19096003, -0.9771515 , 0.66975606], + [-3.04982852, -1.1091943 , -1.40027242, -0.1974623 , -0.78930791]]) + B = np.array([[-0.], [-1.42827213], [ 0.76806551], [-1.07987454], [0.]]) + C = np.array([[-0., 0.35557249, 0.35941791, -0., -1.42320969]]) + D = np.array([[0]]) + sys = ct.ss(A, B, C, D) + + # With a small indent_radius, all should be fine + N_sys = ct.nyquist_plot(sys, indent_radius=0.001) + assert _Z(sys) == N_sys + _P(sys) + + # With a larger indent_radius, we get a warning message + wrong answer + with pytest.warns(UserWarning, match="contour may miss closed loop pole"): + N_sys = ct.nyquist_plot(sys, indent_radius=0.2) + assert _Z(sys) != N_sys + _P(sys) + # Unstable system sys = ct.tf([10], [1, 2, 2, 1]) N_sys = ct.nyquist_plot(sys) @@ -67,13 +94,18 @@ def test_nyquist_basic(): contour[contour.real == 0], 1j*np.linspace(0, 1e2, 100)[contour.real == 0]) + # # Make sure that we can turn off frequency modification + # + # Start with a case where indentation should occur count, contour_indented = ct.nyquist_plot( - sys, np.linspace(1e-4, 1e2, 100), return_contour=True) + sys, np.linspace(1e-4, 1e2, 100), indent_radius=1e-2, + return_contour=True) assert not all(contour_indented.real == 0) - count, contour = ct.nyquist_plot( - sys, np.linspace(1e-4, 1e2, 100), return_contour=True, - indent_direction='none') + with pytest.warns(UserWarning, match="encirclements does not match"): + count, contour = ct.nyquist_plot( + sys, np.linspace(1e-4, 1e2, 100), indent_radius=1e-2, + return_contour=True, indent_direction='none') np.testing.assert_almost_equal(contour, 1j*np.linspace(1e-4, 1e2, 100)) # Nyquist plot with poles at the origin, omega unspecified @@ -87,15 +119,20 @@ def test_nyquist_basic(): assert _Z(sys) == count + _P(sys) # Nyquist plot with poles on imaginary axis, omega specified + # (can miss encirclements due to the imaginary poles at +/- 1j) sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) - count = ct.nyquist_plot(sys, np.linspace(1e-3, 1e1, 1000)) - assert _Z(sys) == count + _P(sys) + with pytest.warns(UserWarning, match="does not match") as records: + count = ct.nyquist_plot(sys, np.linspace(1e-3, 1e1, 1000)) + if len(records) == 0: + assert _Z(sys) == count + _P(sys) # Nyquist plot with poles on imaginary axis, omega specified, with contour sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) - count, contour = ct.nyquist_plot( - sys, np.linspace(1e-3, 1e1, 1000), return_contour=True) - assert _Z(sys) == count + _P(sys) + with pytest.warns(UserWarning, match="does not match") as records: + count, contour = ct.nyquist_plot( + sys, np.linspace(1e-3, 1e1, 1000), return_contour=True) + if len(records) == 0: + assert _Z(sys) == count + _P(sys) # Nyquist plot with poles on imaginary axis, return contour sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) @@ -139,14 +176,16 @@ def test_nyquist_fbs_examples(): plt.figure() plt.title("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2) [zoom]") - count = ct.nyquist_plot(sys, omega_limits=[1.5, 1e3]) - # Frequency limits for zoom give incorrect encirclement count - # assert _Z(sys) == count + _P(sys) - assert count == -1 + with pytest.warns(UserWarning, match="encirclements does not match"): + count = ct.nyquist_plot(sys, omega_limits=[1.5, 1e3]) + # Frequency limits for zoom give incorrect encirclement count + # assert _Z(sys) == count + _P(sys) + assert count == -1 @pytest.mark.parametrize("arrows", [ None, # default argument + False, # no arrows 1, 2, 3, 4, # specified number of arrows [0.1, 0.5, 0.9], # specify arc lengths ]) @@ -181,43 +220,67 @@ def test_nyquist_encirclements(): plt.title("Pole at the origin; encirclements = %d" % count) assert _Z(sys) == count + _P(sys) - -def test_nyquist_indent(): + # Non-integer number of encirclements + plt.figure(); + sys = 1 / (s**2 + s + 1) + with pytest.warns(UserWarning, match="encirclements was a non-integer"): + count = ct.nyquist_plot(sys, omega_limits=[0.5, 1e3]) + with pytest.warns(None) as records: + count = ct.nyquist_plot( + sys, omega_limits=[0.5, 1e3], encirclement_threshold=0.2) + assert len(records) == 0 + plt.title("Non-integer number of encirclements [%g]" % count) + + +@pytest.fixture +def indentsys(): # FBS Figure 10.10 - s = ct.tf('s') - sys = 3 * (s+6)**2 / (s * (s+1)**2) # poles: [-1, -1, 0] + s = ct.tf('s') + return 3 * (s+6)**2 / (s * (s+1)**2) + +def test_nyquist_indent_default(indentsys): plt.figure(); - count = ct.nyquist_plot(sys) + count = ct.nyquist_plot(indentsys) plt.title("Pole at origin; indent_radius=default") - assert _Z(sys) == count + _P(sys) + assert _Z(indentsys) == count + _P(indentsys) + +def test_nyquist_indent_dont(indentsys): # first value of default omega vector was 0.1, replaced by 0. for contour # indent_radius is larger than 0.1 -> no extra quater circle around origin - count, contour = ct.nyquist_plot(sys, plot=False, indent_radius=.1007, - return_contour=True) + with pytest.warns(UserWarning, match="encirclements does not match"): + count, contour = ct.nyquist_plot( + indentsys, omega=[0, 0.2, 0.3, 0.4], indent_radius=.1007, + plot=False, return_contour=True) np.testing.assert_allclose(contour[0], .1007+0.j) # second value of omega_vector is larger than indent_radius: not indented assert np.all(contour.real[2:] == 0.) + +def test_nyquist_indent_do(indentsys): plt.figure(); - count, contour = ct.nyquist_plot(sys, indent_radius=0.01, - return_contour=True) + count, contour = ct.nyquist_plot( + indentsys, indent_radius=0.01, return_contour=True) plt.title("Pole at origin; indent_radius=0.01; encirclements = %d" % count) - assert _Z(sys) == count + _P(sys) + assert _Z(indentsys) == count + _P(indentsys) # indent radius is smaller than the start of the default omega vector # check that a quarter circle around the pole at origin has been added. np.testing.assert_allclose(contour[:50].real**2 + contour[:50].imag**2, 0.01**2) + +def test_nyquist_indent_left(indentsys): plt.figure(); - count = ct.nyquist_plot(sys, indent_direction='left') + count = ct.nyquist_plot(indentsys, indent_direction='left') plt.title( "Pole at origin; indent_direction='left'; encirclements = %d" % count) - assert _Z(sys) == count + _P(sys, indent='left') + assert _Z(indentsys) == count + _P(indentsys, indent='left') + - # System with poles on the imaginary axis +def test_nyquist_indent_im(): + """Test system with poles on the imaginary axis.""" sys = ct.tf([1, 1], [1, 0, 1]) # Imaginary poles with standard indentation @@ -235,8 +298,9 @@ def test_nyquist_indent(): # Imaginary poles with no indentation plt.figure(); - count = ct.nyquist_plot( - sys, np.linspace(0, 1e3, 1000), indent_direction='none') + with pytest.warns(UserWarning, match="encirclements does not match"): + count = ct.nyquist_plot( + sys, np.linspace(0, 1e3, 1000), indent_direction='none') plt.title( "Imaginary poles; indent_direction='none'; encirclements = %d" % count) assert _Z(sys) == count + _P(sys) @@ -255,6 +319,15 @@ def test_nyquist_exceptions(): with pytest.warns(FutureWarning, match="use `arrow_size` instead"): ct.nyquist_plot(sys, arrow_width=8, arrow_length=6) + # Unknown arrow keyword + with pytest.raises(ValueError, match="unsupported arrow location"): + ct.nyquist_plot(sys, arrows='uniform') + + # Bad value for indent direction + sys = ct.tf([1], [1, 0, 1]) + with pytest.raises(ValueError, match="unknown value for indent"): + ct.nyquist_plot(sys, indent_direction='up') + # Discrete time system sampled above Nyquist frequency sys = ct.drss(2, 1, 1) sys.dt = 0.01 @@ -262,12 +335,46 @@ def test_nyquist_exceptions(): ct.nyquist_plot(sys, np.logspace(-2, 3)) +def test_linestyle_checks(): + sys = ct.rss(2, 1, 1) + + # Things that should work + ct.nyquist_plot(sys, primary_style=['-', '-'], mirror_style=['-', '-']) + ct.nyquist_plot(sys, mirror_style=None) + + with pytest.raises(ValueError, match="invalid 'primary_style'"): + ct.nyquist_plot(sys, primary_style=False) + + with pytest.raises(ValueError, match="invalid 'mirror_style'"): + ct.nyquist_plot(sys, mirror_style=0.2) + + # If only one line style is given use, the default value for the other + # TODO: for now, just make sure the signature works; no correct check yet + with pytest.warns(PendingDeprecationWarning, match="single string"): + ct.nyquist_plot(sys, primary_style=':', mirror_style='-.') + +@pytest.mark.usefixtures("editsdefaults") +def test_nyquist_legacy(): + ct.use_legacy_defaults('0.9.1') + + # Example that generated a warning using earlier defaults + s = ct.tf('s') + sys = (0.02 * s**3 - 0.1 * s) / (s**4 + s**3 + s**2 + 0.25 * s + 0.04) + + with pytest.warns(UserWarning, match="indented contour may miss"): + count = ct.nyquist_plot(sys) + +def test_discrete_nyquist(): + # Make sure we can handle discrete time systems with negative poles + sys = ct.tf(1, [1, -0.1], dt=1) * ct.tf(1, [1, 0.1], dt=1) + ct.nyquist_plot(sys) + if __name__ == "__main__": # # Interactive mode: generate plots for manual viewing # - # Running this script in python (or better ipython) will show a collection of - # figures that should all look OK on the screeen. + # Running this script in python (or better ipython) will show a + # collection of figures that should all look OK on the screeen. # # In interactive mode, turn on ipython interactive graphics @@ -289,11 +396,33 @@ def test_nyquist_exceptions(): test_nyquist_encirclements() print("Indentation checks") - test_nyquist_indent() + s = ct.tf('s') + indentsys = 3 * (s+6)**2 / (s * (s+1)**2) + test_nyquist_indent_default(indentsys) + test_nyquist_indent_do(indentsys) + test_nyquist_indent_left(indentsys) + + # Generate a figuring showing effects of different parameters + sys = 3 * (s+6)**2 / (s * (s**2 + 1e-4 * s + 1)) + plt.figure() + ct.nyquist_plot(sys) + ct.nyquist_plot(sys, max_curve_magnitude=15) + ct.nyquist_plot(sys, indent_radius=1e-6, max_curve_magnitude=25) print("Unusual Nyquist plot") sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) plt.figure() - plt.title("Poles: %s" % np.array2string(sys.pole(), precision=2, separator=',')) + plt.title("Poles: %s" % + np.array2string(sys.poles(), precision=2, separator=',')) count = ct.nyquist_plot(sys) assert _Z(sys) == count + _P(sys) + + print("Discrete time systems") + sys = ct.c2d(sys, 0.01) + plt.figure() + plt.title("Discrete-time; poles: %s" % + np.array2string(sys.poles(), precision=2, separator=',')) + count = ct.nyquist_plot(sys) + + + diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 528313e9d..1aa307b60 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -39,11 +39,15 @@ def test_finite_horizon_simple(): # Retrieve the full open-loop predictions res = opt.solve_ocp( - sys, time, x0, cost, constraints, squeeze=True) + sys, time, x0, cost, constraints, squeeze=True, + terminal_cost=cost) # include to match MPT3 formulation t, u_openloop = res.time, res.inputs np.testing.assert_almost_equal( u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=4) + # Make sure the final cost is correct + assert math.isclose(res.cost, 32.4898, rel_tol=1e-5) + # Convert controller to an explicit form (not implemented yet) # mpc_explicit = opt.explicit_mpc(); @@ -57,9 +61,7 @@ def test_finite_horizon_simple(): # # The next unit test is intended to confirm that a finite horizon # optimal control problem with terminal cost set to LQR "cost to go" -# gives the same answer as LQR. Unfortunately, it requires a discrete -# time LQR function which is not yet availbale => for now this just -# tests the interface a bit. +# gives the same answer as LQR. # @slycotonly def test_discrete_lqr(): @@ -76,41 +78,49 @@ def test_discrete_lqr(): # Include weights on states/inputs Q = np.eye(2) R = 1 - K, S, E = ct.lqr(A, B, Q, R) # note: *continuous* time LQR + K, S, E = ct.dlqr(A, B, Q, R) # Compute the integral and terminal cost integral_cost = opt.quadratic_cost(sys, Q, R) terminal_cost = opt.quadratic_cost(sys, S, None) - # Formulate finite horizon MPC problem + # Solve the LQR problem + lqr_sys = ct.ss2io(ct.ss(A - B @ K, B, C, D, 1)) + + # Generate a simulation of the LQR controller time = np.arange(0, 5, 1) x0 = np.array([1, 1]) + _, _, lqr_x = ct.input_output_response( + lqr_sys, time, 0, x0, return_x=True) + + # Use LQR input as initial guess to avoid convergence/precision issues + lqr_u = np.array(-K @ lqr_x[0:time.size]) # convert from matrix + + # Formulate the optimal control problem and compute optimal trajectory optctrl = opt.OptimalControlProblem( - sys, time, integral_cost, terminal_cost=terminal_cost) + sys, time, integral_cost, terminal_cost=terminal_cost, + initial_guess=lqr_u) res1 = optctrl.compute_trajectory(x0, return_states=True) - with pytest.xfail("discrete LQR not implemented"): - # Result should match LQR - K, S, E = ct.dlqr(A, B, Q, R) - lqr_sys = ct.ss2io(ct.ss(A - B @ K, B, C, D, 1)) - _, _, lqr_x = ct.input_output_response( - lqr_sys, time, 0, x0, return_x=True) - np.testing.assert_almost_equal(res1.states, lqr_x) + # Compare to make sure results are the same + np.testing.assert_almost_equal(res1.inputs, lqr_u[0]) + np.testing.assert_almost_equal(res1.states, lqr_x) # Add state and input constraints trajectory_constraints = [ - (sp.optimize.LinearConstraint, np.eye(3), [-10, -10, -1], [10, 10, 1]), + (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -.5], [5, 5, 0.5]), ] # Re-solve res2 = opt.solve_ocp( - sys, time, x0, integral_cost, constraints, terminal_cost=terminal_cost) + sys, time, x0, integral_cost, trajectory_constraints, + terminal_cost=terminal_cost, initial_guess=lqr_u) # Make sure we got a different solution assert np.any(np.abs(res1.inputs - res2.inputs) > 0.1) -def test_mpc_iosystem(): +def test_mpc_iosystem_aircraft(): # model of an aircraft discretized with 0.2s sampling time # Source: https://www.mpt3.org/UI/RegulationProblem A = [[0.99, 0.01, 0.18, -0.09, 0], @@ -164,6 +174,21 @@ def test_mpc_iosystem(): xout[0:sys.nstates, -1], xd, atol=0.1, rtol=0.01) +def test_mpc_iosystem_continuous(): + # Create a random state space system + sys = ct.rss(2, 1, 1) + T, _ = ct.step_response(sys) + + # provide penalties on the system signals + Q = np.eye(sys.nstates) + R = np.eye(sys.ninputs) + cost = opt.quadratic_cost(sys, Q, R) + + # Continuous time MPC controller not implemented + with pytest.raises(NotImplementedError): + ctrl = opt.create_mpc_iosystem(sys, T, cost) + + # Test various constraint combinations; need to use a somewhat convoluted # parametrization due to the need to define sys instead the test function @pytest.mark.parametrize("constraint_list", [ @@ -205,7 +230,9 @@ def test_constraint_specification(constraint_list): # Create a model predictive controller system time = np.arange(0, 5, 1) - optctrl = opt.OptimalControlProblem(sys, time, cost, constraints) + optctrl = opt.OptimalControlProblem( + sys, time, cost, constraints, + terminal_cost=cost) # include to match MPT3 formulation # Compute optimal control and compare against MPT3 solution x0 = [4, 0] @@ -223,7 +250,7 @@ def test_constraint_specification(constraint_list): ([[1, 0], [0, 1]], np.eye(2), np.eye(2), 0, 1), id = "discrete, dt=1"), pytest.param( - (np.zeros((2,2)), np.eye(2), np.eye(2), 0), + (np.zeros((2, 2)), np.eye(2), np.eye(2), 0), id = "continuous"), ]) def test_terminal_constraints(sys_args): @@ -274,8 +301,11 @@ def test_terminal_constraints(sys_args): # Re-run using a basis function and see if we get the same answer res = opt.solve_ocp(sys, time, x0, cost, terminal_constraints=final_point, - basis=flat.BezierFamily(4, Tf)) - np.testing.assert_almost_equal(res.inputs, u1, decimal=2) + basis=flat.BezierFamily(8, Tf)) + + # Final point doesn't affect cost => don't need to test + np.testing.assert_almost_equal( + res.inputs[:, :-1], u1[:, :-1], decimal=2) # Impose some cost on the state, which should change the path Q = np.eye(2) @@ -424,6 +454,22 @@ def test_ocp_argument_errors(): res = opt.solve_ocp( sys, time, x0, cost, constraints, initial_guess=np.zeros((4,1,1))) + # Unrecognized arguments + with pytest.raises(TypeError, match="unrecognized keyword"): + res = opt.solve_ocp( + sys, time, x0, cost, constraints, terminal_constraint=None) + + # Unrecognized trajectory constraint type + constraints = [(None, np.eye(3), [0, 0, 0], [0, 0, 0])] + with pytest.raises(TypeError, match="unknown constraint type"): + res = opt.solve_ocp( + sys, time, x0, cost, trajectory_constraints=constraints) + + # Unrecognized terminal constraint type + with pytest.raises(TypeError, match="unknown constraint type"): + res = opt.solve_ocp( + sys, time, x0, cost, terminal_constraints=constraints) + def test_optimal_basis_simple(): sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) @@ -467,3 +513,113 @@ def test_optimal_basis_simple(): basis=flat.BezierFamily(4, Tf), return_x=True, log=True) assert res3.success np.testing.assert_almost_equal(res3.inputs, res1.inputs, decimal=3) + + +def test_equality_constraints(): + """Test out the ability to handle equality constraints""" + # Create the system (double integrator, continuous time) + sys = ct.ss2io(ct.ss(np.zeros((2, 2)), np.eye(2), np.eye(2), 0)) + + # Shortest path to a point is a line + Q = np.zeros((2, 2)) + R = np.eye(2) + cost = opt.quadratic_cost(sys, Q, R) + + # Set up the terminal constraint to be the origin + final_point = [opt.state_range_constraint(sys, [0, 0], [0, 0])] + + # Create the optimal control problem + time = np.arange(0, 3, 1) + optctrl = opt.OptimalControlProblem( + sys, time, cost, terminal_constraints=final_point) + + # Find a path to the origin + x0 = np.array([4, 3]) + res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + t, u1, x1 = res.time, res.inputs, res.states + + # Bug prior to SciPy 1.6 will result in incorrect results + if NumpyVersion(sp.__version__) < '1.6.0': + pytest.xfail("SciPy 1.6 or higher required") + + np.testing.assert_almost_equal(x1[:,-1], 0, decimal=4) + + # Set up terminal constraints as a nonlinear constraint + def final_point_eval(x, u): + return x + final_point = [ + (sp.optimize.NonlinearConstraint, final_point_eval, [0, 0], [0, 0])] + + optctrl = opt.OptimalControlProblem( + sys, time, cost, terminal_constraints=final_point) + + # Find a path to the origin + x0 = np.array([4, 3]) + res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + t, u2, x2 = res.time, res.inputs, res.states + np.testing.assert_almost_equal(x2[:,-1], 0, decimal=4) + np.testing.assert_almost_equal(u1, u2) + np.testing.assert_almost_equal(x1, x2) + + # Try passing and unknown constraint type + final_point = [(None, final_point_eval, [0, 0], [0, 0])] + with pytest.raises(TypeError, match="unknown constraint type"): + optctrl = opt.OptimalControlProblem( + sys, time, cost, terminal_constraints=final_point) + res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + + +def test_optimal_doc(): + """Test optimal control problem from documentation""" + def vehicle_update(t, x, u, params): + # Get the parameters for the model + l = params.get('wheelbase', 3.) # vehicle wheelbase + phimax = params.get('maxsteer', 0.5) # max steering angle (rad) + + # Saturate the steering input + phi = np.clip(u[1], -phimax, phimax) + + # Return the derivative of the state + return np.array([ + np.cos(x[2]) * u[0], # xdot = cos(theta) v + np.sin(x[2]) * u[0], # ydot = sin(theta) v + (u[0] / l) * np.tan(phi) # thdot = v/l tan(phi) + ]) + + def vehicle_output(t, x, u, params): + return x # return x, y, theta (full state) + + # Define the vehicle steering dynamics as an input/output system + vehicle = ct.NonlinearIOSystem( + vehicle_update, vehicle_output, states=3, name='vehicle', + inputs=('v', 'phi'), outputs=('x', 'y', 'theta')) + + # Define the initial and final points and time interval + x0 = [0., -2., 0.]; u0 = [10., 0.] + xf = [100., 2., 0.]; uf = [10., 0.] + Tf = 10 + + # Define the cost functions + Q = np.diag([0, 0, 0.1]) # don't turn too sharply + R = np.diag([1, 1]) # keep inputs small + P = np.diag([1000, 1000, 1000]) # get close to final point + traj_cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + term_cost = opt.quadratic_cost(vehicle, P, 0, x0=xf) + + # Define the constraints + constraints = [ opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + + # Solve the optimal control problem + horizon = np.linspace(0, Tf, 3, endpoint=True) + result = opt.solve_ocp( + vehicle, horizon, x0, traj_cost, constraints, + terminal_cost= term_cost, initial_guess=u0) + + # Make sure the resulting trajectory generate a good solution + resp = ct.input_output_response( + vehicle, horizon, result.inputs, x0, + t_eval=np.linspace(0, Tf, 10)) + t, y = resp + assert (y[0, -1] - xf[0]) / xf[0] < 0.01 + assert (y[1, -1] - xf[1]) / xf[1] < 0.01 + assert y[2, -1] < 0.1 diff --git a/control/tests/rlocus_test.py b/control/tests/rlocus_test.py index ef9bd7ecb..a0ecebb15 100644 --- a/control/tests/rlocus_test.py +++ b/control/tests/rlocus_test.py @@ -41,7 +41,7 @@ def sys(self, request): def check_cl_poles(self, sys, pole_list, k_list): for k, poles in zip(k_list, pole_list): - poles_expected = np.sort(feedback(sys, k).pole()) + poles_expected = np.sort(feedback(sys, k).poles()) poles = np.sort(poles) np.testing.assert_array_almost_equal(poles, poles_expected) diff --git a/control/tests/sisotool_test.py b/control/tests/sisotool_test.py index 6b8c6d148..d5e9dd013 100644 --- a/control/tests/sisotool_test.py +++ b/control/tests/sisotool_test.py @@ -102,8 +102,8 @@ def test_sisotool(self, tsys): # Check if the bode_mag line has moved bode_mag_moved = np.array( - [674.0242, 667.8354, 661.7033, 655.6275, 649.6074, 643.6426, - 637.7324, 631.8765, 626.0742, 620.3252]) + [69.0065, 68.6749, 68.3448, 68.0161, 67.6889, 67.3631, 67.0388, + 66.7159, 66.3944, 66.0743]) assert_array_almost_equal(ax_mag.lines[0].get_data()[1][10:20], bode_mag_moved, 4) diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index 73410312f..13f164e1f 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -7,12 +7,12 @@ import pytest import control as ct -from control import lqe, pole, rss, ss, tf +from control import lqe, dlqe, poles, rss, ss, tf from control.exception import ControlDimension, ControlSlycot, \ ControlArgument, slycot_check from control.mateqn import care, dare from control.statefbk import (ctrb, obsv, place, place_varga, lqr, dlqr, - lqe, dlqe, gram, acker) + gram, acker) from control.tests.conftest import (slycotonly, check_deprecated_matrix, ismatarrayout, asmatarrayout) @@ -167,12 +167,12 @@ def testAcker(self, fixedseed): # Place the poles at random locations des = rss(states, 1, 1) - poles = pole(des) + desired = poles(des) # Now place the poles using acker - K = acker(sys.A, sys.B, poles) + K = acker(sys.A, sys.B, desired) new = ss(sys.A - sys.B * K, sys.B, sys.C, sys.D) - placed = pole(new) + placed = poles(new) # Debugging code # diff = np.sort(poles) - np.sort(placed) @@ -181,8 +181,8 @@ def testAcker(self, fixedseed): # print(sys) # print("desired = ", poles) - np.testing.assert_array_almost_equal(np.sort(poles), - np.sort(placed), decimal=4) + np.testing.assert_array_almost_equal( + np.sort(desired), np.sort(placed), decimal=4) def checkPlaced(self, P_expected, P_placed): """Check that placed poles are correct""" @@ -440,82 +440,6 @@ def testDLQR_warning(self): with pytest.warns(UserWarning): (K, S, E) = dlqr(A, B, Q, R, N) - def check_LQE(self, L, P, poles, G, QN, RN): - P_expected = asmatarrayout(np.sqrt(G @ QN @ G @ RN)) - L_expected = asmatarrayout(P_expected / RN) - poles_expected = -np.squeeze(np.asarray(L_expected)) - np.testing.assert_array_almost_equal(P, P_expected) - np.testing.assert_array_almost_equal(L, L_expected) - np.testing.assert_array_almost_equal(poles, poles_expected) - - @pytest.mark.parametrize("method", [None, 'slycot', 'scipy']) - def test_LQE(self, matarrayin, method): - if method == 'slycot' and not slycot_check(): - return - - A, G, C, QN, RN = (matarrayin([[X]]) for X in [0., .1, 1., 10., 2.]) - L, P, poles = lqe(A, G, C, QN, RN, method=method) - self.check_LQE(L, P, poles, G, QN, RN) - - @pytest.mark.parametrize("cdlqe", [lqe, dlqe]) - def test_lqe_call_format(self, cdlqe): - # Create a random state space system for testing - sys = rss(4, 3, 2) - sys.dt = None # treat as either continuous or discrete time - - # Covariance matrices - Q = np.eye(sys.ninputs) - R = np.eye(sys.noutputs) - N = np.zeros((sys.ninputs, sys.noutputs)) - - # Standard calling format - Lref, Pref, Eref = cdlqe(sys.A, sys.B, sys.C, Q, R) - - # Call with system instead of matricees - L, P, E = cdlqe(sys, Q, R) - np.testing.assert_array_almost_equal(Lref, L) - np.testing.assert_array_almost_equal(Pref, P) - np.testing.assert_array_almost_equal(Eref, E) - - # Make sure we get an error if we specify N - with pytest.raises(ct.ControlNotImplemented): - L, P, E = cdlqe(sys, Q, R, N) - - # Inconsistent system dimensions - with pytest.raises(ct.ControlDimension, match="Incompatible"): - L, P, E = cdlqe(sys.A, sys.C, sys.B, Q, R) - - # Incorrect covariance matrix dimensions - with pytest.raises(ct.ControlDimension, match="Incompatible"): - L, P, E = cdlqe(sys.A, sys.B, sys.C, R, Q) - - # Too few input arguments - with pytest.raises(ct.ControlArgument, match="not enough input"): - L, P, E = cdlqe(sys.A, sys.C) - - # First argument is the wrong type (use SISO for non-slycot tests) - sys_tf = tf(rss(3, 1, 1)) - sys_tf.dt = None # treat as either continuous or discrete time - with pytest.raises(ct.ControlArgument, match="LTI system must be"): - L, P, E = cdlqe(sys_tf, Q, R) - - def check_DLQE(self, L, P, poles, G, QN, RN): - P_expected = asmatarrayout(G.dot(QN).dot(G)) - L_expected = asmatarrayout(0) - poles_expected = -np.squeeze(np.asarray(L_expected)) - np.testing.assert_array_almost_equal(P, P_expected) - np.testing.assert_array_almost_equal(L, L_expected) - np.testing.assert_array_almost_equal(poles, poles_expected) - - @pytest.mark.parametrize("method", [None, 'slycot', 'scipy']) - def test_DLQE(self, matarrayin, method): - if method == 'slycot' and not slycot_check(): - return - - A, G, C, QN, RN = (matarrayin([[X]]) for X in [0., .1, 1., 10., 2.]) - L, P, poles = dlqe(A, G, C, QN, RN, method=method) - self.check_DLQE(L, P, poles, G, QN, RN) - def test_care(self, matarrayin): """Test stabilizing and anti-stabilizing feedback, continuous""" A = matarrayin(np.diag([1, -1])) @@ -584,35 +508,272 @@ def test_lqr_discrete(self): with pytest.raises(ControlArgument, match="dsys must be discrete"): K, S, E = ct.dlqr(csys, Q, R) - def test_lqe_discrete(self): - """Test overloading of lqe operator for discrete time systems""" - csys = ct.rss(2, 1, 1) - dsys = ct.drss(2, 1, 1) - Q = np.eye(1) - R = np.eye(1) - - # Calling with a system versus explicit A, B should be the sam - K_csys, S_csys, E_csys = ct.lqe(csys, Q, R) - K_expl, S_expl, E_expl = ct.lqe(csys.A, csys.B, csys.C, Q, R) - np.testing.assert_almost_equal(K_csys, K_expl) - np.testing.assert_almost_equal(S_csys, S_expl) - np.testing.assert_almost_equal(E_csys, E_expl) - - # Calling lqe() with a discrete time system should call dlqe() - K_lqe, S_lqe, E_lqe = ct.lqe(dsys, Q, R) - K_dlqe, S_dlqe, E_dlqe = ct.dlqe(dsys, Q, R) - np.testing.assert_almost_equal(K_lqe, K_dlqe) - np.testing.assert_almost_equal(S_lqe, S_dlqe) - np.testing.assert_almost_equal(E_lqe, E_dlqe) + @pytest.mark.parametrize( + 'nstates, noutputs, ninputs, nintegrators, type', + [(2, 0, 1, 0, None), + (2, 1, 1, 0, None), + (4, 0, 2, 0, None), + (4, 3, 2, 0, None), + (2, 0, 1, 1, None), + (4, 0, 2, 2, None), + (4, 3, 2, 2, None), + (2, 0, 1, 0, 'nonlinear'), + (4, 0, 2, 2, 'nonlinear'), + (4, 3, 2, 2, 'nonlinear'), + ]) + def test_statefbk_iosys( + self, nstates, ninputs, noutputs, nintegrators, type): + # Create the system to be controlled (and estimator) + # TODO: make sure it is controllable? + if noutputs == 0: + # Create a system with full state output + sys = ct.rss(nstates, nstates, ninputs, strictly_proper=True) + sys.C = np.eye(nstates) + est = None - # Calling lqe() with no timebase should call lqe() - asys = ct.ss(csys.A, csys.B, csys.C, csys.D, dt=None) - K_asys, S_asys, E_asys = ct.lqe(asys, Q, R) - K_expl, S_expl, E_expl = ct.lqe(csys.A, csys.B, csys.C, Q, R) - np.testing.assert_almost_equal(K_asys, K_expl) - np.testing.assert_almost_equal(S_asys, S_expl) - np.testing.assert_almost_equal(E_asys, E_expl) + else: + # Create a system with of the desired size + sys = ct.rss(nstates, noutputs, ninputs, strictly_proper=True) + + # Create an estimator with different signal names + L, _, _ = ct.lqe( + sys.A, sys.B, sys.C, np.eye(ninputs), np.eye(noutputs)) + est = ss( + sys.A - L @ sys.C, np.hstack([L, sys.B]), np.eye(nstates), 0, + inputs=sys.output_labels + sys.input_labels, + outputs=[f'xhat[{i}]' for i in range(nstates)]) + + # Decide whether to include integral action + if nintegrators: + # Choose the first 'n' outputs as integral terms + C_int = np.eye(nintegrators, nstates) + + # Set up an augmented system for LQR computation + # TODO: move this computation into LQR + A_aug = np.block([ + [sys.A, np.zeros((sys.nstates, nintegrators))], + [C_int, np.zeros((nintegrators, nintegrators))] + ]) + B_aug = np.vstack([sys.B, np.zeros((nintegrators, ninputs))]) + C_aug = np.hstack([sys.C, np.zeros((sys.C.shape[0], nintegrators))]) + aug = ss(A_aug, B_aug, C_aug, 0) + else: + C_int = np.zeros((0, nstates)) + aug = sys + + # Design an LQR controller + K, _, _ = ct.lqr(aug, np.eye(nstates + nintegrators), np.eye(ninputs)) + Kp, Ki = K[:, :nstates], K[:, nstates:] + + # Create an I/O system for the controller + ctrl, clsys = ct.create_statefbk_iosystem( + sys, K, integral_action=C_int, estimator=est, type=type) + + # If we used a nonlinear controller, linearize it for testing + if type == 'nonlinear': + clsys = clsys.linearize(0, 0) + + # Make sure the linear system elements are correct + if noutputs == 0: + # No estimator + Ac = np.block([ + [sys.A - sys.B @ Kp, -sys.B @ Ki], + [C_int, np.zeros((nintegrators, nintegrators))] + ]) + Bc = np.block([ + [sys.B @ Kp, sys.B], + [-C_int, np.zeros((nintegrators, ninputs))] + ]) + Cc = np.block([ + [np.eye(nstates), np.zeros((nstates, nintegrators))], + [-Kp, -Ki] + ]) + Dc = np.block([ + [np.zeros((nstates, nstates + ninputs))], + [Kp, np.eye(ninputs)] + ]) + else: + # Estimator + Be1, Be2 = est.B[:, :noutputs], est.B[:, noutputs:] + Ac = np.block([ + [sys.A, -sys.B @ Ki, -sys.B @ Kp], + [np.zeros((nintegrators, nstates + nintegrators)), C_int], + [Be1 @ sys.C, -Be2 @ Ki, est.A - Be2 @ Kp] + ]) + Bc = np.block([ + [sys.B @ Kp, sys.B], + [-C_int, np.zeros((nintegrators, ninputs))], + [Be2 @ Kp, Be2] + ]) + Cc = np.block([ + [sys.C, np.zeros((noutputs, nintegrators + nstates))], + [np.zeros_like(Kp), -Ki, -Kp] + ]) + Dc = np.block([ + [np.zeros((noutputs, nstates + ninputs))], + [Kp, np.eye(ninputs)] + ]) + + # Check to make sure everything matches + np.testing.assert_array_almost_equal(clsys.A, Ac) + np.testing.assert_array_almost_equal(clsys.B, Bc) + np.testing.assert_array_almost_equal(clsys.C, Cc) + np.testing.assert_array_almost_equal(clsys.D, Dc) + + def test_lqr_integral_continuous(self): + # Generate a continuous time system for testing + sys = ct.rss(4, 4, 2, strictly_proper=True) + sys.C = np.eye(4) # reset output to be full state + C_int = np.eye(2, 4) # integrate outputs for first two states + nintegrators = C_int.shape[0] + + # Generate a controller with integral action + K, _, _ = ct.lqr( + sys, np.eye(sys.nstates + nintegrators), np.eye(sys.ninputs), + integral_action=C_int) + Kp, Ki = K[:, :sys.nstates], K[:, sys.nstates:] + + # Create an I/O system for the controller + ctrl, clsys = ct.create_statefbk_iosystem( + sys, K, integral_action=C_int) + + # Construct the state space matrices for the controller + # Controller inputs = xd, ud, x + # Controller state = z (integral of x-xd) + # Controller output = ud - Kp(x - xd) - Ki z + A_ctrl = np.zeros((nintegrators, nintegrators)) + B_ctrl = np.block([ + [-C_int, np.zeros((nintegrators, sys.ninputs)), C_int] + ]) + C_ctrl = -K[:, sys.nstates:] + D_ctrl = np.block([[Kp, np.eye(nintegrators), -Kp]]) + + # Check to make sure everything matches + np.testing.assert_array_almost_equal(ctrl.A, A_ctrl) + np.testing.assert_array_almost_equal(ctrl.B, B_ctrl) + np.testing.assert_array_almost_equal(ctrl.C, C_ctrl) + np.testing.assert_array_almost_equal(ctrl.D, D_ctrl) + + # Construct the state space matrices for the closed loop system + A_clsys = np.block([ + [sys.A - sys.B @ Kp, -sys.B @ Ki], + [C_int, np.zeros((nintegrators, nintegrators))] + ]) + B_clsys = np.block([ + [sys.B @ Kp, sys.B], + [-C_int, np.zeros((nintegrators, sys.ninputs))] + ]) + C_clsys = np.block([ + [np.eye(sys.nstates), np.zeros((sys.nstates, nintegrators))], + [-Kp, -Ki] + ]) + D_clsys = np.block([ + [np.zeros((sys.nstates, sys.nstates + sys.ninputs))], + [Kp, np.eye(sys.ninputs)] + ]) + + # Check to make sure closed loop matches + np.testing.assert_array_almost_equal(clsys.A, A_clsys) + np.testing.assert_array_almost_equal(clsys.B, B_clsys) + np.testing.assert_array_almost_equal(clsys.C, C_clsys) + np.testing.assert_array_almost_equal(clsys.D, D_clsys) + + # Check the poles of the closed loop system + assert all(np.real(clsys.poles()) < 0) + + # Make sure controller infinite zero frequency gain + if slycot_check(): + ctrl_tf = tf(ctrl) + assert abs(ctrl_tf(1e-9)[0][0]) > 1e6 + assert abs(ctrl_tf(1e-9)[1][1]) > 1e6 + + def test_lqr_integral_discrete(self): + # Generate a discrete time system for testing + sys = ct.drss(4, 4, 2, strictly_proper=True) + sys.C = np.eye(4) # reset output to be full state + C_int = np.eye(2, 4) # integrate outputs for first two states + nintegrators = C_int.shape[0] + + # Generate a controller with integral action + K, _, _ = ct.lqr( + sys, np.eye(sys.nstates + nintegrators), np.eye(sys.ninputs), + integral_action=C_int) + Kp, Ki = K[:, :sys.nstates], K[:, sys.nstates:] + + # Create an I/O system for the controller + ctrl, clsys = ct.create_statefbk_iosystem( + sys, K, integral_action=C_int) + + # Construct the state space matrices by hand + A_ctrl = np.eye(nintegrators) + B_ctrl = np.block([ + [-C_int, np.zeros((nintegrators, sys.ninputs)), C_int] + ]) + C_ctrl = -K[:, sys.nstates:] + D_ctrl = np.block([[Kp, np.eye(nintegrators), -Kp]]) + + # Check to make sure everything matches + assert ct.isdtime(clsys) + np.testing.assert_array_almost_equal(ctrl.A, A_ctrl) + np.testing.assert_array_almost_equal(ctrl.B, B_ctrl) + np.testing.assert_array_almost_equal(ctrl.C, C_ctrl) + np.testing.assert_array_almost_equal(ctrl.D, D_ctrl) - # Calling dlqe() with a continuous time system should raise an error - with pytest.raises(ControlArgument, match="called with a continuous"): - K, S, E = ct.dlqe(csys, Q, R) + @pytest.mark.parametrize( + "rss_fun, lqr_fun", + [(ct.rss, lqr), (ct.drss, dlqr)]) + def test_lqr_errors(self, rss_fun, lqr_fun): + # Generate a discrete time system for testing + sys = rss_fun(4, 4, 2, strictly_proper=True) + + with pytest.raises(ControlArgument, match="must pass an array"): + K, _, _ = lqr_fun( + sys, np.eye(sys.nstates), np.eye(sys.ninputs), + integral_action="invalid argument") + + with pytest.raises(ControlArgument, match="gain size must match"): + C_int = np.eye(2, 3) + K, _, _ = lqr_fun( + sys, np.eye(sys.nstates), np.eye(sys.ninputs), + integral_action=C_int) + + with pytest.raises(TypeError, match="unrecognized keywords"): + K, _, _ = lqr_fun( + sys, np.eye(sys.nstates), np.eye(sys.ninputs), + integrator=None) + + def test_statefbk_errors(self): + sys = ct.rss(4, 4, 2, strictly_proper=True) + K, _, _ = ct.lqr(sys, np.eye(sys.nstates), np.eye(sys.ninputs)) + + with pytest.raises(ControlArgument, match="must be I/O system"): + sys_tf = ct.tf([1], [1, 1]) + ctrl, clsys = ct.create_statefbk_iosystem(sys_tf, K) + + with pytest.raises(ControlArgument, match="output size must match"): + est = ct.rss(3, 3, 2) + ctrl, clsys = ct.create_statefbk_iosystem(sys, K, estimator=est) + + with pytest.raises(ControlArgument, match="must be the full state"): + sys_nf = ct.rss(4, 3, 2, strictly_proper=True) + ctrl, clsys = ct.create_statefbk_iosystem(sys_nf, K) + + with pytest.raises(ControlArgument, match="gain must be an array"): + ctrl, clsys = ct.create_statefbk_iosystem(sys, "bad argument") + + with pytest.raises(ControlArgument, match="unknown type"): + ctrl, clsys = ct.create_statefbk_iosystem(sys, K, type=1) + + # Errors involving integral action + C_int = np.eye(2, 4) + K_int, _, _ = ct.lqr( + sys, np.eye(sys.nstates + C_int.shape[0]), np.eye(sys.ninputs), + integral_action=C_int) + + with pytest.raises(ControlArgument, match="must pass an array"): + ctrl, clsys = ct.create_statefbk_iosystem( + sys, K_int, integral_action="bad argument") + + with pytest.raises(ControlArgument, match="must be an array of size"): + ctrl, clsys = ct.create_statefbk_iosystem( + sys, K, integral_action=C_int) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 78eacf857..315b5f152 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -18,13 +18,16 @@ from control.config import defaults from control.dtime import sample_system from control.lti import evalfr -from control.statesp import (StateSpace, _convert_to_statespace, drss, - rss, ss, tf2ss, _statesp_defaults, _rss_generate) +from control.statesp import StateSpace, _convert_to_statespace, tf2ss, \ + _statesp_defaults, _rss_generate, linfnorm +from control.iosys import ss, rss, drss from control.tests.conftest import ismatarrayout, slycotonly from control.xferfcn import TransferFunction, ss2tf + from .conftest import editsdefaults + class TestStateSpace: """Tests for the StateSpace class.""" @@ -124,7 +127,7 @@ def test_constructor(self, sys322ABCD, dt, argfun): @pytest.mark.parametrize("args, exc, errmsg", [((True, ), TypeError, "(can only take in|sys must be) a StateSpace"), - ((1, 2), ValueError, "1, 4, or 5 arguments"), + ((1, 2), TypeError, "1, 4, or 5 arguments"), ((np.ones((3, 2)), np.ones((3, 2)), np.ones((2, 2)), np.ones((2, 2))), ValueError, "A must be square"), @@ -179,16 +182,17 @@ def test_copy_constructor(self): linsys.A[0, 0] = -3 np.testing.assert_allclose(cpysys.A, [[-1]]) # original value + @pytest.mark.skip("obsolete test") def test_copy_constructor_nodt(self, sys322): """Test the copy constructor when an object without dt is passed""" sysin = sample_system(sys322, 1.) - del sysin.dt + del sysin.dt # this is a nonsensical thing to do sys = StateSpace(sysin) assert sys.dt == defaults['control.default_dt'] # test for static gain sysin = StateSpace([], [], [], [[1, 2], [3, 4]], 1.) - del sysin.dt + del sysin.dt # this is a nonsensical thing to do sys = StateSpace(sysin) assert sys.dt is None @@ -229,7 +233,7 @@ def test_D_broadcast(self, sys623): def test_pole(self, sys322): """Evaluate the poles of a MIMO system.""" - p = np.sort(sys322.pole()) + p = np.sort(sys322.poles()) true_p = np.sort([3.34747678408874, -3.17373839204437 + 1.47492908003839j, -3.17373839204437 - 1.47492908003839j]) @@ -239,7 +243,7 @@ def test_pole(self, sys322): def test_zero_empty(self): """Test to make sure zero() works with no zeros in system.""" sys = _convert_to_statespace(TransferFunction([1], [1, 2, 1])) - np.testing.assert_array_equal(sys.zero(), np.array([])) + np.testing.assert_array_equal(sys.zeros(), np.array([])) @slycotonly def test_zero_siso(self, sys222): @@ -251,9 +255,9 @@ def test_zero_siso(self, sys222): # compute zeros as root of the characteristic polynomial at the numerator of tf111 # this method is simple and assumed as valid in this test - true_z = np.sort(tf111[0, 0].zero()) + true_z = np.sort(tf111[0, 0].zeros()) # Compute the zeros through ab08nd, which is tested here - z = np.sort(sys111.zero()) + z = np.sort(sys111.zeros()) np.testing.assert_almost_equal(true_z, z) @@ -261,7 +265,7 @@ def test_zero_siso(self, sys222): def test_zero_mimo_sys322_square(self, sys322): """Evaluate the zeros of a square MIMO system.""" - z = np.sort(sys322.zero()) + z = np.sort(sys322.zeros()) true_z = np.sort([44.41465, -0.490252, -5.924398]) np.testing.assert_array_almost_equal(z, true_z) @@ -269,7 +273,7 @@ def test_zero_mimo_sys322_square(self, sys322): def test_zero_mimo_sys222_square(self, sys222): """Evaluate the zeros of a square MIMO system.""" - z = np.sort(sys222.zero()) + z = np.sort(sys222.zeros()) true_z = np.sort([-10.568501, 3.368501]) np.testing.assert_array_almost_equal(z, true_z) @@ -277,7 +281,7 @@ def test_zero_mimo_sys222_square(self, sys222): def test_zero_mimo_sys623_non_square(self, sys623): """Evaluate the zeros of a non square MIMO system.""" - z = np.sort(sys623.zero()) + z = np.sort(sys623.zeros()) true_z = np.sort([2., -1.]) np.testing.assert_array_almost_equal(z, true_z) @@ -569,15 +573,24 @@ def test_scalar_static_gain(self): """ g1 = StateSpace([], [], [], [2]) g2 = StateSpace([], [], [], [3]) + assert g1.dt == None + assert g2.dt == None g3 = g1 * g2 assert 6 == g3.D[0, 0] + assert g3.dt == None + g4 = g1 + g2 assert 5 == g4.D[0, 0] + assert g4.dt == None + g5 = g1.feedback(g2) np.testing.assert_allclose(2. / 7, g5.D[0, 0]) + assert g5.dt == None + g6 = g1.append(g2) np.testing.assert_allclose(np.diag([2, 3]), g6.D) + assert g6.dt == None def test_matrix_static_gain(self): """Regression: can we create matrix static gains?""" @@ -748,9 +761,9 @@ def test_str(self, sys322): assert str(sysdt1) == tref + "\ndt = {}\n".format(1.) def test_pole_static(self): - """Regression: pole() of static gain is empty array.""" + """Regression: poles() of static gain is empty array.""" np.testing.assert_array_equal(np.array([]), - StateSpace([], [], [], [[1]]).pole()) + StateSpace([], [], [], [[1]]).poles()) def test_horner(self, sys322): """Test horner() function""" @@ -852,7 +865,7 @@ def test_shape(self, states, outputs, inputs): def test_pole(self, states, outputs, inputs): """Test that the poles of rss outputs have a negative real part.""" sys = rss(states, outputs, inputs) - p = sys.pole() + p = sys.poles() for z in p: assert z.real < 0 @@ -904,7 +917,7 @@ def test_shape(self, states, outputs, inputs): def test_pole(self, states, outputs, inputs): """Test that the poles of drss outputs have less than unit magnitude.""" sys = drss(states, outputs, inputs) - p = sys.pole() + p = sys.poles() for z in p: assert abs(z) < 1 @@ -1096,3 +1109,52 @@ def test_latex_repr_testsize(editsdefaults): gstatic = ss([], [], [], 1) assert gstatic._repr_latex_() is None + + +class TestLinfnorm: + # these are simple tests; we assume ab13dd is correct + # python-control specific behaviour is: + # - checking for continuous- and discrete-time + # - scaling fpeak for discrete-time + # - handling static gains + + # the underdamped gpeak and fpeak are found from + # gpeak = 1/(2*zeta*(1-zeta**2)**0.5) + # fpeak = wn*(1-2*zeta**2)**0.5 + @pytest.fixture(params=[ + ('static', ct.tf, ([1.23],[1]), 1.23, 0), + ('underdamped', ct.tf, ([100],[1, 2*0.5*10, 100]), 1.1547005, 7.0710678), + ]) + def ct_siso(self, request): + name, systype, sysargs, refgpeak, reffpeak = request.param + return systype(*sysargs), refgpeak, reffpeak + + @pytest.fixture(params=[ + ('underdamped', ct.tf, ([100],[1, 2*0.5*10, 100]), 1e-4, 1.1547005, 7.0710678), + ]) + def dt_siso(self, request): + name, systype, sysargs, dt, refgpeak, reffpeak = request.param + return ct.c2d(systype(*sysargs), dt), refgpeak, reffpeak + + @slycotonly + def test_linfnorm_ct_siso(self, ct_siso): + sys, refgpeak, reffpeak = ct_siso + gpeak, fpeak = linfnorm(sys) + np.testing.assert_allclose(gpeak, refgpeak) + np.testing.assert_allclose(fpeak, reffpeak) + + @slycotonly + def test_linfnorm_dt_siso(self, dt_siso): + sys, refgpeak, reffpeak = dt_siso + gpeak, fpeak = linfnorm(sys) + # c2d pole-mapping has round-off + np.testing.assert_allclose(gpeak, refgpeak) + np.testing.assert_allclose(fpeak, reffpeak) + + @slycotonly + def test_linfnorm_ct_mimo(self, ct_siso): + siso, refgpeak, reffpeak = ct_siso + sys = ct.append(siso, siso) + gpeak, fpeak = linfnorm(sys) + np.testing.assert_allclose(gpeak, refgpeak) + np.testing.assert_allclose(fpeak, reffpeak) diff --git a/control/tests/stochsys_test.py b/control/tests/stochsys_test.py new file mode 100644 index 000000000..11084d9db --- /dev/null +++ b/control/tests/stochsys_test.py @@ -0,0 +1,262 @@ +# stochsys_test.py - test stochastic system operations +# RMM, 16 Mar 2022 + +import numpy as np +import pytest +from control.tests.conftest import asmatarrayout + +import control as ct +from control import lqe, dlqe, rss, drss, tf, ss, ControlArgument, slycot_check + +# Utility function to check LQE answer +def check_LQE(L, P, poles, G, QN, RN): + P_expected = asmatarrayout(np.sqrt(G @ QN @ G @ RN)) + L_expected = asmatarrayout(P_expected / RN) + poles_expected = -np.squeeze(np.asarray(L_expected)) + np.testing.assert_almost_equal(P, P_expected) + np.testing.assert_almost_equal(L, L_expected) + np.testing.assert_almost_equal(poles, poles_expected) + +# Utility function to check discrete LQE solutions +def check_DLQE(L, P, poles, G, QN, RN): + P_expected = asmatarrayout(G.dot(QN).dot(G)) + L_expected = asmatarrayout(0) + poles_expected = -np.squeeze(np.asarray(L_expected)) + np.testing.assert_almost_equal(P, P_expected) + np.testing.assert_almost_equal(L, L_expected) + np.testing.assert_almost_equal(poles, poles_expected) + +@pytest.mark.parametrize("method", [None, 'slycot', 'scipy']) +def test_LQE(matarrayin, method): + if method == 'slycot' and not slycot_check(): + return + + A, G, C, QN, RN = (matarrayin([[X]]) for X in [0., .1, 1., 10., 2.]) + L, P, poles = lqe(A, G, C, QN, RN, method=method) + check_LQE(L, P, poles, G, QN, RN) + +@pytest.mark.parametrize("cdlqe", [lqe, dlqe]) +def test_lqe_call_format(cdlqe): + # Create a random state space system for testing + sys = rss(4, 3, 2) + sys.dt = None # treat as either continuous or discrete time + + # Covariance matrices + Q = np.eye(sys.ninputs) + R = np.eye(sys.noutputs) + N = np.zeros((sys.ninputs, sys.noutputs)) + + # Standard calling format + Lref, Pref, Eref = cdlqe(sys.A, sys.B, sys.C, Q, R) + + # Call with system instead of matricees + L, P, E = cdlqe(sys, Q, R) + np.testing.assert_almost_equal(Lref, L) + np.testing.assert_almost_equal(Pref, P) + np.testing.assert_almost_equal(Eref, E) + + # Make sure we get an error if we specify N + with pytest.raises(ct.ControlNotImplemented): + L, P, E = cdlqe(sys, Q, R, N) + + # Inconsistent system dimensions + with pytest.raises(ct.ControlDimension, match="Incompatible"): + L, P, E = cdlqe(sys.A, sys.C, sys.B, Q, R) + + # Incorrect covariance matrix dimensions + with pytest.raises(ct.ControlDimension, match="Incompatible"): + L, P, E = cdlqe(sys.A, sys.B, sys.C, R, Q) + + # Too few input arguments + with pytest.raises(ct.ControlArgument, match="not enough input"): + L, P, E = cdlqe(sys.A, sys.C) + + # First argument is the wrong type (use SISO for non-slycot tests) + sys_tf = tf(rss(3, 1, 1)) + sys_tf.dt = None # treat as either continuous or discrete time + with pytest.raises(ct.ControlArgument, match="LTI system must be"): + L, P, E = cdlqe(sys_tf, Q, R) + +@pytest.mark.parametrize("method", [None, 'slycot', 'scipy']) +def test_DLQE(matarrayin, method): + if method == 'slycot' and not slycot_check(): + return + + A, G, C, QN, RN = (matarrayin([[X]]) for X in [0., .1, 1., 10., 2.]) + L, P, poles = dlqe(A, G, C, QN, RN, method=method) + check_DLQE(L, P, poles, G, QN, RN) + +def test_lqe_discrete(): + """Test overloading of lqe operator for discrete time systems""" + csys = ct.rss(2, 1, 1) + dsys = ct.drss(2, 1, 1) + Q = np.eye(1) + R = np.eye(1) + + # Calling with a system versus explicit A, B should be the sam + K_csys, S_csys, E_csys = ct.lqe(csys, Q, R) + K_expl, S_expl, E_expl = ct.lqe(csys.A, csys.B, csys.C, Q, R) + np.testing.assert_almost_equal(K_csys, K_expl) + np.testing.assert_almost_equal(S_csys, S_expl) + np.testing.assert_almost_equal(E_csys, E_expl) + + # Calling lqe() with a discrete time system should call dlqe() + K_lqe, S_lqe, E_lqe = ct.lqe(dsys, Q, R) + K_dlqe, S_dlqe, E_dlqe = ct.dlqe(dsys, Q, R) + np.testing.assert_almost_equal(K_lqe, K_dlqe) + np.testing.assert_almost_equal(S_lqe, S_dlqe) + np.testing.assert_almost_equal(E_lqe, E_dlqe) + + # Calling lqe() with no timebase should call lqe() + asys = ct.ss(csys.A, csys.B, csys.C, csys.D, dt=None) + K_asys, S_asys, E_asys = ct.lqe(asys, Q, R) + K_expl, S_expl, E_expl = ct.lqe(csys.A, csys.B, csys.C, Q, R) + np.testing.assert_almost_equal(K_asys, K_expl) + np.testing.assert_almost_equal(S_asys, S_expl) + np.testing.assert_almost_equal(E_asys, E_expl) + + # Calling dlqe() with a continuous time system should raise an error + with pytest.raises(ControlArgument, match="called with a continuous"): + K, S, E = ct.dlqe(csys, Q, R) + +def test_estimator_iosys(): + sys = ct.drss(4, 2, 2, strictly_proper=True) + + Q, R = np.eye(sys.nstates), np.eye(sys.ninputs) + K, _, _ = ct.dlqr(sys, Q, R) + + P0 = np.eye(sys.nstates) + QN = np.eye(sys.ninputs) + RN = np.eye(sys.noutputs) + estim = ct.create_estimator_iosystem(sys, QN, RN, P0) + + ctrl, clsys = ct.create_statefbk_iosystem(sys, K, estimator=estim) + + # Extract the elements of the estimator + est = estim.linearize(0, 0) + Be1 = est.B[:sys.nstates, :sys.noutputs] + Be2 = est.B[:sys.nstates, sys.noutputs:] + A_clchk = np.block([ + [sys.A, -sys.B @ K], + [Be1 @ sys.C, est.A[:sys.nstates, :sys.nstates] - Be2 @ K] + ]) + B_clchk = np.block([ + [sys.B @ K, sys.B], + [Be2 @ K, Be2] + ]) + C_clchk = np.block([ + [sys.C, np.zeros((sys.noutputs, sys.nstates))], + [np.zeros_like(K), -K] + ]) + D_clchk = np.block([ + [np.zeros((sys.noutputs, sys.nstates + sys.ninputs))], + [K, np.eye(sys.ninputs)] + ]) + + # Check to make sure everything matches + cls = clsys.linearize(0, 0) + nstates = sys.nstates + np.testing.assert_almost_equal(cls.A[:2*nstates, :2*nstates], A_clchk) + np.testing.assert_almost_equal(cls.B[:2*nstates, :], B_clchk) + np.testing.assert_almost_equal(cls.C[:, :2*nstates], C_clchk) + np.testing.assert_almost_equal(cls.D, D_clchk) + + +def test_estimator_errors(): + sys = ct.drss(4, 2, 2, strictly_proper=True) + P0 = np.eye(sys.nstates) + QN = np.eye(sys.ninputs) + RN = np.eye(sys.noutputs) + + with pytest.raises(ct.ControlArgument, match="Input system must be I/O"): + sys_tf = ct.tf([1], [1, 1], dt=True) + estim = ct.create_estimator_iosystem(sys_tf, QN, RN) + + with pytest.raises(NotImplementedError, match="continuous time not"): + sys_ct = ct.rss(4, 2, 2, strictly_proper=True) + estim = ct.create_estimator_iosystem(sys_ct, QN, RN) + + with pytest.raises(ValueError, match="output must be full state"): + C = np.eye(2, 4) + estim = ct.create_estimator_iosystem(sys, QN, RN, C=C) + + with pytest.raises(ValueError, match="output is the wrong size"): + sys_fs = ct.drss(4, 4, 2, strictly_proper=True) + sys_fs.C = np.eye(4) + C = np.eye(1, 4) + estim = ct.create_estimator_iosystem(sys_fs, QN, RN, C=C) + + +def test_white_noise(): + # Scalar white noise signal + T = np.linspace(0, 1000, 1000) + R = 0.5 + V = ct.white_noise(T, R) + assert abs(np.mean(V)) < 0.1 # can occassionally fail + assert abs(np.cov(V) - 0.5) < 0.1 # can occassionally fail + + # Vector white noise signal + R = [[0.5, 0], [0, 0.1]] + V = ct.white_noise(T, R) + assert abs(np.mean(V)) < 0.1 # can occassionally fail + assert np.all(abs(np.cov(V) - R) < 0.1) # can occassionally fail + + # Make sure time scaling works properly + T = T / 10 + V = ct.white_noise(T, R) + assert abs(np.mean(V)) < np.sqrt(10) # can occassionally fail + assert np.all(abs(np.cov(V) - R) < 10) # can occassionally fail + + # Make sure discrete time works properly + V = ct.white_noise(T, R, dt=T[1] - T[0]) + assert abs(np.mean(V)) < 0.1 # can occassionally fail + assert np.all(abs(np.cov(V) - R) < 0.1) # can occassionally fail + + # Test error conditions + with pytest.raises(ValueError, match="T must be 1D"): + V = ct.white_noise(R, R) + + with pytest.raises(ValueError, match="Q must be square"): + R = np.outer(np.eye(2, 3), np.ones_like(T)) + V = ct.white_noise(T, R) + + with pytest.raises(ValueError, match="Time values must be equally"): + T = np.logspace(0, 2, 100) + R = [[0.5, 0], [0, 0.1]] + V = ct.white_noise(T, R) + + +def test_correlation(): + # Create an uncorrelated random sigmal + T = np.linspace(0, 1000, 1000) + R = 0.5 + V = ct.white_noise(T, R) + + # Compute the correlation + tau, Rtau = ct.correlation(T, V) + + # Make sure the correlation makes sense + zero_index = np.where(tau == 0) + np.testing.assert_almost_equal(Rtau[zero_index], np.cov(V), decimal=2) + for i, t in enumerate(tau): + if i == zero_index: + continue + assert abs(Rtau[i]) < 0.01 + + # Try passing a second argument + tau, Rneg = ct.correlation(T, V, -V) + np.testing.assert_equal(Rtau, -Rneg) + + # Test error conditions + with pytest.raises(ValueError, match="Time vector T must be 1D"): + tau, Rtau = ct.correlation(V, V) + + with pytest.raises(ValueError, match="X and Y must be 2D"): + tau, Rtau = ct.correlation(T, np.zeros((3, T.size, 2))) + + with pytest.raises(ValueError, match="X and Y must have same length as T"): + tau, Rtau = ct.correlation(T, V[:, 0:-1]) + + with pytest.raises(ValueError, match="Time values must be equally"): + T = np.logspace(0, 2, T.size) + tau, Rtau = ct.correlation(T, V) diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py index 61c0cae38..fe73ab4a9 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -9,7 +9,7 @@ import control as ct from control import StateSpace, TransferFunction, c2d, isctime, ss2tf, tf2ss -from control.exception import slycot_check +from control.exception import slycot_check, pandas_check from control.tests.conftest import slycotonly from control.timeresp import (_default_time_vector, _ideal_tfinal_and_dt, forced_response, impulse_response, @@ -875,7 +875,7 @@ def test_time_vector(self, tsystem, fun, squeeze, matarrayout): kw['U'] = np.vstack([np.sin(t) for i in range(sys.ninputs)]) elif fun == forced_response and isctime(sys, strict=True): pytest.skip("No continuous forced_response without time vector.") - if hasattr(sys, "nstates"): + if hasattr(sys, "nstates") and sys.nstates is not None: kw['X0'] = np.arange(sys.nstates) + 1 if sys.ninputs > 1 and fun in [step_response, impulse_response]: kw['input'] = 1 @@ -1180,3 +1180,55 @@ def test_response_transpose( assert t.shape == (T.size, ) assert y.shape == ysh_no assert x.shape == (T.size, sys.nstates) + + +@pytest.mark.skipif(not pandas_check(), reason="pandas not installed") +def test_to_pandas(): + # Create a SISO time response + sys = ct.rss(2, 1, 1) + timepts = np.linspace(0, 10, 10) + resp = ct.input_output_response(sys, timepts, 1) + + # Convert to pandas + df = resp.to_pandas() + + # Check to make sure the data make senses + np.testing.assert_equal(df['time'], resp.time) + np.testing.assert_equal(df['u[0]'], resp.inputs) + np.testing.assert_equal(df['y[0]'], resp.outputs) + np.testing.assert_equal(df['x[0]'], resp.states[0]) + np.testing.assert_equal(df['x[1]'], resp.states[1]) + + # Create a MIMO time response + sys = ct.rss(2, 2, 1) + resp = ct.input_output_response(sys, timepts, np.sin(timepts)) + df = resp.to_pandas() + np.testing.assert_equal(df['time'], resp.time) + np.testing.assert_equal(df['u[0]'], resp.inputs[0]) + np.testing.assert_equal(df['y[0]'], resp.outputs[0]) + np.testing.assert_equal(df['y[1]'], resp.outputs[1]) + np.testing.assert_equal(df['x[0]'], resp.states[0]) + np.testing.assert_equal(df['x[1]'], resp.states[1]) + + # Change the time points + sys = ct.rss(2, 1, 1) + T = np.linspace(0, timepts[-1]/2, timepts.size * 2) + resp = ct.input_output_response(sys, timepts, np.sin(timepts), t_eval=T) + df = resp.to_pandas() + np.testing.assert_equal(df['time'], resp.time) + np.testing.assert_equal(df['u[0]'], resp.inputs) + np.testing.assert_equal(df['y[0]'], resp.outputs) + np.testing.assert_equal(df['x[0]'], resp.states[0]) + np.testing.assert_equal(df['x[1]'], resp.states[1]) + + +@pytest.mark.skipif(pandas_check(), reason="pandas installed") +def test_no_pandas(): + # Create a SISO time response + sys = ct.rss(2, 1, 1) + timepts = np.linspace(0, 10, 10) + resp = ct.input_output_response(sys, timepts, 1) + + # Convert to pandas + with pytest.raises(ImportError, match="pandas"): + df = resp.to_pandas() diff --git a/control/tests/trdata_test.py b/control/tests/trdata_test.py index fcd8676e9..734d35599 100644 --- a/control/tests/trdata_test.py +++ b/control/tests/trdata_test.py @@ -208,7 +208,7 @@ def test_response_copy(): assert response.input_labels == ['u'] # Unknown keyword - with pytest.raises(ValueError, match="Unknown parameter(s)*"): + with pytest.raises(TypeError, match="unrecognized keywords"): response_bad_kw = response_mimo(input=0) diff --git a/control/tests/type_conversion_test.py b/control/tests/type_conversion_test.py index dadcc587e..cdf302015 100644 --- a/control/tests/type_conversion_test.py +++ b/control/tests/type_conversion_test.py @@ -13,13 +13,13 @@ @pytest.fixture() def sys_dict(): sdict = {} - sdict['ss'] = ct.ss([[-1]], [[1]], [[1]], [[0]]) - sdict['tf'] = ct.tf([1],[0.5, 1]) - sdict['tfx'] = ct.tf([1, 1],[1]) # non-proper transfer function - sdict['frd'] = ct.frd([10+0j, 9 + 1j, 8 + 2j], [1,2,3]) + sdict['ss'] = ct.StateSpace([[-1]], [[1]], [[1]], [[0]]) + sdict['tf'] = ct.TransferFunction([1],[0.5, 1]) + sdict['tfx'] = ct.TransferFunction([1, 1], [1]) # non-proper TF + sdict['frd'] = ct.frd([10+0j, 9 + 1j, 8 + 2j, 7 + 3j], [1, 2, 3, 4]) sdict['lio'] = ct.LinearIOSystem(ct.ss([[-1]], [[5]], [[5]], [[0]])) sdict['ios'] = ct.NonlinearIOSystem( - sdict['lio']._rhs, sdict['lio']._out, 1, 1, 1) + sdict['lio']._rhs, sdict['lio']._out, inputs=1, outputs=1, states=1) sdict['arr'] = np.array([[2.0]]) sdict['flt'] = 3. return sdict @@ -59,39 +59,39 @@ def sys_dict(): rtype_list = ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'flt'] conversion_table = [ # op left ss tf frd lio ios arr flt - ('add', 'ss', ['ss', 'ss', 'xrd', 'ss', 'xos', 'ss', 'ss' ]), - ('add', 'tf', ['tf', 'tf', 'xrd', 'tf', 'xos', 'tf', 'tf' ]), - ('add', 'frd', ['xrd', 'xrd', 'frd', 'xrd', 'E', 'xrd', 'xrd']), + ('add', 'ss', ['ss', 'ss', 'frd', 'ss', 'ios', 'ss', 'ss' ]), + ('add', 'tf', ['tf', 'tf', 'frd', 'lio', 'ios', 'tf', 'tf' ]), + ('add', 'frd', ['frd', 'frd', 'frd', 'frd', 'E', 'frd', 'frd']), ('add', 'lio', ['lio', 'lio', 'xrd', 'lio', 'ios', 'lio', 'lio']), ('add', 'ios', ['ios', 'ios', 'E', 'ios', 'ios', 'ios', 'ios']), - ('add', 'arr', ['ss', 'tf', 'xrd', 'lio', 'ios', 'arr', 'arr']), - ('add', 'flt', ['ss', 'tf', 'xrd', 'lio', 'ios', 'arr', 'flt']), + ('add', 'arr', ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'arr']), + ('add', 'flt', ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'flt']), # op left ss tf frd lio ios arr flt - ('sub', 'ss', ['ss', 'ss', 'xrd', 'ss', 'xos', 'ss', 'ss' ]), - ('sub', 'tf', ['tf', 'tf', 'xrd', 'tf', 'xos', 'tf', 'tf' ]), - ('sub', 'frd', ['xrd', 'xrd', 'frd', 'xrd', 'E', 'xrd', 'xrd']), + ('sub', 'ss', ['ss', 'ss', 'frd', 'ss', 'ios', 'ss', 'ss' ]), + ('sub', 'tf', ['tf', 'tf', 'frd', 'lio', 'ios', 'tf', 'tf' ]), + ('sub', 'frd', ['frd', 'frd', 'frd', 'frd', 'E', 'frd', 'frd']), ('sub', 'lio', ['lio', 'lio', 'xrd', 'lio', 'ios', 'lio', 'lio']), ('sub', 'ios', ['ios', 'ios', 'E', 'ios', 'ios', 'ios', 'ios']), - ('sub', 'arr', ['ss', 'tf', 'xrd', 'lio', 'ios', 'arr', 'arr']), - ('sub', 'flt', ['ss', 'tf', 'xrd', 'lio', 'ios', 'arr', 'flt']), + ('sub', 'arr', ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'arr']), + ('sub', 'flt', ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'flt']), # op left ss tf frd lio ios arr flt - ('mul', 'ss', ['ss', 'ss', 'xrd', 'ss', 'xos', 'ss', 'ss' ]), - ('mul', 'tf', ['tf', 'tf', 'xrd', 'tf', 'xos', 'tf', 'tf' ]), - ('mul', 'frd', ['xrd', 'xrd', 'frd', 'xrd', 'E', 'xrd', 'frd']), + ('mul', 'ss', ['ss', 'ss', 'frd', 'ss', 'ios', 'ss', 'ss' ]), + ('mul', 'tf', ['tf', 'tf', 'frd', 'lio', 'ios', 'tf', 'tf' ]), + ('mul', 'frd', ['frd', 'frd', 'frd', 'frd', 'E', 'frd', 'frd']), ('mul', 'lio', ['lio', 'lio', 'xrd', 'lio', 'ios', 'lio', 'lio']), ('mul', 'ios', ['ios', 'ios', 'E', 'ios', 'ios', 'ios', 'ios']), - ('mul', 'arr', ['ss', 'tf', 'xrd', 'lio', 'ios', 'arr', 'arr']), + ('mul', 'arr', ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'arr']), ('mul', 'flt', ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'flt']), # op left ss tf frd lio ios arr flt - ('truediv', 'ss', ['xs', 'tf', 'xrd', 'xio', 'xos', 'xs', 'xs' ]), + ('truediv', 'ss', ['xs', 'tf', 'frd', 'xio', 'xos', 'xs', 'xs' ]), ('truediv', 'tf', ['tf', 'tf', 'xrd', 'tf', 'xos', 'tf', 'tf' ]), - ('truediv', 'frd', ['xrd', 'xrd', 'frd', 'xrd', 'E', 'xrd', 'frd']), - ('truediv', 'lio', ['xio', 'tf', 'xrd', 'xio', 'xio', 'xio', 'xio']), + ('truediv', 'frd', ['frd', 'frd', 'frd', 'frd', 'E', 'frd', 'frd']), + ('truediv', 'lio', ['xio', 'tf', 'frd', 'xio', 'xio', 'xio', 'xio']), ('truediv', 'ios', ['xos', 'xos', 'E', 'xos', 'xos' 'xos', 'xos']), - ('truediv', 'arr', ['xs', 'tf', 'xrd', 'xio', 'xos', 'arr', 'arr']), + ('truediv', 'arr', ['xs', 'tf', 'frd', 'xio', 'xos', 'arr', 'arr']), ('truediv', 'flt', ['xs', 'tf', 'frd', 'xio', 'xos', 'arr', 'flt'])] # Now create list of the tests we actually want to run @@ -147,9 +147,8 @@ def test_operator_type_conversion(opname, ltype, rtype, expected, sys_dict): # Note: tfx = non-proper transfer function, order(num) > order(den) # -type_list = ['ss', 'tf', 'tfx', 'frd', 'lio', 'ios', 'arr', 'flt'] +type_list = ['ss', 'tf', 'tfx', 'frd', 'lio', 'ios', 'arr', 'flt'] conversion_table = [ - # L \ R ['ss', 'tf', 'tfx', 'frd', 'lio', 'ios', 'arr', 'flt'] ('ss', ['ss', 'ss', 'tf' 'frd', 'lio', 'ios', 'ss', 'ss' ]), ('tf', ['tf', 'tf', 'tf' 'frd', 'lio', 'ios', 'tf', 'tf' ]), ('tfx', ['tf', 'tf', 'tf', 'frd', 'E', 'E', 'tf', 'tf' ]), @@ -161,6 +160,7 @@ def test_operator_type_conversion(opname, ltype, rtype, expected, sys_dict): @pytest.mark.skip(reason="future test; conversions not yet fully implemented") # @pytest.mark.parametrize("opname", ['add', 'sub', 'mul', 'truediv']) +# @pytest.mark.parametrize("opname", ['add', 'sub', 'mul']) # @pytest.mark.parametrize("ltype", type_list) # @pytest.mark.parametrize("rtype", type_list) def test_binary_op_type_conversions(opname, ltype, rtype, sys_dict): @@ -185,3 +185,9 @@ def test_binary_op_type_conversions(opname, ltype, rtype, sys_dict): # Print out what we are testing in case something goes wrong assert isinstance(result, type_dict[expected]) + + # Make sure that input, output, and state names make sense + assert len(result.input_labels) == result.ninputs + assert len(result.output_labels) == result.noutputs + if result.nstates is not None: + assert len(result.state_labels) == result.nstates diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index bd073e0f3..79273f31b 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -8,14 +8,11 @@ import operator import control as ct -from control.statesp import StateSpace, _convert_to_statespace, rss -from control.xferfcn import TransferFunction, _convert_to_transfer_function, \ - ss2tf -from control.lti import evalfr +from control import StateSpace, TransferFunction, rss, ss2tf, evalfr +from control import isctime, isdtime, sample_system, defaults +from control.statesp import _convert_to_statespace +from control.xferfcn import _convert_to_transfer_function from control.tests.conftest import slycotonly, nopython2, matrixfilter -from control.lti import isctime, isdtime -from control.dtime import sample_system -from control.config import defaults class TestXferFcn: @@ -42,7 +39,7 @@ def test_constructor_bad_input_type(self): TransferFunction([1]) # Too many arguments - with pytest.raises(ValueError): + with pytest.raises(TypeError): TransferFunction(1, 2, 3, 4) # Different numbers of elements in numerator rows @@ -88,18 +85,19 @@ def test_constructor_zero_denominator(self): TransferFunction([[[1.], [2., 3.]], [[-1., 4.], [3., 2.]]], [[[1., 0.], [0.]], [[0., 0.], [2.]]]) + @pytest.mark.skip("outdated test") def test_constructor_nodt(self): """Test the constructor when an object without dt is passed""" sysin = TransferFunction([[[0., 1.], [2., 3.]]], [[[5., 2.], [3., 0.]]]) - del sysin.dt + del sysin.dt # this doesn't make sense and now breaks sys = TransferFunction(sysin) assert sys.dt == defaults['control.default_dt'] # test for static gain sysin = TransferFunction([[[2.], [3.]]], [[[1.], [.1]]]) - del sysin.dt + del sysin.dt # this doesn't make sense and now breaks sys = TransferFunction(sysin) assert sys.dt is None @@ -599,7 +597,7 @@ def test_pole_mimo(self): sys = TransferFunction( [[[1.], [1.]], [[1.], [1.]]], [[[1., 2.], [1., 3.]], [[1., 4., 4.], [1., 9., 14.]]]) - p = sys.pole() + p = sys.poles() np.testing.assert_array_almost_equal(p, [-2., -2., -7., -3., -2.]) @@ -607,14 +605,14 @@ def test_pole_mimo(self): sys2 = TransferFunction( [[[1., 2., 3., 4.], [1.]], [[1.], [1.]]], [[[1., 2.], [1., 3.]], [[1., 4., 4.], [1., 9., 14.]]]) - p2 = sys2.pole() + p2 = sys2.poles() np.testing.assert_array_almost_equal(p2, [-2., -2., -7., -3., -2.]) def test_double_cancelling_poles_siso(self): H = TransferFunction([1, 1], [1, 2, 1]) - p = H.pole() + p = H.poles() np.testing.assert_array_almost_equal(p, [-1, -1]) # Tests for TransferFunction.feedback diff --git a/control/timeresp.py b/control/timeresp.py index 3f3eacc27..aa1261ccd 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -79,7 +79,8 @@ from copy import copy from . import config -from .lti import isctime, isdtime +from .exception import pandas_check +from .namedio import isctime, isdtime from .statesp import StateSpace, _convert_to_statespace, _mimo2simo, _mimo2siso from .xferfcn import TransferFunction @@ -87,7 +88,7 @@ 'initial_response', 'impulse_response', 'TimeResponseData'] -class TimeResponseData(): +class TimeResponseData: """A class for returning time responses. This class maintains and manipulates the data corresponding to the @@ -462,7 +463,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) + response.return_x = kwargs.pop('return_x', self.return_x) # Check for new labels input_labels = kwargs.pop('input_labels', None) @@ -480,9 +481,9 @@ def __call__(self, **kwargs): 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) + # Make sure there were no extraneous keywords + if kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) return response @@ -638,6 +639,23 @@ def __getitem__(self, index): def __len__(self): return 3 if self.return_x else 2 + # Convert to pandas + def to_pandas(self): + if not pandas_check(): + ImportError('pandas not installed') + import pandas + + # Create a dict for setting up the data frame + data = {'time': self.time} + data.update( + {name: self.u[i] for i, name in enumerate(self.input_labels)}) + data.update( + {name: self.y[i] for i, name in enumerate(self.output_labels)}) + data.update( + {name: self.x[i] for i, name in enumerate(self.state_labels)}) + + return pandas.DataFrame(data) + # Process signal labels def _process_labels(labels, signal, length): diff --git a/control/xferfcn.py b/control/xferfcn.py index 856b421ef..d3671c533 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -59,8 +59,10 @@ from warnings import warn from itertools import chain from re import sub -from .lti import LTI, common_timebase, isdtime, _process_frequency_response +from .lti import LTI, _process_frequency_response +from .namedio import common_timebase, isdtime, _process_namedio_keywords from .exception import ControlMIMONotImplemented +from .frdata import FrequencyResponseData from . import config __all__ = ['TransferFunction', 'tf', 'ss2tf', 'tfdata'] @@ -161,13 +163,22 @@ def __init__(self, *args, **kwargs): (continuous or discrete). """ - args = deepcopy(args) + # + # Process positional arguments + # if len(args) == 2: # The user provided a numerator and a denominator. - (num, den) = args + num, den = args + elif len(args) == 3: # Discrete time transfer function - (num, den, dt) = args + num, den, dt = args + if 'dt' in kwargs: + warn("received multiple dt arguments, " + "using positional arg dt = %s" % dt) + kwargs['dt'] = dt + args = args[:-1] + elif len(args) == 1: # Use the copy constructor. if not isinstance(args[0], TransferFunction): @@ -176,43 +187,68 @@ def __init__(self, *args, **kwargs): % type(args[0])) num = args[0].num den = args[0].den + else: - raise ValueError("Needs 1, 2 or 3 arguments; received %i." + raise TypeError("Needs 1, 2 or 3 arguments; received %i." % len(args)) num = _clean_part(num) den = _clean_part(den) - inputs = len(num[0]) - outputs = len(num) + # + # Process keyword arguments + # + + # Determine if the transfer function is static (needed for dt) + static = True + for col in num + den: + for poly in col: + if len(poly) > 1: + static = False + + defaults = args[0] if len(args) == 1 else \ + {'inputs': len(num[0]), 'outputs': len(num)} + name, inputs, outputs, states, dt = _process_namedio_keywords( + kwargs, defaults, static=static, end=True) + if states: + raise TypeError( + "states keyword not allowed for transfer functions") + + # Initialize LTI (NamedIOSystem) object + super().__init__( + name=name, inputs=inputs, outputs=outputs, dt=dt) + + # + # Check to make sure everything is consistent + # # Make sure numerator and denominator matrices have consistent sizes - if inputs != len(den[0]): + if self.ninputs != len(den[0]): raise ValueError( "The numerator has %i input(s), but the denominator has " - "%i input(s)." % (inputs, len(den[0]))) - if outputs != len(den): + "%i input(s)." % (self.ninputs, len(den[0]))) + if self.noutputs != len(den): raise ValueError( "The numerator has %i output(s), but the denominator has " - "%i output(s)." % (outputs, len(den))) + "%i output(s)." % (self.noutputs, len(den))) # Additional checks/updates on structure of the transfer function - for i in range(outputs): + for i in range(self.noutputs): # Make sure that each row has the same number of columns - if len(num[i]) != inputs: + if len(num[i]) != self.ninputs: raise ValueError( "Row 0 of the numerator matrix has %i elements, but row " - "%i has %i." % (inputs, i, len(num[i]))) - if len(den[i]) != inputs: + "%i has %i." % (self.ninputs, i, len(num[i]))) + if len(den[i]) != self.ninputs: raise ValueError( "Row 0 of the denominator matrix has %i elements, but row " - "%i has %i." % (inputs, i, len(den[i]))) + "%i has %i." % (self.ninputs, i, len(den[i]))) # Check for zeros in numerator or denominator # TODO: Right now these checks are only done during construction. # It might be worthwhile to think of a way to perform checks if the # user modifies the transfer function after construction. - for j in range(inputs): + for j in range(self.ninputs): # Check that we don't have any zero denominators. zeroden = True for k in den[i][j]: @@ -233,37 +269,16 @@ def __init__(self, *args, **kwargs): if zeronum: den[i][j] = ones(1) - LTI.__init__(self, inputs, outputs) + # Store the numerator and denominator self.num = num self.den = den + # + # Final processing + # + # Truncate leading zeros self._truncatecoeff() - # get dt - if len(args) == 2: - # no dt given in positional arguments - if 'dt' in kwargs: - dt = kwargs['dt'] - elif self._isstatic(): - dt = None - else: - dt = config.defaults['control.default_dt'] - elif len(args) == 3: - # Discrete time transfer function - if 'dt' in kwargs: - warn('received multiple dt arguments, ' - 'using positional arg dt=%s' % dt) - elif len(args) == 1: - # TODO: not sure this can ever happen since dt is always present - try: - dt = args[0].dt - except AttributeError: - if self._isstatic(): - dt = None - else: - dt = config.defaults['control.default_dt'] - self.dt = dt - # # Class attributes # @@ -523,6 +538,12 @@ def __add__(self, other): """Add two LTI objects (parallel connection).""" from .statesp import StateSpace + # Check to see if the right operator has priority + if getattr(other, '__array_priority__', None) and \ + getattr(self, '__array_priority__', None) and \ + other.__array_priority__ > self.__array_priority__: + return other.__radd__(self) + # Convert the second argument to a transfer function. if isinstance(other, StateSpace): other = _convert_to_transfer_function(other) @@ -568,6 +589,12 @@ def __rsub__(self, other): def __mul__(self, other): """Multiply two LTI objects (serial connection).""" + # Check to see if the right operator has priority + if getattr(other, '__array_priority__', None) and \ + getattr(self, '__array_priority__', None) and \ + other.__array_priority__ > self.__array_priority__: + return other.__rmul__(self) + # Convert the second argument to a transfer function. if isinstance(other, (int, float, complex, np.number)): other = _convert_to_transfer_function(other, inputs=self.ninputs, @@ -765,15 +792,15 @@ def freqresp(self, omega): "MATLAB compatibility module instead", DeprecationWarning) return self.frequency_response(omega) - def pole(self): + def poles(self): """Compute the poles of a transfer function.""" _, den, denorder = self._common_den(allow_nonproper=True) rts = [] for d, o in zip(den, denorder): rts.extend(roots(d[:o + 1])) - return np.array(rts) + return np.array(rts).astype(complex) - def zero(self): + def zeros(self): """Compute the zeros of a transfer function.""" if self.ninputs > 1 or self.noutputs > 1: raise NotImplementedError( @@ -781,7 +808,7 @@ def zero(self): "for SISO systems.") else: # for now, just give zeros of a SISO tf - return roots(self.num[0][0]) + return roots(self.num[0][0]).astype(complex) def feedback(self, other=1, sign=-1): """Feedback interconnection between two LTI objects.""" @@ -1220,10 +1247,9 @@ def _c2d_matched(sysC, Ts): sysDnum, sysDden = zpk2tf(zzeros, zpoles, gain) return TransferFunction(sysDnum, sysDden, Ts) + # Utility function to convert a transfer function polynomial to a string # Borrowed from poly1d library - - def _tf_polynomial_to_string(coeffs, var='s'): """Convert a transfer function polynomial to a string""" @@ -1296,7 +1322,7 @@ def _add_siso(num1, den1, num2, den2): return num, den -def _convert_to_transfer_function(sys, **kw): +def _convert_to_transfer_function(sys, inputs=1, outputs=1): """Convert a system to transfer function form (if needed). If sys is already a transfer function, then it is returned. If sys is a @@ -1313,6 +1339,9 @@ def _convert_to_transfer_function(sys, **kw): If sys is an array-like type, then it is converted to a constant-gain transfer function. + Note: no renaming of inputs and outputs is performed; this should be done + by the calling function. + >>> sys = _convert_to_transfer_function([[1., 0.], [2., 3.]]) In this example, the numerator matrix will be @@ -1321,15 +1350,12 @@ def _convert_to_transfer_function(sys, **kw): """ from .statesp import StateSpace + kwargs = {} if isinstance(sys, TransferFunction): - if len(kw): - raise TypeError("If sys is a TransferFunction, " + - "_convertToTransferFunction cannot take keywords.") - return sys - elif isinstance(sys, StateSpace): + elif isinstance(sys, StateSpace): if 0 == sys.nstates: # Slycot doesn't like static SS->TF conversion, so handle # it first. Can't join this with the no-Slycot branch, @@ -1340,14 +1366,9 @@ def _convert_to_transfer_function(sys, **kw): for i in range(sys.noutputs)] else: try: - from slycot import tb04ad - if len(kw): - raise TypeError( - "If sys is a StateSpace, " + - "_convertToTransferFunction cannot take keywords.") - # Use Slycot to make the transformation # Make sure to convert system matrices to numpy arrays + from slycot import tb04ad tfout = tb04ad( sys.nstates, sys.ninputs, sys.noutputs, array(sys.A), array(sys.B), array(sys.C), array(sys.D), tol1=0.0) @@ -1377,22 +1398,19 @@ def _convert_to_transfer_function(sys, **kw): num = squeeze(num) # Convert to 1D array den = squeeze(den) # Probably not needed - return TransferFunction(num, den, sys.dt) + return TransferFunction( + num, den, sys.dt, inputs=sys.input_labels, + outputs=sys.output_labels) elif isinstance(sys, (int, float, complex, np.number)): - if "inputs" in kw: - inputs = kw["inputs"] - else: - inputs = 1 - if "outputs" in kw: - outputs = kw["outputs"] - else: - outputs = 1 - num = [[[sys] for j in range(inputs)] for i in range(outputs)] den = [[[1] for j in range(inputs)] for i in range(outputs)] - return TransferFunction(num, den) + return TransferFunction( + num, den, inputs=inputs, outputs=outputs) + + elif isinstance(sys, FrequencyResponseData): + raise TypeError("Can't convert given FRD to TransferFunction system.") # If this is array-like, try to create a constant feedthrough try: @@ -1401,6 +1419,7 @@ def _convert_to_transfer_function(sys, **kw): num = [[[D[i, j]] for j in range(inputs)] for i in range(outputs)] den = [[[1] for j in range(inputs)] for i in range(outputs)] return TransferFunction(num, den) + except Exception: raise TypeError("Can't convert given type to TransferFunction system.") @@ -1450,6 +1469,16 @@ def tf(*args, **kwargs): out: :class:`TransferFunction` The new linear system + Other Parameters + ---------------- + inputs, outputs : str, or list of str, optional + List of strings that name the individual signals of the transformed + system. If not given, the inputs and outputs are the same as the + original system. + name : string, optional + System name. If unspecified, a generic name is generated + with a unique integer id. + Raises ------ ValueError @@ -1496,19 +1525,26 @@ def tf(*args, **kwargs): if len(args) == 2 or len(args) == 3: return TransferFunction(*args, **kwargs) - elif len(args) == 1: + + elif len(args) == 1 and isinstance(args[0], str): + # Make sure there were no extraneous keywords + if kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) + # Look for special cases defining differential/delay operator if args[0] == 's': return TransferFunction.s elif args[0] == 'z': return TransferFunction.z + elif len(args) == 1: from .statesp import StateSpace sys = args[0] if isinstance(sys, StateSpace): - return ss2tf(sys) + return ss2tf(sys, **kwargs) elif isinstance(sys, TransferFunction): - return deepcopy(sys) + # Use copy constructor + return TransferFunction(sys, **kwargs) else: raise TypeError("tf(sys): sys must be a StateSpace or " "TransferFunction object. It is %s." % type(sys)) @@ -1524,14 +1560,14 @@ def ss2tf(*args, **kwargs): The function accepts either 1 or 4 parameters: ``ss2tf(sys)`` - Convert a linear system into space system form. Always creates a - new system, even if sys is already a StateSpace object. + Convert a linear system from state space into transfer function + form. Always creates a new system. ``ss2tf(A, B, C, D)`` - Create a state space system from the matrices of its state and + Create a transfer function system from the matrices of its state and output equations. - For details see: :func:`ss` + For details see: :func:`tf` Parameters ---------- @@ -1551,6 +1587,16 @@ def ss2tf(*args, **kwargs): out: TransferFunction New linear system in transfer function form + Other Parameters + ---------------- + inputs, outputs : str, or list of str, optional + List of strings that name the individual signals of the transformed + system. If not given, the inputs and outputs are the same as the + original system. + name : string, optional + System name. If unspecified, a generic name is generated + with a unique integer id. + Raises ------ ValueError @@ -1583,10 +1629,11 @@ def ss2tf(*args, **kwargs): # Assume we were given the A, B, C, D matrix and (optional) dt return _convert_to_transfer_function(StateSpace(*args, **kwargs)) - elif len(args) == 1: + if len(args) == 1: sys = args[0] if isinstance(sys, StateSpace): - return _convert_to_transfer_function(sys) + return TransferFunction( + _convert_to_transfer_function(sys), **kwargs) else: raise TypeError( "ss2tf(sys): sys must be a StateSpace object. It is %s." diff --git a/doc/.gitignore b/doc/.gitignore new file mode 100644 index 000000000..d948f64d2 --- /dev/null +++ b/doc/.gitignore @@ -0,0 +1 @@ +*.fig.bak diff --git a/doc/Makefile b/doc/Makefile index b72312be4..3f372684c 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -14,7 +14,11 @@ help: .PHONY: help Makefile +# Rules to create figures +FIGS = classes.pdf +classes.pdf: classes.fig; fig2dev -Lpdf $< $@ + # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file +html pdf: Makefile $(FIGS) + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/doc/classes.fig b/doc/classes.fig new file mode 100644 index 000000000..950510c01 --- /dev/null +++ b/doc/classes.fig @@ -0,0 +1,149 @@ +#FIG 3.2 Produced by xfig version 3.2.8b +Landscape +Center +Inches +Letter +100.00 +Single +-2 +1200 2 +2 2 1 1 0 7 50 -1 -1 4.000 0 0 -1 0 0 5 + 9750 3375 12075 3375 12075 4725 9750 4725 9750 3375 +2 2 1 1 0 7 50 -1 -1 4.000 0 0 -1 0 0 5 + 9750 6000 12075 6000 12075 7350 9750 7350 9750 6000 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 1 0 2 + 1 1 1.00 60.00 120.00 + 8925 3600 9750 3600 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 1 0 2 + 1 1 1.00 60.00 120.00 + 10875 3750 10875 4350 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 1 0 2 + 1 1 1.00 60.00 120.00 + 6375 3750 9975 6150 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 1 0 2 + 1 1 1.00 60.00 120.00 + 10875 6375 10875 6975 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 1 0 2 + 1 1 1.00 60.00 120.00 + 6750 6225 9975 6225 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 1 0 2 + 1 1 1.00 60.00 120.00 + 6000 6075 6000 6975 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 1 0 2 + 1 1 1.00 60.00 120.00 + 2700 5400 3075 5850 +2 2 1 1 0 7 50 -1 -1 4.000 0 0 -1 0 0 5 + 1650 4500 6750 4500 6750 7425 1650 7425 1650 4500 +2 2 1 1 0 7 50 -1 -1 4.000 0 0 -1 0 0 5 + 1650 7950 6150 7950 6150 8550 1650 8550 1650 7950 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 -1 1 0 2 + 1 1 1.00 60.00 120.00 + 2775 8175 4200 8175 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 -1 1 0 2 + 1 1 1.00 60.00 120.00 + 9075 8100 9675 8100 +2 2 1 1 0 7 50 -1 -1 4.000 0 0 -1 0 0 5 + 9075 8250 9675 8250 9675 8550 9075 8550 9075 8250 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 1 1.00 60.00 120.00 + 4725 5925 5175 5925 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 1 1 2 + 1 1 1.00 60.00 120.00 + 1 1 1.00 60.00 120.00 + 6525 3600 7275 3600 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 1 0 2 + 1 1 1.00 60.00 120.00 + 5775 8175 9975 6300 +2 2 1 1 0 7 50 -1 -1 4.000 0 0 -1 0 0 5 + 5400 3375 6600 3375 6600 3900 5400 3900 5400 3375 +2 2 1 1 0 7 50 -1 -1 4.000 0 0 -1 0 0 5 + 7050 2175 8100 2175 8100 2700 7050 2700 7050 2175 +2 2 1 1 1 7 50 -1 -1 4.000 0 0 -1 0 0 5 + 4500 975 6525 975 6525 1500 4500 1500 4500 975 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 5250 1350 3825 4575 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 5775 1350 7575 2250 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 7875 2550 10875 3450 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 7575 2550 8025 3450 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 7350 2550 6225 3450 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 3300 4875 3000 5100 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 3825 4875 3825 5775 +2 1 0 2 4 7 50 -1 -1 0.000 0 0 7 1 0 2 + 1 1 1.00 60.00 120.00 + 4350 4875 5625 5775 +2 2 1 1 0 7 50 -1 -1 4.000 0 0 -1 0 0 5 + 7350 3375 8925 3375 8925 3900 7350 3900 7350 3375 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 -1 0 1 2 + 1 0 1.00 60.00 90.00 + 9075 7800 9675 7800 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 4350 6075 5625 6975 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 -1 0 1 2 + 1 0 1.00 60.00 90.00 + 2400 5400 2400 8025 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 5850 6075 5850 6975 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 4125 4875 5400 5775 +2 1 0 2 1 7 50 -1 -1 0.000 0 0 7 0 1 2 + 1 0 1.00 60.00 90.00 + 5925 3750 5925 5775 +4 0 0 50 -1 0 12 0.0000 4 165 885 5400 3300 statesp.py\001 +4 0 0 50 -1 0 12 0.0000 4 195 420 8175 2325 lti.py\001 +4 2 0 50 -1 0 12 0.0000 4 195 885 8925 3300 xferfcn.py\001 +4 2 0 50 -1 0 12 0.0000 4 195 780 12075 3300 frdata.py\001 +4 2 0 50 -1 0 12 0.0000 4 195 780 12075 5925 trdata.py\001 +4 1 1 50 -1 0 12 0.0000 4 150 345 7575 2475 LTI\001 +4 1 1 50 -1 0 12 0.0000 4 195 1440 5925 6000 LinearIOSystem\001 +4 0 0 50 -1 0 12 0.0000 4 195 615 1650 7875 flatsys/\001 +4 0 0 50 -1 0 12 0.0000 4 195 705 1650 4425 iosys.py\001 +4 0 0 50 -1 0 12 0.0000 4 195 720 8700 7575 Legend:\001 +4 1 1 50 -1 16 12 0.0000 4 210 1590 5475 1275 NamedIOSystem\001 +4 1 1 50 -1 16 12 0.0000 4 210 1770 3975 4800 InputOutputSystem\001 +4 1 1 50 -1 16 12 0.0000 4 210 1830 2625 5325 NonlinearIOSystem\001 +4 0 0 50 -1 0 12 0.0000 4 195 1005 6600 1125 namedio.py\001 +4 0 4 50 -1 16 12 0.0000 4 210 945 4800 5100 linearize()\001 +4 1 1 50 -1 16 12 0.0000 4 210 2115 3750 6000 InterconnectedSystem\001 +4 0 4 50 -1 16 12 0.0000 4 210 1875 3000 6750 ic() = interconnect()\001 +4 1 1 50 -1 16 12 0.0000 4 210 1500 5925 7200 LinearICSystem\001 +4 1 1 50 -1 16 12 0.0000 4 210 1035 2250 8250 FlatSystem\001 +4 1 4 50 -1 16 12 0.0000 4 210 1500 3525 8400 point_to_point()\001 +4 1 1 50 -1 16 12 0.0000 4 210 1095 6000 3675 StateSpace\001 +4 1 1 50 -1 16 12 0.0000 4 165 1605 8100 3675 TransferFunction\001 +4 1 1 50 -1 16 12 0.0000 4 210 2400 10875 3675 FrequencyResponseData\001 +4 0 4 50 -1 16 12 0.0000 4 210 1155 10950 4050 to_pandas()\001 +4 1 1 50 -1 16 12 0.0000 4 210 1800 10875 4575 pandas.DataFrame\001 +4 0 4 50 -1 16 12 0.0000 4 210 1560 7950 4725 step_response()\001 +4 0 4 50 -1 16 12 0.0000 4 210 1635 8400 5025 initial_response()\001 +4 0 4 50 -1 16 12 0.0000 4 210 1755 8850 5325 forced_response()\001 +4 1 1 50 -1 16 12 0.0000 4 210 1875 10875 6300 TimeResponseData\001 +4 0 4 50 -1 16 12 0.0000 4 210 1155 10950 6675 to_pandas()\001 +4 1 1 50 -1 16 12 0.0000 4 210 1800 10875 7200 pandas.DataFrame\001 +4 0 1 50 -1 16 12 0.0000 4 210 1755 9750 7875 Class dependency\001 +4 0 4 50 -1 16 12 0.0000 4 210 2475 9750 8175 Conversion [via function()]\001 +4 0 0 50 -1 0 12 0.0000 4 150 1380 9750 8475 Source code file\001 +4 1 4 50 -1 16 12 0.0000 4 210 300 3150 5625 ic()\001 +4 0 4 50 -1 16 12 0.0000 4 210 300 6075 6600 ic()\001 +4 1 1 50 -1 16 12 0.0000 4 210 1650 4950 8250 SystemTrajectory\001 +4 1 4 50 -1 16 12 0.0000 4 210 945 9375 3825 freqresp()\001 +4 1 4 50 -1 16 12 0.0000 4 210 600 6975 3825 tf2ss()\001 +4 1 4 50 -1 16 12 0.0000 4 210 600 6975 3450 ss2tf()\001 +4 1 4 50 -1 16 12 0.0000 4 210 300 5025 6150 ic()\001 +4 1 4 50 -1 16 12 0.0000 4 210 2295 8325 6075 input_output_response()\001 +4 2 4 50 -1 16 12 0.0000 4 210 1035 8175 6975 response()\001 diff --git a/doc/classes.pdf b/doc/classes.pdf new file mode 100644 index 000000000..66ef25e10 Binary files /dev/null and b/doc/classes.pdf differ diff --git a/doc/classes.rst b/doc/classes.rst index 0753271c4..87ce457de 100644 --- a/doc/classes.rst +++ b/doc/classes.rst @@ -5,20 +5,30 @@ Control system classes ********************** -The classes listed below are used to represent models of linear time-invariant -(LTI) systems. They are usually created from factory functions such as -:func:`tf` and :func:`ss`, so the user should normally not need to instantiate -these directly. +The classes listed below are used to represent models of input/output +systems (both linear time-invariant and nonlinear). They are usually +created from factory functions such as :func:`tf` and :func:`ss`, so the +user should normally not need to instantiate these directly. .. autosummary:: :toctree: generated/ :template: custom-class-template.rst - TransferFunction StateSpace + TransferFunction + InputOutputSystem FrequencyResponseData TimeResponseData +The following figure illustrates the relationship between the classes and +some of the functions that can be used to convert objects from one class to +another: + +.. image:: classes.pdf + :width: 800 + +| + Input/output system subclasses ============================== Input/output systems are accessed primarily via a set of subclasses diff --git a/doc/control.rst b/doc/control.rst index 87c1151eb..fc6618d24 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -83,8 +83,8 @@ Control system analysis margin stability_margins phase_crossover_frequencies - pole - zero + poles + zeros pzmap root_locus sisotool @@ -108,13 +108,14 @@ Control system synthesis :toctree: generated/ acker + create_statefbk_iosystem + dlqr h2syn hinfsyn lqr - lqe mixsyn place - rlocus_pid_designer + rootlocus_pid_designer Model simplification tools ========================== @@ -143,6 +144,17 @@ Nonlinear system support tf2io flatsys.point_to_point +Stochastic system support +========================= +.. autosummary:: + :toctree: generated/ + + correlation + create_estimator_iosystem + dlqe + lqe + white_noise + .. _utility-and-conversions: Utility functions and conversions diff --git a/doc/conventions.rst b/doc/conventions.rst index 462a71408..1832b9525 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -29,9 +29,9 @@ of linear time-invariant (LTI) systems: where u is the input, y is the output, and x is the state. -To create a state space system, use the :class:`StateSpace` constructor: +To create a state space system, use the :fun:`ss` function: - sys = StateSpace(A, B, C, D) + sys = ct.ss(A, B, C, D) State space systems can be manipulated using standard arithmetic operations as well as the :func:`feedback`, :func:`parallel`, and :func:`series` @@ -51,10 +51,9 @@ transfer functions where n is generally greater than or equal to m (for a proper transfer function). -To create a transfer function, use the :class:`TransferFunction` -constructor: +To create a transfer function, use the :func:`tf` function: - sys = TransferFunction(num, den) + sys = ct.tf(num, den) Transfer functions can be manipulated using standard arithmetic operations as well as the :func:`feedback`, :func:`parallel`, and :func:`series` @@ -75,6 +74,28 @@ FRD systems have a somewhat more limited set of functions that are available, although all of the standard algebraic manipulations can be performed. +The FRD class is also used as the return type for the +:func:`frequency_response` function (and the equivalent method for the +:class:`StateSpace` and :class:`TransferFunction` classes). This +object can be assigned to a tuple using + + mag, phase, omega = response + +where `mag` is the magnitude (absolute value, not dB or log10) of the +system frequency response, `phase` is the wrapped phase in radians of +the system frequency response, and `omega` is the (sorted) frequencies +at which the response was evaluated. If the system is SISO and the +`squeeze` argument to :func:`frequency_response` is not True, +`magnitude` and `phase` are 1D, indexed by frequency. If the system +is not SISO or `squeeze` is False, the array is 3D, indexed by the +output, input, and frequency. If `squeeze` is True then +single-dimensional axes are removed. The processing of the `squeeze` +keyword can be changed by calling the response function with a new +argument: + + mag, phase, omega = response(squeeze=False) + + Discrete time systems --------------------- A discrete time system is created by specifying a nonzero 'timebase', dt. @@ -89,14 +110,16 @@ Only the :class:`StateSpace`, :class:`TransferFunction`, and :class:`InputOutputSystem` classes allow explicit representation of discrete time systems. -Systems must have compatible timebases in order to be combined. A discrete time -system with unspecified sampling time (`dt = True`) can be combined with a system -having a specified sampling time; the result will be a discrete time system with the sample time of the latter -system. Similarly, a system with timebase `None` can be combined with a system having a specified -timebase; the result will have the timebase of the latter system. For continuous -time systems, the :func:`sample_system` function or the :meth:`StateSpace.sample` and :meth:`TransferFunction.sample` methods -can be used to create a discrete time system from a continuous time system. -See :ref:`utility-and-conversions`. The default value of 'dt' can be changed by +Systems must have compatible timebases in order to be combined. A discrete +time system with unspecified sampling time (`dt = True`) can be combined with +a system having a specified sampling time; the result will be a discrete time +system with the sample time of the latter system. Similarly, a system with +timebase `None` can be combined with a system having a specified timebase; the +result will have the timebase of the latter system. For continuous time +systems, the :func:`sample_system` function or the :meth:`StateSpace.sample` +and :meth:`TransferFunction.sample` methods can be used to create a discrete +time system from a continuous time system. See +:ref:`utility-and-conversions`. The default value of 'dt' can be changed by changing the value of ``control.config.defaults['control.default_dt']``. Conversion between representations @@ -129,11 +152,6 @@ and :func:`initial_response`. Thus, all 2D values must be transposed when they are used with functions from `scipy.signal`_. -Types: - - * **Arguments** can be **arrays**, **matrices**, or **nested lists**. - * **Return values** are **arrays** (not matrices). - The time vector is a 1D array with shape (n, ):: T = [t1, t2, t3, ..., tn ] @@ -170,8 +188,8 @@ Functions that return time responses (e.g., :func:`forced_response`, response. These data can be accessed via the ``time``, ``outputs``, ``states`` and ``inputs`` properties:: - sys = rss(4, 1, 1) - response = step_response(sys) + sys = ct.rss(4, 1, 1) + response = ct.step_response(sys) plot(response.time, response.outputs) The dimensions of the response properties depend on the function being @@ -185,12 +203,12 @@ 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) + t, y = ct.step_response(sys) plot(t, y) -The output of a MIMO system can be plotted like this:: +The output of a MIMO LTI system can be plotted like this:: - t, y = forced_response(sys, t, u) + t, y = ct.forced_response(sys, t, u) plot(t, y[0], label='y_0') plot(t, y[1], label='y_1') @@ -201,6 +219,16 @@ response, can be computed like this:: ft = D @ U +Finally, the `to_pandas()` function can be used to create a pandas dataframe: + + df = response.to_pandas() + +The column labels for the data frame are `time` and the labels for the input, +output, and state signals (`u[i]`, `y[i]`, and `x[i]` by default, but these +can be changed using the `inputs`, `outputs`, and `states` keywords when +constructing the system, as described in :func:`ss`, :func:`tf`, and other +system creation function. Note that when exporting to pandas, "rows" in the +data frame correspond to time and "cols" (DataSeries) correspond to signals. .. currentmodule:: control .. _package-configuration-parameters: @@ -218,14 +246,14 @@ element of the `control.config.defaults` dictionary: .. code-block:: python - control.config.defaults['module.parameter'] = value + ct.config.defaults['module.parameter'] = value The `~control.config.set_defaults` function can also be used to set multiple configuration parameters at the same time: .. code-block:: python - control.config.set_defaults('module', param1=val1, param2=val2, ...] + ct.config.set_defaults('module', param1=val1, param2=val2, ...] Finally, there are also functions available set collections of variables based on standard configurations. diff --git a/doc/examples.rst b/doc/examples.rst index 91476bc9d..0f23576bd 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -29,6 +29,7 @@ other sources. robust_mimo cruise-control steering-gainsched + steering-optimal kincar-flatsys Jupyter notebooks @@ -43,6 +44,9 @@ using running examples in FBS2e. cruise describing_functions + kincar-fusion mpc_aircraft steering pvtol-lqr-nested + pvtol-outputfbk + stochresp diff --git a/doc/iosys.rst b/doc/iosys.rst index 41e37cfec..1da7f5884 100644 --- a/doc/iosys.rst +++ b/doc/iosys.rst @@ -13,25 +13,22 @@ The dynamics of the system can be in continuous or discrete time. To simulate an input/output system, use the :func:`~control.input_output_response` function:: - t, y = input_output_response(io_sys, T, U, X0, params) + t, y = ct.input_output_response(io_sys, T, U, X0, params) An input/output system can be linearized around an equilibrium point to obtain a :class:`~control.StateSpace` linear system. Use the :func:`~control.find_eqpt` function to obtain an equilibrium point and the :func:`~control.linearize` function to linearize about that equilibrium point:: - xeq, ueq = find_eqpt(io_sys, X0, U0) - ss_sys = linearize(io_sys, xeq, ueq) + xeq, ueq = ct.find_eqpt(io_sys, X0, U0) + ss_sys = ct.linearize(io_sys, xeq, ueq) -Input/output systems can be created from state space LTI systems by using the -:class:`~control.LinearIOSystem` class`:: - - io_sys = LinearIOSystem(ss_sys) - -Nonlinear input/output systems can be created using the -:class:`~control.NonlinearIOSystem` class, which requires the definition of an -update function (for the right hand side of the differential or different -equation) and and output function (computes the outputs from the state):: +Input/output systems are automatically created for state space LTI systems +when using the :func:`ss` function. Nonlinear input/output systems can be +created using the :class:`~control.NonlinearIOSystem` class, which requires +the definition of an update function (for the right hand side of the +differential or different equation) and an output function (computes the +outputs from the state):: io_sys = NonlinearIOSystem(updfcn, outfcn, inputs=M, outputs=P, states=N) @@ -64,7 +61,7 @@ We begin by defining the dynamics of the system .. code-block:: python - import control + import control as ct import numpy as np import matplotlib.pyplot as plt @@ -94,7 +91,7 @@ We now create an input/output system using these dynamics: .. code-block:: python - io_predprey = control.NonlinearIOSystem( + io_predprey = ct.NonlinearIOSystem( predprey_rhs, None, inputs=('u'), outputs=('H', 'L'), states=('H', 'L'), name='predprey') @@ -110,7 +107,7 @@ of the system: T = np.linspace(0, 70, 500) # Simulation 70 years of time # Simulate the system - t, y = control.input_output_response(io_predprey, T, 0, X0) + t, y = ct.input_output_response(io_predprey, T, 0, X0) # Plot the response plt.figure(1) @@ -125,9 +122,9 @@ system and computing the linearization about that point. .. code-block:: python - eqpt = control.find_eqpt(io_predprey, X0, 0) + eqpt = ct.find_eqpt(io_predprey, X0, 0) xeq = eqpt[0] # choose the nonzero equilibrium point - lin_predprey = control.linearize(io_predprey, xeq, 0) + lin_predprey = ct.linearize(io_predprey, xeq, 0) We next compute a controller that stabilizes the equilibrium point using eigenvalue placement and computing the feedforward gain using the number of @@ -135,7 +132,7 @@ lynxes as the desired output (following FBS2e, Example 7.5): .. code-block:: python - K = control.place(lin_predprey.A, lin_predprey.B, [-0.1, -0.2]) + K = ct.place(lin_predprey.A, lin_predprey.B, [-0.1, -0.2]) A, B = lin_predprey.A, lin_predprey.B C = np.array([[0, 1]]) # regulated output = number of lynxes kf = -1/(C @ np.linalg.inv(A - B @ K) @ B) @@ -147,7 +144,7 @@ constructed using the `~control.ios.NonlinearIOSystem` class: .. code-block:: python - io_controller = control.NonlinearIOSystem( + io_controller = ct.NonlinearIOSystem( None, lambda t, x, u, params: -K @ (u[1:] - xeq) + kf * (u[0] - xeq[1]), inputs=('Ld', 'u1', 'u2'), outputs=1, name='control') @@ -161,7 +158,7 @@ function: .. code-block:: python - io_closed = control.interconnect( + io_closed = ct.interconnect( [io_predprey, io_controller], # systems connections=[ ['predprey.u', 'control.y[0]'], @@ -177,7 +174,7 @@ Finally, we simulate the closed loop system: .. code-block:: python # Simulate the system - t, y = control.input_output_response(io_closed, T, 30, [15, 20]) + t, y = ct.input_output_response(io_closed, T, 30, [15, 20]) # Plot the response plt.figure(2) @@ -245,10 +242,10 @@ interconnecting systems, especially when combined with the :func:`~control.summing_junction` function. For example, the following code will create a unity gain, negative feedback system:: - P = control.tf2io(control.tf(1, [1, 0]), inputs='u', outputs='y') - C = control.tf2io(control.tf(10, [1, 1]), inputs='e', outputs='u') - sumblk = control.summing_junction(inputs=['r', '-y'], output='e') - T = control.interconnect([P, C, sumblk], inplist='r', outlist='y') + P = ct.tf2io([1], [1, 0], inputs='u', outputs='y') + C = ct.tf2io([10], [1, 1], inputs='e', outputs='u') + sumblk = ct.summing_junction(inputs=['r', '-y'], output='e') + T = ct.interconnect([P, C, sumblk], inplist='r', outlist='y') If a signal name appears in multiple outputs then that signal will be summed when it is interconnected. Similarly, if a signal name appears in multiple diff --git a/doc/kincar-fusion.ipynb b/doc/kincar-fusion.ipynb new file mode 120000 index 000000000..def600898 --- /dev/null +++ b/doc/kincar-fusion.ipynb @@ -0,0 +1 @@ +../examples/kincar-fusion.ipynb \ No newline at end of file diff --git a/doc/optimal.rst b/doc/optimal.rst index e173e430b..bb952e9cc 100644 --- a/doc/optimal.rst +++ b/doc/optimal.rst @@ -79,7 +79,7 @@ Every :math:`\Delta T` seconds, an optimal control problem is solved over a :math:`T` second horizon, starting from the current state. The first :math:`\Delta T` seconds of the optimal control :math:`u_T^{\*}(\cdot; x(t))` is then applied to the system. If we let :math:`x_T^{\*}(\cdot; -x(t))` represent the optimal trajectory starting from :math:`x(t)`$ then the +x(t))` represent the optimal trajectory starting from :math:`x(t)` then the system state evolves from :math:`x(t)` at current time :math:`t` to :math:`x_T^{*}(\delta T, x(t))` at the next sample time :math:`t + \Delta T`, assuming no model uncertainty. @@ -99,7 +99,10 @@ The optimal control module provides a means of computing optimal trajectories for nonlinear systems and implementing optimization-based controllers, including model predictive control. It follows the basic problem setup described above, but carries out all computations in *discrete -time* (so that integrals become sums) and over a *finite horizon*. +time* (so that integrals become sums) and over a *finite horizon*. To local +the optimal control modules, import `control.optimal`: + + import control.optimal as obc To describe an optimal control problem we need an input/output system, a time horizon, a cost function, and (optionally) a set of constraints on the @@ -219,9 +222,11 @@ with a starting and ending velocity of 10 m/s:: To set up the optimal control problem we design a cost function that penalizes the state and input using quadratic cost functions:: - Q = np.diag([0.1, 10, .1]) # keep lateral error low - R = np.eye(2) * 0.1 - cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + Q = np.diag([0, 0, 0.1]) # don't turn too sharply + R = np.diag([1, 1]) # keep inputs small + P = np.diag([1000, 1000, 1000]) # get close to final point + traj_cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + term_cost = opt.quadratic_cost(vehicle, P, 0, x0=xf) We also constraint the maximum turning rate to 0.1 radians (about 6 degees) and constrain the velocity to be in the range of 9 m/s to 11 m/s:: @@ -230,20 +235,19 @@ and constrain the velocity to be in the range of 9 m/s to 11 m/s:: Finally, we solve for the optimal inputs:: - horizon = np.linspace(0, Tf, 20, endpoint=True) - bend_left = [10, 0.01] # slight left veer - + horizon = np.linspace(0, Tf, 3, endpoint=True) result = opt.solve_ocp( - vehicle, horizon, x0, cost, constraints, initial_guess=bend_left, - options={'eps': 0.01}) # set step size for gradient calculation - - # Extract the results - u = result.inputs - t, y = ct.input_output_response(vehicle, horizon, u, x0) + vehicle, horizon, x0, traj_cost, constraints, + terminal_cost=term_cost, initial_guess=u0) Plotting the results:: - # Plot the results + # Simulate the system dynamics (open loop) + resp = ct.input_output_response( + vehicle, horizon, result.inputs, x0, + t_eval=np.linspace(0, Tf, 100)) + t, y, u = resp.time, resp.outputs, resp.inputs + plt.subplot(3, 1, 1) plt.plot(y[0], y[1]) plt.plot(x0[0], x0[1], 'ro', xf[0], xf[1], 'ro') @@ -252,15 +256,13 @@ Plotting the results:: plt.subplot(3, 1, 2) plt.plot(t, u[0]) - plt.axis([0, 10, 8.5, 11.5]) - plt.plot([0, 10], [9, 9], 'k--', [0, 10], [11, 11], 'k--') + plt.axis([0, 10, 9.9, 10.1]) plt.xlabel("t [sec]") plt.ylabel("u1 [m/s]") plt.subplot(3, 1, 3) plt.plot(t, u[1]) - plt.axis([0, 10, -0.15, 0.15]) - plt.plot([0, 10], [-0.1, -0.1], 'k--', [0, 10], [0.1, 0.1], 'k--') + plt.axis([0, 10, -0.01, 0.01]) plt.xlabel("t [sec]") plt.ylabel("u2 [rad/s]") @@ -272,6 +274,47 @@ yields .. image:: steering-optimal.png +Optimization Tips +================= + +The python-control optimization module makes use of the SciPy optimization +toolbox and it can sometimes be tricky to get the optimization to converge. +If you are getting errors when solving optimal control problems or your +solutions do not seem close to optimal, here are a few things to try: + +* Less is more: try using a smaller number of time points in your + optimiation. The default optimal control problem formulation uses the + value of the inputs at each time point as a free variable and this can + generate a large number of parameters quickly. Often you can find very + good solutions with a small number of free variables (the example above + uses 3 time points for 2 inputs, so a total of 6 optimization variables). + Note that you can "resample" the optimal trajectory by running a + simulation of the sytem and using the `t_eval` keyword in + `input_output_response` (as done above). + +* Use a smooth basis: as an alternative to parameterizing the optimal + control inputs using the value of the control at the listed time points, + you can specify a set of basis functions using the `basis` keyword in + :func:`~control.solve_ocp` and then parameterize the controller by linear + combination of the basis functions. The :mod:`!control.flatsys` module + defines several sets of basis functions that can be used. + +* Tweak the optimizer: by using the `minimize_method`, `minimize_options`, + and `minimize_kwargs` keywords in :func:`~control.solve_ocp`, you can + choose the SciPy optimization function that you use and set many + parameters. See :func:`scipy.optimize.minimize` for more information on + the optimzers that are available and the options and keywords that they + accept. + +* Walk before you run: try setting up a simpler version of the optimization, + remove constraints or simplifying the cost to get a simple version of the + problem working and then add complexity. Sometimes this can help you find + the right set of options or identify situations in which you are being too + aggressive in what your are trying to get the system to do. + +See :ref:`steering-optimal` for some examples of different problem +formulations. + Module classes and functions ============================ diff --git a/doc/pvtol-outputfbk.ipynb b/doc/pvtol-outputfbk.ipynb new file mode 120000 index 000000000..ffcfd5401 --- /dev/null +++ b/doc/pvtol-outputfbk.ipynb @@ -0,0 +1 @@ +../examples/pvtol-outputfbk.ipynb \ No newline at end of file diff --git a/doc/steering-optimal.png b/doc/steering-optimal.png index 6ff50c0f4..f847923b5 100644 Binary files a/doc/steering-optimal.png and b/doc/steering-optimal.png differ diff --git a/doc/steering-optimal.py b/doc/steering-optimal.py new file mode 120000 index 000000000..506033ec1 --- /dev/null +++ b/doc/steering-optimal.py @@ -0,0 +1 @@ +../examples/steering-optimal.py \ No newline at end of file diff --git a/doc/steering-optimal.rst b/doc/steering-optimal.rst new file mode 100644 index 000000000..777278c1c --- /dev/null +++ b/doc/steering-optimal.rst @@ -0,0 +1,14 @@ +.. _steering-optimal: + +Optimal control for vehicle steeering (lane change) +--------------------------------------------------- + +Code +.... +.. literalinclude:: steering-optimal.py + :language: python + :linenos: + + +Notes +..... diff --git a/doc/stochresp.ipynb b/doc/stochresp.ipynb new file mode 120000 index 000000000..36190a54c --- /dev/null +++ b/doc/stochresp.ipynb @@ -0,0 +1 @@ +../examples/stochresp.ipynb \ No newline at end of file diff --git a/examples/kincar-flatsys.py b/examples/kincar-flatsys.py index ca2a946ed..967bdb310 100644 --- a/examples/kincar-flatsys.py +++ b/examples/kincar-flatsys.py @@ -109,7 +109,7 @@ def plot_results(t, x, ud): # Create differentially flat input/output system vehicle_flat = fs.FlatSystem( vehicle_flat_forward, vehicle_flat_reverse, vehicle_update, - inputs=('v', 'delta'), outputs=('x', 'y', 'theta'), + inputs=('v', 'delta'), outputs=('x', 'y'), states=('x', 'y', 'theta')) # Define the endpoints of the trajectory diff --git a/examples/kincar-fusion.ipynb b/examples/kincar-fusion.ipynb new file mode 100644 index 000000000..d8e680b81 --- /dev/null +++ b/examples/kincar-fusion.ipynb @@ -0,0 +1,581 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "eec23018", + "metadata": {}, + "source": [ + "# Discrete Time Sensor Fusion\n", + "RMM, 24 Feb 2022\n", + "\n", + "In this example we work through estimation of the state of a car changing lanes with two different sensors available: one with good longitudinal accuracy and the other with good lateral accuracy.\n", + "\n", + "All calculations are done in discrete time, using both a Kalman filter formulation and predictor-corrector form." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "107a6613", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import scipy as sp\n", + "import matplotlib.pyplot as plt\n", + "import control as ct\n", + "import control.optimal as opt\n", + "import control.flatsys as fs\n", + "from IPython.display import Image\n", + "\n", + "# Define line styles\n", + "ebarstyle = {'elinewidth': 0.5, 'capsize': 2}\n", + "xdstyle = {'color': 'k', 'linestyle': '--', 'linewidth': 0.5, \n", + " 'marker': '+', 'markersize': 4}" + ] + }, + { + "cell_type": "markdown", + "id": "ea8807a4", + "metadata": {}, + "source": [ + "## System definition\n", + "\n", + "We consider a bicycle model for an automobile:\n", + "\n", + "\n", + "\n", + "### Continuous time model\n", + "The dynamics are given by\n", + "\n", + "$$\n", + " \\begin{aligned}\n", + " \\dot x &= \\cos\\theta \\, v, \\qquad\n", + " \\dot y &= \\sin\\theta \\, v, \\qquad\n", + " \\dot \\theta &= \\frac{v}{l} \\tan\\phi,\n", + " \\end{aligned}\n", + "$$\n", + "\n", + "where $(x, y, \\theta)$ are the position and orientation of the vehicle, $v$ is the forward velocity, $\\phi$ is the steering wheel angle, and $l$ is the wheelbase.\n", + "\n", + "These dynamics are included in the file `vehicle.py`:" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "a04106f8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Object: vehicle\n", + "Inputs (2): v, delta, \n", + "Outputs (3): x, y, theta, \n", + "States (3): x, y, theta, \n" + ] + } + ], + "source": [ + "# Vehicle steering dynamics\n", + "#\n", + "# System state: x, y, theta\n", + "# System input: v, phi\n", + "# System output: x, y\n", + "# System parameters: wheelbase, maxsteer\n", + "#\n", + "from vehicle import vehicle, plot_lanechange\n", + "print(vehicle)" + ] + }, + { + "cell_type": "markdown", + "id": "e8ae0344", + "metadata": {}, + "source": [ + "This system is differentially flat and so we can define a trajectory for the system using the `flatsys` module. We generate a motion that corresponds to changing lanes on a road:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "69c048ed", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Generate a trajectory for the vehicle\n", + "# Define the endpoints of the trajectory\n", + "x0 = [0., -2., 0.]; u0 = [10., 0.]\n", + "xf = [40., 2., 0.]; uf = [10., 0.]\n", + "Tf = 4\n", + "\n", + "# Find a trajectory between the initial condition and the final condition\n", + "traj = fs.point_to_point(vehicle, Tf, x0, u0, xf, uf, basis=fs.PolyFamily(6))\n", + "\n", + "# Create the desired trajectory between the initial and final condition\n", + "Ts = 0.1\n", + "# Ts = 0.5\n", + "T = np.arange(0, Tf + Ts, Ts)\n", + "xd, ud = traj.eval(T)\n", + "\n", + "plot_lanechange(T, xd, ud)" + ] + }, + { + "cell_type": "markdown", + "id": "aeeaa39e", + "metadata": {}, + "source": [ + "### Discrete time system model\n", + "\n", + "For the model that we use for the Kalman filter, we take a simple discretization using the approximation that $\\dot x = (x[k+1] - x[k])/T_s$ where $T_s$ is the sampling time." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "2469c60e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Object: sys[6]\n", + "Inputs (2): u[0], u[1], \n", + "Outputs (3): y[0], y[1], y[2], \n", + "States (3): x[0], x[1], x[2], \n", + "\n", + "A = [[ 1.0000000e+00 0.0000000e+00 -5.0004445e-07]\n", + " [ 0.0000000e+00 1.0000000e+00 1.0000000e+00]\n", + " [ 0.0000000e+00 0.0000000e+00 1.0000000e+00]]\n", + "\n", + "B = [[0.1 0. ]\n", + " [0. 0. ]\n", + " [0. 0.33333333]]\n", + "\n", + "C = [[1. 0. 0.]\n", + " [0. 1. 0.]\n", + " [0. 0. 1.]]\n", + "\n", + "D = [[0. 0.]\n", + " [0. 0.]\n", + " [0. 0.]]\n", + "\n", + "dt = 0.1\n", + "\n" + ] + } + ], + "source": [ + "#\n", + "# Create a discrete time, linear model\n", + "#\n", + "\n", + "# Linearize about the starting point\n", + "linsys = ct.linearize(vehicle, x0, u0)\n", + "\n", + "# Create a discrete time model by hand\n", + "Ad = np.eye(linsys.nstates) + linsys.A * Ts\n", + "Bd = linsys.B * Ts\n", + "discsys = ct.LinearIOSystem(ct.ss(Ad, Bd, np.eye(linsys.nstates), 0, dt=Ts))\n", + "print(discsys)" + ] + }, + { + "cell_type": "markdown", + "id": "084c5ae8", + "metadata": {}, + "source": [ + "### Sensor model\n", + "\n", + "We assume that we have two sensors: one with good longitudinal accuracy and the other with good lateral accuracy. For each sensor we define the map from the state space to the sensor outputs, the covariance matrix for the measurements, and a white noise signal (now in discrete time)." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "0a19d109", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Sensor #1: longitudinal\n", + "C_lon = np.eye(2, discsys.nstates)\n", + "Rw_lon = np.diag([0.1 ** 2, 1 ** 2])\n", + "W_lon = ct.white_noise(T, Rw_lon, dt=Ts)\n", + "\n", + "# Sensor #2: lateral\n", + "C_lat = np.eye(2, discsys.nstates)\n", + "Rw_lat = np.diag([1 ** 2, 0.1 ** 2])\n", + "W_lat = ct.white_noise(T, Rw_lat, dt=Ts)\n", + "\n", + "# Plot the noisy signals\n", + "plt.subplot(2, 1, 1)\n", + "Y = xd[0:2] + W_lon\n", + "plt.plot(Y[0], Y[1])\n", + "plt.plot(xd[0], xd[1], **xdstyle)\n", + "plt.xlabel(\"$x$ position [m]\")\n", + "plt.ylabel(\"$y$ position [m]\")\n", + "plt.title(\"Sensor #1\")\n", + " \n", + "plt.subplot(2, 1, 2)\n", + "Y = xd[0:2] + W_lat\n", + "plt.plot(Y[0], Y[1])\n", + "plt.plot(xd[0], xd[1], **xdstyle)\n", + "plt.xlabel(\"$x$ position [m]\")\n", + "plt.ylabel(\"$y$ position [m]\")\n", + "plt.title(\"Sensor #2\")\n", + "plt.tight_layout()" + ] + }, + { + "cell_type": "markdown", + "id": "c3fa1a3d", + "metadata": {}, + "source": [ + "## Linear Quadratic Estimator\n", + "\n", + "To estimate the position of the vehicle, we construct an optimal estimator (Kalman filter)." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "993601a2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Object: sys[7]\n", + "Inputs (6): y[0], y[1], y[2], y[3], u[0], u[1], \n", + "Outputs (3): xhat[0], xhat[1], xhat[2], \n", + "States (12): xhat[0], xhat[1], xhat[2], P[0,0], P[0,1], P[0,2], P[1,0], P[1,1], P[1,2], P[2,0], P[2,1], P[2,2], \n" + ] + } + ], + "source": [ + "#\n", + "# Create an estimator for the system\n", + "#\n", + "\n", + "# Disturbance and initial condition model\n", + "Rv = np.diag([0.1, 0.01]) * Ts\n", + "# Rv = np.diag([10, 0.1]) * Ts # No input data\n", + "P0 = np.diag([1, 1, 0.1])\n", + "\n", + "# Combine the sensors\n", + "C = np.vstack([C_lon, C_lat])\n", + "Rw = sp.linalg.block_diag(Rw_lon, Rw_lat)\n", + "\n", + "estim = ct.create_estimator_iosystem(discsys, Rv, Rw, C=C, P0=P0)\n", + "print(estim)" + ] + }, + { + "cell_type": "markdown", + "id": "d9e2e618", + "metadata": {}, + "source": [ + "Finally, we estimate the position of the vehicle based on sensor measurements. We assume that the input to the vehicle (velocity and steering angle) is available, though we can also explore what happens if that information is not available (see commented out code).\n", + "\n", + "We also carry out a prediction of the position of the vehicle by turning off the correction term in the Kalman filter." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "3d02ec33", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Compute the inputs to the estimator\n", + "Y = np.vstack([xd[0:2] + W_lon, xd[0:2] + W_lat])\n", + "U = np.vstack([Y, ud]) # add input to the Kalman filter\n", + "# U = np.vstack([Y, ud * 0]) # with no input information\n", + "X0 = np.hstack([xd[:, 0], P0.reshape(-1)])\n", + "\n", + "# Run the estimator on the trajectory\n", + "estim_resp = ct.input_output_response(estim, T, U, X0)\n", + "\n", + "# Run a prediction to see what happens next\n", + "T_predict = np.arange(T[-1], T[-1] + 4 + Ts, Ts)\n", + "U_predict = np.outer(U[:, -1], np.ones_like(T_predict))\n", + "predict_resp = ct.input_output_response(\n", + " estim, T_predict, U_predict, estim_resp.states[:, -1],\n", + " params={'correct': False})\n", + "\n", + "# Plot the estimated trajectory versus the actual trajectory\n", + "plt.subplot(2, 1, 1)\n", + "plt.errorbar(\n", + " estim_resp.time, estim_resp.outputs[0], \n", + " estim_resp.states[estim.find_state('P[0,0]')], fmt='b-', **ebarstyle)\n", + "plt.errorbar(\n", + " predict_resp.time, predict_resp.outputs[0], \n", + " predict_resp.states[estim.find_state('P[0,0]')], fmt='r-', **ebarstyle)\n", + "plt.plot(T, xd[0], 'k--')\n", + "plt.ylabel(\"$x$ position [m]\")\n", + "\n", + "plt.subplot(2, 1, 2)\n", + "plt.errorbar(\n", + " estim_resp.time, estim_resp.outputs[1], \n", + " estim_resp.states[estim.find_state('P[1,1]')], fmt='b-', **ebarstyle)\n", + "plt.errorbar(\n", + " predict_resp.time, predict_resp.outputs[1], \n", + " predict_resp.states[estim.find_state('P[1,1]')], fmt='r-', **ebarstyle)\n", + "# lims = plt.axis(); plt.axis([lims[0], lims[1], -5, 5])\n", + "plt.plot(T, xd[1], 'k--');\n", + "plt.ylabel(\"$y$ position [m]\")\n", + "plt.xlabel(\"Time $t$ [s]\");" + ] + }, + { + "cell_type": "markdown", + "id": "9f9e3d59", + "metadata": {}, + "source": [ + "More insight can be obtained by focusing on the errors in prediction:" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "44f69f79", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Plot the estimated errors\n", + "plt.subplot(2, 1, 1)\n", + "plt.errorbar(\n", + " estim_resp.time, estim_resp.outputs[0] - xd[0], \n", + " estim_resp.states[estim.find_state('P[0,0]')], fmt='b-', **ebarstyle)\n", + "plt.errorbar(\n", + " predict_resp.time, predict_resp.outputs[0] - (xd[0] + xd[0, -1]), \n", + " predict_resp.states[estim.find_state('P[0,0]')], fmt='r-', **ebarstyle)\n", + "lims = plt.axis(); plt.axis([lims[0], lims[1], -0.2, 0.2])\n", + "# lims = plt.axis(); plt.axis([lims[0], lims[1], -2, 0.2])\n", + "\n", + "plt.subplot(2, 1, 2)\n", + "plt.errorbar(\n", + " estim_resp.time, estim_resp.outputs[1] - xd[1], \n", + " estim_resp.states[estim.find_state('P[1,1]')], fmt='b-', **ebarstyle)\n", + "plt.errorbar(\n", + " predict_resp.time, predict_resp.outputs[1] - xd[1, -1], \n", + " predict_resp.states[estim.find_state('P[1,1]')], fmt='r-', **ebarstyle)\n", + "lims = plt.axis(); plt.axis([lims[0], lims[1], -0.2, 0.2]);" + ] + }, + { + "cell_type": "markdown", + "id": "6f6c1b6f", + "metadata": {}, + "source": [ + "### Things to try\n", + "\n", + "To gain a bit more insight into sensor fusion, you can try the following:\n", + "* Remove the input (and update P0)\n", + "* Change the sampling rate" + ] + }, + { + "cell_type": "markdown", + "id": "8f680b92", + "metadata": {}, + "source": [ + "### Predictor-corrector form\n", + "\n", + "Instead of using `create_estimator_iosystem`, we can also compute out the estimate in a more manual fashion, done here using the predictor-corrector form:" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "fa488d51", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# System matrices\n", + "A, B, F = discsys.A, discsys.B, discsys.B\n", + "\n", + "# Create an array to store the results\n", + "xhat = np.zeros((discsys.nstates, T.size))\n", + "P = np.zeros((discsys.nstates, discsys.nstates, T.size))\n", + "\n", + "# Update the estimates at each time\n", + "for i, t in enumerate(T):\n", + " # Prediction step\n", + " if i == 0:\n", + " # Use the initial condition\n", + " xkkm1 = xd[:, 0]\n", + " Pkkm1 = P0\n", + " else:\n", + " xkkm1 = A @ xkk + B @ ud[:, i-1]\n", + " Pkkm1 = A @ Pkk @ A.T + F @ Rv @ F.T\n", + " \n", + " # Correction step\n", + " L = Pkkm1 @ C.T @ np.linalg.inv(Rw + C @ Pkkm1 @ C.T)\n", + " xkk = xkkm1 - L @ (C @ xkkm1 - Y[:, i])\n", + " Pkk = Pkkm1 - L @ C @ Pkkm1\n", + "\n", + " # Save the state estimate and covariance for later plotting\n", + " xhat[:, i], P[:, :, i] = xkk, Pkk\n", + " # xhat[:, i], P[:, :, i] = xkkm1, Pkkm1 # For comparison to Kalman form\n", + " \n", + "plt.subplot(2, 1, 1)\n", + "plt.errorbar(T, xhat[0], P[0, 0], fmt='b-', **ebarstyle)\n", + "plt.plot(T, xd[0], 'k--')\n", + "plt.ylabel(\"$x$ position [m]\")\n", + "\n", + "plt.subplot(2, 1, 2)\n", + "plt.errorbar(T, xhat[1], P[1, 1], fmt='b-', **ebarstyle)\n", + "plt.plot(T, xd[1], 'k--')\n", + "plt.ylabel(\"$x$ position [m]\");" + ] + }, + { + "cell_type": "markdown", + "id": "a9d5cb32", + "metadata": {}, + "source": [ + "We can compare the results of the predictor-corrector form to the Kalman filter form used at the top of the notebook:" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "4eda4729", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Plot the estimated errors (and compare to Kalman form)\n", + "plt.subplot(2, 1, 1)\n", + "plt.errorbar(T, xhat[0] - xd[0], P[0, 0], fmt='b-', **ebarstyle)\n", + "plt.plot(estim_resp.time, estim_resp.outputs[0] - xd[0], 'r--')\n", + "lims = plt.axis(); plt.axis([lims[0], lims[1], -0.2, 0.2])\n", + "\n", + "plt.subplot(2, 1, 2)\n", + "plt.errorbar(T, xhat[1] - xd[1], P[1, 1], fmt='b-', **ebarstyle)\n", + "plt.plot(estim_resp.time, estim_resp.outputs[1] - xd[1], 'r--')\n", + "lims = plt.axis(); plt.axis([lims[0], lims[1], -0.2, 0.2]);" + ] + }, + { + "cell_type": "markdown", + "id": "3f7e3e4d", + "metadata": {}, + "source": [ + "Note that the estimates are not the same! It turns out that to get the correspondence of the two formulations, we need to define $\\hat{x}_\\text{KF}(k) = \\hat{x}_\\text{PC}(k|k-1)$ (see commented out code above)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0796fc56", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/pvtol-lqr-nested.ipynb b/examples/pvtol-lqr-nested.ipynb index 59e97472a..63fde31f3 100644 --- a/examples/pvtol-lqr-nested.ipynb +++ b/examples/pvtol-lqr-nested.ipynb @@ -532,7 +532,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, diff --git a/examples/pvtol-lqr.py b/examples/pvtol-lqr.py index 8654c77ad..8a9ff55d9 100644 --- a/examples/pvtol-lqr.py +++ b/examples/pvtol-lqr.py @@ -9,8 +9,8 @@ import os import numpy as np -import matplotlib.pyplot as plt # MATLAB plotting functions -from control.matlab import * # MATLAB-like functions +import matplotlib.pyplot as plt # MATLAB-like plotting functions +import control as ct # # System dynamics @@ -28,14 +28,13 @@ # State space dynamics xe = [0, 0, 0, 0, 0, 0] # equilibrium point of interest -ue = [0, m*g] # (note these are lists, not matrices) +ue = [0, m * g] # (note these are lists, not matrices) # TODO: The following objects need converting from np.matrix to np.array # This will involve re-working the subsequent equations as the shapes # See below. -# Dynamics matrix (use matrix type so that * works for multiplication) -A = np.matrix( +A = np.array( [[0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 1, 0], [0, 0, 0, 0, 0, 1], @@ -45,7 +44,7 @@ ) # Input matrix -B = np.matrix( +B = np.array( [[0, 0], [0, 0], [0, 0], [np.cos(xe[2])/m, -np.sin(xe[2])/m], [np.sin(xe[2])/m, np.cos(xe[2])/m], @@ -53,8 +52,8 @@ ) # Output matrix -C = np.matrix([[1, 0, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0]]) -D = np.matrix([[0, 0], [0, 0]]) +C = np.array([[1, 0, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0]]) +D = np.array([[0, 0], [0, 0]]) # # Construct inputs and outputs corresponding to steps in xy position @@ -74,8 +73,8 @@ # so that xd corresponds to the desired steady state. # -xd = np.matrix([[1], [0], [0], [0], [0], [0]]) -yd = np.matrix([[0], [1], [0], [0], [0], [0]]) +xd = np.array([[1], [0], [0], [0], [0], [0]]) +yd = np.array([[0], [1], [0], [0], [0], [0]]) # # Extract the relevant dynamics for use with SISO library @@ -93,14 +92,14 @@ # Decoupled dynamics Ax = A[np.ix_(lat, lat)] -Bx = B[lat, 0] -Cx = C[0, lat] -Dx = D[0, 0] +Bx = B[np.ix_(lat, [0])] +Cx = C[np.ix_([0], lat)] +Dx = D[np.ix_([0], [0])] Ay = A[np.ix_(alt, alt)] -By = B[alt, 1] -Cy = C[1, alt] -Dy = D[1, 1] +By = B[np.ix_(alt, [1])] +Cy = C[np.ix_([1], alt)] +Dy = D[np.ix_([1], [1])] # Label the plot plt.clf() @@ -113,44 +112,24 @@ # Start with a diagonal weighting Qx1 = np.diag([1, 1, 1, 1, 1, 1]) Qu1a = np.diag([1, 1]) -K, X, E = lqr(A, B, Qx1, Qu1a) -K1a = np.matrix(K) +K1a, X, E = ct.lqr(A, B, Qx1, Qu1a) # Close the loop: xdot = Ax - B K (x-xd) +# # Note: python-control requires we do this 1 input at a time # H1a = ss(A-B*K1a, B*K1a*concatenate((xd, yd), axis=1), C, D); -# (T, Y) = step(H1a, T=np.linspace(0,10,100)); - -# TODO: The following equations will need modifying when converting from np.matrix to np.array -# because the results and even intermediate calculations will be different with numpy arrays -# For example: -# Bx = B[lat, 0] -# Will need to be changed to: -# Bx = B[lat, 0].reshape(-1, 1) -# (if we want it to have the same shape as before) - -# For reference, here is a list of the correct shapes of these objects: -# A: (6, 6) -# B: (6, 2) -# C: (2, 6) -# D: (2, 2) -# xd: (6, 1) -# yd: (6, 1) -# Ax: (4, 4) -# Bx: (4, 1) -# Cx: (1, 4) -# Dx: () -# Ay: (2, 2) -# By: (2, 1) -# Cy: (1, 2) +# (T, Y) = step_response(H1a, T=np.linspace(0,10,100)); +# # Step response for the first input -H1ax = ss(Ax - Bx*K1a[0, lat], Bx*K1a[0, lat]*xd[lat, :], Cx, Dx) -Yx, Tx = step(H1ax, T=np.linspace(0, 10, 100)) +H1ax = ct.ss(Ax - Bx @ K1a[np.ix_([0], lat)], + Bx @ K1a[np.ix_([0], lat)] @ xd[lat, :], Cx, Dx) +Tx, Yx = ct.step_response(H1ax, T=np.linspace(0, 10, 100)) # Step response for the second input -H1ay = ss(Ay - By*K1a[1, alt], By*K1a[1, alt]*yd[alt, :], Cy, Dy) -Yy, Ty = step(H1ay, T=np.linspace(0, 10, 100)) +H1ay = ct.ss(Ay - By @ K1a[np.ix_([1], alt)], + By @ K1a[np.ix_([1], alt)] @ yd[alt, :], Cy, Dy) +Ty, Yy = ct.step_response(H1ay, T=np.linspace(0, 10, 100)) plt.subplot(221) plt.title("Identity weights") @@ -164,20 +143,23 @@ # Look at different input weightings Qu1a = np.diag([1, 1]) -K1a, X, E = lqr(A, B, Qx1, Qu1a) -H1ax = ss(Ax - Bx*K1a[0, lat], Bx*K1a[0, lat]*xd[lat, :], Cx, Dx) +K1a, X, E = ct.lqr(A, B, Qx1, Qu1a) +H1ax = ct.ss(Ax - Bx @ K1a[np.ix_([0], lat)], + Bx @ K1a[np.ix_([0], lat)] @ xd[lat, :], Cx, Dx) Qu1b = (40 ** 2)*np.diag([1, 1]) -K1b, X, E = lqr(A, B, Qx1, Qu1b) -H1bx = ss(Ax - Bx*K1b[0, lat], Bx*K1b[0, lat]*xd[lat, :], Cx, Dx) +K1b, X, E = ct.lqr(A, B, Qx1, Qu1b) +H1bx = ct.ss(Ax - Bx @ K1b[np.ix_([0], lat)], + Bx @ K1b[np.ix_([0], lat)] @ xd[lat, :], Cx, Dx) Qu1c = (200 ** 2)*np.diag([1, 1]) -K1c, X, E = lqr(A, B, Qx1, Qu1c) -H1cx = ss(Ax - Bx*K1c[0, lat], Bx*K1c[0, lat]*xd[lat, :], Cx, Dx) +K1c, X, E = ct.lqr(A, B, Qx1, Qu1c) +H1cx = ct.ss(Ax - Bx @ K1c[np.ix_([0], lat)], + Bx @ K1c[np.ix_([0], lat)] @ xd[lat, :], Cx, Dx) -[Y1, T1] = step(H1ax, T=np.linspace(0, 10, 100)) -[Y2, T2] = step(H1bx, T=np.linspace(0, 10, 100)) -[Y3, T3] = step(H1cx, T=np.linspace(0, 10, 100)) +T1, Y1 = ct.step_response(H1ax, T=np.linspace(0, 10, 100)) +T2, Y2 = ct.step_response(H1bx, T=np.linspace(0, 10, 100)) +T3, Y3 = ct.step_response(H1cx, T=np.linspace(0, 10, 100)) plt.subplot(222) plt.title("Effect of input weights") @@ -189,21 +171,22 @@ plt.axis([0, 10, -0.1, 1.4]) # arcarrow([1.3, 0.8], [5, 0.45], -6) -plt.text(5.3, 0.4, 'rho') +plt.text(5.3, 0.4, r'$\rho$') # Output weighting - change Qx to use outputs -Qx2 = C.T*C -Qu2 = 0.1*np.diag([1, 1]) -K, X, E = lqr(A, B, Qx2, Qu2) -K2 = np.matrix(K) +Qx2 = C.T @ C +Qu2 = 0.1 * np.diag([1, 1]) +K2, X, E = ct.lqr(A, B, Qx2, Qu2) -H2x = ss(Ax - Bx*K2[0, lat], Bx*K2[0, lat]*xd[lat, :], Cx, Dx) -H2y = ss(Ay - By*K2[1, alt], By*K2[1, alt]*yd[alt, :], Cy, Dy) +H2x = ct.ss(Ax - Bx @ K2[np.ix_([0], lat)], + Bx @ K2[np.ix_([0], lat)] @ xd[lat, :], Cx, Dx) +H2y = ct.ss(Ay - By @ K2[np.ix_([1], alt)], + By @ K2[np.ix_([1], alt)] @ yd[alt, :], Cy, Dy) plt.subplot(223) plt.title("Output weighting") -[Y2x, T2x] = step(H2x, T=np.linspace(0, 10, 100)) -[Y2y, T2y] = step(H2y, T=np.linspace(0, 10, 100)) +T2x, Y2x = ct.step_response(H2x, T=np.linspace(0, 10, 100)) +T2y, Y2y = ct.step_response(H2y, T=np.linspace(0, 10, 100)) plt.plot(T2x.T, Y2x.T, T2y.T, Y2y.T) plt.ylabel('position') plt.xlabel('time') @@ -220,19 +203,21 @@ Qx3 = np.diag([100, 10, 2*np.pi/5, 0, 0, 0]) Qu3 = 0.1*np.diag([1, 10]) -(K, X, E) = lqr(A, B, Qx3, Qu3) -K3 = np.matrix(K) +K3, X, E = ct.lqr(A, B, Qx3, Qu3) -H3x = ss(Ax - Bx*K3[0, lat], Bx*K3[0, lat]*xd[lat, :], Cx, Dx) -H3y = ss(Ay - By*K3[1, alt], By*K3[1, alt]*yd[alt, :], Cy, Dy) +H3x = ct.ss(Ax - Bx @ K3[np.ix_([0], lat)], + Bx @ K3[np.ix_([0], lat)] @ xd[lat, :], Cx, Dx) +H3y = ct.ss(Ay - By @ K3[np.ix_([1], alt)], + By @ K3[np.ix_([1], alt)] @ yd[alt, :], Cy, Dy) plt.subplot(224) -# step(H3x, H3y, 10) -[Y3x, T3x] = step(H3x, T=np.linspace(0, 10, 100)) -[Y3y, T3y] = step(H3y, T=np.linspace(0, 10, 100)) +# step_response(H3x, H3y, 10) +T3x, Y3x = ct.step_response(H3x, T=np.linspace(0, 10, 100)) +T3y, Y3y = ct.step_response(H3y, T=np.linspace(0, 10, 100)) plt.plot(T3x.T, Y3x.T, T3y.T, Y3y.T) plt.title("Physically motivated weights") plt.xlabel('time') plt.legend(('x', 'y'), loc='lower right') +plt.tight_layout() if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: plt.show() diff --git a/examples/pvtol-nested.py b/examples/pvtol-nested.py index 24cd7d1c5..040b4a1f4 100644 --- a/examples/pvtol-nested.py +++ b/examples/pvtol-nested.py @@ -9,8 +9,8 @@ # import os -import matplotlib.pyplot as plt # MATLAB plotting functions -from control.matlab import * # MATLAB-like functions +import matplotlib.pyplot as plt # MATLAB-like plotting functions +import control as ct import numpy as np # System parameters @@ -21,8 +21,8 @@ c = 0.05 # damping factor (estimated) # Transfer functions for dynamics -Pi = tf([r], [J, 0, 0]) # inner loop (roll) -Po = tf([1], [m, c, 0]) # outer loop (position) +Pi = ct.tf([r], [J, 0, 0]) # inner loop (roll) +Po = ct.tf([1], [m, c, 0]) # outer loop (position) # # Inner loop control design @@ -34,59 +34,58 @@ # Design a simple lead controller for the system k, a, b = 200, 2, 50 -Ci = k*tf([1, a], [1, b]) # lead compensator -Li = Pi*Ci +Ci = k * ct.tf([1, a], [1, b]) # lead compensator +Li = Pi * Ci # Bode plot for the open loop process plt.figure(1) -bode(Pi) +ct.bode_plot(Pi) # Bode plot for the loop transfer function, with margins plt.figure(2) -bode(Li) +ct.bode_plot(Li) # Compute out the gain and phase margins -#! Not implemented -# gm, pm, wcg, wcp = margin(Li) +gm, pm, wcg, wcp = ct.margin(Li) # Compute the sensitivity and complementary sensitivity functions -Si = feedback(1, Li) -Ti = Li*Si +Si = ct.feedback(1, Li) +Ti = Li * Si # Check to make sure that the specification is met plt.figure(3) -gangof4(Pi, Ci) +ct.gangof4(Pi, Ci) # Compute out the actual transfer function from u1 to v1 (see L8.2 notes) # Hi = Ci*(1-m*g*Pi)/(1+Ci*Pi) -Hi = parallel(feedback(Ci, Pi), -m*g*feedback(Ci*Pi, 1)) +Hi = ct.parallel(ct.feedback(Ci, Pi), -m * g *ct.feedback(Ci * Pi, 1)) plt.figure(4) plt.clf() plt.subplot(221) -bode(Hi) +ct.bode_plot(Hi) # Now design the lateral control system a, b, K = 0.02, 5, 2 -Co = -K*tf([1, 0.3], [1, 10]) # another lead compensator +Co = -K * ct.tf([1, 0.3], [1, 10]) # another lead compensator Lo = -m*g*Po*Co plt.figure(5) -bode(Lo) # margin(Lo) +ct.bode_plot(Lo) # margin(Lo) # Finally compute the real outer-loop loop gain + responses -L = Co*Hi*Po -S = feedback(1, L) -T = feedback(L, 1) +L = Co * Hi * Po +S = ct.feedback(1, L) +T = ct.feedback(L, 1) # Compute stability margins -gm, pm, wgc, wpc = margin(L) +gm, pm, wgc, wpc = ct.margin(L) print("Gain margin: %g at %g" % (gm, wgc)) print("Phase margin: %g at %g" % (pm, wpc)) plt.figure(6) plt.clf() -bode(L, np.logspace(-4, 3)) +ct.bode_plot(L, np.logspace(-4, 3)) # Add crossover line to the magnitude plot # @@ -113,7 +112,7 @@ break # Recreate the frequency response and shift the phase -mag, phase, w = freqresp(L, np.logspace(-4, 3)) +mag, phase, w = ct.freqresp(L, np.logspace(-4, 3)) phase = phase - 360 # Replot the phase by hand @@ -130,7 +129,7 @@ # plt.figure(7) plt.clf() -nyquist(L, (0.0001, 1000)) +ct.nyquist_plot(L, (0.0001, 1000)) # Add a box in the region we are going to expand plt.plot([-2, -2, 1, 1, -2], [-4, 4, 4, -4, -4], 'r-') @@ -138,7 +137,7 @@ # Expanded region plt.figure(8) plt.clf() -nyquist(L) +ct.nyquist_plot(L) plt.axis([-2, 1, -4, 4]) # set up the color @@ -154,21 +153,21 @@ # 'EdgeColor', color, 'FaceColor', color); plt.figure(9) -Yvec, Tvec = step(T, np.linspace(0, 20)) +Tvec, Yvec = ct.step_response(T, np.linspace(0, 20)) plt.plot(Tvec.T, Yvec.T) -Yvec, Tvec = step(Co*S, np.linspace(0, 20)) +Tvec, Yvec = ct.step_response(Co*S, np.linspace(0, 20)) plt.plot(Tvec.T, Yvec.T) plt.figure(10) plt.clf() -P, Z = pzmap(T, plot=True, grid=True) +P, Z = ct.pzmap(T, plot=True, grid=True) print("Closed loop poles and zeros: ", P, Z) # Gang of Four plt.figure(11) plt.clf() -gangof4(Hi*Po, Co) +ct.gangof4_plot(Hi * Po, Co) if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: plt.show() diff --git a/examples/pvtol-outputfbk.ipynb b/examples/pvtol-outputfbk.ipynb new file mode 100644 index 000000000..8656ed241 --- /dev/null +++ b/examples/pvtol-outputfbk.ipynb @@ -0,0 +1,511 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c017196f", + "metadata": {}, + "source": [ + "# Output feedback control using LQR and extended Kalman filtering\n", + "RMM, 14 Feb 2022\n", + "\n", + "This notebook illustrates the implementation of an extended Kalman filter and the use of the estimated state for LQR feedback of a vectored thrust aircraft model." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "544525ab", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import matplotlib.patches as patches\n", + "import control as ct" + ] + }, + { + "cell_type": "markdown", + "id": "859834cf", + "metadata": {}, + "source": [ + "## System definition\n", + "We consider a (planar) vertical takeoff and landing aircraf model:\n", + "\n", + "![PVTOL diagram](https://murray.cds.caltech.edu/images/murray.cds/7/7d/Pvtol-diagram.png)\n", + "\n", + "The dynamics of the system with disturbances on the $x$ and $y$ variables are given by\n", + "\n", + "$$\n", + " \\begin{aligned}\n", + " m \\ddot x &= F_1 \\cos\\theta - F_2 \\sin\\theta - c \\dot x + d_x, \\\\\n", + " m \\ddot y &= F_1 \\sin\\theta + F_2 \\cos\\theta - c \\dot y - m g + d_y, \\\\\n", + " J \\ddot \\theta &= r F_1.\n", + " \\end{aligned}\n", + "$$\n", + "\n", + "The measured values of the system are the position and orientation,\n", + "with added noise $n_x$, $n_y$, and $n_\\theta$:\n", + "\n", + "$$\n", + " \\vec y = \\begin{bmatrix} x \\\\ y \\\\ \\theta \\end{bmatrix} + \n", + " \\begin{bmatrix} n_x \\\\ n_y \\\\ n_z \\end{bmatrix}.\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "198a068d", + "metadata": {}, + "source": [ + "The dynamics are defined in the `pvtol` module:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "ffafed74", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Object: pvtol\n", + "Inputs (2): F1, F2, \n", + "Outputs (6): x0, x1, x2, x3, x4, x5, \n", + "States (6): x0, x1, x2, x3, x4, x5, \n", + "\n", + "Object: noisy_pvtol\n", + "Inputs (7): F1, F2, Dx, Dy, Nx, Ny, Nth, \n", + "Outputs (6): x0, x1, x2, x3, x4, x5, \n", + "States (6): x0, x1, x2, x3, x4, x5, \n" + ] + } + ], + "source": [ + "# pvtol = nominal system (no disturbances or noise)\n", + "# noisy_pvtol = pvtol w/ process disturbances and sensor noise\n", + "from pvtol import pvtol, noisy_pvtol, plot_results\n", + "\n", + "# Find the equiblirum point corresponding to the origin\n", + "xe, ue = ct.find_eqpt(\n", + " pvtol, np.zeros(pvtol.nstates),\n", + " np.zeros(pvtol.ninputs), [0, 0, 0, 0, 0, 0],\n", + " iu=range(2, pvtol.ninputs), iy=[0, 1])\n", + "\n", + "x0, u0 = ct.find_eqpt(\n", + " pvtol, np.zeros(pvtol.nstates),\n", + " np.zeros(pvtol.ninputs), np.array([2, 1, 0, 0, 0, 0]),\n", + " iu=range(2, pvtol.ninputs), iy=[0, 1])\n", + "\n", + "# Extract the linearization for use in LQR design\n", + "pvtol_lin = pvtol.linearize(xe, ue)\n", + "A, B = pvtol_lin.A, pvtol_lin.B\n", + "\n", + "print(pvtol, \"\\n\")\n", + "print(noisy_pvtol)" + ] + }, + { + "cell_type": "markdown", + "id": "be6ec05c", + "metadata": {}, + "source": [ + "We also define the properties of the disturbances, noise, and initial conditions:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "1e1ee7c9", + "metadata": {}, + "outputs": [], + "source": [ + "# Disturbance and noise intensities\n", + "Qv = np.diag([1e-2, 1e-2])\n", + "Qw = np.array([[2e-4, 0, 1e-5], [0, 2e-4, 1e-5], [1e-5, 1e-5, 1e-4]])\n", + "Qwinv = np.linalg.inv(Qw)\n", + "\n", + "# Initial state covariance\n", + "P0 = np.eye(pvtol.nstates)" + ] + }, + { + "cell_type": "markdown", + "id": "e4c52c73", + "metadata": {}, + "source": [ + "## Control system design\n", + "\n", + "We start be defining an extended Kalman filter to estimate the state of the system from the measured outputs." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "3647bf15", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Object: sys[3]\n", + "Inputs (5): x0, x1, x2, F1, F2, \n", + "Outputs (6): xh0, xh1, xh2, xh3, xh4, xh5, \n", + "States (42): x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], x[8], x[9], x[10], x[11], x[12], x[13], x[14], x[15], x[16], x[17], x[18], x[19], x[20], x[21], x[22], x[23], x[24], x[25], x[26], x[27], x[28], x[29], x[30], x[31], x[32], x[33], x[34], x[35], x[36], x[37], x[38], x[39], x[40], x[41], \n" + ] + } + ], + "source": [ + "# Define the disturbance input and measured output matrices\n", + "F = np.array([[0, 0], [0, 0], [0, 0], [1, 0], [0, 1], [0, 0]])\n", + "C = np.eye(3, 6)\n", + "\n", + "# Estimator update law\n", + "def estimator_update(t, x, u, params):\n", + " # Extract the states of the estimator\n", + " xhat = x[0:pvtol.nstates]\n", + " P = x[pvtol.nstates:].reshape(pvtol.nstates, pvtol.nstates)\n", + "\n", + " # Extract the inputs to the estimator\n", + " y = u[0:3] # just grab the first three outputs\n", + " u = u[3:5] # get the inputs that were applied as well\n", + "\n", + " # Compute the linearization at the current state\n", + " A = pvtol.A(xhat, u) # A matrix depends on current state\n", + " # A = pvtol.A(xe, ue) # Fixed A matrix (for testing/comparison)\n", + " \n", + " # Compute the optimal again\n", + " L = P @ C.T @ Qwinv\n", + "\n", + " # Update the state estimate\n", + " xhatdot = pvtol.updfcn(t, xhat, u, params) - L @ (C @ xhat - y)\n", + "\n", + " # Update the covariance\n", + " Pdot = A @ P + P @ A.T - P @ C.T @ Qwinv @ C @ P + F @ Qv @ F.T\n", + "\n", + " # Return the derivative\n", + " return np.hstack([xhatdot, Pdot.reshape(-1)])\n", + "\n", + "estimator = ct.NonlinearIOSystem(\n", + " estimator_update, lambda t, x, u, params: x[0:pvtol.nstates],\n", + " states=pvtol.nstates + pvtol.nstates**2,\n", + " inputs= noisy_pvtol.state_labels[0:3] \\\n", + " + noisy_pvtol.input_labels[0:pvtol.ninputs],\n", + " outputs=[f'xh{i}' for i in range(pvtol.nstates)],\n", + ")\n", + "print(estimator)" + ] + }, + { + "cell_type": "markdown", + "id": "8c97626d", + "metadata": {}, + "source": [ + "We now define an LQR controller, using a physically motivated weighting:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "9787db61", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Object: control\n", + "Inputs (14): xd[0], xd[1], xd[2], xd[3], xd[4], xd[5], ud[0], ud[1], xh0, xh1, xh2, xh3, xh4, xh5, \n", + "Outputs (2): F1, F2, \n", + "States (0): \n", + "\n", + "A = []\n", + "\n", + "B = []\n", + "\n", + "C = []\n", + "\n", + "D = [[-3.16227766e+00 -1.31948924e-07 8.67680175e+00 -2.35855555e+00\n", + " -6.98881806e-08 1.91220852e+00 1.00000000e+00 0.00000000e+00\n", + " 3.16227766e+00 1.31948924e-07 -8.67680175e+00 2.35855555e+00\n", + " 6.98881806e-08 -1.91220852e+00]\n", + " [-1.31948923e-06 3.16227766e+00 -2.32324805e-07 -2.36396241e-06\n", + " 4.97998224e+00 7.90913288e-08 0.00000000e+00 1.00000000e+00\n", + " 1.31948923e-06 -3.16227766e+00 2.32324805e-07 2.36396241e-06\n", + " -4.97998224e+00 -7.90913288e-08]]\n", + " \n", + "\n", + "Object: xh5\n", + "Inputs (13): xd[0], xd[1], xd[2], xd[3], xd[4], xd[5], ud[0], ud[1], Dx, Dy, Nx, Ny, Nth, \n", + "Outputs (14): x0, x1, x2, x3, x4, x5, F1, F2, xh0, xh1, xh2, xh3, xh4, xh5, \n", + "States (48): noisy_pvtol_x0, noisy_pvtol_x1, noisy_pvtol_x2, noisy_pvtol_x3, noisy_pvtol_x4, noisy_pvtol_x5, sys[3]_x[0], sys[3]_x[1], sys[3]_x[2], sys[3]_x[3], sys[3]_x[4], sys[3]_x[5], sys[3]_x[6], sys[3]_x[7], sys[3]_x[8], sys[3]_x[9], sys[3]_x[10], sys[3]_x[11], sys[3]_x[12], sys[3]_x[13], sys[3]_x[14], sys[3]_x[15], sys[3]_x[16], sys[3]_x[17], sys[3]_x[18], sys[3]_x[19], sys[3]_x[20], sys[3]_x[21], sys[3]_x[22], sys[3]_x[23], sys[3]_x[24], sys[3]_x[25], sys[3]_x[26], sys[3]_x[27], sys[3]_x[28], sys[3]_x[29], sys[3]_x[30], sys[3]_x[31], sys[3]_x[32], sys[3]_x[33], sys[3]_x[34], sys[3]_x[35], sys[3]_x[36], sys[3]_x[37], sys[3]_x[38], sys[3]_x[39], sys[3]_x[40], sys[3]_x[41], \n" + ] + } + ], + "source": [ + "# Shoot for 1 cm error in x, 10 cm error in y. Try to keep the angle\n", + "# less than 5 degrees in making the adjustments. Penalize side forces\n", + "# due to loss in efficiency.\n", + "#\n", + "\n", + "Qx = np.diag([100, 10, (180/np.pi) / 5, 0, 0, 0])\n", + "Qu = np.diag([10, 1])\n", + "K, _, _ = ct.lqr(A, B, Qx, Qu)\n", + "\n", + "#\n", + "# Control system construction: combine LQR w/ EKF\n", + "#\n", + "# Use the linearization around the origin to design the optimal gains\n", + "# to see how they compare to the final value of P for the EKF\n", + "#\n", + "\n", + "# Construct the state feedback controller with estimated state as input\n", + "statefbk, _ = ct.create_statefbk_iosystem(pvtol, K, estimator=estimator)\n", + "print(statefbk, \"\\n\")\n", + "\n", + "# Reconstruct the control system with the noisy version of the process\n", + "# Create a closed loop system around the controller\n", + "clsys = ct.interconnect(\n", + " [noisy_pvtol, statefbk, estimator],\n", + " inplist = statefbk.input_labels[0:pvtol.ninputs + pvtol.nstates] + \\\n", + " noisy_pvtol.input_labels[pvtol.ninputs:],\n", + " inputs = statefbk.input_labels[0:pvtol.ninputs + pvtol.nstates] + \\\n", + " noisy_pvtol.input_labels[pvtol.ninputs:],\n", + " outlist = pvtol.output_labels + statefbk.output_labels + estimator.output_labels,\n", + " outputs = pvtol.output_labels + statefbk.output_labels + estimator.output_labels\n", + ")\n", + "print(clsys)" + ] + }, + { + "cell_type": "markdown", + "id": "7bf558a0", + "metadata": {}, + "source": [ + "## Simulations\n", + "\n", + "We now simulate the response of the system, starting with an instantiation of the noise:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "c2583a0e", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Create the time vector for the simulation\n", + "Tf = 10\n", + "T = np.linspace(0, Tf, 1000)\n", + "\n", + "# Create representative process disturbance and sensor noise vectors\n", + "np.random.seed(117) # avoid figures changing from run to run\n", + "V = ct.white_noise(T, Qv) # smaller disturbances and noise then design\n", + "W = ct.white_noise(T, Qw)\n", + "plt.plot(T, V[0], label=\"V[0]\")\n", + "plt.plot(T, W[0], label=\"W[0]\")\n", + "plt.legend();" + ] + }, + { + "cell_type": "markdown", + "id": "4d944709", + "metadata": {}, + "source": [ + "### LQR with EKF" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "ad7a9750", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Put together the input for the system\n", + "U = np.vstack([\n", + " np.outer(xe, np.ones_like(T)), # xd\n", + " np.outer(ue, np.ones_like(T)), # ud\n", + " V, W # disturbances and noise\n", + "])\n", + "X0 = np.hstack([x0, np.zeros(pvtol.nstates), P0.reshape(-1)])\n", + "\n", + "# Initial condition response\n", + "resp = ct.input_output_response(clsys, T, U, X0)\n", + "\n", + "# Plot the response\n", + "plot_results(T, resp.states, resp.outputs[pvtol.nstates:])" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "c5f24119", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Response of the first two states, including internal estimates\n", + "plt.figure()\n", + "h1, = plt.plot(resp.time, resp.outputs[0], 'b-', linewidth=0.75)\n", + "h2, = plt.plot(resp.time, resp.outputs[1], 'r-', linewidth=0.75)\n", + "\n", + "# Add on the internal estimator states\n", + "xh0 = clsys.find_output('xh0')\n", + "xh1 = clsys.find_output('xh1')\n", + "h3, = plt.plot(resp.time, resp.outputs[xh0], 'k--')\n", + "h4, = plt.plot(resp.time, resp.outputs[xh1], 'k--')\n", + "\n", + "plt.plot([0, 10], [0, 0], 'k--', linewidth=0.5)\n", + "plt.ylabel(\"Position $x$, $y$ [m]\")\n", + "plt.xlabel(\"Time $t$ [s]\")\n", + "plt.legend(\n", + " [h1, h2, h3, h4], ['$x$', '$y$', '$\\hat{x}$', '$\\hat{y}$'], \n", + " loc='upper right', frameon=False, ncol=2)" + ] + }, + { + "cell_type": "markdown", + "id": "0c0d5c99", + "metadata": {}, + "source": [ + "### Full state feedback\n", + "\n", + "As a comparison, we can investigate the response of the system if the exact state was available:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "3b6a1f1c", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Compute the full state feedback solution\n", + "lqr_ctrl, _ = ct.create_statefbk_iosystem(pvtol, K)\n", + "\n", + "lqr_clsys = ct.interconnect(\n", + " [noisy_pvtol, lqr_ctrl],\n", + " inplist = lqr_ctrl.input_labels[0:pvtol.ninputs + pvtol.nstates] + \\\n", + " noisy_pvtol.input_labels[pvtol.ninputs:],\n", + " inputs = lqr_ctrl.input_labels[0:pvtol.ninputs + pvtol.nstates] + \\\n", + " noisy_pvtol.input_labels[pvtol.ninputs:],\n", + " outlist = pvtol.output_labels + lqr_ctrl.output_labels,\n", + " outputs = pvtol.output_labels + lqr_ctrl.output_labels\n", + ")\n", + "\n", + "# Put together the input for the system\n", + "U = np.vstack([\n", + " np.outer(xe, np.ones_like(T)), # xd\n", + " np.outer(ue, np.ones_like(T)), # ud\n", + " V, W * 0 # disturbances and noise\n", + "])\n", + "\n", + "# Run a simulation with full state feedback\n", + "lqr_resp = ct.input_output_response(lqr_clsys, T, U, x0)\n", + "\n", + "# Compare the results\n", + "plt.plot(resp.states[0], resp.states[1], 'b-', label=\"Extended KF\")\n", + "plt.plot(lqr_resp.states[0], lqr_resp.states[1], 'r-', label=\"Full state\")\n", + "\n", + "plt.xlabel('$x$ [m]')\n", + "plt.ylabel('$y$ [m]')\n", + "plt.axis('equal')\n", + "plt.legend(frameon=False);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dc86067c", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/pvtol.py b/examples/pvtol.py new file mode 100644 index 000000000..277d0faa1 --- /dev/null +++ b/examples/pvtol.py @@ -0,0 +1,315 @@ +# pvtol.py - (planar) vertical takeoff and landing system model +# RMM, 19 Jan 2022 +# +# This file contains a model and utility function for a (planar) +# vertical takeoff and landing system, as described in FBS2e and OBC. +# This system is approximately differentially flat and the flat system +# mappings are included. +# + +import numpy as np +import matplotlib.pyplot as plt +import control as ct +import control.flatsys as fs +from math import sin, cos +from warnings import warn + +# PVTOL dynamics +def pvtol_update(t, x, u, params): + # Get the parameter values + m = params.get('m', 4.) # mass of aircraft + J = params.get('J', 0.0475) # inertia around pitch axis + r = params.get('r', 0.25) # distance to center of force + g = params.get('g', 9.8) # gravitational constant + c = params.get('c', 0.05) # damping factor (estimated) + + # Get the inputs and states + x, y, theta, xdot, ydot, thetadot = x + F1, F2 = u + + # Constrain the inputs + F2 = np.clip(F2, 0, 1.5 * m * g) + F1 = np.clip(F1, -0.1 * F2, 0.1 * F2) + + # Dynamics + xddot = (F1 * cos(theta) - F2 * sin(theta) - c * xdot) / m + yddot = (F1 * sin(theta) + F2 * cos(theta) - m * g - c * ydot) / m + thddot = (r * F1) / J + + return np.array([xdot, ydot, thetadot, xddot, yddot, thddot]) + +def pvtol_output(t, x, u, params): + return x + +# PVTOL flat system mappings +def pvtol_flat_forward(states, inputs, params={}): + # Get the parameter values + m = params.get('m', 4.) # mass of aircraft + J = params.get('J', 0.0475) # inertia around pitch axis + r = params.get('r', 0.25) # distance to center of force + g = params.get('g', 9.8) # gravitational constant + c = params.get('c', 0.05) # damping factor (estimated) + + # Make sure that c is zero + if c != 0: + warn("System is only approximately flat (c != 0)") + + # Create a list of arrays to store the flat output and its derivatives + zflag = [np.zeros(5), np.zeros(5)] + + # Store states and inputs in variables to make things easier to read + x, y, theta, xdot, ydot, thdot = states + F1, F2 = inputs + + # Use equations of motion for higher derivates + x1ddot = (F1 * cos(theta) - F2 * sin(theta)) / m + x2ddot = (F1 * sin(theta) + F2 * cos(theta) - m * g) / m + thddot = (r * F1) / J + + # Flat output is a point above the vertical axis + zflag[0][0] = x - (J / (m * r)) * sin(theta) + zflag[1][0] = y + (J / (m * r)) * cos(theta) + + zflag[0][1] = xdot - (J / (m * r)) * cos(theta) * thdot + zflag[1][1] = ydot - (J / (m * r)) * sin(theta) * thdot + + zflag[0][2] = (F1 * cos(theta) - F2 * sin(theta)) / m \ + + (J / (m * r)) * sin(theta) * thdot**2 \ + - (J / (m * r)) * cos(theta) * thddot + zflag[1][2] = (F1 * sin(theta) + F2 * cos(theta) - m * g) / m \ + - (J / (m * r)) * cos(theta) * thdot**2 \ + - (J / (m * r)) * sin(theta) * thddot + + # For the third derivative, assume F1, F2 are constant (also thddot) + zflag[0][3] = (-F1 * sin(theta) - F2 * cos(theta)) * (thdot / m) \ + + (J / (m * r)) * cos(theta) * thdot**3 \ + + 3 * (J / (m * r)) * sin(theta) * thdot * thddot + zflag[1][3] = (F1 * cos(theta) - F2 * sin(theta)) * (thdot / m) \ + + (J / (m * r)) * sin(theta) * thdot**3 \ + - 3 * (J / (m * r)) * cos(theta) * thdot * thddot + + # For the fourth derivative, assume F1, F2 are constant (also thddot) + zflag[0][4] = (-F1 * sin(theta) - F2 * cos(theta)) * (thddot / m) \ + + (-F1 * cos(theta) + F2 * sin(theta)) * (thdot**2 / m) \ + + 6 * (J / (m * r)) * cos(theta) * thdot**2 * thddot \ + + 3 * (J / (m * r)) * sin(theta) * thddot**2 \ + - (J / (m * r)) * sin(theta) * thdot**4 + zflag[1][4] = (F1 * cos(theta) - F2 * sin(theta)) * (thddot / m) \ + + (-F1 * sin(theta) - F2 * cos(theta)) * (thdot**2 / m) \ + - 6 * (J / (m * r)) * sin(theta) * thdot**2 * thddot \ + - 3 * (J / (m * r)) * cos(theta) * thddot**2 \ + + (J / (m * r)) * cos(theta) * thdot**4 + + return zflag + +def pvtol_flat_reverse(zflag, params={}): + # Get the parameter values + m = params.get('m', 4.) # mass of aircraft + J = params.get('J', 0.0475) # inertia around pitch axis + r = params.get('r', 0.25) # distance to center of force + g = params.get('g', 9.8) # gravitational constant + c = params.get('c', 0.05) # damping factor (estimated) + + # Create a vector to store the state and inputs + x = np.zeros(6) + u = np.zeros(2) + + # Given the flat variables, solve for the state + theta = np.arctan2(-zflag[0][2], zflag[1][2] + g) + x = zflag[0][0] + (J / (m * r)) * sin(theta) + y = zflag[1][0] - (J / (m * r)) * cos(theta) + + # Solve for thdot using next derivative + thdot = (zflag[0][3] * cos(theta) + zflag[1][3] * sin(theta)) \ + / (zflag[0][2] * sin(theta) - (zflag[1][2] + g) * cos(theta)) + + # xdot and ydot can be found from first derivative of flat outputs + xdot = zflag[0][1] + (J / (m * r)) * cos(theta) * thdot + ydot = zflag[1][1] + (J / (m * r)) * sin(theta) * thdot + + # Solve for the input forces + F2 = m * ((zflag[1][2] + g) * cos(theta) - zflag[0][2] * sin(theta) + + (J / (m * r)) * thdot**2) + F1 = (J / r) * \ + (zflag[0][4] * cos(theta) + zflag[1][4] * sin(theta) +# - 2 * (zflag[0][3] * sin(theta) - zflag[1][3] * cos(theta)) * thdot \ + - 2 * zflag[0][3] * sin(theta) * thdot \ + + 2 * zflag[1][3] * cos(theta) * thdot \ +# - (zflag[0][2] * cos(theta) +# + (zflag[1][2] + g) * sin(theta)) * thdot**2) \ + - zflag[0][2] * cos(theta) * thdot**2 + - (zflag[1][2] + g) * sin(theta) * thdot**2) \ + / (zflag[0][2] * sin(theta) - (zflag[1][2] + g) * cos(theta)) + + return np.array([x, y, theta, xdot, ydot, thdot]), np.array([F1, F2]) + +pvtol = fs.FlatSystem( + pvtol_flat_forward, pvtol_flat_reverse, name='pvtol', + updfcn=pvtol_update, outfcn=pvtol_output, + states = [f'x{i}' for i in range(6)], + inputs = ['F1', 'F2'], + outputs = [f'x{i}' for i in range(6)], + params = { + 'm': 4., # mass of aircraft + 'J': 0.0475, # inertia around pitch axis + 'r': 0.25, # distance to center of force + 'g': 9.8, # gravitational constant + 'c': 0.05, # damping factor (estimated) + } +) + +# +# PVTOL dynamics with wind +# + +def windy_update(t, x, u, params): + # Get the input vector + F1, F2, d = u + + # Get the system response from the original dynamics + xdot, ydot, thetadot, xddot, yddot, thddot = \ + pvtol_update(t, x, u[0:2], params) + + # Now add the wind term + m = params.get('m', 4.) # mass of aircraft + xddot += d / m + + return np.array([xdot, ydot, thetadot, xddot, yddot, thddot]) + +windy_pvtol = ct.NonlinearIOSystem( + windy_update, pvtol_output, name="windy_pvtol", + states = [f'x{i}' for i in range(6)], + inputs = ['F1', 'F2', 'd'], + outputs = [f'x{i}' for i in range(6)] +) + +# +# PVTOL dynamics with noise and disturbances +# + +def noisy_update(t, x, u, params): + # Get the inputs + F1, F2, Dx, Dy, Nx, Ny, Nth = u + + # Get the system response from the original dynamics + xdot, ydot, thetadot, xddot, yddot, thddot = \ + pvtol_update(t, x, u[0:2], params) + + # Get the parameter values we need + m = params.get('m', 4.) # mass of aircraft + J = params.get('J', 0.0475) # inertia around pitch axis + + # Now add the disturbances + xddot += Dx / m + yddot += Dy / m + + return np.array([xdot, ydot, thetadot, xddot, yddot, thddot]) + +def noisy_output(t, x, u, params): + F1, F2, dx, Dy, Nx, Ny, Nth = u + return x + np.array([Nx, Ny, Nth, 0, 0, 0]) + +noisy_pvtol = ct.NonlinearIOSystem( + noisy_update, noisy_output, name="noisy_pvtol", + states = [f'x{i}' for i in range(6)], + inputs = ['F1', 'F2'] + ['Dx', 'Dy'] + ['Nx', 'Ny', 'Nth'], + outputs = pvtol.state_labels +) + +# Add the linearitizations to the dynamics as additional methods +def noisy_pvtol_A(x, u, params={}): + # Get the parameter values we need + m = params.get('m', 4.) # mass of aircraft + J = params.get('J', 0.0475) # inertia around pitch axis + c = params.get('c', 0.05) # damping factor (estimated) + + # Get the angle and compute sine and cosine + theta = x[[2]] + cth, sth = cos(theta), sin(theta) + + # Return the linearized dynamics matrix + return np.array([ + [0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 1], + [0, 0, (-u[0] * sth - u[1] * cth)/m, -c/m, 0, 0], + [0, 0, ( u[0] * cth - u[1] * sth)/m, 0, -c/m, 0], + [0, 0, 0, 0, 0, 0] + ]) +pvtol.A = noisy_pvtol_A + +# Plot the trajectory in xy coordinates +def plot_results(t, x, u): + # Set the size of the figure + plt.figure(figsize=(10, 6)) + + # Top plot: xy trajectory + plt.subplot(2, 1, 1) + plt.plot(x[0], x[1]) + plt.xlabel('x [m]') + plt.ylabel('y [m]') + plt.axis('equal') + + # Time traces of the state and input + plt.subplot(2, 4, 5) + plt.plot(t, x[1]) + plt.xlabel('Time t [sec]') + plt.ylabel('y [m]') + + plt.subplot(2, 4, 6) + plt.plot(t, x[2]) + plt.xlabel('Time t [sec]') + plt.ylabel('theta [rad]') + + plt.subplot(2, 4, 7) + plt.plot(t, u[0]) + plt.xlabel('Time t [sec]') + plt.ylabel('$F_1$ [N]') + + plt.subplot(2, 4, 8) + plt.plot(t, u[1]) + plt.xlabel('Time t [sec]') + plt.ylabel('$F_2$ [N]') + plt.tight_layout() + +# +# Additional functions for testing +# + +# Check flatness calculations +def _pvtol_check_flat(test_points=None, verbose=False): + if test_points is None: + # If no test points, use internal set + mg = 9.8 * 4 + test_points = [ + ([0, 0, 0, 0, 0, 0], [0, mg]), + ([1, 0, 0, 0, 0, 0], [0, mg]), + ([0, 1, 0, 0, 0, 0], [0, mg]), + ([1, 1, 0.1, 0, 0, 0], [0, mg]), + ([0, 0, 0.1, 0, 0, 0], [0, mg]), + ([0, 0, 0, 1, 0, 0], [0, mg]), + ([0, 0, 0, 0, 1, 0], [0, mg]), + ([0, 0, 0, 0, 0, 0.1], [0, mg]), + ([0, 0, 0, 1, 1, 0.1], [0, mg]), + ([0, 0, 0, 0, 0, 0], [1, mg]), + ([0, 0, 0, 0, 0, 0], [0, mg + 1]), + ([0, 0, 0, 0, 0, 0.1], [1, mg]), + ([0.1, 0.2, 0.3, 0.4, 0.5, 0.6], [0.7, mg + 1]), + ] + elif isinstance(test_points, tuple): + # If we only got one test point, convert to a list + test_points = [test_points] + + for x, u in test_points: + x, u = np.array(x), np.array(u) + flag = pvtol_flat_forward(x, u) + xc, uc = pvtol_flat_reverse(flag) + print(f'({x}, {u}): ', end='') + if verbose: + print(f'\n flag: {flag}') + print(f' check: ({xc}, {uc}): ', end='') + if np.allclose(x, xc) and np.allclose(u, uc): + print("OK") + else: + print("ERR") + diff --git a/examples/steering-gainsched.py b/examples/steering-gainsched.py index 7db2d9a73..7ddc6b5b8 100644 --- a/examples/steering-gainsched.py +++ b/examples/steering-gainsched.py @@ -10,7 +10,7 @@ import numpy as np import control as ct from cmath import sqrt -import matplotlib.pyplot as mpl +import matplotlib.pyplot as plt # # Vehicle steering dynamics @@ -137,7 +137,7 @@ def trajgen_output(t, x, u, params): # We construct the system using the InterconnectedSystem constructor and using # signal labels to keep track of everything. -steering = ct.InterconnectedSystem( +steering = ct.interconnect( # List of subsystems (trajgen, controller, vehicle), name='steering', @@ -167,10 +167,10 @@ def trajgen_output(t, x, u, params): T = np.linspace(0, 5, 100) # Set up a figure for plotting the results -mpl.figure(); +plt.figure(); # Plot the reference trajectory for the y position -mpl.plot([0, 5], [yref, yref], 'k--') +plt.plot([0, 5], [yref, yref], 'k--') # Find the signals we want to plot y_index = steering.find_output('y') @@ -183,13 +183,13 @@ def trajgen_output(t, x, u, params): steering, T, [vref * np.ones(len(T)), yref * np.ones(len(T))]) # Plot the reference speed - mpl.plot([0, 5], [vref, vref], 'k--') + plt.plot([0, 5], [vref, vref], 'k--') # Plot the system output - y_line, = mpl.plot(tout, yout[y_index, :], 'r') # lateral position - v_line, = mpl.plot(tout, yout[v_index, :], 'b') # vehicle velocity + y_line, = plt.plot(tout, yout[y_index, :], 'r') # lateral position + v_line, = plt.plot(tout, yout[v_index, :], 'b') # vehicle velocity # Add axis labels -mpl.xlabel('Time (s)') -mpl.ylabel('x vel (m/s), y pos (m)') -mpl.legend((v_line, y_line), ('v', 'y'), loc='center right', frameon=False) +plt.xlabel('Time (s)') +plt.ylabel('x vel (m/s), y pos (m)') +plt.legend((v_line, y_line), ('v', 'y'), loc='center right', frameon=False) diff --git a/examples/stochresp.ipynb b/examples/stochresp.ipynb new file mode 100644 index 000000000..224d7f208 --- /dev/null +++ b/examples/stochresp.ipynb @@ -0,0 +1,292 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "03aa22e7", + "metadata": {}, + "source": [ + "# Stochastic Response\n", + "Richard M. Murray, 6 Feb 2022\n", + "\n", + "This notebook illustrates the implementation of random processes and stochastic response. We focus on a system of the form\n", + "\n", + "$$\n", + " \\dot X = A X + F V \\qquad X \\in {\\mathbb R}^n\n", + "$$\n", + "\n", + "where $V$ is a white noise process and the system is a first order linear system." + ] + }, + { + "cell_type": "code", + "execution_count": 83, + "id": "902af902", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import scipy as sp\n", + "import matplotlib.pyplot as plt\n", + "import control as ct\n", + "from math import sqrt, exp\n", + "\n", + "# Fix random number seed to avoid spurious figure regeneration\n", + "np.random.seed(1)" + ] + }, + { + "cell_type": "markdown", + "id": "d020a2ec", + "metadata": {}, + "source": [ + "We begin by defining a simple first order system\n", + "\n", + "$$\n", + "\\frac{dX}{dt} = - a X + V, \\qquad Y = c X\n", + "$$\n", + "\n", + "and a (scalar) white noise signal $V$ with intensity $Q$." + ] + }, + { + "cell_type": "code", + "execution_count": 84, + "id": "60192a8c", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# First order system\n", + "a = 1\n", + "c = 1\n", + "sys = ct.ss([[-a]], [[1]], [[c]], 0)\n", + "\n", + "# Create the time vector that we want to use\n", + "Tf = 5\n", + "T = np.linspace(0, Tf, 1000)\n", + "dt = T[1] - T[0]\n", + "\n", + "# Create the basis for a white noise signal\n", + "Q = np.array([[0.1]])\n", + "V = ct.white_noise(T, Q)\n", + "\n", + "plt.plot(T, V[0])\n", + "plt.xlabel('Time [s]')\n", + "plt.ylabel('$V$');" + ] + }, + { + "cell_type": "markdown", + "id": "b4629e2c", + "metadata": {}, + "source": [ + "Note that the magnitude of the signal seems to be much larger than $Q$. This is because we have a Guassian process $\\implies$ the signal consists of a sequence of \"impulse-like\" functions that have magnitude that increases with the time step $dt$ as $1/\\sqrt{dt}$ (this gives covariance $\\mathbb{E}(V(t_1) V^T(t_2)) = Q \\delta(t_2 - t_1)$." + ] + }, + { + "cell_type": "code", + "execution_count": 85, + "id": "23319dc6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "mean(V) [0.0] = 0.17348786109316244\n", + "cov(V) * dt [0.1] = 0.09633133133133133\n" + ] + } + ], + "source": [ + "# Calculate the sample properties and make sure they match\n", + "print(\"mean(V) [0.0] = \", np.mean(V))\n", + "print(\"cov(V) * dt [%0.3g] = \" % Q, np.round(np.cov(V), decimals=3) * dt)" + ] + }, + { + "cell_type": "markdown", + "id": "3196c60d", + "metadata": {}, + "source": [ + "The response of the system to white noise can be computed using the `input_output_response` function:" + ] + }, + { + "cell_type": "code", + "execution_count": 86, + "id": "2bdaaccf", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Response of the first order system\n", + "T, Y = ct.input_output_response(sys, T, V)\n", + "plt.plot(T, Y)\n", + "plt.xlabel('Time [s]')\n", + "plt.ylabel('$Y$');" + ] + }, + { + "cell_type": "markdown", + "id": "ead0232e", + "metadata": {}, + "source": [ + "This is a first order system, and so we can compute the analytical correlation function and compare this to the sampled data:" + ] + }, + { + "cell_type": "code", + "execution_count": 87, + "id": "d31ce324", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mean(Y) [0] = 0.165\n", + "* cov(Y) [0.05] = 0.0151\n" + ] + } + ], + "source": [ + "# Compare static properties to what we expect analytically\n", + "def r(tau):\n", + " return c**2 * Q / (2 * a) * exp(-a * abs(tau))\n", + " \n", + "print(\"* mean(Y) [%0.3g] = %0.3g\" % (0, np.mean(Y)))\n", + "print(\"* cov(Y) [%0.3g] = %0.3g\" % (r(0), np.cov(Y)))" + ] + }, + { + "cell_type": "markdown", + "id": "28321bee", + "metadata": {}, + "source": [ + "Finally, we look at the correlation function for the input and the output:" + ] + }, + { + "cell_type": "code", + "execution_count": 88, + "id": "1cf5a4b1", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Correlation function for the input\n", + "tau, r_V = ct.correlation(T, V)\n", + "\n", + "plt.plot(tau, r_V, 'r-')\n", + "plt.xlabel(r'$\\tau$')\n", + "plt.ylabel(r'$r_V(\\tau)$');" + ] + }, + { + "cell_type": "code", + "execution_count": 89, + "id": "62af90a4", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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zry5Npzqeov/EgkMGxV9JosVB5N4sJc3zVxrWrucCsvKyuLChQsAvPLKK2zZsoW33nrL0DmGRNWTkZFBSEgId999NxMmTOD06dPWLqnCajs78M8RbTicmMG60KpXf0VIYNigS1N81HZy4IlBraxdToVorfn1119p3749L7zwAgDdunVj4MCB1i1M2CQ3Nzf27NnDW2+9xerVq2nbti0LFiwwfEEmc7u7iydtG7vw3sZI8gurVu0VIYFhgzYePc3WqFRmDvXDrXbVGW4aHh7OyJEjGTNmDLVr12bixInWLklUAU5OTrz66quEhITQpUsX3njjjbKr06rC3k7x4si2nEjL5ps9x61djmEkMGxMdn4hb64Np21jFybf5mPtcky2dOlSOnbsSFBQEB999BFHjhzhtttus3ZZogpp3bo1f/zxB3v27KFu3brk5+czc+ZM4uLirF2aSQa28WBQGw8++P0YieeyrV2OISQwbMziHfGcOp/LW3e2t/mHgc6dO0dCQgIA/fv3Z8aMGURHR/PMM8/IMxaiUpRSeHt7A3D48GEWL16Mv78/jz/+eNm/NVullOLfd3VAo/lo8zFrl2MI2/6NdIvJzi/kq13xDPVvaOg04jcrNTWV119/HR8fn7IlOX19ffn0009l/QphNj169CA6OpopU6awePFiWrVqxfTp07l48aK1S7umZq41ebC3D78cOklMStW6rWYKCQwb8tP+RNKzC3h0QEtrl1KuyMhIpk+fjpeXF2+99RbDhw/n7bfftnZZohpr2rQpX3zxBTExMTzyyCMcOXKEWrVKlgZOTEy0cnXlm9HfFycHOxZuj73xwVWMBIaNyC8sZuH2OLp5u9HNhq4uCgsLKSwsBOCHH35g2bJlPPTQQ4SHh/Pf//6X9u3bW7lCcSto3rw5n376Kbt370YpRWZmJu3ataNnz55899135OXlWbvEMvXrOPO3bl6sOnSS0+dvvBpgVSKBYSNWHkzi1PlcnhxsG8NoY2JiePnll2nevDlr1qwB4JlnniEhIYEFCxbg7+9v5QrFrcjevmTqfkdHR959910yMjKYOHEiTZo04cknnyQ+Pt7KFZZ4pJ8vxRqW7KwaHfamksCwARnZ+bz/exSBXq4MaG29PoDCwkLmzZtH37598fPzY/bs2XTt2rVsjQo3NzfpoxA2oWbNmjzxxBNERETw+++/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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Correlation function for the output\n", + "tau, r_Y = ct.correlation(T, Y)\n", + "plt.plot(tau, r_Y)\n", + "plt.xlabel(r'$\\tau$')\n", + "plt.ylabel(r'$r_Y(\\tau)$')\n", + "\n", + "# Compare to the analytical answer\n", + "plt.plot(tau, [r(t)[0, 0] for t in tau], 'k--');" + ] + }, + { + "cell_type": "markdown", + "id": "2a2785e9", + "metadata": {}, + "source": [ + "The analytical curve may or may not line up that well with the correlation function based on the sample. Try running the code again with a different random seed to see how things change based on the specific random sequence chosen at the start.\n", + "\n", + "Note: the _right_ way to compute the correlation function would be to run a lot of different samples of white noise filtered through the system dynamics and compute $R(t_1, t_2)$ across those samples. The `correlation` function computes the covariance between $Y(t + \\tau)$ and $Y(t)$ by varying $t$ over the time range." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd5dfc75", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tfvis.py b/examples/tfvis.py index 30a084ffb..0cb789db4 100644 --- a/examples/tfvis.py +++ b/examples/tfvis.py @@ -270,8 +270,8 @@ def button_release(self, event): tfcn = self.tfi.get_tf() if (tfcn): - self.zeros = tfcn.zero() - self.poles = tfcn.pole() + self.zeros = tfcn.zeros() + self.poles = tfcn.poles() self.sys = tfcn self.redraw() @@ -314,8 +314,8 @@ def apply(self): tfcn = self.tfi.get_tf() if (tfcn): - self.zeros = tfcn.zero() - self.poles = tfcn.pole() + self.zeros = tfcn.zeros() + self.poles = tfcn.poles() self.sys = tfcn self.redraw() diff --git a/examples/vehicle-steering.png b/examples/vehicle-steering.png new file mode 100644 index 000000000..f10aab853 Binary files /dev/null and b/examples/vehicle-steering.png differ diff --git a/examples/vehicle.py b/examples/vehicle.py new file mode 100644 index 000000000..b316ceced --- /dev/null +++ b/examples/vehicle.py @@ -0,0 +1,111 @@ +# vehicle.py - planar vehicle model (with flatness) +# RMM, 16 Jan 2022 + +import numpy as np +import matplotlib.pyplot as plt +import control as ct +import control.flatsys as fs + +# +# Vehicle dynamics +# + +# Function to take states, inputs and return the flat flag +def _vehicle_flat_forward(x, u, params={}): + # Get the parameter values + b = params.get('wheelbase', 3.) + + # Create a list of arrays to store the flat output and its derivatives + zflag = [np.zeros(3), np.zeros(3)] + + # Flat output is the x, y position of the rear wheels + zflag[0][0] = x[0] + zflag[1][0] = x[1] + + # First derivatives of the flat output + zflag[0][1] = u[0] * np.cos(x[2]) # dx/dt + zflag[1][1] = u[0] * np.sin(x[2]) # dy/dt + + # First derivative of the angle + thdot = (u[0]/b) * np.tan(u[1]) + + # Second derivatives of the flat output (setting vdot = 0) + zflag[0][2] = -u[0] * thdot * np.sin(x[2]) + zflag[1][2] = u[0] * thdot * np.cos(x[2]) + + return zflag + +# Function to take the flat flag and return states, inputs +def _vehicle_flat_reverse(zflag, params={}): + # Get the parameter values + b = params.get('wheelbase', 3.) + dir = params.get('dir', 'f') + + # Create a vector to store the state and inputs + x = np.zeros(3) + u = np.zeros(2) + + # Given the flat variables, solve for the state + x[0] = zflag[0][0] # x position + x[1] = zflag[1][0] # y position + if dir == 'f': + x[2] = np.arctan2(zflag[1][1], zflag[0][1]) # tan(theta) = ydot/xdot + elif dir == 'r': + # Angle is flipped by 180 degrees (since v < 0) + x[2] = np.arctan2(-zflag[1][1], -zflag[0][1]) + else: + raise ValueError("unknown direction:", dir) + + # And next solve for the inputs + u[0] = zflag[0][1] * np.cos(x[2]) + zflag[1][1] * np.sin(x[2]) + thdot_v = zflag[1][2] * np.cos(x[2]) - zflag[0][2] * np.sin(x[2]) + u[1] = np.arctan2(thdot_v, u[0]**2 / b) + + return x, u + +# Function to compute the RHS of the system dynamics +def _vehicle_update(t, x, u, params): + b = params.get('wheelbase', 3.) # get parameter values + dx = np.array([ + np.cos(x[2]) * u[0], + np.sin(x[2]) * u[0], + (u[0]/b) * np.tan(u[1]) + ]) + return dx + +def _vehicle_output(t, x, u, params): + return x # return x, y, theta (full state) + +# Create differentially flat input/output system +vehicle = fs.FlatSystem( + _vehicle_flat_forward, _vehicle_flat_reverse, name="vehicle", + updfcn=_vehicle_update, outfcn=_vehicle_output, + inputs=('v', 'delta'), outputs=('x', 'y', 'theta'), + states=('x', 'y', 'theta')) + +# +# Utility function to plot lane change manuever +# + +def plot_lanechange(t, y, u, figure=None, yf=None): + # Plot the xy trajectory + plt.subplot(3, 1, 1, label='xy') + plt.plot(y[0], y[1]) + plt.xlabel("x [m]") + plt.ylabel("y [m]") + if yf: + plt.plot(yf[0], yf[1], 'ro') + + # Plot the inputs as a function of time + plt.subplot(3, 1, 2, label='v') + plt.plot(t, u[0]) + plt.xlabel("t [sec]") + plt.ylabel("velocity [m/s]") + + plt.subplot(3, 1, 3, label='delta') + plt.plot(t, u[1]) + plt.xlabel("t [sec]") + plt.ylabel("steering [rad/s]") + + plt.suptitle("Lane change manuever") + plt.tight_layout()