From ad83edc3faa90ebe8e701e7660007f5cc226ab8e Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 19 Jun 2023 08:59:25 -0700 Subject: [PATCH] rename files/modules: iosys -> nlsys, namedio -> iosysm --- control/__init__.py | 4 +- control/canonical.py | 2 +- control/config.py | 4 +- control/dtime.py | 4 +- control/flatsys/flatsys.py | 2 +- control/flatsys/linflat.py | 2 +- control/frdata.py | 2 +- control/iosys.py | 3646 ++++++-------------------------- control/lti.py | 2 +- control/margins.py | 2 +- control/matlab/__init__.py | 4 +- control/matlab/wrappers.py | 2 +- control/modelsimp.py | 2 +- control/namedio.py | 756 ------- control/nlsys.py | 2602 +++++++++++++++++++++++ control/optimal.py | 2 +- control/pzmap.py | 2 +- control/rlocus.py | 2 +- control/sisotool.py | 6 +- control/statefbk.py | 6 +- control/statesp.py | 1307 +++++++++--- control/stochsys.py | 8 +- control/tests/config_test.py | 2 +- control/tests/frd_test.py | 2 +- control/tests/iosys_test.py | 98 +- control/tests/kwargs_test.py | 4 +- control/tests/namedio_test.py | 8 +- control/tests/statesp_test.py | 2 +- control/tests/stochsys_test.py | 4 +- control/timeresp.py | 2 +- control/xferfcn.py | 2 +- 31 files changed, 4266 insertions(+), 4227 deletions(-) delete mode 100644 control/namedio.py create mode 100644 control/nlsys.py diff --git a/control/__init__.py b/control/__init__.py index cfc23ed19..3cc538c82 100644 --- a/control/__init__.py +++ b/control/__init__.py @@ -77,7 +77,7 @@ from .margins import * from .mateqn import * from .modelsimp import * -from .namedio import * +from .iosys import * from .nichols import * from .phaseplot import * from .pzmap import * @@ -93,7 +93,7 @@ from .robust import * from .config import * from .sisotool import * -from .iosys import * +from .nlsys import * from .passivity import * # Exceptions diff --git a/control/canonical.py b/control/canonical.py index 9c9a2a738..06a554859 100644 --- a/control/canonical.py +++ b/control/canonical.py @@ -2,7 +2,7 @@ # RMM, 10 Nov 2012 from .exception import ControlNotImplemented, ControlSlycot -from .namedio import issiso +from .iosys 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 9009a30f3..50a92f8dc 100644 --- a/control/config.py +++ b/control/config.py @@ -123,7 +123,7 @@ def reset_defaults(): from .sisotool import _sisotool_defaults defaults.update(_sisotool_defaults) - from .namedio import _namedio_defaults + from .iosys import _namedio_defaults defaults.update(_namedio_defaults) from .xferfcn import _xferfcn_defaults @@ -132,7 +132,7 @@ def reset_defaults(): from .statesp import _statesp_defaults defaults.update(_statesp_defaults) - from .iosys import _iosys_defaults + from .nlsys import _iosys_defaults defaults.update(_iosys_defaults) from .optimal import _optimal_defaults diff --git a/control/dtime.py b/control/dtime.py index 38fcf8056..3238419b2 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -47,7 +47,7 @@ """ -from .namedio import isctime +from .iosys import isctime from .statesp import StateSpace __all__ = ['sample_system', 'c2d'] @@ -127,4 +127,4 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None, method=method, alpha=alpha, prewarp_frequency=prewarp_frequency, name=name, copy_names=copy_names, **kwargs) -c2d = sample_system \ No newline at end of file +c2d = sample_system diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index 4bd767a99..3ae5d7968 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -45,7 +45,7 @@ import warnings from .poly import PolyFamily from .systraj import SystemTrajectory -from ..iosys import NonlinearIOSystem +from ..nlsys import NonlinearIOSystem from ..timeresp import _check_convert_array diff --git a/control/flatsys/linflat.py b/control/flatsys/linflat.py index 8e6c23604..9ffd78ce7 100644 --- a/control/flatsys/linflat.py +++ b/control/flatsys/linflat.py @@ -38,7 +38,7 @@ import numpy as np import control from .flatsys import FlatSystem -from ..iosys import LinearIOSystem +from ..statesp import LinearIOSystem class LinearFlatSystem(FlatSystem, LinearIOSystem): diff --git a/control/frdata.py b/control/frdata.py index 83873a120..23ede321f 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -54,7 +54,7 @@ from .lti import LTI, _process_frequency_response from .exception import pandas_check -from .namedio import NamedIOSystem, _process_namedio_keywords +from .iosys import NamedIOSystem, _process_namedio_keywords from . import config __all__ = ['FrequencyResponseData', 'FRD', 'frd'] diff --git a/control/iosys.py b/control/iosys.py index 00ed288fa..02deb5afe 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1,3195 +1,759 @@ -# iosys.py - input/output system module +# namedio.py - named I/O system class and helper functions +# RMM, 13 Mar 2022 # -# RMM, 28 April 2019 -# -# Additional features to add -# * Allow constant inputs for MIMO input_output_response (w/out ones) -# * Add support for constants/matrices as part of operators (1 + P) -# * Add unit tests (and example?) for time-varying systems -# * Allow time vector for discrete time simulations to be multiples of dt -# * Check the way initial outputs for discrete time systems are handled -# - -"""The :mod:`~control.iosys` module contains the -:class:`~control.InputOutputSystem` class that represents (possibly nonlinear) -input/output systems. The :class:`~control.InputOutputSystem` class is a -general class that defines any continuous or discrete time dynamical system. -Input/output systems can be simulated and also used to compute equilibrium -points and linearizations. - -""" - -__author__ = "Richard Murray" -__copyright__ = "Copyright 2019, California Institute of Technology" -__credits__ = ["Richard Murray"] -__license__ = "BSD" -__maintainer__ = "Richard Murray" -__email__ = "murray@cds.caltech.edu" +# 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 -import scipy as sp -import copy +from copy import deepcopy 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 +import re from . import config -__all__ = ['InputOutputSystem', 'LinearIOSystem', 'NonlinearIOSystem', - 'InterconnectedSystem', 'LinearICSystem', 'input_output_response', - 'find_eqpt', 'linearize', 'ss', 'rss', 'drss', 'ss2io', 'tf2io', - 'interconnect', 'summing_junction'] +__all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', + 'isdtime', 'isctime'] # Define module default parameter values -_iosys_defaults = {} - - -class InputOutputSystem(NamedIOSystem): - """A class for representing input/output systems. - - The InputOutputSystem class allows (possibly nonlinear) input/output - systems to be represented in Python. It is used as a parent class for - a set of subclasses that are used to implement specific structures and - operations for different types of input/output dynamical systems. - - Parameters - ---------- - inputs : int, list of str, or None - Description of the system inputs. This can be given as an integer - 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 given by the `input_prefix` parameter and - has default value 'u'). If this parameter is not given or given as - `None`, the relevant quantity will be determined when possible - based on other information provided to functions using the system. - outputs : int, list of str, or None - Description of the system outputs. Same format as `inputs`, with - the prefix given by output_prefix (defaults to 'y'). - states : int, list of str, or None - Description of the system states. Same format as `inputs`, with - the prefix given by state_prefix (defaults to 'x'). - 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. - params : dict, optional - Parameter values for the system. Passed to the evaluation functions - for the system as default values, overriding internal defaults. - - Attributes - ---------- - ninputs, noutputs, nstates : int - Number of input, output and state variables - input_index, output_index, state_index : dict - Dictionary of signal names for the inputs, outputs and states and the - index of the corresponding array - dt : None, True or float - 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. - name : string, optional - System name (used for specifying signals) - - Other Parameters - ---------------- - input_prefix : string, optional - Set the prefix for input signals. Default = 'u'. - output_prefix : string, optional - Set the prefix for output signals. Default = 'y'. - state_prefix : string, optional - Set the prefix for state signals. Default = 'x'. - - Notes - ----- - The :class:`~control.InputOuputSystem` class (and its subclasses) makes - use of two special methods for implementing much of the work of the class: - - * _rhs(t, x, u): compute the right hand side of the differential or - difference equation for the system. This must be specified by the - subclass for the system. - - * _out(t, x, u): compute the output for the current state of the system. - The default is to return the entire system state. - - """ - - # Allow ndarray * InputOutputSystem to give IOSystem._rmul_() priority - __array_priority__ = 12 # override ndarray, matrix, SS types - - def __init__(self, params=None, **kwargs): - """Create an input/output system. - - The InputOutputSystem constructor is used to create an input/output - object with the core information required for all input/output - systems. Instances of this class are normally created by one of the - input/output subclasses: :class:`~control.LinearICSystem`, - :class:`~control.LinearIOSystem`, :class:`~control.NonlinearIOSystem`, - :class:`~control.InterconnectedSystem`. - - """ - # Store the system name, inputs, outputs, and states - name, inputs, outputs, states, dt = _process_namedio_keywords(kwargs) - - # 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, **kwargs) - - # default parameters - self.params = {} if params is None else params.copy() - - def __mul__(sys2, sys1): - """Multiply two input/output systems (series interconnection)""" - # Note: order of arguments is flipped so that self = sys2, - # corresponding to the ordering convention of sys2 * sys1 - - # Convert sys1 to an I/O system if needed - if isinstance(sys1, (int, float, np.number)): - sys1 = LinearIOSystem(StateSpace( - [], [], [], sys1 * np.eye(sys2.ninputs))) - - elif isinstance(sys1, np.ndarray): - sys1 = LinearIOSystem(StateSpace([], [], [], sys1)) - - elif isinstance(sys1, (StateSpace, TransferFunction)) and \ - not isinstance(sys1, LinearIOSystem): - sys1 = LinearIOSystem(sys1) - - elif not isinstance(sys1, InputOutputSystem): - raise TypeError("Unknown I/O system object ", sys1) - - # Make sure systems can be interconnected - if sys1.noutputs != sys2.ninputs: - raise ValueError("Can't multiply systems with incompatible " - "inputs and outputs") - - # Make sure timebase are compatible - dt = common_timebase(sys1.dt, sys2.dt) - - # Create a new system to handle the composition - inplist = [(0, i) for i in range(sys1.ninputs)] - outlist = [(1, i) for i in range(sys2.noutputs)] - newsys = InterconnectedSystem( - (sys1, sys2), inplist=inplist, outlist=outlist) - - # Set up the connection map manually - newsys.set_connect_map(np.block( - [[np.zeros((sys1.ninputs, sys1.noutputs)), - np.zeros((sys1.ninputs, sys2.noutputs))], - [np.eye(sys2.ninputs, sys1.noutputs), - np.zeros((sys2.ninputs, sys2.noutputs))]] - )) - - # If both systems are linear, create LinearICSystem - if isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): - ss_sys = StateSpace.__mul__(sys2, sys1) - return LinearICSystem(newsys, ss_sys) - - # Return the newly created InterconnectedSystem - return newsys - - def __rmul__(sys1, sys2): - """Pre-multiply an input/output systems by a scalar/matrix""" - # Convert sys2 to an I/O system if needed - if isinstance(sys2, (int, float, np.number)): - sys2 = LinearIOSystem(StateSpace( - [], [], [], sys2 * np.eye(sys1.noutputs))) - - elif isinstance(sys2, np.ndarray): - sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) - - elif isinstance(sys2, (StateSpace, TransferFunction)) and \ - not isinstance(sys2, LinearIOSystem): - sys2 = LinearIOSystem(sys2) - - elif not isinstance(sys2, InputOutputSystem): - raise TypeError("Unknown I/O system object ", sys2) - - return InputOutputSystem.__mul__(sys2, sys1) - - def __add__(sys1, sys2): - """Add two input/output systems (parallel interconnection)""" - # Convert sys1 to an I/O system if needed - if isinstance(sys2, (int, float, np.number)): - sys2 = LinearIOSystem(StateSpace( - [], [], [], sys2 * np.eye(sys1.ninputs))) - - elif isinstance(sys2, np.ndarray): - sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) - - elif isinstance(sys2, (StateSpace, TransferFunction)) and \ - not isinstance(sys2, LinearIOSystem): - sys2 = LinearIOSystem(sys2) - - elif not isinstance(sys2, InputOutputSystem): - raise TypeError("Unknown I/O system object ", sys2) - - # Make sure number of input and outputs match - if sys1.ninputs != sys2.ninputs or sys1.noutputs != sys2.noutputs: - raise ValueError("Can't add systems with incompatible numbers of " - "inputs or outputs.") - ninputs = sys1.ninputs - noutputs = sys1.noutputs - - # Create a new system to handle the composition - inplist = [[(0, i), (1, i)] for i in range(ninputs)] - outlist = [[(0, i), (1, i)] for i in range(noutputs)] - newsys = InterconnectedSystem( - (sys1, sys2), inplist=inplist, outlist=outlist) - - # If both systems are linear, create LinearICSystem - if isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): - ss_sys = StateSpace.__add__(sys2, sys1) - return LinearICSystem(newsys, ss_sys) - - # Return the newly created InterconnectedSystem - return newsys - - def __radd__(sys1, sys2): - """Parallel addition of input/output system to a compatible object.""" - # Convert sys2 to an I/O system if needed - if isinstance(sys2, (int, float, np.number)): - sys2 = LinearIOSystem(StateSpace( - [], [], [], sys2 * np.eye(sys1.noutputs))) - - elif isinstance(sys2, np.ndarray): - sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) - - elif isinstance(sys2, (StateSpace, TransferFunction)) and \ - not isinstance(sys2, LinearIOSystem): - sys2 = LinearIOSystem(sys2) - - elif not isinstance(sys2, InputOutputSystem): - raise TypeError("Unknown I/O system object ", sys2) - - return InputOutputSystem.__add__(sys2, sys1) - - def __sub__(sys1, sys2): - """Subtract two input/output systems (parallel interconnection)""" - # Convert sys1 to an I/O system if needed - if isinstance(sys2, (int, float, np.number)): - sys2 = LinearIOSystem(StateSpace( - [], [], [], sys2 * np.eye(sys1.ninputs))) - - elif isinstance(sys2, np.ndarray): - sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) - - elif isinstance(sys2, (StateSpace, TransferFunction)) and \ - not isinstance(sys2, LinearIOSystem): - sys2 = LinearIOSystem(sys2) - - elif not isinstance(sys2, InputOutputSystem): - raise TypeError("Unknown I/O system object ", sys2) - - # Make sure number of input and outputs match - if sys1.ninputs != sys2.ninputs or sys1.noutputs != sys2.noutputs: - raise ValueError("Can't add systems with incompatible numbers of " - "inputs or outputs.") - ninputs = sys1.ninputs - noutputs = sys1.noutputs - - # Create a new system to handle the composition - inplist = [[(0, i), (1, i)] for i in range(ninputs)] - outlist = [[(0, i), (1, i, -1)] for i in range(noutputs)] - newsys = InterconnectedSystem( - (sys1, sys2), inplist=inplist, outlist=outlist) - - # If both systems are linear, create LinearICSystem - if isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): - ss_sys = StateSpace.__sub__(sys1, sys2) - return LinearICSystem(newsys, ss_sys) - - # Return the newly created InterconnectedSystem - return newsys - - def __rsub__(sys1, sys2): - """Parallel subtraction of I/O system to a compatible object.""" - # Convert sys2 to an I/O system if needed - if isinstance(sys2, (int, float, np.number)): - sys2 = LinearIOSystem(StateSpace( - [], [], [], sys2 * np.eye(sys1.noutputs))) - - elif isinstance(sys2, np.ndarray): - sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) - - elif isinstance(sys2, (StateSpace, TransferFunction)) and \ - not isinstance(sys2, LinearIOSystem): - sys2 = LinearIOSystem(sys2) - - elif not isinstance(sys2, InputOutputSystem): - raise TypeError("Unknown I/O system object ", sys2) - - return InputOutputSystem.__sub__(sys2, sys1) - - def __neg__(sys): - """Negate an input/output systems (rescale)""" - if sys.ninputs is None or sys.noutputs is None: - raise ValueError("Can't determine number of inputs or outputs") - - # Create a new system to hold the negation - inplist = [(0, i) for i in range(sys.ninputs)] - outlist = [(0, i, -1) for i in range(sys.noutputs)] - newsys = InterconnectedSystem( - (sys,), dt=sys.dt, inplist=inplist, outlist=outlist) - - # If the system is linear, create LinearICSystem - if isinstance(sys, StateSpace): - ss_sys = StateSpace.__neg__(sys) - return LinearICSystem(newsys, ss_sys) - - # Return the newly created system - return newsys - - def __truediv__(sys2, sys1): - """Division of input/output systems - - Only division by scalars and arrays of scalars is supported""" - # Note: order of arguments is flipped so that self = sys2, - # corresponding to the ordering convention of sys2 * sys1 - - if not isinstance(sys1, (LTI, NamedIOSystem)): - return sys2 * (1/sys1) - else: - return NotImplemented - - - # Update parameters used for _rhs, _out (used by subclasses) - def _update_params(self, params, warning=False): - if warning: - warn("Parameters passed to InputOutputSystem ignored.") - - def _rhs(self, t, x, u): - """Evaluate right hand side of a differential or difference equation. - - Private function used to compute the right hand side of an - input/output system model. Intended for fast - evaluation; for a more user-friendly interface - you may want to use :meth:`dynamics`. - - """ - raise NotImplementedError("Evaluation not implemented for system of type ", - type(self)) - - def dynamics(self, t, x, u, params=None): - """Compute the dynamics of a differential or difference equation. - - Given time `t`, input `u` and state `x`, returns the value of the - right hand side of the dynamical system. If the system is continuous, - returns the time derivative - - dx/dt = f(t, x, u[, params]) - - where `f` is the system's (possibly nonlinear) dynamics function. - If the system is discrete-time, returns the next value of `x`: - - x[t+dt] = f(t, x[t], u[t][, params]) - - where `t` is a scalar. - - The inputs `x` and `u` must be of the correct length. The `params` - argument is an optional dictionary of parameter values. - - Parameters - ---------- - t : float - the time at which to evaluate - x : array_like - current state - u : array_like - input - params : dict (optional) - system parameter values - - Returns - ------- - dx/dt or x[t+dt] : ndarray - """ - self._update_params(params) - return self._rhs(t, x, u) - - def _out(self, t, x, u): - """Evaluate the output of a system at a given state, input, and time - - Private function used to compute the output of of an input/output - system model given the state, input, parameters. Intended for fast - evaluation; for a more user-friendly interface you may want to use - :meth:`output`. - - """ - # If no output function was defined in subclass, return state - return x - - def output(self, t, x, u, params=None): - """Compute the output of the system - - Given time `t`, input `u` and state `x`, returns the output of the - system: - - y = g(t, x, u[, params]) - - The inputs `x` and `u` must be of the correct length. - - Parameters - ---------- - t : float - the time at which to evaluate - x : array_like - current state - u : array_like - input - params : dict (optional) - system parameter values - - Returns - ------- - y : ndarray - """ - self._update_params(params) - return self._out(t, x, u) - - def feedback(self, other=1, sign=-1, params=None): - """Feedback interconnection between two input/output systems - - Parameters - ---------- - sys1: InputOutputSystem - The primary process. - sys2: InputOutputSystem - The feedback process (often a feedback controller). - sign: scalar, optional - The sign of feedback. `sign` = -1 indicates negative feedback, - and `sign` = 1 indicates positive feedback. `sign` is an optional - argument; it assumes a value of -1 if not specified. - - Returns - ------- - out: InputOutputSystem - - Raises - ------ - ValueError - if the inputs, outputs, or timebases of the systems are - incompatible. - - """ - # TODO: add conversion to I/O system when needed - if not isinstance(other, InputOutputSystem): - # Try converting to a state space system - try: - other = _convert_to_statespace(other) - except TypeError: - raise TypeError( - "Feedback around I/O system must be an I/O system " - "or convertable to an I/O system.") - other = LinearIOSystem(other) - - # Make sure systems can be interconnected - if self.noutputs != other.ninputs or other.noutputs != self.ninputs: - raise ValueError("Can't connect systems with incompatible " - "inputs and outputs") - - # Make sure timebases are compatible - dt = common_timebase(self.dt, other.dt) - - inplist = [(0, i) for i in range(self.ninputs)] - outlist = [(0, i) for i in range(self.noutputs)] - - # Return the series interconnection between the systems - newsys = InterconnectedSystem( - (self, other), inplist=inplist, outlist=outlist, - params=params, dt=dt) - - # Set up the connecton map manually - newsys.set_connect_map(np.block( - [[np.zeros((self.ninputs, self.noutputs)), - sign * np.eye(self.ninputs, other.noutputs)], - [np.eye(other.ninputs, self.noutputs), - np.zeros((other.ninputs, other.noutputs))]] - )) - - if isinstance(self, StateSpace) and isinstance(other, StateSpace): - # Special case: maintain linear systems structure - ss_sys = StateSpace.feedback(self, other, sign=sign) - return LinearICSystem(newsys, ss_sys) - - # Return the newly created system - return newsys - - def linearize(self, x0, u0, t=0, params=None, eps=1e-6, - name=None, copy_names=False, **kwargs): - """Linearize an input/output system at a given state and input. - - Return the linearization of an input/output system at a given state - and input value as a StateSpace system. See - :func:`~control.linearize` for complete documentation. - - """ - # - # If the linearization is not defined by the subclass, perform a - # numerical linearization use the `_rhs()` and `_out()` member - # functions. - # - - # If x0 and u0 are specified as lists, concatenate the elements - x0 = _concatenate_list_elements(x0, 'x0') - u0 = _concatenate_list_elements(u0, 'u0') - - # Figure out dimensions if they were not specified. - nstates = _find_size(self.nstates, x0) - ninputs = _find_size(self.ninputs, u0) - - # Convert x0, u0 to arrays, if needed - if np.isscalar(x0): - x0 = np.ones((nstates,)) * x0 - if np.isscalar(u0): - u0 = np.ones((ninputs,)) * u0 - - # Compute number of outputs by evaluating the output function - noutputs = _find_size(self.noutputs, self._out(t, x0, u0)) - - # Update the current parameters - self._update_params(params) - - # Compute the nominal value of the update law and output - F0 = self._rhs(t, x0, u0) - H0 = self._out(t, x0, u0) - - # Create empty matrices that we can fill up with linearizations - A = np.zeros((nstates, nstates)) # Dynamics matrix - B = np.zeros((nstates, ninputs)) # Input matrix - C = np.zeros((noutputs, nstates)) # Output matrix - D = np.zeros((noutputs, ninputs)) # Direct term - - # Perturb each of the state variables and compute linearization - for i in range(nstates): - dx = np.zeros((nstates,)) - dx[i] = eps - A[:, i] = (self._rhs(t, x0 + dx, u0) - F0) / eps - C[:, i] = (self._out(t, x0 + dx, u0) - H0) / eps - - # Perturb each of the input variables and compute linearization - for i in range(ninputs): - du = np.zeros((ninputs,)) - du[i] = eps - B[:, i] = (self._rhs(t, x0, u0 + du) - F0) / eps - D[:, i] = (self._out(t, x0, u0 + du) - H0) / eps - - # Create the state space system - linsys = LinearIOSystem( - StateSpace(A, B, C, D, self.dt, remove_useless_states=False)) - - # Set the system name, inputs, outputs, and states - if 'copy' in kwargs: - copy_names = kwargs.pop('copy') - warn("keyword 'copy' is deprecated. please use 'copy_names'", - DeprecationWarning) - - if copy_names: - linsys._copy_names(self, prefix_suffix_name='linearized') - if name is not None: - linsys.name = name - - # re-init to include desired signal names if names were provided - return LinearIOSystem(linsys, **kwargs) - -class LinearIOSystem(InputOutputSystem, StateSpace): - """Input/output representation of a linear (state space) system. - - This class is used to implement a system that is a linear state - space system (defined by the StateSpace system object). - - Parameters - ---------- - linsys : StateSpace or TransferFunction - LTI system to be converted. - inputs : int, list of str or None, optional - New system input labels (defaults to linsys input labels). - outputs : int, list of str or None, optional - New system output labels (defaults to linsys output labels). - states : int, list of str, or None, optional - New system input labels (defaults to linsys output labels). - 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. - params : dict, optional - Parameter values for the systems. Passed to the evaluation functions - for the system as default values, overriding internal defaults. - - Attributes - ---------- - ninputs, noutputs, nstates, dt, etc - See :class:`InputOutputSystem` for inherited attributes. - - A, B, C, D - See :class:`~control.StateSpace` for inherited attributes. - - See Also - -------- - InputOutputSystem : Input/output system class. +_namedio_defaults = { + 'namedio.state_name_delim': '_', + 'namedio.duplicate_system_name_prefix': '', + 'namedio.duplicate_system_name_suffix': '$copy', + 'namedio.linearized_system_name_prefix': '', + 'namedio.linearized_system_name_suffix': '$linearized', + 'namedio.sampled_system_name_prefix': '', + 'namedio.sampled_system_name_suffix': '$sampled', + 'namedio.indexed_system_name_prefix': '', + 'namedio.indexed_system_name_suffix': '$indexed', + 'namedio.converted_system_name_prefix': '', + 'namedio.converted_system_name_suffix': '$converted', +} + + +class NamedIOSystem(object): + def __init__( + self, name=None, inputs=None, outputs=None, states=None, + input_prefix='u', output_prefix='y', state_prefix='x', **kwargs): + + # system name + self.name = self._name_or_default(name) + + # Parse and store the number of inputs and outputs + self.set_inputs(inputs, prefix=input_prefix) + self.set_outputs(outputs, prefix=output_prefix) + self.set_states(states, prefix=state_prefix) + + # 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)) - """ - def __init__(self, linsys, **kwargs): - """Create an I/O system from a state space linear system. - - Converts a :class:`~control.StateSpace` system into an - :class:`~control.InputOutputSystem` with the same inputs, outputs, and - states. The new system can be a continuous or discrete time system. - - """ - if isinstance(linsys, TransferFunction): - # Convert system to StateSpace - linsys = _convert_to_statespace(linsys) - - elif not isinstance(linsys, StateSpace): - raise TypeError("Linear I/O system must be a state space " - "or transfer function object") - - # Process keyword arguments - name, inputs, outputs, states, dt = _process_namedio_keywords( - kwargs, linsys) - - # Create the I/O system object - # Note: don't use super() to override StateSpace MRO - InputOutputSystem.__init__( - self, inputs=inputs, outputs=outputs, states=states, - params=None, dt=dt, name=name, **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, prefix_suffix_name=None): + if name is None: + name = "sys[{}]".format(NamedIOSystem._idCounter) + NamedIOSystem._idCounter += 1 + elif re.match(r".*\..*", name): + raise ValueError(f"invalid system name '{name}' ('.' not allowed)") + + prefix = "" if prefix_suffix_name is None else config.defaults[ + 'namedio.' + prefix_suffix_name + '_system_name_prefix'] + suffix = "" if prefix_suffix_name is None else config.defaults[ + 'namedio.' + prefix_suffix_name + '_system_name_suffix'] + return prefix + name + suffix + + # Check if system name is generic + def _generic_name_check(self): + return re.match(r'^sys\[\d*\]$', self.name) is not None - # Initalize additional state space variables - StateSpace.__init__( - self, linsys, remove_useless_states=False, init_namedio=False) + # + # Class attributes + # + # These attributes are defined as class attributes so that they are + # documented properly. They are "overwritten" in __init__. + # - # When sampling a LinearIO system, return a LinearIOSystem - def sample(self, *args, **kwargs): - return LinearIOSystem(StateSpace.sample(self, *args, **kwargs)) + #: Number of system inputs. + #: + #: :meta hide-value: + ninputs = None - sample.__doc__ = StateSpace.sample.__doc__ + #: Number of system outputs. + #: + #: :meta hide-value: + noutputs = None - # The following text needs to be replicated from StateSpace in order for - # this entry to show up properly in sphinx doccumentation (not sure why, - # but it was the only way to get it to work). - # - #: Deprecated attribute; use :attr:`nstates` instead. + #: Number of system states. #: - #: The ``state`` attribute was used to store the number of states for : a - #: state space system. It is no longer used. If you need to access the - #: number of states, use :attr:`nstates`. - states = property(StateSpace._get_states, StateSpace._set_states) - - def _update_params(self, params=None, warning=True): - # Parameters not supported; issue a warning - if params and warning: - warn("Parameters passed to LinearIOSystems are ignored.") - - def _rhs(self, t, x, u): - # Convert input to column vector and then change output to 1D array - xdot = self.A @ np.reshape(x, (-1, 1)) \ - + self.B @ np.reshape(u, (-1, 1)) - return np.array(xdot).reshape((-1,)) - - def _out(self, t, x, u): - # Convert input to column vector and then change output to 1D array - y = self.C @ np.reshape(x, (-1, 1)) \ - + self.D @ np.reshape(u, (-1, 1)) - return np.array(y).reshape((-1,)) + #: :meta hide-value: + nstates = None def __repr__(self): - # Need to define so that I/O system gets used instead of StateSpace - return InputOutputSystem.__repr__(self) + return f'<{self.__class__.__name__}:{self.name}:' + \ + f'{list(self.input_labels)}->{list(self.output_labels)}>' def __str__(self): - return InputOutputSystem.__str__(self) + "\n\n" \ - + StateSpace.__str__(self) - - -class NonlinearIOSystem(InputOutputSystem): - """Nonlinear I/O system. - - Creates an :class:`~control.InputOutputSystem` for a nonlinear system by - specifying a state update function and an output function. The new system - can be a continuous or discrete time system (Note: discrete-time systems - are not yet supported by most functions.) - - Parameters - ---------- - updfcn : callable - Function returning the state update function - - `updfcn(t, x, u, params) -> array` - - where `x` is a 1-D array with shape (nstates,), `u` is a 1-D array - with shape (ninputs,), `t` is a float representing the currrent - time, and `params` is a dict containing the values of parameters - used by the function. - - outfcn : callable - Function returning the output at the given state - - `outfcn(t, x, u, params) -> array` - - where the arguments are the same as for `upfcn`. - - inputs : int, list of str or None, optional - 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`). If - this parameter is not given or given as `None`, the relevant - quantity will be determined when possible based on other - information provided to functions using the system. - - 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`. - - 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 - following values: - - * dt = 0: continuous time system (default) - * dt > 0: discrete time system with sampling period 'dt' - * dt = True: discrete time with unspecified sampling period - * dt = None: no timebase specified - - 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. - - See Also - -------- - InputOutputSystem : Input/output system class. - - """ - def __init__(self, updfcn, outfcn=None, params=None, **kwargs): - """Create a nonlinear I/O system given update and output functions.""" - # Process keyword arguments - name, inputs, outputs, states, dt = _process_namedio_keywords( - kwargs, end=True) - - # Initialize the rest of the structure - super().__init__( - inputs=inputs, outputs=outputs, states=states, - params=params, dt=dt, name=name - ) - - # Store the update and output functions - self.updfcn = updfcn - self.outfcn = outfcn - - # Check to make sure arguments are consistent - if updfcn is None: - if self.nstates is None: - self.nstates = 0 + """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) + + # Find a list of signals by name, index, or pattern + def _find_signals(self, name_list, sigdict): + if not isinstance(name_list, (list, tuple)): + name_list = [name_list] + + index_list = [] + for name in name_list: + # Look for signal ranges (slice-like or base name) + ms = re.match(r'([\w$]+)\[([\d]*):([\d]*)\]$', name) # slice + mb = re.match(r'([\w$]+)$', name) # base + if ms: + base = ms.group(1) + start = None if ms.group(2) == '' else int(ms.group(2)) + stop = None if ms.group(3) == '' else int(ms.group(3)) + for var in sigdict: + # Find variables that match + msig = re.match(r'([\w$]+)\[([\d]+)\]$', var) + if msig and msig.group(1) == base and \ + (start is None or int(msig.group(2)) >= start) and \ + (stop is None or int(msig.group(2)) < stop): + index_list.append(sigdict.get(var)) + elif mb and sigdict.get(name, None) is None: + # Try to use name as a base name + for var in sigdict: + msig = re.match(name + r'\[([\d]+)\]$', var) + if msig: + index_list.append(sigdict.get(var)) else: - raise ValueError("States specified but no update function " - "given.") - if outfcn is None: - # No output function specified => outputs = states - if self.noutputs is None and self.nstates is not None: - self.noutputs = self.nstates - elif self.noutputs is not None and self.noutputs == self.nstates: - # Number of outputs = number of states => all is OK - pass - elif self.noutputs is not None and self.noutputs != 0: - raise ValueError("Outputs specified but no output function " - "(and nstates not known).") - - # Initialize current parameters to default parameters - self._current_params = {} if params is None else params.copy() + index_list.append(sigdict.get(name, None)) + + return None if len(index_list) == 0 or \ + any([idx is None for idx in index_list]) else index_list + + def _copy_names(self, sys, prefix="", suffix="", prefix_suffix_name=None): + """copy the signal and system name of sys. Name is given as a keyword + in case a specific name (e.g. append 'linearized') is desired. """ + # Figure out the system name and assign it + if prefix == "" and prefix_suffix_name is not None: + prefix = config.defaults[ + 'namedio.' + prefix_suffix_name + '_system_name_prefix'] + if suffix == "" and prefix_suffix_name is not None: + suffix = config.defaults[ + 'namedio.' + prefix_suffix_name + '_system_name_suffix'] + self.name = prefix + sys.name + suffix + + # Name the inputs, outputs, and states + self.input_index = sys.input_index.copy() + self.output_index = sys.output_index.copy() + if self.nstates and sys.nstates: + # only copy state names for state space systems + self.state_index = sys.state_index.copy() + + 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['namedio.duplicate_system_name_prefix'] + and appending config.defaults['namedio.duplicate_system_name_suffix']. + Otherwise, a generic system name of the form `sys[]` is used, + where `` is based on an internal counter. - def __str__(self): - return f"{InputOutputSystem.__str__(self)}\n\n" + \ - f"Update: {self.updfcn}\n" + \ - f"Output: {self.outfcn}" + """ + # Create a copy of the system + newsys = deepcopy(self) + + # Update the system name + if name is None and use_prefix_suffix: + # Get the default prefix and suffix to use + newsys.name = self._name_or_default( + self.name, prefix_suffix_name='duplicate') + else: + newsys.name = self._name_or_default(name) - # 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 + return newsys - If a nonlinear I/O system has no internal state, then evaluating the - system at an input `u` gives the output `y = F(u)`, determined by the - output function. + def set_inputs(self, inputs, prefix='u'): + """Set the number/names of the system inputs. Parameters ---------- - params : dict, optional - Parameter values for the system. Passed to the evaluation function - for the system as default values, overriding internal defaults. - squeeze : bool, optional - If True and if the system has a single output, return the system - output as a 1D array rather than a 2D array. If False, return the - system output as a 2D array even if the system is SISO. Default - value set by config.defaults['control.squeeze_time_response']. + 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) - # Make sure the call makes sense - if not sys._isstatic(): - raise TypeError( - "function evaluation is only supported for static " - "input/output systems") - - # If we received any parameters, update them before calling _out() - if params is not None: - sys._update_params(params) - - # Evaluate the function on the argument - out = sys._out(0, np.array((0,)), np.asarray(u)) - _, out = _process_time_response( - None, out, issiso=sys.issiso(), squeeze=squeeze) - return out - - def _update_params(self, params, warning=False): - # Update the current parameter values - self._current_params = self.params.copy() - if params: - self._current_params.update(params) + 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 _rhs(self, t, x, u): - xdot = self.updfcn(t, x, u, self._current_params) \ - if self.updfcn is not None else [] - return np.array(xdot).reshape((-1,)) + def find_inputs(self, name_list): + """Return list of indices matching input spec (`None` if not found)""" + return self._find_signals(name_list, self.input_index) - def _out(self, t, x, u): - y = self.outfcn(t, x, u, self._current_params) \ - if self.outfcn is not None else x - return np.array(y).reshape((-1,)) + # Property for getting and setting list of input signals + input_labels = property( + lambda self: list(self.input_index.keys()), # getter + set_inputs) # setter - -class InterconnectedSystem(InputOutputSystem): - """Interconnection of a set of input/output systems. - - This class is used to implement a system that is an interconnection of - input/output systems. The sys consists of a collection of subsystems - whose inputs and outputs are connected via a connection map. The overall - system inputs and outputs are subsets of the subsystem inputs and outputs. - - The function :func:`~control.interconnect` should be used to create an - interconnected I/O system since it performs additional argument - processing and checking. - - """ - def __init__(self, syslist, connections=None, inplist=None, outlist=None, - params=None, warn_duplicate=None, **kwargs): - """Create an I/O system from a list of systems + connection info.""" - # Convert input and output names to lists if they aren't already - if inplist is not None and not isinstance(inplist, list): - inplist = [inplist] - if outlist is not None and not isinstance(outlist, list): - outlist = [outlist] - - # Check if dt argument was given; if not, pull from systems - dt = kwargs.pop('dt', None) - - # Process keyword arguments (except dt) - name, inputs, outputs, states, _ = _process_namedio_keywords(kwargs) - - # Initialize the system list and index - self.syslist = list(syslist) # insure modifications can be made - self.syslist_index = {} - - # 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(self.syslist): - # If we were passed a SS or TF system, convert to LinearIOSystem - if isinstance(sys, (StateSpace, TransferFunction)) and \ - not isinstance(sys, LinearIOSystem): - sys = LinearIOSystem(sys, name=sys.name) - self.syslist[sysidx] = sys - - # Make sure time bases are consistent - dt = common_timebase(dt, sys.dt) - - # Make sure number of inputs, outputs, states is given - if sys.ninputs is None or sys.noutputs is None or \ - sys.nstates is None: - raise TypeError("System '%s' must define number of inputs, " - "outputs, states in order to be connected" % - sys.name) - - # Keep track of the offsets into the states, inputs, outputs - self.input_offset.append(ninputs) - self.output_offset.append(noutputs) - self.state_offset.append(nstates) - - # Keep track of the total number of states, inputs, outputs - nstates += sys.nstates - ninputs += sys.ninputs - noutputs += sys.noutputs - - # Check for duplicate systems or duplicate names - # Duplicates are renamed sysname_1, sysname_2, etc. - if sys in sysobj_name_dct: - # 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 - - 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 - self.syslist_index[sys.name] = sysidx - - if states is None: - states = [] - state_name_delim = config.defaults['namedio.state_name_delim'] - for sys, sysname in sysobj_name_dct.items(): - 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}") - - # Figure out what the inputs and outputs are - if inputs is None and inplist is not None: - inputs = len(inplist) - - if outputs is None and outlist is not None: - outputs = len(outlist) - - # Create the I/O system - # 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, **kwargs) - - # Convert the list of interconnections to a connection map (matrix) - self.connect_map = np.zeros((ninputs, noutputs)) - for connection in connections or []: - input_indices = self._parse_input_spec(connection[0]) - for output_spec in connection[1:]: - output_indices, gain = self._parse_output_spec(output_spec) - if len(output_indices) != len(input_indices): - raise ValueError( - f"inconsistent number of signals in connecting" - f" '{output_spec}' to '{connection[0]}'") - - for input_index, output_index in zip( - input_indices, output_indices): - if self.connect_map[input_index, output_index] != 0: - warn("multiple connections given for input %d" % - input_index + ". Combining with previous entries.") - self.connect_map[input_index, output_index] += gain - - # Convert the input list to a matrix: maps system to subsystems - self.input_map = np.zeros((ninputs, self.ninputs)) - for index, inpspec in enumerate(inplist or []): - if isinstance(inpspec, (int, str, tuple)): - inpspec = [inpspec] - if not isinstance(inpspec, list): - raise ValueError("specifications in inplist must be of type " - "int, str, tuple or list.") - for spec in inpspec: - ulist_indices = self._parse_input_spec(spec) - for j, ulist_index in enumerate(ulist_indices): - if self.input_map[ulist_index, index] != 0: - warn("multiple connections given for input %d" % - index + ". Combining with previous entries.") - self.input_map[ulist_index, index + j] += 1 - - # Convert the output list to a matrix: maps subsystems to system - self.output_map = np.zeros((self.noutputs, noutputs + ninputs)) - for index, outspec in enumerate(outlist or []): - if isinstance(outspec, (int, str, tuple)): - outspec = [outspec] - if not isinstance(outspec, list): - raise ValueError("specifications in outlist must be of type " - "int, str, tuple or list.") - for spec in outspec: - ylist_indices, gain = self._parse_output_spec(spec) - for j, ylist_index in enumerate(ylist_indices): - if self.output_map[index, ylist_index] != 0: - warn("multiple connections given for output %d" % - index + ". Combining with previous entries.") - self.output_map[index + j, ylist_index] += gain - - def _update_params(self, params, warning=False): - for sys in self.syslist: - local = sys.params.copy() # start with system parameters - local.update(self.params) # update with global params - if params: - local.update(params) # update with locally passed parameters - sys._update_params(local, warning=warning) - - def _rhs(self, t, x, u): - # Make sure state and input are vectors - x = np.array(x, ndmin=1) - u = np.array(u, ndmin=1) - - # Compute the input and output vectors - ulist, ylist = self._compute_static_io(t, x, u) - - # Go through each system and update the right hand side for that system - xdot = np.zeros((self.nstates,)) # Array to hold results - state_index, input_index = 0, 0 # Start at the beginning - for sys in self.syslist: - # Update the right hand side for this subsystem - if sys.nstates != 0: - xdot[state_index:state_index + sys.nstates] = sys._rhs( - t, x[state_index:state_index + sys.nstates], - ulist[input_index:input_index + sys.ninputs]) - - # Update the state and input index counters - state_index += sys.nstates - input_index += sys.ninputs - - return xdot - - def _out(self, t, x, u): - # Make sure state and input are vectors - x = np.array(x, ndmin=1) - u = np.array(u, ndmin=1) - - # Compute the input and output vectors - ulist, ylist = self._compute_static_io(t, x, u) - - # Make the full set of subsystem outputs to system output - return self.output_map @ ylist - - def _compute_static_io(self, t, x, u): - # Figure out the total number of inputs and outputs - (ninputs, noutputs) = self.connect_map.shape - - # - # Get the outputs and inputs at the current system state - # - - # Initialize the lists used to keep track of internal signals - ulist = np.dot(self.input_map, u) - ylist = np.zeros((noutputs + ninputs,)) - - # To allow for feedthrough terms, iterate multiple times to allow - # feedthrough elements to propagate. For n systems, we could need to - # cycle through n+1 times before reaching steady state - # TODO (later): see if there is a more efficient way to compute - cycle_count = len(self.syslist) + 1 - while cycle_count > 0: - state_index, input_index, output_index = 0, 0, 0 - for sys in self.syslist: - # Compute outputs for each system from current state - ysys = sys._out( - t, x[state_index:state_index + sys.nstates], - ulist[input_index:input_index + sys.ninputs]) - - # Store the outputs at the start of ylist - ylist[output_index:output_index + sys.noutputs] = \ - ysys.reshape((-1,)) - - # Store the input in the second part of ylist - ylist[noutputs + input_index: - noutputs + input_index + sys.ninputs] = \ - ulist[input_index:input_index + sys.ninputs] - - # Increment the index pointers - state_index += sys.nstates - input_index += sys.ninputs - output_index += sys.noutputs - - # Compute inputs based on connection map - new_ulist = self.connect_map @ ylist[:noutputs] \ - + np.dot(self.input_map, u) - - # Check to see if any of the inputs changed - if (ulist == new_ulist).all(): - break - else: - ulist = new_ulist - - # Decrease the cycle counter - cycle_count -= 1 - - # Make sure that we stopped before detecting an algebraic loop - if cycle_count == 0: - raise RuntimeError("Algebraic loop detected.") - - return ulist, ylist - - def _parse_input_spec(self, spec): - """Parse an input specification and returns the indices.""" - # Parse the signal that we received - subsys_index, input_indices, gain = _parse_spec( - self.syslist, spec, 'input') - if gain != 1: - raise ValueError("gain not allowed in spec '%s'." % str(spec)) - - # Return the indices into the input vector list (ylist) - return [self.input_offset[subsys_index] + i for i in input_indices] - - def _parse_output_spec(self, spec): - """Parse an output specification and returns the indices and gain.""" - # Parse the rest of the spec with standard signal parsing routine - try: - # Start by looking in the set of subsystem outputs - subsys_index, output_indices, gain = \ - _parse_spec(self.syslist, spec, 'output') - output_offset = self.output_offset[subsys_index] - - except ValueError: - # Try looking in the set of subsystem *inputs* - subsys_index, output_indices, gain = _parse_spec( - self.syslist, spec, 'input or output', dictname='input_index') - - # Return the index into the input vector list (ylist) - output_offset = sum(sys.noutputs for sys in self.syslist) + \ - self.input_offset[subsys_index] - - return [output_offset + i for i in output_indices], gain - - def _find_system(self, name): - return self.syslist_index.get(name, None) - - def set_connect_map(self, connect_map): - """Set the connection map for an interconnected I/O system. + def set_outputs(self, outputs, prefix='y'): + """Set the number/names of the system outputs. Parameters ---------- - connect_map : 2D array - Specify the matrix that will be used to multiply the vector of - subsystem outputs to obtain the vector of subsystem inputs. + 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]`. """ - # Make sure the connection map is the right size - if connect_map.shape != self.connect_map.shape: - ValueError("Connection map is not the right shape") - self.connect_map = connect_map + self.noutputs, self.output_index = \ + _process_signal_list(outputs, prefix=prefix) - def set_input_map(self, input_map): - """Set the input map for an interconnected I/O system. + 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) - Parameters - ---------- - input_map : 2D array - Specify the matrix that will be used to multiply the vector of - system inputs to obtain the vector of subsystem inputs. These - values are added to the inputs specified in the connection map. + def find_outputs(self, name_list): + """Return list of indices matching output spec (`None` if not found)""" + return self._find_signals(name_list, self.output_index) - """ - # Figure out the number of internal inputs - ninputs = sum(sys.ninputs for sys in self.syslist) + # Property for getting and setting list of output signals + output_labels = property( + lambda self: list(self.output_index.keys()), # getter + set_outputs) # setter - # Make sure the input map is the right size - if input_map.shape[0] != ninputs: - ValueError("Input map is not the right shape") - self.input_map = input_map - self.ninputs = input_map.shape[1] - - def set_output_map(self, output_map): - """Set the output map for an interconnected I/O system. + def set_states(self, states, prefix='x'): + """Set the number/names of the system states. Parameters ---------- - output_map : 2D array - Specify the matrix that will be used to multiply the vector of - subsystem outputs concatenated with subsystem inputs to obtain - the vector of system outputs. + 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]`. """ - # Figure out the number of internal inputs and outputs - ninputs = sum(sys.ninputs for sys in self.syslist) - noutputs = sum(sys.noutputs for sys in self.syslist) - - # Make sure the output map is the right size - if output_map.shape[1] == noutputs: - # For backward compatibility, add zeros to the end of the array - output_map = np.concatenate( - (output_map, - np.zeros((output_map.shape[0], ninputs))), - axis=1) - - if output_map.shape[1] != noutputs + ninputs: - ValueError("Output map is not the right shape") - self.output_map = output_map - self.noutputs = output_map.shape[0] + self.nstates, self.state_index = \ + _process_signal_list(states, prefix=prefix, allow_dot=True) - def unused_signals(self): - """Find unused subsystem inputs and outputs + 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) - Returns - ------- + def find_states(self, name_list): + """Return list of indices matching state spec (`None` if not found)""" + return self._find_signals(name_list, self.state_index) - unused_inputs : dict - A mapping from tuple of indices (isys, isig) to string - '{sys}.{sig}', for all unused subsystem inputs. - - unused_outputs : dict - A mapping from tuple of indices (osys, osig) to string - '{sys}.{sig}', for all unused subsystem outputs. + # 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): """ - used_sysinp_via_inp = np.nonzero(self.input_map)[0] - used_sysout_via_out = np.nonzero(self.output_map)[1] - used_sysinp_via_con, used_sysout_via_con = np.nonzero(self.connect_map) - - used_sysinp = set(used_sysinp_via_inp) | set(used_sysinp_via_con) - used_sysout = set(used_sysout_via_out) | set(used_sysout_via_con) - - nsubsysinp = sum(sys.ninputs for sys in self.syslist) - nsubsysout = sum(sys.noutputs for sys in self.syslist) - - unused_sysinp = sorted(set(range(nsubsysinp)) - used_sysinp) - unused_sysout = sorted(set(range(nsubsysout)) - used_sysout) - - inputs = [(isys, isig, f'{sys.name}.{sig}') - for isys, sys in enumerate(self.syslist) - for sig, isig in sys.input_index.items()] - - outputs = [(isys, isig, f'{sys.name}.{sig}') - for isys, sys in enumerate(self.syslist) - for sig, isig in sys.output_index.items()] - - return ({inputs[i][:2]: inputs[i][2] for i in unused_sysinp}, - {outputs[i][:2]: outputs[i][2] for i in unused_sysout}) - - def _find_inputs_by_basename(self, basename): - """Find all subsystem inputs matching basename - - Returns - ------- - Mapping from (isys, isig) to '{sys}.{sig}' + 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. """ - return {(isys, isig): f'{sys.name}.{basename}' - for isys, sys in enumerate(self.syslist) - for sig, isig in sys.input_index.items() - if sig == (basename)} - - def _find_outputs_by_basename(self, basename): - """Find all subsystem outputs matching basename - - Returns - ------- - Mapping from (isys, isig) to '{sys}.{sig}' + # 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): """ - return {(isys, isig): f'{sys.name}.{basename}' - for isys, sys in enumerate(self.syslist) - for sig, isig in sys.output_index.items() - if sig == (basename)} - - def check_unused_signals( - self, ignore_inputs=None, ignore_outputs=None, warning=True): - """Check for unused subsystem inputs and outputs - - Check to see if there are any unused signals and return a list of - unused input and output signal descriptions. If `warning` is True - and any unused inputs or outputs are found, emit a warning. + Check to see if a system is a discrete-time system Parameters ---------- - ignore_inputs : list of input-spec - Subsystem inputs known to be unused. input-spec can be any of: - 'sig', 'sys.sig', (isys, isig), ('sys', isig) - - If the 'sig' form is used, all subsystem inputs with that - name are considered ignored. - - ignore_outputs : list of output-spec - Subsystem outputs known to be unused. output-spec can be any of: - 'sig', 'sys.sig', (isys, isig), ('sys', isig) - - If the 'sig' form is used, all subsystem outputs with that - name are considered ignored. - - Returns - ------- - dropped_inputs: list of tuples - A list of the dropped input signals, with each element of the - list in the form of (isys, isig). - - dropped_outputs: list of tuples - A list of the dropped output signals, with each element of the - list in the form of (osys, osig). - + strict: bool, optional + If strict is True, make sure that timebase is not None. Default + is False. """ - if ignore_inputs is None: - ignore_inputs = [] - - if ignore_outputs is None: - ignore_outputs = [] - - unused_inputs, unused_outputs = self.unused_signals() - - # (isys, isig) -> signal-spec - ignore_input_map = {} - for ignore_input in ignore_inputs: - if isinstance(ignore_input, str) and '.' not in ignore_input: - ignore_idxs = self._find_inputs_by_basename(ignore_input) - if not ignore_idxs: - raise ValueError("Couldn't find ignored input " - f"{ignore_input} in subsystems") - ignore_input_map.update(ignore_idxs) - else: - isys, isigs = _parse_spec( - self.syslist, ignore_input, 'input')[:2] - for isig in isigs: - ignore_input_map[(isys, isig)] = ignore_input - - # (osys, osig) -> signal-spec - ignore_output_map = {} - for ignore_output in ignore_outputs: - if isinstance(ignore_output, str) and '.' not in ignore_output: - ignore_found = self._find_outputs_by_basename(ignore_output) - if not ignore_found: - raise ValueError("Couldn't find ignored output " - f"{ignore_output} in subsystems") - ignore_output_map.update(ignore_found) - else: - osys, osigs = _parse_spec( - self.syslist, ignore_output, 'output')[:2] - for osig in osigs: - ignore_output_map[(osys, osig)] = ignore_output - - dropped_inputs = set(unused_inputs) - set(ignore_input_map) - dropped_outputs = set(unused_outputs) - set(ignore_output_map) - - used_ignored_inputs = set(ignore_input_map) - set(unused_inputs) - used_ignored_outputs = set(ignore_output_map) - set(unused_outputs) - - if warning and dropped_inputs: - msg = ('Unused input(s) in InterconnectedSystem: ' - + '; '.join(f'{inp}={unused_inputs[inp]}' - for inp in dropped_inputs)) - warn(msg) + # If no timebase is given, answer depends on strict flag + if self.dt == None: + return True if not strict else False - if warning and dropped_outputs: - msg = ('Unused output(s) in InterconnectedSystem: ' - + '; '.join(f'{out} : {unused_outputs[out]}' - for out in dropped_outputs)) - warn(msg) + # Look for dt > 0 (also works if dt = True) + return self.dt > 0 - if warning and used_ignored_inputs: - msg = ('Input(s) specified as ignored is (are) used: ' - + '; '.join(f'{inp} : {ignore_input_map[inp]}' - for inp in used_ignored_inputs)) - warn(msg) + def issiso(self): + """Check to see if a system is single input, single output""" + return self.ninputs == 1 and self.noutputs == 1 - if warning and used_ignored_outputs: - msg = ('Output(s) specified as ignored is (are) used: ' - + '; '.join(f'{out}={ignore_output_map[out]}' - for out in used_ignored_outputs)) - warn(msg) + def _isstatic(self): + """Check to see if a system is a static system (no states)""" + return self.nstates == 0 - return dropped_inputs, dropped_outputs - - -class LinearICSystem(InterconnectedSystem, LinearIOSystem): - - """Interconnection of a set of linear input/output systems. - - This class is used to implement a system that is an interconnection of - linear input/output systems. It has all of the structure of an - :class:`~control.InterconnectedSystem`, but also maintains the requirement - elements of :class:`~control.LinearIOSystem`, including the - :class:`StateSpace` class structure, allowing it to be passed to functions - that expect a :class:`StateSpace` system. - - This class is generated using :func:`~control.interconnect` and - not called directly. +# Test to see if a system is SISO +def issiso(sys, strict=False): """ - - 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 (essentially empty) I/O system object - InputOutputSystem.__init__( - self, name=io_sys.name, params=io_sys.params) - - # Copy over the named I/O system attributes - self.syslist = io_sys.syslist - 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 - self.syslist_index = io_sys.syslist_index - self.state_offset = io_sys.state_offset - self.input_offset = io_sys.input_offset - self.output_offset = io_sys.output_offset - self.connect_map = io_sys.connect_map - self.input_map = io_sys.input_map - self.output_map = io_sys.output_map - self.params = io_sys.params - - # If we didnt' get a state space system, linearize the full system - # TODO: this could be replaced with a direct computation (someday) - if ss_sys is None: - ss_sys = self.linearize(0, 0) - - # Initialize the state space attributes - if isinstance(ss_sys, StateSpace): - # 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_states=False, init_namedio=False) - - else: - raise TypeError("Second argument must be a state space system.") - - # The following text needs to be replicated from StateSpace in order for - # this entry to show up properly in sphinx doccumentation (not sure why, - # but it was the only way to get it to work). - # - #: Deprecated attribute; use :attr:`nstates` instead. - #: - #: The ``state`` attribute was used to store the number of states for : a - #: state space system. It is no longer used. If you need to access the - #: number of states, use :attr:`nstates`. - states = property(StateSpace._get_states, StateSpace._set_states) - - -def input_output_response( - sys, T, U=0., X0=0, params=None, - transpose=False, return_x=False, squeeze=None, - solve_ivp_kwargs=None, 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 - and state values. + Check to see if a system is single input, single output Parameters ---------- - sys : InputOutputSystem - Input/output system to simulate. - - T : array-like - Time steps at which the input is defined; values must be evenly spaced. - - U : array-like, 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, 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. - - t_eval : array-list, optional - List of times at which the time response should be computed. - Defaults to ``T``. - - return_x : bool, optional - If True, return the state vector when assigning to a tuple (default = - False). See :func:`forced_response` for more details. - If True, return the values of the state at each time (default = False). - - squeeze : bool, optional - If True and if the system has a single output, return the system - output as a 1D array rather than a 2D array. If False, return the - system output as a 2D array even if the system is SISO. Default value - set by 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 2D (indexed - by output and time). - - * states (array): Time evolution of the state vector, represented as - a 2D array indexed by state and time. - - * inputs (array): Input(s) to the system, indexed by input and time. - - The return value of the system can also be accessed by assigning the - function to a tuple of length 2 (time, output) or of length 3 (time, - output, state) if ``return_x`` is ``True``. If the input/output - system signals are named, these names will be used as labels for the - time response. - - Other parameters - ---------------- - solve_ivp_method : str, optional - Set the method used by :func:`scipy.integrate.solve_ivp`. Defaults - to 'RK45'. - solve_ivp_kwargs : dict, optional - Pass additional keywords to :func:`scipy.integrate.solve_ivp`. - - Raises - ------ - TypeError - If the system is not an input/output system. - 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. - - 2. If discontinuous inputs are given, the underlying SciPy numerical - integration algorithms can sometimes produce erroneous results due - to the default tolerances that are used. The `ivp_method` and - `ivp_keywords` parameters can be used to tune the ODE solver and - produce better results. In particular, using 'LSODA' as the - `ivp_method` or setting the `rtol` parameter to a smaller value - (e.g. using `ivp_kwargs={'rtol': 1e-4}`) can provide more accurate - results. - + sys : I/O or LTI system + System to be checked + strict: bool (default = False) + If strict is True, do not treat scalars as SISO """ - # - # Process keyword arguments - # + 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") - # Figure out the method to be used - solve_ivp_kwargs = solve_ivp_kwargs.copy() if solve_ivp_kwargs else {} - 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.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] - 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 + # Done with the tricky stuff... + return sys.issiso() - else: - raise ValueError(f"Input element {i} has inconsistent shape") +# Return the timebase (with conversion if unspecified) +def timebase(sys, strict=True): + """Return the timebase for a system - # Append this input to our list - U_elements.append(u) + dt = timebase(sys) - # Save the newly created input vector - U = np.vstack(U_elements) + 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") - # Make sure the input has the right shape - if sys.ninputs is None or sys.ninputs == 1: - legal_shapes = [(ntimepts,), (1, ntimepts)] - else: - 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 - X0 = _concatenate_list_elements(X0, 'X0') - - # 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)]) - - # Compute the number of states - nstates = _find_size(sys.nstates, X0) - - # create X0 if not given, test if X0 has correct shape - X0 = _check_convert_array(X0, [(nstates,), (nstates, 1)], - 'Parameter ``X0``: ', squeeze=True) - - # 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 + # Return the sample time, with converstion to float if strict is false + if (sys.dt == None): + return None + elif (strict): + return float(sys.dt) - # Update the parameter values - sys._update_params(params) + return sys.dt - # - # Define a function to evaluate the input at an arbitrary time - # - # This is equivalent to the function - # - # ufun = sp.interpolate.interp1d(T, U, fill_value='extrapolate') - # - # but has a lot less overhead => simulation runs much faster - def ufun(t): - # Find the value of the index using linear interpolation - # Use clip to allow for extrapolation if t is out of range - idx = np.clip(np.searchsorted(T, t, side='left'), 1, len(T)-1) - 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_labels, input_labels=sys.input_labels, - transpose=transpose, return_x=return_x, squeeze=squeeze) - - # Create a lambda function for the right hand side - def ivp_rhs(t, x): - return sys._rhs(t, x, ufun(t)) - - # Perform the simulation - if isctime(sys): - if not hasattr(sp.integrate, 'solve_ivp'): - 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_eval, - vectorized=False, **solve_ivp_kwargs) - if not soln.success: - raise RuntimeError("solve_ivp failed: " + soln.message) - - # 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_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: - # Make sure that the time increment is a multiple of sampling time - - # TODO: add back functionality for undersampling - # TODO: this test is brittle if dt = sys.dt - # First make sure that time increment is bigger than sampling time - # if dt < sys.dt: - # raise ValueError("Time steps ``T`` must match sampling time") - - # Check to make sure sampling time matches time increments - if not np.isclose(dt, sys.dt): - raise ValueError("Time steps ``T`` must be equal to " - "sampling time") - - # Compute the solution - soln = sp.optimize.OptimizeResult() - soln.t = t_eval # Store the time vector directly - x = np.array(X0) # State vector (store as floats) - soln.y = [] # Solution, following scipy convention - u, y = [], [] # System input, output - for t in t_eval: - # Store the current input, state, and output - soln.y.append(x) - u.append(ufun(t)) - y.append(sys._out(t, x, u[-1])) - - # Update the state for the next iteration - 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 - - else: # Neither ctime or dtime?? - raise TypeError("Can't determine system type") - - return TimeResponseData( - soln.t, y, soln.y, u, issiso=sys.issiso(), - output_labels=sys.output_labels, input_labels=sys.input_labels, - state_labels=sys.state_labels, - transpose=transpose, return_x=return_x, squeeze=squeeze) - - -def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None, - iu=None, iy=None, ix=None, idx=None, dx0=None, - 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 - either input value or desired output value for the equilibrium point. +def common_timebase(dt1, dt2): + """ + Find the common timebase when interconnecting systems Parameters ---------- - x0 : list of initial state values - Initial guess for the value of the state near the equilibrium point. - u0 : list of input values, optional - If `y0` is not specified, sets the equilibrium value of the input. If - `y0` is given, provides an initial guess for the value of the input. - Can be omitted if the system does not have any inputs. - y0 : list of output values, optional - If specified, sets the desired values of the outputs at the - equilibrium point. - t : float, optional - Evaluation time, for time-varying systems - params : dict, optional - Parameter values for the system. Passed to the evaluation functions - for the system as default values, overriding internal defaults. - iu : list of input indices, optional - If specified, only the inputs with the given indices will be fixed at - the specified values in solving for an equilibrium point. All other - inputs will be varied. Input indices can be listed in any order. - iy : list of output indices, optional - If specified, only the outputs with the given indices will be fixed at - the specified values in solving for an equilibrium point. All other - outputs will be varied. Output indices can be listed in any order. - ix : list of state indices, optional - If specified, states with the given indices will be fixed at the - specified values in solving for an equilibrium point. All other - states will be varied. State indices can be listed in any order. - dx0 : list of update values, optional - If specified, the value of update map must match the listed value - instead of the default value of 0. - idx : list of state indices, optional - If specified, state updates with the given indices will have their - update maps fixed at the values given in `dx0`. All other update - values will be ignored in solving for an equilibrium point. State - indices can be listed in any order. By default, all updates will be - fixed at `dx0` in searching for an equilibrium point. - return_y : bool, optional - If True, return the value of output at the equilibrium point. - return_result : bool, optional - If True, return the `result` option from the - :func:`scipy.optimize.root` function used to compute the equilibrium - point. + dt1, dt2: number or system with a 'dt' attribute (e.g. TransferFunction + or StateSpace system) Returns ------- - xeq : array of states - Value of the states at the equilibrium point, or `None` if no - equilibrium point was found and `return_result` was False. - ueq : array of input values - Value of the inputs at the equilibrium point, or `None` if no - equilibrium point was found and `return_result` was False. - yeq : array of output values, optional - If `return_y` is True, returns the value of the outputs at the - equilibrium point, or `None` if no equilibrium point was found and - `return_result` was False. - result : :class:`scipy.optimize.OptimizeResult`, optional - If `return_result` is True, returns the `result` from the - :func:`scipy.optimize.root` function. - - Notes - ----- - For continuous time systems, equilibrium points are defined as points for - which the right hand side of the differential equation is zero: - :math:`f(t, x_e, u_e) = 0`. For discrete time systems, equilibrium points - are defined as points for which the right hand side of the difference - equation returns the current state: :math:`f(t, x_e, u_e) = x_e`. + 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 """ - from scipy.optimize import root - - # Figure out the number of states, inputs, and outputs - nstates = _find_size(sys.nstates, x0) - ninputs = _find_size(sys.ninputs, u0) - noutputs = _find_size(sys.noutputs, y0) - - # Convert x0, u0, y0 to arrays, if needed - if np.isscalar(x0): - x0 = np.ones((nstates,)) * x0 - if np.isscalar(u0): - u0 = np.ones((ninputs,)) * u0 - if np.isscalar(y0): - y0 = np.ones((ninputs,)) * y0 - - # Make sure the input arguments match the sizes of the system - if len(x0) != nstates or \ - (u0 is not None and len(u0) != ninputs) or \ - (y0 is not None and len(y0) != noutputs) or \ - (dx0 is not None and len(dx0) != nstates): - raise ValueError("Length of input arguments does not match system.") - - # Update the parameter values - sys._update_params(params) - - # Decide what variables to minimize - if all([x is None for x in (iu, iy, ix, idx)]): - # Special cases: either inputs or outputs are constrained - if y0 is None: - # Take u0 as fixed and minimize over x - if sys.isdtime(strict=True): - def state_rhs(z): return sys._rhs(t, z, u0) - z - else: - def state_rhs(z): return sys._rhs(t, z, u0) - - result = root(state_rhs, x0) - z = (result.x, u0, sys._out(t, result.x, u0)) - + # 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: - # Take y0 as fixed and minimize over x and u - if sys.isdtime(strict=True): - def rootfun(z): - x, u = np.split(z, [nstates]) - return np.concatenate( - (sys._rhs(t, x, u) - x, sys._out(t, x, u) - y0), - axis=0) - else: - def rootfun(z): - x, u = np.split(z, [nstates]) - 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) # Find the eq point - x, u = np.split(result.x, [nstates]) # Split result back in two - z = (x, u, sys._out(t, x, u)) - - else: - # General case: figure out what variables to constrain - # Verify the indices we are using are all in range - if iu is not None: - iu = np.unique(iu) - if any([not isinstance(x, int) for x in iu]) or \ - (len(iu) > 0 and (min(iu) < 0 or max(iu) >= ninputs)): - assert ValueError("One or more input indices is invalid") - else: - iu = [] - - if iy is not None: - iy = np.unique(iy) - if any([not isinstance(x, int) for x in iy]) or \ - min(iy) < 0 or max(iy) >= noutputs: - assert ValueError("One or more output indices is invalid") - else: - iy = list(range(noutputs)) - - if ix is not None: - ix = np.unique(ix) - if any([not isinstance(x, int) for x in ix]) or \ - min(ix) < 0 or max(ix) >= nstates: - assert ValueError("One or more state indices is invalid") + raise ValueError("Systems have incompatible timebases") + elif dt2 is True: + if dt1 > 0: + return dt1 else: - ix = [] - - if idx is not None: - idx = np.unique(idx) - if any([not isinstance(x, int) for x in idx]) or \ - min(idx) < 0 or max(idx) >= nstates: - assert ValueError("One or more deriv indices is invalid") - else: - idx = list(range(nstates)) - - # Construct the index lists for mapping variables and constraints - # - # The mechanism by which we implement the root finding function is to - # map the subset of variables we are searching over into the inputs - # and states, and then return a function that represents the equations - # we are trying to solve. - # - # To do this, we need to carry out the following operations: - # - # 1. Given the current values of the free variables (z), map them into - # the portions of the state and input vectors that are not fixed. - # - # 2. Compute the update and output maps for the input/output system - # and extract the subset of equations that should be equal to zero. - # - # We perform these functions by computing four sets of index lists: - # - # * state_vars: indices of states that are allowed to vary - # * input_vars: indices of inputs that are allowed to vary - # * deriv_vars: indices of derivatives that must be constrained - # * output_vars: indices of outputs that must be constrained - # - # This index lists can all be precomputed based on the `iu`, `iy`, - # `ix`, and `idx` lists that were passed as arguments to `find_eqpt` - # and were processed above. - - # Get the states and inputs that were not listed as fixed - state_vars = (range(nstates) if not len(ix) - else np.delete(np.array(range(nstates)), ix)) - input_vars = (range(ninputs) if not len(iu) - else np.delete(np.array(range(ninputs)), iu)) - - # Set the outputs and derivs that will serve as constraints - output_vars = np.array(iy) - deriv_vars = np.array(idx) - - # Verify that the number of degrees of freedom all add up correctly - num_freedoms = len(state_vars) + len(input_vars) - num_constraints = len(output_vars) + len(deriv_vars) - if num_constraints != num_freedoms: - warn("Number of constraints (%d) does not match number of degrees " - "of freedom (%d). Results may be meaningless." % - (num_constraints, num_freedoms)) - - # Make copies of the state and input variables to avoid overwriting - # and convert to floats (in case ints were used for initial conditions) - x = np.array(x0, dtype=float) - u = np.array(u0, dtype=float) - dx0 = np.array(dx0, dtype=float) if dx0 is not None \ - else np.zeros(x.shape) - - # Keep track of the number of states in the set of free variables - nstate_vars = len(state_vars) - - def rootfun(z): - # Map the vector of values into the states and inputs - x[state_vars] = z[:nstate_vars] - u[input_vars] = z[nstate_vars:] - - # Compute the update and output maps - dx = sys._rhs(t, x, u) - dx0 - if sys.isdtime(strict=True): - dx -= x - - # If no y0 is given, don't evaluate the output function - if y0 is None: - return dx[deriv_vars] - else: - dy = sys._out(t, x, u) - y0 - - # Map the results into the constrained variables - return np.concatenate((dx[deriv_vars], dy[output_vars]), axis=0) - - # Set the initial condition for the root finding algorithm - z0 = np.concatenate((x[state_vars], u[input_vars]), axis=0) - - # Finally, call the root finding function - result = root(rootfun, z0) - - # Extract out the results and insert into x and u - x[state_vars] = result.x[:nstate_vars] - u[input_vars] = result.x[nstate_vars:] - z = (x, u, sys._out(t, x, u)) - - # Return the result based on what the user wants and what we found - if not return_y: - z = z[0:2] # Strip y from result if not desired - if return_result: - # Return whatever we got, along with the result dictionary - return z + (result,) - elif result.success: - # Return the result of the optimization - return z + raise ValueError("Systems have incompatible timebases") + elif np.isclose(dt1, dt2): + return dt1 else: - # Something went wrong, don't return anything - return (None, None, None) if return_y else (None, None) - - -# Linearize an input/output system -def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): - """Linearize an input/output system at a given state and input. - - This function computes the linearization of an input/output system at a - given state and input value and returns a :class:`~control.StateSpace` - object. The evaluation point need not be an equilibrium point. + raise ValueError("Systems have incompatible timebases") - Parameters - ---------- - sys : InputOutputSystem - The system to be linearized - xeq : array - The state at which the linearization will be evaluated (does not need - to be an equilibrium state). - ueq : array - The input at which the linearization will be evaluated (does not need - to correspond to an equlibrium state). - t : float, optional - The time at which the linearization will be computed (for time-varying - systems). - 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 - Set the name of the linearized system. If not specified and - if `copy_names` is `False`, a generic name is generated - with a unique integer id. If `copy_names` is `True`, the new system - name is determined by adding the prefix and suffix strings in - config.defaults['namedio.linearized_system_name_prefix'] and - config.defaults['namedio.linearized_system_name_suffix'], with the - default being to add the suffix '$linearized'. - copy_names : bool, Optional - If True, Copy the names of the input signals, output signals, and - states to the linearized system. - - Returns - ------- - ss_sys : LinearIOSystem - The linearization of the system, as a :class:`~control.LinearIOSystem` - object (which is also a :class:`~control.StateSpace` object. - - Other 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`. +# Check to see if two timebases are equal +def timebaseEqual(sys1, sys2): """ - if not isinstance(sys, InputOutputSystem): - raise TypeError("Can only linearize InputOutputSystem types") - return sys.linearize(xeq, ueq, t=t, params=params, **kw) + Check to see if two systems have the same timebase + timebaseEqual(sys1, sys2) -def _find_size(sysval, vecval): - """Utility function to find the size of a system parameter - - If both parameters are not None, they must be consistent. + 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. """ - if hasattr(vecval, '__len__'): - if sysval is not None and sysval != len(vecval): - raise ValueError("Inconsistent information to determine size " - "of system component") - return len(vecval) - # None or 0, which is a valid value for "a (sysval, ) vector of zeros". - if not vecval: - return 0 if sysval is None else sysval - elif sysval == 1: - # (1, scalar) is also a valid combination from legacy code - return 1 - raise ValueError("Can't determine size of system component.") - - -# Define a state space object that is an I/O system -def ss(*args, **kwargs): - r"""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, outfcn)`` - 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:: - - dx/dt &= A x + B u \\ - y &= C x + D 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 x[k] + B u[k] \\ - y[k] &= C x[k] + D u[k] + 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 - The matrices can be given as *array like* data types or strings. - ``ss(args, inputs=['u1', ..., 'up'], outputs=['y1', ..., 'yq'], states=['x1', ..., 'xn'])`` - Create a system with named input, output, and state signals. +# 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 : 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 matrices. - - >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]]) - - Convert a TransferFunction to a StateSpace object. - - >>> sys_tf = ct.tf([2.], [1., 3]) - >>> sys2 = ct.ss(sys_tf) - + 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 """ - # 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, - use_prefix_suffix=not sys._generic_name_check()), - **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)) + # 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 - return sys + # 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 -# Utility function to allow lists states, inputs -def _concatenate_list_elements(X, name='X'): - # If we were passed a list, concatenate the elements together - if isinstance(X, (tuple, list)): - X_list = [] - for i, x in enumerate(X): - x = np.array(x).reshape(-1) # convert everyting to 1D array - X_list += x.tolist() # add elements to initial state - return np.array(X_list) + # Look for dt > 0 (also works if dt = True) + return sys.dt > 0 - # Otherwise, do nothing - return X + # Got passed something we don't recognize + return False -def rss(states=1, outputs=1, inputs=1, strictly_proper=False, **kwargs): - """Create a stable random state space object. +# 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 ---------- - 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. + 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 + """ - Returns - ------- - sys : LinearIOSystem - The randomly created linear system. + # 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 - Raises - ------ - ValueError - if any input is not a positive integer. + # Check for a transfer function or state space object + if isinstance(sys, NamedIOSystem): + return sys.isctime(strict) - 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. + # 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 - """ - # Process keyword arguments - kwargs.update({'states': states, 'outputs': outputs, 'inputs': inputs}) - name, inputs, outputs, states, dt = _process_namedio_keywords(kwargs) + # Got passed something we don't recognize + return False - # 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) +# Utility function to parse nameio keywords +def _process_namedio_keywords( + keywords={}, defaults={}, static=False, end=False): + """Process namedio specification - return LinearIOSystem( - sys, name=name, states=states, inputs=inputs, outputs=outputs, dt=dt, - **kwargs) + 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. -def drss(*args, **kwargs): """ - drss([states, outputs, inputs, strictly_proper]) + # 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} - Create a stable, discrete-time, random state space system + if sys.nstates is not None: + defaults['states'] = sys.state_labels - 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. + elif not isinstance(defaults, dict): + raise TypeError("default must be dict or sys") - Examples - -------- - >>> G = ct.drss(states=4, outputs=2, inputs=1) - >>> G.ninputs, G.noutputs, G.nstates - (1, 2, 4) - >>> G.isdtime() - True - - - """ - # 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 + 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 - # Create the system - sys = rss(*args, **kwargs) - - # Reset the timebase (in case it was specified as None) - sys.dt = 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'] - return sys + # 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 -# Convert a state space system into an input/output system (wrapper) -def ss2io(*args, **kwargs): - return LinearIOSystem(*args, **kwargs) -ss2io.__doc__ = LinearIOSystem.__init__.__doc__ +# Utility function to parse a list of signals +def _process_signal_list(signals, prefix='s', allow_dot=False): + if signals is None: + # No information provided; try and make it up later + return None, {} -# Convert a transfer function into an input/output system (wrapper) -def tf2io(*args, **kwargs): - """tf2io(sys[, ...]) + 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)} - Convert a transfer function into an I/O system + elif isinstance(signals, str): + # Single string given => single signal with given name + if not allow_dot and re.match(r".*\..*", signals): + raise ValueError( + f"invalid signal name '{signals}' ('.' not allowed)") + return 1, {signals: 0} - The function accepts either 1 or 2 parameters: + elif all(isinstance(s, str) for s in signals): + # Use the list of strings as the signal names + for signal in signals: + if not allow_dot and re.match(r".*\..*", signal): + raise ValueError( + f"invalid signal name '{signal}' ('.' not allowed)") + return len(signals), {signals[i]: i for i in range(len(signals))} - ``tf2io(sys)`` - Convert a linear system into space space form. Always creates - a new system, even if sys is already a StateSpace object. + else: + raise TypeError("Can't parse signal list %s" % str(signals)) - ``tf2io(num, den)`` - Create a linear I/O system from its numerator and denominator - polynomial coefficients. - For details see: :func:`tf` +# +# Utility functions to process signal indices +# +# Signal indices can be specified in one of four ways: +# +# 1. As a positive integer 'm', in which case we return a list +# corresponding to the first 'm' elements of a range of a given length +# +# 2. As a negative integer '-m', in which case we return a list +# corresponding to the last 'm' elements of a range of a given length +# +# 3. As a slice, in which case we return the a list corresponding to the +# indices specified by the slice of a range of a given length +# +# 4. As a list of ints or strings specifying specific indices. Strings are +# compared to a list of labels to determine the index. +# +def _process_indices(arg, name, labels, length): + # Default is to return indices up to a certain length + arg = length if arg is None else arg - 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. + if isinstance(arg, int): + # Return the start or end of the list of possible indices + return list(range(arg)) if arg > 0 else list(range(length))[arg:] - 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. + elif isinstance(arg, slice): + # Return the indices referenced by the slice + return list(range(length))[arg] - 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 = ct.tf2ss(num, den) - - >>> sys_tf = ct.tf(num, den) - >>> G = ct.tf2ss(sys_tf) - >>> G.ninputs, G.noutputs, G.nstates - (2, 2, 8) + elif isinstance(arg, list): + # Make sure the length is OK + if len(arg) > length: + raise ValueError( + f"{name}_indices list is too long; max length = {length}") - """ - # Convert the system to a state space system - linsys = tf2ss(*args) + # Return the list, replacing strings with corresponding indices + arg=arg.copy() + for i, idx in enumerate(arg): + if isinstance(idx, str): + arg[i] = labels.index(arg[i]) + return arg - # Now convert the state space system to an I/O system - return LinearIOSystem(linsys, **kwargs) + raise ValueError(f"invalid argument for {name}_indices") +# +# Process control and disturbance indices +# +# For systems with inputs and disturbances, the control_indices and +# disturbance_indices keywords are used to specify which is which. If only +# one is given, the other is assumed to be the remaining indices in the +# system input. If neither is given, the disturbance inputs are assumed to +# be the same as the control inputs. +# +def _process_control_disturbance_indices( + sys, control_indices, disturbance_indices): -# Function to create an interconnected system -def interconnect( - syslist, connections=None, inplist=None, outlist=None, params=None, - check_unused=True, add_unused=False, ignore_inputs=None, - ignore_outputs=None, warn_duplicate=None, debug=False, **kwargs): - """Interconnect a set of input/output systems. + if control_indices is None and disturbance_indices is None: + # Disturbances enter in the same place as the controls + dist_idx = ctrl_idx = list(range(sys.ninputs)) - This function creates a new system that is an interconnection of a set of - input/output systems. If all of the input systems are linear I/O systems - (type :class:`~control.LinearIOSystem`) then the resulting system will be - a linear interconnected I/O system (type :class:`~control.LinearICSystem`) - with the appropriate inputs, outputs, and states. Otherwise, an - interconnected I/O system (type :class:`~control.InterconnectedSystem`) - will be created. + elif control_indices is not None: + # Process the control indices + ctrl_idx = _process_indices( + control_indices, 'control', sys.input_labels, sys.ninputs) - Parameters - ---------- - syslist : list of InputOutputSystems - The list of input/output systems to be connected - - connections : list of connections, optional - Description of the internal connections between the subsystems: - - [connection1, connection2, ...] - - Each connection is itself a list that describes an input to one of the - subsystems. The entries are of the form: - - [input-spec, output-spec1, output-spec2, ...] - - The input-spec can be in a number of different forms. The lowest - level representation is a tuple of the form `(subsys_i, inp_j)` - where `subsys_i` is the index into `syslist` and `inp_j` is the - index into the input vector for the subsystem. If the signal index - is omitted, then all subsystem inputs are used. If systems and - signals are given names, then the forms 'sys.sig' or ('sys', 'sig') - are also recognized. Finally, for multivariable systems the signal - index can be given as a list, for example '(subsys_i, [inp_j1, ..., - inp_jn])'; as a slice, for example, 'sys.sig[i:j]'; or as a base - name `sys.sig` (which matches `sys.sig[i]`). - - Similarly, each output-spec should describe an output signal from - one of the subsystems. The lowest level representation is a tuple - of the form `(subsys_i, out_j, gain)`. The input will be - constructed by summing the listed outputs after multiplying by the - gain term. If the gain term is omitted, it is assumed to be 1. If - the subsystem index `subsys_i` is omitted, then all outputs of the - subsystem are used. If systems and signals are given names, then - the form 'sys.sig', ('sys', 'sig') or ('sys', 'sig', gain) are also - recognized, and the special form '-sys.sig' can be used to specify - a signal with gain -1. Lists, slices, and base namess can also be - used, as long as the number of elements for each output spec - mataches the input spec. - - If omitted, the `interconnect` function will attempt to create the - interconnection map by connecting all signals with the same base names - (ignoring the system name). Specifically, for each input signal name - in the list of systems, if that signal name corresponds to the output - signal in any of the systems, it will be connected to that input (with - a summation across all signals if the output name occurs in more than - one system). - - The `connections` keyword can also be set to `False`, which will leave - the connection map empty and it can be specified instead using the - low-level :func:`~control.InterconnectedSystem.set_connect_map` - method. - - inplist : list of input connections, optional - List of connections for how the inputs for the overall system are - mapped to the subsystem inputs. The input specification is similar to - the form defined in the connection specification, except that - connections do not specify an input-spec, since these are the system - inputs. The entries for a connection are thus of the form: - - [input-spec1, input-spec2, ...] - - Each system input is added to the input for the listed subsystem. - If the system input connects to a subsystem with a single input, a - single input specification can be given (without the inner list). - - If omitted the `input` parameter will be used to identify the list - of input signals to the overall system. - - outlist : list of output connections, optional - List of connections for how the outputs from the subsystems are - mapped to overall system outputs. The output connection - description is the same as the form defined in the inplist - specification (including the optional gain term). Numbered outputs - must be chosen from the list of subsystem outputs, but named - outputs can also be contained in the list of subsystem inputs. - - If an output connection contains more than one signal specification, - then those signals are added together (multiplying by the any gain - term) to form the system output. - - If omitted, the output map can be specified using the - :func:`~control.InterconnectedSystem.set_output_map` method. - - inputs : int, list of str or None, optional - 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`). If this parameter - is not given or given as `None`, the relevant quantity will be - determined when possible based on other information provided to - functions using the system. - - 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`. The - default is `None`, in which case the states will be given names of the - form '.', for each subsys in syslist and each - state_name of each subsys. - - 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 following - values: - - * dt = 0: continuous time system (default) - * dt > 0: discrete time system with sampling period 'dt' - * dt = True: discrete time with unspecified sampling period - * dt = None: no timebase specified - - name : string, optional - System name (used for specifying signals). If unspecified, a generic - name is generated with a unique integer id. - - check_unused : bool, optional - If True, check for unused sub-system signals. This check is - not done if connections is False, and neither input nor output - mappings are specified. - - add_unused : bool, optional - If True, subsystem signals that are not connected to other components - are added as inputs and outputs of the interconnected system. - - ignore_inputs : list of input-spec, optional - A list of sub-system inputs known not to be connected. This is - *only* used in checking for unused signals, and does not - disable use of the input. - - Besides the usual input-spec forms (see `connections`), an - input-spec can be just the signal base name, in which case all - signals from all sub-systems with that base name are - considered ignored. - - ignore_outputs : list of output-spec, optional - A list of sub-system outputs known not to be connected. This - is *only* used in checking for unused signals, and does not - disable use of the output. - - Besides the usual output-spec forms (see `connections`), an - output-spec can be just the signal base name, in which all - outputs from all sub-systems with that base name are - considered ignored. - - warn_duplicate : None, True, or False, optional - 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. - - debug : bool, default=False - Print out information about how signals are being processed that - may be useful in understanding why something is not working. - - - Examples - -------- - >>> P = ct.rss(2, 2, 2, strictly_proper=True, name='P') - >>> C = ct.rss(2, 2, 2, name='C') - >>> T = ct.interconnect( - ... [P, C], - ... connections=[ - ... ['P.u[0]', 'C.y[0]'], ['P.u[1]', 'C.y[1]'], - ... ['C.u[0]', '-P.y[0]'], ['C.u[1]', '-P.y[1]']], - ... inplist=['C.u[0]', 'C.u[1]'], - ... outlist=['P.y[0]', 'P.y[1]'], - ... ) - - This expression can be simplified using either slice notation or - just signal basenames: - - >>> T = ct.interconnect( - ... [P, C], connections=[['P.u[:]', 'C.y[:]'], ['C.u', '-P.y']], - ... inplist='C.u', outlist='P.y[:]') - - or further simplified by omitting the input and output signal - specifications (since all inputs and outputs are used): - - >>> T = ct.interconnect( - ... [P, C], connections=[['P', 'C'], ['C', '-P']], - ... inplist=['C'], outlist=['P']) - - A feedback system can also be constructed using the - :func:`~control.summing_block` function and the ability to - automatically interconnect signals with the same names: - - >>> P = ct.tf(1, [1, 0], inputs='u', outputs='y') - >>> C = ct.tf(10, [1, 1], inputs='e', outputs='u') - >>> sumblk = ct.summing_junction(inputs=['r', '-y'], output='e') - >>> T = ct.interconnect([P, C, sumblk], inputs='r', outputs='y') - - Notes - ----- - If a system is duplicated in the list of systems to be connected, - a warning is generated and a copy of the system is created with the - name of the new system determined by adding the prefix and suffix - strings in config.defaults['namedio.linearized_system_name_prefix'] - and config.defaults['namedio.linearized_system_name_suffix'], with the - default being to add the suffix '$copy' to the system name. - - In addition to explicit lists of system signals, it is possible to - lists vectors of signals, using one of the following forms:: - - (subsys, [i1, ..., iN], gain) signals with indices i1, ..., in - 'sysname.signal[i:j]' range of signal names, i through j-1 - 'sysname.signal[:]' all signals with given prefix - - While in many Python functions tuples can be used in place of lists, - for the interconnect() function the only use of tuples should be in the - specification of an input- or output-signal via the tuple notation - `(subsys_i, signal_j, gain)` (where `gain` is optional). If you get an - unexpected error message about a specification being of the wrong type - or not being found, check to make sure you are not using a tuple where - you should be using a list. - - In addition to its use for general nonlinear I/O systems, the - :func:`~control.interconnect` function allows linear systems to be - interconnected using named signals (compared with the - :func:`~control.connect` function, which uses signal indices) and to be - treated as both a :class:`~control.StateSpace` system as well as an - :class:`~control.InputOutputSystem`. - - The `input` and `output` keywords can be used instead of `inputs` and - `outputs`, for more natural naming of SISO systems. + # Disturbance indices are the complement of control indices + dist_idx = [i for i in range(sys.ninputs) if i not in ctrl_idx] - """ - dt = kwargs.pop('dt', None) # by pass normal 'dt' processing - name, inputs, outputs, states, _ = _process_namedio_keywords(kwargs) - - 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: - # user has disabled auto-connect, and supplied neither input - # nor output mappings; assume they know what they're doing - check_unused = False - - # If connections was not specified, set up default connection list - if connections is None: - # For each system input, look for outputs with the same name - connections = [] - for input_sys in syslist: - 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_labels: - connect.append(output_sys.name + "." + input_name) - if len(connect) > 1: - connections.append(connect) - - auto_connect = True - - elif connections is False: - check_unused = False - # Use an empty connections list - connections = [] - - elif isinstance(connections, list) and \ - all([isinstance(cnxn, (str, tuple)) for cnxn in connections]): - # Special case where there is a single connection - connections = [connections] - - # If inplist/outlist is not present, try using inputs/outputs instead - inplist_none, outlist_none = False, False - if inplist is None: - inplist = inputs or [] - inplist_none = True # use to rewrite inputs below - if outlist is None: - outlist = outputs or [] - outlist_none = True # use to rewrite outputs below - - # Define a local debugging function - dprint = lambda s: None if not debug else print(s) + else: # disturbance_indices is not None + # If passed an integer, count from the end of the input vector + arg = -disturbance_indices if isinstance(disturbance_indices, int) \ + else disturbance_indices - # - # Pre-process connecton list - # - # Support for various "vector" forms of specifications is handled here, - # by expanding any specifications that refer to more than one signal. - # This includes signal lists such as ('sysname', ['sig1', 'sig2', ...]) - # as well as slice-based specifications such as 'sysname.signal[i:j]'. - # - dprint(f"Pre-processing connections:") - new_connections = [] - for connection in connections: - dprint(f" parsing {connection=}") - if not isinstance(connection, list): - raise ValueError( - f"invalid connection {connection}: should be a list") - # Parse and expand the input specification - input_spec = _parse_spec(syslist, connection[0], 'input') - input_spec_list = [input_spec] - - # Parse and expand the output specifications - output_specs_list = [[]] * len(input_spec_list) - for spec in connection[1:]: - output_spec = _parse_spec(syslist, spec, 'output') - output_specs_list[0].append(output_spec) - - # Create the new connection entry - for input_spec, output_specs in zip(input_spec_list, output_specs_list): - new_connection = [input_spec] + output_specs - dprint(f" adding {new_connection=}") - new_connections.append(new_connection) - connections = new_connections + dist_idx = _process_indices( + arg, 'disturbance', sys.input_labels, sys.ninputs) - # - # Pre-process input connections list - # - # Similar to the connections list, we now handle "vector" forms of - # specifications in the inplist parameter. This needs to be handled - # here because the InterconnectedSystem constructor assumes that the - # number of elements in `inplist` will match the number of inputs for - # the interconnected system. - # - # If inplist_none is True then inplist is a copy of inputs and so we - # also have to be careful that if we encounter any multivariable - # signals, we need to update the input list. - # - dprint(f"Pre-processing input connections: {inplist}") - if not isinstance(inplist, list): - dprint(f" converting inplist to list") - inplist = [inplist] - new_inplist, new_inputs = [], [] if inplist_none else inputs - - # Go through the list of inputs and process each one - for iinp, connection in enumerate(inplist): - # Check for system name or signal names without a system name - if isinstance(connection, str) and len(connection.split('.')) == 1: - # Create an empty connections list to store matching connections - new_connections = [] - - # Get the signal/system name - sname = connection[1:] if connection[0] == '-' else connection - gain = -1 if connection[0] == '-' else 1 - - # Look for the signal name as a system input - found_system, found_signal = False, False - for isys, sys in enumerate(syslist): - # Look for matching signals (returns None if no matches - indices = sys._find_signals(sname, sys.input_index) - - # See what types of matches we found - if sname == sys.name: - # System name matches => use all inputs - for isig in range(sys.ninputs): - dprint(f" adding input {(isys, isig, gain)}") - new_inplist.append((isys, isig, gain)) - found_system = True - elif indices: - # Signal name matches => store new connections - new_connection = [] - for isig in indices: - dprint(f" collecting input {(isys, isig, gain)}") - new_connection.append((isys, isig, gain)) - - if len(new_connections) == 0: - # First time we have seen this signal => initalize - for cnx in new_connection: - new_connections.append([cnx]) - if inplist_none: - # See if we need to rewrite the inputs - if len(new_connection) != 1: - new_inputs += [ - sys.input_labels[i] for i in indices] - else: - new_inputs.append(inputs[iinp]) - else: - # Additional signal match found =. add to the list - for i, cnx in enumerate(new_connection): - new_connections[i].append(cnx) - found_signal = True - - if found_system and found_signal: - raise ValueError( - f"signal '{sname}' is both signal and system name") - elif found_signal: - dprint(f" adding inputs {new_connections}") - new_inplist += new_connections - elif not found_system: - raise ValueError("could not find signal %s" % sname) - else: - # Regular signal specification - if not isinstance(connection, list): - dprint(f" converting item to list") - connection = [connection] - for spec in connection: - isys, indices, gain = _parse_spec(syslist, spec, 'input') - for isig in indices: - dprint(f" adding input {(isys, isig, gain)}") - new_inplist.append((isys, isig, gain)) - inplist, inputs = new_inplist, new_inputs - dprint(f" {inplist=}\n {inputs=}") + # Set control indices to complement disturbance indices + ctrl_idx = [i for i in range(sys.ninputs) if i not in dist_idx] - # - # Pre-process output list - # - # This is similar to the processing of the input list, but we need to - # additionally take into account the fact that you can list subsystem - # inputs as system outputs. - # - dprint(f"Pre-processing output connections: {outlist}") - if not isinstance(outlist, list): - dprint(f" converting outlist to list") - outlist = [outlist] - new_outlist, new_outputs = [], [] if outlist_none else outputs - for iout, connection in enumerate(outlist): - # Create an empty connection list - new_connections = [] - - # Check for system name or signal names without a system name - if isinstance(connection, str) and len(connection.split('.')) == 1: - # Get the signal/system name - sname = connection[1:] if connection[0] == '-' else connection - gain = -1 if connection[0] == '-' else 1 - - # Look for the signal name as a system output - found_system, found_signal = False, False - for osys, sys in enumerate(syslist): - indices = sys._find_signals(sname, sys.output_index) - if sname == sys.name: - # Use all outputs - for osig in range(sys.noutputs): - dprint(f" adding output {(osys, osig, gain)}") - new_outlist.append((osys, osig, gain)) - found_system = True - elif indices: - new_connection = [] - for osig in indices: - dprint(f" collecting output {(osys, osig, gain)}") - new_connection.append((osys, osig, gain)) - if len(new_connections) == 0: - for cnx in new_connection: - new_connections.append([cnx]) - if outlist_none: - # See if we need to rewrite the outputs - if len(new_connection) != 1: - new_outputs += [ - sys.output_labels[i] for i in indices] - else: - new_outputs.append(outputs[iout]) - else: - # Additional signal match found =. add to the list - for i, cnx in enumerate(new_connection): - new_connections[i].append(cnx) - found_signal = True - - if found_system and found_signal: - raise ValueError( - f"signal '{sname}' is both signal and system name") - elif found_signal: - dprint(f" adding outputs {new_connections}") - new_outlist += new_connections - elif not found_system: - raise ValueError("could not find signal %s" % sname) - else: - # Regular signal specification - if not isinstance(connection, list): - dprint(f" converting item to list") - connection = [connection] - for spec in connection: - try: - # First trying looking in the output signals - osys, indices, gain = _parse_spec(syslist, spec, 'output') - for osig in indices: - dprint(f" adding output {(osys, osig, gain)}") - new_outlist.append((osys, osig, gain)) - except ValueError: - # If not, see if we can find it in inputs - isys, indices, gain = _parse_spec( - syslist, spec, 'input or output', - dictname='input_index') - for isig in indices: - # Use string form to allow searching input list - dprint(f" adding input {(isys, isig, gain)}") - new_outlist.append( - (syslist[isys].name, - syslist[isys].input_labels[isig], gain)) - outlist, outputs = new_outlist, new_outputs - dprint(f" {outlist=}\n {outputs=}") - - # Make sure inputs and outputs match inplist outlist, if specified - if inputs and ( - isinstance(inputs, (list, tuple)) and len(inputs) != len(inplist) - or isinstance(inputs, int) and inputs != len(inplist)): - raise ValueError("`inputs` incompatible with `inplist`") - if outputs and ( - isinstance(outputs, (list, tuple)) and len(outputs) != len(outlist) - or isinstance(outputs, int) and outputs != len(outlist)): - raise ValueError("`outputs` incompatible with `outlist`") - - newsys = InterconnectedSystem( - syslist, connections=connections, inplist=inplist, - outlist=outlist, inputs=inputs, outputs=outputs, states=states, - params=params, dt=dt, name=name, warn_duplicate=warn_duplicate, - **kwargs) - - # See if we should add any signals - if add_unused: - # Get all unused signals - dropped_inputs, dropped_outputs = newsys.check_unused_signals( - ignore_inputs, ignore_outputs, warning=False) - - # Add on any unused signals that we aren't ignoring - for isys, isig in dropped_inputs: - inplist.append((isys, isig)) - inputs.append(newsys.syslist[isys].input_labels[isig]) - for osys, osig in dropped_outputs: - outlist.append((osys, osig)) - outputs.append(newsys.syslist[osys].output_labels[osig]) - - # Rebuild the system with new inputs/outputs - newsys = InterconnectedSystem( - syslist, connections=connections, inplist=inplist, - outlist=outlist, inputs=inputs, outputs=outputs, states=states, - params=params, dt=dt, name=name, warn_duplicate=warn_duplicate, - **kwargs) - - # check for implicitly 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 newsys.syslist]): - return LinearICSystem(newsys, None) - - return newsys - - -# Summing junction -def summing_junction( - 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 - the inputs, potentially with a change in sign for each individual input. - The input/output system that is created by this function can be used as a - component in the :func:`~control.interconnect` function. + return ctrl_idx, dist_idx - Parameters - ---------- - inputs : int, string or list of strings - Description of the inputs to the summing junction. This can be given - as an integer count, a string, or a list of strings. If an integer - count is specified, the names of the input signals will be of the form - `u[i]`. - output : string, optional - Name of the system output. If not specified, the output will be 'y'. - dimension : int, optional - The dimension of the summing junction. If the dimension is set to a - positive integer, a multi-input, multi-output summing junction will be - created. The input and output signal names will be of the form - `[i]` where `signal` is the input/output signal name specified - by the `inputs` and `output` keywords. Default value is `None`. - name : string, optional - System name (used for specifying signals). If unspecified, a generic - name is generated with a unique integer id. - 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]`. - Returns - ------- - sys : static LinearIOSystem - Linear input/output system object with no states and only a direct - term that implements the summing junction. - - Examples - -------- - >>> 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], inputs='r', outputs='y') - >>> T.ninputs, T.noutputs, T.nstates - (1, 1, 2) +# Process labels +def _process_labels(labels, name, default): + if isinstance(labels, str): + labels = [labels.format(i=i) for i in range(len(default))] - """ - # Utility function to parse input and output signal lists - def _parse_list(signals, signame='input', prefix='u'): - # Parse signals, including gains - if isinstance(signals, int): - nsignals = signals - names = ["%s[%d]" % (prefix, i) for i in range(nsignals)] - gains = np.ones((nsignals,)) - elif isinstance(signals, str): - nsignals = 1 - gains = [-1 if signals[0] == '-' else 1] - names = [signals[1:] if signals[0] == '-' else signals] - elif isinstance(signals, list) and \ - all([isinstance(x, str) for x in signals]): - nsignals = len(signals) - gains = np.ones((nsignals,)) - names = [] - for i in range(nsignals): - if signals[i][0] == '-': - gains[i] = -1 - names.append(signals[i][1:]) - else: - names.append(signals[i]) - else: + if labels is None: + labels = default + elif isinstance(labels, list): + if len(labels) != len(default): raise ValueError( - "could not parse %s description '%s'" - % (signame, str(signals))) - - # Return the parsed list - return nsignals, names, gains - - # 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") - - # Read the input list - ninputs, input_names, input_gains = _parse_list( - inputs, signame="input", prefix=prefix) - noutputs, output_names, output_gains = _parse_list( - output, signame="output", prefix='y') - if noutputs > 1: - raise NotImplementedError("vector outputs not yet supported") - - # If the dimension keyword is present, vectorize inputs and outputs - if isinstance(dimension, int) and dimension >= 1: - # Create a new list of input/output names and update parameters - input_names = ["%s[%d]" % (name, dim) - for name in input_names - for dim in range(dimension)] - ninputs = ninputs * dimension - - output_names = ["%s[%d]" % (name, dim) - for name in output_names - for dim in range(dimension)] - noutputs = noutputs * dimension - elif dimension is not None: - raise ValueError( - "unrecognized dimension value '%s'" % str(dimension)) + f"incorrect length of {name}_labels: {len(labels)}" + f" instead of {len(default)}") else: - dimension = 1 - - # Create the direct term - D = np.kron(input_gains * output_gains[0], np.eye(dimension)) - - # Create a linear system of the appropriate size - ss_sys = StateSpace( - np.zeros((0, 0)), np.ones((0, ninputs)), np.ones((noutputs, 0)), D) + raise ValueError(f"{name}_labels should be a string or a list") - # Create a LinearIOSystem - return LinearIOSystem( - ss_sys, inputs=input_names, outputs=output_names, name=name) + return labels # diff --git a/control/lti.py b/control/lti.py index c904c1509..f50945ad8 100644 --- a/control/lti.py +++ b/control/lti.py @@ -9,7 +9,7 @@ from numpy import real, angle, abs from warnings import warn from . import config -from .namedio import NamedIOSystem +from .iosys import NamedIOSystem __all__ = ['poles', 'zeros', 'damp', 'evalfr', 'frequency_response', 'freqresp', 'dcgain', 'bandwidth', 'pole', 'zero'] diff --git a/control/margins.py b/control/margins.py index 28daaf358..cd1d12ea3 100644 --- a/control/margins.py +++ b/control/margins.py @@ -53,7 +53,7 @@ import scipy as sp from . import xferfcn from .lti import evalfr -from .namedio import issiso +from .iosys 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 ef14248c0..e0708c9ab 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -62,10 +62,10 @@ # Control system library from ..statesp import * -from ..iosys import ss, rss, drss # moved from .statesp +from ..statesp import ss, rss, drss # moved from .statesp from ..xferfcn import * from ..lti import * -from ..namedio import * +from ..iosys import * from ..frdata import * from ..dtime import * from ..exception import ControlArgument diff --git a/control/matlab/wrappers.py b/control/matlab/wrappers.py index d98dcabf0..2fabd98ab 100644 --- a/control/matlab/wrappers.py +++ b/control/matlab/wrappers.py @@ -3,7 +3,7 @@ """ import numpy as np -from ..iosys import ss +from ..statesp import ss from ..xferfcn import tf from ..lti import LTI from ..exception import ControlArgument diff --git a/control/modelsimp.py b/control/modelsimp.py index f7b15093d..cbaf242c3 100644 --- a/control/modelsimp.py +++ b/control/modelsimp.py @@ -45,7 +45,7 @@ import warnings from .exception import ControlSlycot, ControlMIMONotImplemented, \ ControlDimension -from .namedio import isdtime, isctime +from .iosys import isdtime, isctime from .statesp import StateSpace from .statefbk import gram diff --git a/control/namedio.py b/control/namedio.py deleted file mode 100644 index a37155f09..000000000 --- a/control/namedio.py +++ /dev/null @@ -1,756 +0,0 @@ -# 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 deepcopy -from warnings import warn -import re -from . import config - -__all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', - 'isdtime', 'isctime'] - -# Define module default parameter values -_namedio_defaults = { - 'namedio.state_name_delim': '_', - 'namedio.duplicate_system_name_prefix': '', - 'namedio.duplicate_system_name_suffix': '$copy', - 'namedio.linearized_system_name_prefix': '', - 'namedio.linearized_system_name_suffix': '$linearized', - 'namedio.sampled_system_name_prefix': '', - 'namedio.sampled_system_name_suffix': '$sampled', - 'namedio.indexed_system_name_prefix': '', - 'namedio.indexed_system_name_suffix': '$indexed', - 'namedio.converted_system_name_prefix': '', - 'namedio.converted_system_name_suffix': '$converted', -} - - -class NamedIOSystem(object): - def __init__( - self, name=None, inputs=None, outputs=None, states=None, - input_prefix='u', output_prefix='y', state_prefix='x', **kwargs): - - # system name - self.name = self._name_or_default(name) - - # Parse and store the number of inputs and outputs - self.set_inputs(inputs, prefix=input_prefix) - self.set_outputs(outputs, prefix=output_prefix) - self.set_states(states, prefix=state_prefix) - - # 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, prefix_suffix_name=None): - if name is None: - name = "sys[{}]".format(NamedIOSystem._idCounter) - NamedIOSystem._idCounter += 1 - elif re.match(r".*\..*", name): - raise ValueError(f"invalid system name '{name}' ('.' not allowed)") - - prefix = "" if prefix_suffix_name is None else config.defaults[ - 'namedio.' + prefix_suffix_name + '_system_name_prefix'] - suffix = "" if prefix_suffix_name is None else config.defaults[ - 'namedio.' + prefix_suffix_name + '_system_name_suffix'] - return prefix + name + suffix - - # Check if system name is generic - def _generic_name_check(self): - 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) - - # Find a list of signals by name, index, or pattern - def _find_signals(self, name_list, sigdict): - if not isinstance(name_list, (list, tuple)): - name_list = [name_list] - - index_list = [] - for name in name_list: - # Look for signal ranges (slice-like or base name) - ms = re.match(r'([\w$]+)\[([\d]*):([\d]*)\]$', name) # slice - mb = re.match(r'([\w$]+)$', name) # base - if ms: - base = ms.group(1) - start = None if ms.group(2) == '' else int(ms.group(2)) - stop = None if ms.group(3) == '' else int(ms.group(3)) - for var in sigdict: - # Find variables that match - msig = re.match(r'([\w$]+)\[([\d]+)\]$', var) - if msig and msig.group(1) == base and \ - (start is None or int(msig.group(2)) >= start) and \ - (stop is None or int(msig.group(2)) < stop): - index_list.append(sigdict.get(var)) - elif mb and sigdict.get(name, None) is None: - # Try to use name as a base name - for var in sigdict: - msig = re.match(name + r'\[([\d]+)\]$', var) - if msig: - index_list.append(sigdict.get(var)) - else: - index_list.append(sigdict.get(name, None)) - - return None if len(index_list) == 0 or \ - any([idx is None for idx in index_list]) else index_list - - def _copy_names(self, sys, prefix="", suffix="", prefix_suffix_name=None): - """copy the signal and system name of sys. Name is given as a keyword - in case a specific name (e.g. append 'linearized') is desired. """ - # Figure out the system name and assign it - if prefix == "" and prefix_suffix_name is not None: - prefix = config.defaults[ - 'namedio.' + prefix_suffix_name + '_system_name_prefix'] - if suffix == "" and prefix_suffix_name is not None: - suffix = config.defaults[ - 'namedio.' + prefix_suffix_name + '_system_name_suffix'] - self.name = prefix + sys.name + suffix - - # Name the inputs, outputs, and states - self.input_index = sys.input_index.copy() - self.output_index = sys.output_index.copy() - if self.nstates and sys.nstates: - # only copy state names for state space systems - self.state_index = sys.state_index.copy() - - 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['namedio.duplicate_system_name_prefix'] - and appending config.defaults['namedio.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 = deepcopy(self) - - # Update the system name - if name is None and use_prefix_suffix: - # Get the default prefix and suffix to use - newsys.name = self._name_or_default( - self.name, prefix_suffix_name='duplicate') - 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) - - def find_inputs(self, name_list): - """Return list of indices matching input spec (`None` if not found)""" - return self._find_signals(name_list, self.input_index) - - # 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) - - def find_outputs(self, name_list): - """Return list of indices matching output spec (`None` if not found)""" - return self._find_signals(name_list, self.output_index) - - # 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, allow_dot=True) - - 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 find_states(self, name_list): - """Return list of indices matching state spec (`None` if not found)""" - return self._find_signals(name_list, self.state_index) - - # 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', allow_dot=False): - 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 - if not allow_dot and re.match(r".*\..*", signals): - raise ValueError( - f"invalid signal name '{signals}' ('.' not allowed)") - return 1, {signals: 0} - - elif all(isinstance(s, str) for s in signals): - # Use the list of strings as the signal names - for signal in signals: - if not allow_dot and re.match(r".*\..*", signal): - raise ValueError( - f"invalid signal name '{signal}' ('.' not allowed)") - return len(signals), {signals[i]: i for i in range(len(signals))} - - else: - raise TypeError("Can't parse signal list %s" % str(signals)) - - -# -# Utility functions to process signal indices -# -# Signal indices can be specified in one of four ways: -# -# 1. As a positive integer 'm', in which case we return a list -# corresponding to the first 'm' elements of a range of a given length -# -# 2. As a negative integer '-m', in which case we return a list -# corresponding to the last 'm' elements of a range of a given length -# -# 3. As a slice, in which case we return the a list corresponding to the -# indices specified by the slice of a range of a given length -# -# 4. As a list of ints or strings specifying specific indices. Strings are -# compared to a list of labels to determine the index. -# -def _process_indices(arg, name, labels, length): - # Default is to return indices up to a certain length - arg = length if arg is None else arg - - if isinstance(arg, int): - # Return the start or end of the list of possible indices - return list(range(arg)) if arg > 0 else list(range(length))[arg:] - - elif isinstance(arg, slice): - # Return the indices referenced by the slice - return list(range(length))[arg] - - elif isinstance(arg, list): - # Make sure the length is OK - if len(arg) > length: - raise ValueError( - f"{name}_indices list is too long; max length = {length}") - - # Return the list, replacing strings with corresponding indices - arg=arg.copy() - for i, idx in enumerate(arg): - if isinstance(idx, str): - arg[i] = labels.index(arg[i]) - return arg - - raise ValueError(f"invalid argument for {name}_indices") - -# -# Process control and disturbance indices -# -# For systems with inputs and disturbances, the control_indices and -# disturbance_indices keywords are used to specify which is which. If only -# one is given, the other is assumed to be the remaining indices in the -# system input. If neither is given, the disturbance inputs are assumed to -# be the same as the control inputs. -# -def _process_control_disturbance_indices( - sys, control_indices, disturbance_indices): - - if control_indices is None and disturbance_indices is None: - # Disturbances enter in the same place as the controls - dist_idx = ctrl_idx = list(range(sys.ninputs)) - - elif control_indices is not None: - # Process the control indices - ctrl_idx = _process_indices( - control_indices, 'control', sys.input_labels, sys.ninputs) - - # Disturbance indices are the complement of control indices - dist_idx = [i for i in range(sys.ninputs) if i not in ctrl_idx] - - else: # disturbance_indices is not None - # If passed an integer, count from the end of the input vector - arg = -disturbance_indices if isinstance(disturbance_indices, int) \ - else disturbance_indices - - dist_idx = _process_indices( - arg, 'disturbance', sys.input_labels, sys.ninputs) - - # Set control indices to complement disturbance indices - ctrl_idx = [i for i in range(sys.ninputs) if i not in dist_idx] - - return ctrl_idx, dist_idx - - -# Process labels -def _process_labels(labels, name, default): - if isinstance(labels, str): - labels = [labels.format(i=i) for i in range(len(default))] - - if labels is None: - labels = default - elif isinstance(labels, list): - if len(labels) != len(default): - raise ValueError( - f"incorrect length of {name}_labels: {len(labels)}" - f" instead of {len(default)}") - else: - raise ValueError(f"{name}_labels should be a string or a list") - - return labels diff --git a/control/nlsys.py b/control/nlsys.py new file mode 100644 index 000000000..b4d7f8177 --- /dev/null +++ b/control/nlsys.py @@ -0,0 +1,2602 @@ +# iosys.py - input/output system module +# +# RMM, 28 April 2019 +# +# Additional features to add +# * Allow constant inputs for MIMO input_output_response (w/out ones) +# * Add support for constants/matrices as part of operators (1 + P) +# * Add unit tests (and example?) for time-varying systems +# * Allow time vector for discrete time simulations to be multiples of dt +# * Check the way initial outputs for discrete time systems are handled +# + +"""The :mod:`~control.nlsys` module contains the +:class:`~control.InputOutputSystem` class that represents (possibly nonlinear) +input/output systems. The :class:`~control.InputOutputSystem` class is a +general class that defines any continuous or discrete time dynamical system. +Input/output systems can be simulated and also used to compute equilibrium +points and linearizations. + +""" + +__author__ = "Richard Murray" +__copyright__ = "Copyright 2019, California Institute of Technology" +__credits__ = ["Richard Murray"] +__license__ = "BSD" +__maintainer__ = "Richard Murray" +__email__ = "murray@cds.caltech.edu" + +import numpy as np +import scipy as sp +import copy +from warnings import warn + +from . import config +from .iosys import NamedIOSystem, _process_signal_list, _parse_spec, \ + _process_namedio_keywords, isctime, isdtime, common_timebase + +__all__ = ['InputOutputSystem', 'NonlinearIOSystem', + 'InterconnectedSystem', 'input_output_response', + 'find_eqpt', 'linearize', 'interconnect'] + +# Define module default parameter values +_iosys_defaults = {} + + +class InputOutputSystem(NamedIOSystem): + """A class for representing input/output systems. + + The InputOutputSystem class allows (possibly nonlinear) input/output + systems to be represented in Python. It is used as a parent class for + a set of subclasses that are used to implement specific structures and + operations for different types of input/output dynamical systems. + + Parameters + ---------- + inputs : int, list of str, or None + Description of the system inputs. This can be given as an integer + 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 given by the `input_prefix` parameter and + has default value 'u'). If this parameter is not given or given as + `None`, the relevant quantity will be determined when possible + based on other information provided to functions using the system. + outputs : int, list of str, or None + Description of the system outputs. Same format as `inputs`, with + the prefix given by output_prefix (defaults to 'y'). + states : int, list of str, or None + Description of the system states. Same format as `inputs`, with + the prefix given by state_prefix (defaults to 'x'). + 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. + params : dict, optional + Parameter values for the system. Passed to the evaluation functions + for the system as default values, overriding internal defaults. + + Attributes + ---------- + ninputs, noutputs, nstates : int + Number of input, output and state variables + input_index, output_index, state_index : dict + Dictionary of signal names for the inputs, outputs and states and the + index of the corresponding array + dt : None, True or float + 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. + name : string, optional + System name (used for specifying signals) + + Other Parameters + ---------------- + input_prefix : string, optional + Set the prefix for input signals. Default = 'u'. + output_prefix : string, optional + Set the prefix for output signals. Default = 'y'. + state_prefix : string, optional + Set the prefix for state signals. Default = 'x'. + + Notes + ----- + The :class:`~control.InputOuputSystem` class (and its subclasses) makes + use of two special methods for implementing much of the work of the class: + + * _rhs(t, x, u): compute the right hand side of the differential or + difference equation for the system. This must be specified by the + subclass for the system. + + * _out(t, x, u): compute the output for the current state of the system. + The default is to return the entire system state. + + """ + + # Allow ndarray * InputOutputSystem to give IOSystem._rmul_() priority + __array_priority__ = 12 # override ndarray, matrix, SS types + + def __init__(self, params=None, **kwargs): + """Create an input/output system. + + The InputOutputSystem constructor is used to create an input/output + object with the core information required for all input/output + systems. Instances of this class are normally created by one of the + input/output subclasses: :class:`~control.LinearICSystem`, + :class:`~control.LinearIOSystem`, :class:`~control.NonlinearIOSystem`, + :class:`~control.InterconnectedSystem`. + + """ + # Store the system name, inputs, outputs, and states + name, inputs, outputs, states, dt = _process_namedio_keywords(kwargs) + + # 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, **kwargs) + + # default parameters + self.params = {} if params is None else params.copy() + + def __mul__(sys2, sys1): + """Multiply two input/output systems (series interconnection)""" + # Note: order of arguments is flipped so that self = sys2, + # corresponding to the ordering convention of sys2 * sys1 + from .statesp import StateSpace, LinearIOSystem, LinearICSystem + from .xferfcn import TransferFunction + + # Convert sys1 to an I/O system if needed + if isinstance(sys1, (int, float, np.number)): + sys1 = LinearIOSystem(StateSpace( + [], [], [], sys1 * np.eye(sys2.ninputs))) + + elif isinstance(sys1, np.ndarray): + sys1 = LinearIOSystem(StateSpace([], [], [], sys1)) + + elif isinstance(sys1, (StateSpace, TransferFunction)) and \ + not isinstance(sys1, LinearIOSystem): + sys1 = LinearIOSystem(sys1) + + elif not isinstance(sys1, InputOutputSystem): + raise TypeError("Unknown I/O system object ", sys1) + + # Make sure systems can be interconnected + if sys1.noutputs != sys2.ninputs: + raise ValueError("Can't multiply systems with incompatible " + "inputs and outputs") + + # Make sure timebase are compatible + dt = common_timebase(sys1.dt, sys2.dt) + + # Create a new system to handle the composition + inplist = [(0, i) for i in range(sys1.ninputs)] + outlist = [(1, i) for i in range(sys2.noutputs)] + newsys = InterconnectedSystem( + (sys1, sys2), inplist=inplist, outlist=outlist) + + # Set up the connection map manually + newsys.set_connect_map(np.block( + [[np.zeros((sys1.ninputs, sys1.noutputs)), + np.zeros((sys1.ninputs, sys2.noutputs))], + [np.eye(sys2.ninputs, sys1.noutputs), + np.zeros((sys2.ninputs, sys2.noutputs))]] + )) + + # If both systems are linear, create LinearICSystem + if isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): + ss_sys = StateSpace.__mul__(sys2, sys1) + return LinearICSystem(newsys, ss_sys) + + # Return the newly created InterconnectedSystem + return newsys + + def __rmul__(sys1, sys2): + """Pre-multiply an input/output systems by a scalar/matrix""" + from .statesp import StateSpace, LinearIOSystem, LinearICSystem + from .xferfcn import TransferFunction + + # Convert sys2 to an I/O system if needed + if isinstance(sys2, (int, float, np.number)): + sys2 = LinearIOSystem(StateSpace( + [], [], [], sys2 * np.eye(sys1.noutputs))) + + elif isinstance(sys2, np.ndarray): + sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) + + elif isinstance(sys2, (StateSpace, TransferFunction)) and \ + not isinstance(sys2, LinearIOSystem): + sys2 = LinearIOSystem(sys2) + + elif not isinstance(sys2, InputOutputSystem): + raise TypeError("Unknown I/O system object ", sys2) + + return InputOutputSystem.__mul__(sys2, sys1) + + def __add__(sys1, sys2): + """Add two input/output systems (parallel interconnection)""" + from .statesp import StateSpace, LinearIOSystem, LinearICSystem + from .xferfcn import TransferFunction + + # Convert sys1 to an I/O system if needed + if isinstance(sys2, (int, float, np.number)): + sys2 = LinearIOSystem(StateSpace( + [], [], [], sys2 * np.eye(sys1.ninputs))) + + elif isinstance(sys2, np.ndarray): + sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) + + elif isinstance(sys2, (StateSpace, TransferFunction)) and \ + not isinstance(sys2, LinearIOSystem): + sys2 = LinearIOSystem(sys2) + + elif not isinstance(sys2, InputOutputSystem): + raise TypeError("Unknown I/O system object ", sys2) + + # Make sure number of input and outputs match + if sys1.ninputs != sys2.ninputs or sys1.noutputs != sys2.noutputs: + raise ValueError("Can't add systems with incompatible numbers of " + "inputs or outputs.") + ninputs = sys1.ninputs + noutputs = sys1.noutputs + + # Create a new system to handle the composition + inplist = [[(0, i), (1, i)] for i in range(ninputs)] + outlist = [[(0, i), (1, i)] for i in range(noutputs)] + newsys = InterconnectedSystem( + (sys1, sys2), inplist=inplist, outlist=outlist) + + # If both systems are linear, create LinearICSystem + if isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): + ss_sys = StateSpace.__add__(sys2, sys1) + return LinearICSystem(newsys, ss_sys) + + # Return the newly created InterconnectedSystem + return newsys + + def __radd__(sys1, sys2): + """Parallel addition of input/output system to a compatible object.""" + from .statesp import StateSpace, LinearIOSystem, LinearICSystem + from .xferfcn import TransferFunction + + # Convert sys2 to an I/O system if needed + if isinstance(sys2, (int, float, np.number)): + sys2 = LinearIOSystem(StateSpace( + [], [], [], sys2 * np.eye(sys1.noutputs))) + + elif isinstance(sys2, np.ndarray): + sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) + + elif isinstance(sys2, (StateSpace, TransferFunction)) and \ + not isinstance(sys2, LinearIOSystem): + sys2 = LinearIOSystem(sys2) + + elif not isinstance(sys2, InputOutputSystem): + raise TypeError("Unknown I/O system object ", sys2) + + return InputOutputSystem.__add__(sys2, sys1) + + def __sub__(sys1, sys2): + """Subtract two input/output systems (parallel interconnection)""" + from .statesp import StateSpace, LinearIOSystem, LinearICSystem + from .xferfcn import TransferFunction + + # Convert sys1 to an I/O system if needed + if isinstance(sys2, (int, float, np.number)): + sys2 = LinearIOSystem(StateSpace( + [], [], [], sys2 * np.eye(sys1.ninputs))) + + elif isinstance(sys2, np.ndarray): + sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) + + elif isinstance(sys2, (StateSpace, TransferFunction)) and \ + not isinstance(sys2, LinearIOSystem): + sys2 = LinearIOSystem(sys2) + + elif not isinstance(sys2, InputOutputSystem): + raise TypeError("Unknown I/O system object ", sys2) + + # Make sure number of input and outputs match + if sys1.ninputs != sys2.ninputs or sys1.noutputs != sys2.noutputs: + raise ValueError("Can't add systems with incompatible numbers of " + "inputs or outputs.") + ninputs = sys1.ninputs + noutputs = sys1.noutputs + + # Create a new system to handle the composition + inplist = [[(0, i), (1, i)] for i in range(ninputs)] + outlist = [[(0, i), (1, i, -1)] for i in range(noutputs)] + newsys = InterconnectedSystem( + (sys1, sys2), inplist=inplist, outlist=outlist) + + # If both systems are linear, create LinearICSystem + if isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): + ss_sys = StateSpace.__sub__(sys1, sys2) + return LinearICSystem(newsys, ss_sys) + + # Return the newly created InterconnectedSystem + return newsys + + def __rsub__(sys1, sys2): + """Parallel subtraction of I/O system to a compatible object.""" + from .statesp import StateSpace, LinearIOSystem, LinearICSystem + from .xferfcn import TransferFunction + + # Convert sys2 to an I/O system if needed + if isinstance(sys2, (int, float, np.number)): + sys2 = LinearIOSystem(StateSpace( + [], [], [], sys2 * np.eye(sys1.noutputs))) + + elif isinstance(sys2, np.ndarray): + sys2 = LinearIOSystem(StateSpace([], [], [], sys2)) + + elif isinstance(sys2, (StateSpace, TransferFunction)) and \ + not isinstance(sys2, LinearIOSystem): + sys2 = LinearIOSystem(sys2) + + elif not isinstance(sys2, InputOutputSystem): + raise TypeError("Unknown I/O system object ", sys2) + + return InputOutputSystem.__sub__(sys2, sys1) + + def __neg__(sys): + """Negate an input/output systems (rescale)""" + from .statesp import StateSpace, LinearIOSystem, LinearICSystem + from .xferfcn import TransferFunction + + if sys.ninputs is None or sys.noutputs is None: + raise ValueError("Can't determine number of inputs or outputs") + + # Create a new system to hold the negation + inplist = [(0, i) for i in range(sys.ninputs)] + outlist = [(0, i, -1) for i in range(sys.noutputs)] + newsys = InterconnectedSystem( + (sys,), dt=sys.dt, inplist=inplist, outlist=outlist) + + # If the system is linear, create LinearICSystem + if isinstance(sys, StateSpace): + ss_sys = StateSpace.__neg__(sys) + return LinearICSystem(newsys, ss_sys) + + # Return the newly created system + return newsys + + def __truediv__(sys2, sys1): + """Division of input/output systems + + Only division by scalars and arrays of scalars is supported""" + from .lti import LTI + + # Note: order of arguments is flipped so that self = sys2, + # corresponding to the ordering convention of sys2 * sys1 + + if not isinstance(sys1, (LTI, NamedIOSystem)): + return sys2 * (1/sys1) + else: + return NotImplemented + + + # Update parameters used for _rhs, _out (used by subclasses) + def _update_params(self, params, warning=False): + if warning: + warn("Parameters passed to InputOutputSystem ignored.") + + def _rhs(self, t, x, u): + """Evaluate right hand side of a differential or difference equation. + + Private function used to compute the right hand side of an + input/output system model. Intended for fast + evaluation; for a more user-friendly interface + you may want to use :meth:`dynamics`. + + """ + raise NotImplementedError("Evaluation not implemented for system of type ", + type(self)) + + def dynamics(self, t, x, u, params=None): + """Compute the dynamics of a differential or difference equation. + + Given time `t`, input `u` and state `x`, returns the value of the + right hand side of the dynamical system. If the system is continuous, + returns the time derivative + + dx/dt = f(t, x, u[, params]) + + where `f` is the system's (possibly nonlinear) dynamics function. + If the system is discrete-time, returns the next value of `x`: + + x[t+dt] = f(t, x[t], u[t][, params]) + + where `t` is a scalar. + + The inputs `x` and `u` must be of the correct length. The `params` + argument is an optional dictionary of parameter values. + + Parameters + ---------- + t : float + the time at which to evaluate + x : array_like + current state + u : array_like + input + params : dict (optional) + system parameter values + + Returns + ------- + dx/dt or x[t+dt] : ndarray + """ + self._update_params(params) + return self._rhs(t, x, u) + + def _out(self, t, x, u): + """Evaluate the output of a system at a given state, input, and time + + Private function used to compute the output of of an input/output + system model given the state, input, parameters. Intended for fast + evaluation; for a more user-friendly interface you may want to use + :meth:`output`. + + """ + # If no output function was defined in subclass, return state + return x + + def output(self, t, x, u, params=None): + """Compute the output of the system + + Given time `t`, input `u` and state `x`, returns the output of the + system: + + y = g(t, x, u[, params]) + + The inputs `x` and `u` must be of the correct length. + + Parameters + ---------- + t : float + the time at which to evaluate + x : array_like + current state + u : array_like + input + params : dict (optional) + system parameter values + + Returns + ------- + y : ndarray + """ + self._update_params(params) + return self._out(t, x, u) + + def feedback(self, other=1, sign=-1, params=None): + """Feedback interconnection between two input/output systems + + Parameters + ---------- + sys1: InputOutputSystem + The primary process. + sys2: InputOutputSystem + The feedback process (often a feedback controller). + sign: scalar, optional + The sign of feedback. `sign` = -1 indicates negative feedback, + and `sign` = 1 indicates positive feedback. `sign` is an optional + argument; it assumes a value of -1 if not specified. + + Returns + ------- + out: InputOutputSystem + + Raises + ------ + ValueError + if the inputs, outputs, or timebases of the systems are + incompatible. + + """ + from .statesp import StateSpace, LinearIOSystem, LinearICSystem, \ + _convert_to_statespace + + # TODO: add conversion to I/O system when needed + if not isinstance(other, InputOutputSystem): + # Try converting to a state space system + try: + other = _convert_to_statespace(other) + except TypeError: + raise TypeError( + "Feedback around I/O system must be an I/O system " + "or convertable to an I/O system.") + other = LinearIOSystem(other) + + # Make sure systems can be interconnected + if self.noutputs != other.ninputs or other.noutputs != self.ninputs: + raise ValueError("Can't connect systems with incompatible " + "inputs and outputs") + + # Make sure timebases are compatible + dt = common_timebase(self.dt, other.dt) + + inplist = [(0, i) for i in range(self.ninputs)] + outlist = [(0, i) for i in range(self.noutputs)] + + # Return the series interconnection between the systems + newsys = InterconnectedSystem( + (self, other), inplist=inplist, outlist=outlist, + params=params, dt=dt) + + # Set up the connecton map manually + newsys.set_connect_map(np.block( + [[np.zeros((self.ninputs, self.noutputs)), + sign * np.eye(self.ninputs, other.noutputs)], + [np.eye(other.ninputs, self.noutputs), + np.zeros((other.ninputs, other.noutputs))]] + )) + + if isinstance(self, StateSpace) and isinstance(other, StateSpace): + # Special case: maintain linear systems structure + ss_sys = StateSpace.feedback(self, other, sign=sign) + return LinearICSystem(newsys, ss_sys) + + # Return the newly created system + return newsys + + def linearize(self, x0, u0, t=0, params=None, eps=1e-6, + name=None, copy_names=False, **kwargs): + """Linearize an input/output system at a given state and input. + + Return the linearization of an input/output system at a given state + and input value as a StateSpace system. See + :func:`~control.linearize` for complete documentation. + + """ + from .statesp import StateSpace, LinearIOSystem + + # + # If the linearization is not defined by the subclass, perform a + # numerical linearization use the `_rhs()` and `_out()` member + # functions. + # + + # If x0 and u0 are specified as lists, concatenate the elements + x0 = _concatenate_list_elements(x0, 'x0') + u0 = _concatenate_list_elements(u0, 'u0') + + # Figure out dimensions if they were not specified. + nstates = _find_size(self.nstates, x0) + ninputs = _find_size(self.ninputs, u0) + + # Convert x0, u0 to arrays, if needed + if np.isscalar(x0): + x0 = np.ones((nstates,)) * x0 + if np.isscalar(u0): + u0 = np.ones((ninputs,)) * u0 + + # Compute number of outputs by evaluating the output function + noutputs = _find_size(self.noutputs, self._out(t, x0, u0)) + + # Update the current parameters + self._update_params(params) + + # Compute the nominal value of the update law and output + F0 = self._rhs(t, x0, u0) + H0 = self._out(t, x0, u0) + + # Create empty matrices that we can fill up with linearizations + A = np.zeros((nstates, nstates)) # Dynamics matrix + B = np.zeros((nstates, ninputs)) # Input matrix + C = np.zeros((noutputs, nstates)) # Output matrix + D = np.zeros((noutputs, ninputs)) # Direct term + + # Perturb each of the state variables and compute linearization + for i in range(nstates): + dx = np.zeros((nstates,)) + dx[i] = eps + A[:, i] = (self._rhs(t, x0 + dx, u0) - F0) / eps + C[:, i] = (self._out(t, x0 + dx, u0) - H0) / eps + + # Perturb each of the input variables and compute linearization + for i in range(ninputs): + du = np.zeros((ninputs,)) + du[i] = eps + B[:, i] = (self._rhs(t, x0, u0 + du) - F0) / eps + D[:, i] = (self._out(t, x0, u0 + du) - H0) / eps + + # Create the state space system + linsys = LinearIOSystem( + StateSpace(A, B, C, D, self.dt, remove_useless_states=False)) + + # Set the system name, inputs, outputs, and states + if 'copy' in kwargs: + copy_names = kwargs.pop('copy') + warn("keyword 'copy' is deprecated. please use 'copy_names'", + DeprecationWarning) + + if copy_names: + linsys._copy_names(self, prefix_suffix_name='linearized') + if name is not None: + linsys.name = name + + # re-init to include desired signal names if names were provided + return LinearIOSystem(linsys, **kwargs) + + +class NonlinearIOSystem(InputOutputSystem): + """Nonlinear I/O system. + + Creates an :class:`~control.InputOutputSystem` for a nonlinear system by + specifying a state update function and an output function. The new system + can be a continuous or discrete time system (Note: discrete-time systems + are not yet supported by most functions.) + + Parameters + ---------- + updfcn : callable + Function returning the state update function + + `updfcn(t, x, u, params) -> array` + + where `x` is a 1-D array with shape (nstates,), `u` is a 1-D array + with shape (ninputs,), `t` is a float representing the currrent + time, and `params` is a dict containing the values of parameters + used by the function. + + outfcn : callable + Function returning the output at the given state + + `outfcn(t, x, u, params) -> array` + + where the arguments are the same as for `upfcn`. + + inputs : int, list of str or None, optional + 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`). If + this parameter is not given or given as `None`, the relevant + quantity will be determined when possible based on other + information provided to functions using the system. + + 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`. + + 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 + following values: + + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified + + 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. + + See Also + -------- + InputOutputSystem : Input/output system class. + + """ + def __init__(self, updfcn, outfcn=None, params=None, **kwargs): + """Create a nonlinear I/O system given update and output functions.""" + # Process keyword arguments + name, inputs, outputs, states, dt = _process_namedio_keywords( + kwargs, end=True) + + # Initialize the rest of the structure + super().__init__( + inputs=inputs, outputs=outputs, states=states, + params=params, dt=dt, name=name + ) + + # Store the update and output functions + self.updfcn = updfcn + self.outfcn = outfcn + + # Check to make sure arguments are consistent + if updfcn is None: + if self.nstates is None: + self.nstates = 0 + else: + raise ValueError("States specified but no update function " + "given.") + if outfcn is None: + # No output function specified => outputs = states + if self.noutputs is None and self.nstates is not None: + self.noutputs = self.nstates + elif self.noutputs is not None and self.noutputs == self.nstates: + # Number of outputs = number of states => all is OK + pass + elif self.noutputs is not None and self.noutputs != 0: + raise ValueError("Outputs specified but no output function " + "(and nstates not known).") + + # Initialize current parameters to default parameters + self._current_params = {} if params is None else 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 + + If a nonlinear I/O system has no internal state, then evaluating the + system at an input `u` gives the output `y = F(u)`, determined by the + output function. + + Parameters + ---------- + params : dict, optional + Parameter values for the system. Passed to the evaluation function + for the system as default values, overriding internal defaults. + squeeze : bool, optional + If True and if the system has a single output, return the system + output as a 1D array rather than a 2D array. If False, return the + system output as a 2D array even if the system is SISO. Default + value set by config.defaults['control.squeeze_time_response']. + + """ + from .timeresp import _process_time_response + + # Make sure the call makes sense + if not sys._isstatic(): + raise TypeError( + "function evaluation is only supported for static " + "input/output systems") + + # If we received any parameters, update them before calling _out() + if params is not None: + sys._update_params(params) + + # Evaluate the function on the argument + out = sys._out(0, np.array((0,)), np.asarray(u)) + _, out = _process_time_response( + None, out, issiso=sys.issiso(), squeeze=squeeze) + return out + + def _update_params(self, params, warning=False): + # Update the current parameter values + self._current_params = self.params.copy() + if params: + self._current_params.update(params) + + def _rhs(self, t, x, u): + xdot = self.updfcn(t, x, u, self._current_params) \ + if self.updfcn is not None else [] + return np.array(xdot).reshape((-1,)) + + def _out(self, t, x, u): + y = self.outfcn(t, x, u, self._current_params) \ + if self.outfcn is not None else x + return np.array(y).reshape((-1,)) + + +class InterconnectedSystem(InputOutputSystem): + """Interconnection of a set of input/output systems. + + This class is used to implement a system that is an interconnection of + input/output systems. The sys consists of a collection of subsystems + whose inputs and outputs are connected via a connection map. The overall + system inputs and outputs are subsets of the subsystem inputs and outputs. + + The function :func:`~control.interconnect` should be used to create an + interconnected I/O system since it performs additional argument + processing and checking. + + """ + def __init__(self, syslist, connections=None, inplist=None, outlist=None, + params=None, warn_duplicate=None, **kwargs): + """Create an I/O system from a list of systems + connection info.""" + from .statesp import _convert_to_statespace + from .statesp import StateSpace, LinearIOSystem, LinearICSystem + from .xferfcn import TransferFunction + + # Convert input and output names to lists if they aren't already + if inplist is not None and not isinstance(inplist, list): + inplist = [inplist] + if outlist is not None and not isinstance(outlist, list): + outlist = [outlist] + + # Check if dt argument was given; if not, pull from systems + dt = kwargs.pop('dt', None) + + # Process keyword arguments (except dt) + name, inputs, outputs, states, _ = _process_namedio_keywords(kwargs) + + # Initialize the system list and index + self.syslist = list(syslist) # insure modifications can be made + self.syslist_index = {} + + # 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(self.syslist): + # If we were passed a SS or TF system, convert to LinearIOSystem + if isinstance(sys, (StateSpace, TransferFunction)) and \ + not isinstance(sys, LinearIOSystem): + sys = LinearIOSystem(sys, name=sys.name) + self.syslist[sysidx] = sys + + # Make sure time bases are consistent + dt = common_timebase(dt, sys.dt) + + # Make sure number of inputs, outputs, states is given + if sys.ninputs is None or sys.noutputs is None or \ + sys.nstates is None: + raise TypeError("System '%s' must define number of inputs, " + "outputs, states in order to be connected" % + sys.name) + + # Keep track of the offsets into the states, inputs, outputs + self.input_offset.append(ninputs) + self.output_offset.append(noutputs) + self.state_offset.append(nstates) + + # Keep track of the total number of states, inputs, outputs + nstates += sys.nstates + ninputs += sys.ninputs + noutputs += sys.noutputs + + # Check for duplicate systems or duplicate names + # Duplicates are renamed sysname_1, sysname_2, etc. + if sys in sysobj_name_dct: + # 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 + + 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 + self.syslist_index[sys.name] = sysidx + + if states is None: + states = [] + state_name_delim = config.defaults['namedio.state_name_delim'] + for sys, sysname in sysobj_name_dct.items(): + 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}") + + # Figure out what the inputs and outputs are + if inputs is None and inplist is not None: + inputs = len(inplist) + + if outputs is None and outlist is not None: + outputs = len(outlist) + + # Create the I/O system + # 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, **kwargs) + + # Convert the list of interconnections to a connection map (matrix) + self.connect_map = np.zeros((ninputs, noutputs)) + for connection in connections or []: + input_indices = self._parse_input_spec(connection[0]) + for output_spec in connection[1:]: + output_indices, gain = self._parse_output_spec(output_spec) + if len(output_indices) != len(input_indices): + raise ValueError( + f"inconsistent number of signals in connecting" + f" '{output_spec}' to '{connection[0]}'") + + for input_index, output_index in zip( + input_indices, output_indices): + if self.connect_map[input_index, output_index] != 0: + warn("multiple connections given for input %d" % + input_index + ". Combining with previous entries.") + self.connect_map[input_index, output_index] += gain + + # Convert the input list to a matrix: maps system to subsystems + self.input_map = np.zeros((ninputs, self.ninputs)) + for index, inpspec in enumerate(inplist or []): + if isinstance(inpspec, (int, str, tuple)): + inpspec = [inpspec] + if not isinstance(inpspec, list): + raise ValueError("specifications in inplist must be of type " + "int, str, tuple or list.") + for spec in inpspec: + ulist_indices = self._parse_input_spec(spec) + for j, ulist_index in enumerate(ulist_indices): + if self.input_map[ulist_index, index] != 0: + warn("multiple connections given for input %d" % + index + ". Combining with previous entries.") + self.input_map[ulist_index, index + j] += 1 + + # Convert the output list to a matrix: maps subsystems to system + self.output_map = np.zeros((self.noutputs, noutputs + ninputs)) + for index, outspec in enumerate(outlist or []): + if isinstance(outspec, (int, str, tuple)): + outspec = [outspec] + if not isinstance(outspec, list): + raise ValueError("specifications in outlist must be of type " + "int, str, tuple or list.") + for spec in outspec: + ylist_indices, gain = self._parse_output_spec(spec) + for j, ylist_index in enumerate(ylist_indices): + if self.output_map[index, ylist_index] != 0: + warn("multiple connections given for output %d" % + index + ". Combining with previous entries.") + self.output_map[index + j, ylist_index] += gain + + def _update_params(self, params, warning=False): + for sys in self.syslist: + local = sys.params.copy() # start with system parameters + local.update(self.params) # update with global params + if params: + local.update(params) # update with locally passed parameters + sys._update_params(local, warning=warning) + + def _rhs(self, t, x, u): + # Make sure state and input are vectors + x = np.array(x, ndmin=1) + u = np.array(u, ndmin=1) + + # Compute the input and output vectors + ulist, ylist = self._compute_static_io(t, x, u) + + # Go through each system and update the right hand side for that system + xdot = np.zeros((self.nstates,)) # Array to hold results + state_index, input_index = 0, 0 # Start at the beginning + for sys in self.syslist: + # Update the right hand side for this subsystem + if sys.nstates != 0: + xdot[state_index:state_index + sys.nstates] = sys._rhs( + t, x[state_index:state_index + sys.nstates], + ulist[input_index:input_index + sys.ninputs]) + + # Update the state and input index counters + state_index += sys.nstates + input_index += sys.ninputs + + return xdot + + def _out(self, t, x, u): + # Make sure state and input are vectors + x = np.array(x, ndmin=1) + u = np.array(u, ndmin=1) + + # Compute the input and output vectors + ulist, ylist = self._compute_static_io(t, x, u) + + # Make the full set of subsystem outputs to system output + return self.output_map @ ylist + + def _compute_static_io(self, t, x, u): + # Figure out the total number of inputs and outputs + (ninputs, noutputs) = self.connect_map.shape + + # + # Get the outputs and inputs at the current system state + # + + # Initialize the lists used to keep track of internal signals + ulist = np.dot(self.input_map, u) + ylist = np.zeros((noutputs + ninputs,)) + + # To allow for feedthrough terms, iterate multiple times to allow + # feedthrough elements to propagate. For n systems, we could need to + # cycle through n+1 times before reaching steady state + # TODO (later): see if there is a more efficient way to compute + cycle_count = len(self.syslist) + 1 + while cycle_count > 0: + state_index, input_index, output_index = 0, 0, 0 + for sys in self.syslist: + # Compute outputs for each system from current state + ysys = sys._out( + t, x[state_index:state_index + sys.nstates], + ulist[input_index:input_index + sys.ninputs]) + + # Store the outputs at the start of ylist + ylist[output_index:output_index + sys.noutputs] = \ + ysys.reshape((-1,)) + + # Store the input in the second part of ylist + ylist[noutputs + input_index: + noutputs + input_index + sys.ninputs] = \ + ulist[input_index:input_index + sys.ninputs] + + # Increment the index pointers + state_index += sys.nstates + input_index += sys.ninputs + output_index += sys.noutputs + + # Compute inputs based on connection map + new_ulist = self.connect_map @ ylist[:noutputs] \ + + np.dot(self.input_map, u) + + # Check to see if any of the inputs changed + if (ulist == new_ulist).all(): + break + else: + ulist = new_ulist + + # Decrease the cycle counter + cycle_count -= 1 + + # Make sure that we stopped before detecting an algebraic loop + if cycle_count == 0: + raise RuntimeError("Algebraic loop detected.") + + return ulist, ylist + + def _parse_input_spec(self, spec): + """Parse an input specification and returns the indices.""" + # Parse the signal that we received + subsys_index, input_indices, gain = _parse_spec( + self.syslist, spec, 'input') + if gain != 1: + raise ValueError("gain not allowed in spec '%s'." % str(spec)) + + # Return the indices into the input vector list (ylist) + return [self.input_offset[subsys_index] + i for i in input_indices] + + def _parse_output_spec(self, spec): + """Parse an output specification and returns the indices and gain.""" + # Parse the rest of the spec with standard signal parsing routine + try: + # Start by looking in the set of subsystem outputs + subsys_index, output_indices, gain = \ + _parse_spec(self.syslist, spec, 'output') + output_offset = self.output_offset[subsys_index] + + except ValueError: + # Try looking in the set of subsystem *inputs* + subsys_index, output_indices, gain = _parse_spec( + self.syslist, spec, 'input or output', dictname='input_index') + + # Return the index into the input vector list (ylist) + output_offset = sum(sys.noutputs for sys in self.syslist) + \ + self.input_offset[subsys_index] + + return [output_offset + i for i in output_indices], gain + + def _find_system(self, name): + return self.syslist_index.get(name, None) + + def set_connect_map(self, connect_map): + """Set the connection map for an interconnected I/O system. + + Parameters + ---------- + connect_map : 2D array + Specify the matrix that will be used to multiply the vector of + subsystem outputs to obtain the vector of subsystem inputs. + + """ + # Make sure the connection map is the right size + if connect_map.shape != self.connect_map.shape: + ValueError("Connection map is not the right shape") + self.connect_map = connect_map + + def set_input_map(self, input_map): + """Set the input map for an interconnected I/O system. + + Parameters + ---------- + input_map : 2D array + Specify the matrix that will be used to multiply the vector of + system inputs to obtain the vector of subsystem inputs. These + values are added to the inputs specified in the connection map. + + """ + # Figure out the number of internal inputs + ninputs = sum(sys.ninputs for sys in self.syslist) + + # Make sure the input map is the right size + if input_map.shape[0] != ninputs: + ValueError("Input map is not the right shape") + self.input_map = input_map + self.ninputs = input_map.shape[1] + + def set_output_map(self, output_map): + """Set the output map for an interconnected I/O system. + + Parameters + ---------- + output_map : 2D array + Specify the matrix that will be used to multiply the vector of + subsystem outputs concatenated with subsystem inputs to obtain + the vector of system outputs. + + """ + # Figure out the number of internal inputs and outputs + ninputs = sum(sys.ninputs for sys in self.syslist) + noutputs = sum(sys.noutputs for sys in self.syslist) + + # Make sure the output map is the right size + if output_map.shape[1] == noutputs: + # For backward compatibility, add zeros to the end of the array + output_map = np.concatenate( + (output_map, + np.zeros((output_map.shape[0], ninputs))), + axis=1) + + if output_map.shape[1] != noutputs + ninputs: + ValueError("Output map is not the right shape") + self.output_map = output_map + self.noutputs = output_map.shape[0] + + def unused_signals(self): + """Find unused subsystem inputs and outputs + + Returns + ------- + + unused_inputs : dict + A mapping from tuple of indices (isys, isig) to string + '{sys}.{sig}', for all unused subsystem inputs. + + unused_outputs : dict + A mapping from tuple of indices (osys, osig) to string + '{sys}.{sig}', for all unused subsystem outputs. + + """ + used_sysinp_via_inp = np.nonzero(self.input_map)[0] + used_sysout_via_out = np.nonzero(self.output_map)[1] + used_sysinp_via_con, used_sysout_via_con = np.nonzero(self.connect_map) + + used_sysinp = set(used_sysinp_via_inp) | set(used_sysinp_via_con) + used_sysout = set(used_sysout_via_out) | set(used_sysout_via_con) + + nsubsysinp = sum(sys.ninputs for sys in self.syslist) + nsubsysout = sum(sys.noutputs for sys in self.syslist) + + unused_sysinp = sorted(set(range(nsubsysinp)) - used_sysinp) + unused_sysout = sorted(set(range(nsubsysout)) - used_sysout) + + inputs = [(isys, isig, f'{sys.name}.{sig}') + for isys, sys in enumerate(self.syslist) + for sig, isig in sys.input_index.items()] + + outputs = [(isys, isig, f'{sys.name}.{sig}') + for isys, sys in enumerate(self.syslist) + for sig, isig in sys.output_index.items()] + + return ({inputs[i][:2]: inputs[i][2] for i in unused_sysinp}, + {outputs[i][:2]: outputs[i][2] for i in unused_sysout}) + + def _find_inputs_by_basename(self, basename): + """Find all subsystem inputs matching basename + + Returns + ------- + Mapping from (isys, isig) to '{sys}.{sig}' + + """ + return {(isys, isig): f'{sys.name}.{basename}' + for isys, sys in enumerate(self.syslist) + for sig, isig in sys.input_index.items() + if sig == (basename)} + + def _find_outputs_by_basename(self, basename): + """Find all subsystem outputs matching basename + + Returns + ------- + Mapping from (isys, isig) to '{sys}.{sig}' + + """ + return {(isys, isig): f'{sys.name}.{basename}' + for isys, sys in enumerate(self.syslist) + for sig, isig in sys.output_index.items() + if sig == (basename)} + + def check_unused_signals( + self, ignore_inputs=None, ignore_outputs=None, warning=True): + """Check for unused subsystem inputs and outputs + + Check to see if there are any unused signals and return a list of + unused input and output signal descriptions. If `warning` is True + and any unused inputs or outputs are found, emit a warning. + + Parameters + ---------- + ignore_inputs : list of input-spec + Subsystem inputs known to be unused. input-spec can be any of: + 'sig', 'sys.sig', (isys, isig), ('sys', isig) + + If the 'sig' form is used, all subsystem inputs with that + name are considered ignored. + + ignore_outputs : list of output-spec + Subsystem outputs known to be unused. output-spec can be any of: + 'sig', 'sys.sig', (isys, isig), ('sys', isig) + + If the 'sig' form is used, all subsystem outputs with that + name are considered ignored. + + Returns + ------- + dropped_inputs: list of tuples + A list of the dropped input signals, with each element of the + list in the form of (isys, isig). + + dropped_outputs: list of tuples + A list of the dropped output signals, with each element of the + list in the form of (osys, osig). + + """ + + if ignore_inputs is None: + ignore_inputs = [] + + if ignore_outputs is None: + ignore_outputs = [] + + unused_inputs, unused_outputs = self.unused_signals() + + # (isys, isig) -> signal-spec + ignore_input_map = {} + for ignore_input in ignore_inputs: + if isinstance(ignore_input, str) and '.' not in ignore_input: + ignore_idxs = self._find_inputs_by_basename(ignore_input) + if not ignore_idxs: + raise ValueError("Couldn't find ignored input " + f"{ignore_input} in subsystems") + ignore_input_map.update(ignore_idxs) + else: + isys, isigs = _parse_spec( + self.syslist, ignore_input, 'input')[:2] + for isig in isigs: + ignore_input_map[(isys, isig)] = ignore_input + + # (osys, osig) -> signal-spec + ignore_output_map = {} + for ignore_output in ignore_outputs: + if isinstance(ignore_output, str) and '.' not in ignore_output: + ignore_found = self._find_outputs_by_basename(ignore_output) + if not ignore_found: + raise ValueError("Couldn't find ignored output " + f"{ignore_output} in subsystems") + ignore_output_map.update(ignore_found) + else: + osys, osigs = _parse_spec( + self.syslist, ignore_output, 'output')[:2] + for osig in osigs: + ignore_output_map[(osys, osig)] = ignore_output + + dropped_inputs = set(unused_inputs) - set(ignore_input_map) + dropped_outputs = set(unused_outputs) - set(ignore_output_map) + + used_ignored_inputs = set(ignore_input_map) - set(unused_inputs) + used_ignored_outputs = set(ignore_output_map) - set(unused_outputs) + + if warning and dropped_inputs: + msg = ('Unused input(s) in InterconnectedSystem: ' + + '; '.join(f'{inp}={unused_inputs[inp]}' + for inp in dropped_inputs)) + warn(msg) + + if warning and dropped_outputs: + msg = ('Unused output(s) in InterconnectedSystem: ' + + '; '.join(f'{out} : {unused_outputs[out]}' + for out in dropped_outputs)) + warn(msg) + + if warning and used_ignored_inputs: + msg = ('Input(s) specified as ignored is (are) used: ' + + '; '.join(f'{inp} : {ignore_input_map[inp]}' + for inp in used_ignored_inputs)) + warn(msg) + + if warning and used_ignored_outputs: + msg = ('Output(s) specified as ignored is (are) used: ' + + '; '.join(f'{out}={ignore_output_map[out]}' + for out in used_ignored_outputs)) + warn(msg) + + return dropped_inputs, dropped_outputs + + +def input_output_response( + sys, T, U=0., X0=0, params=None, + transpose=False, return_x=False, squeeze=None, + solve_ivp_kwargs=None, 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 + and state values. + + Parameters + ---------- + sys : InputOutputSystem + Input/output system to simulate. + + T : array-like + Time steps at which the input is defined; values must be evenly spaced. + + U : array-like, 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, 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. + + t_eval : array-list, optional + List of times at which the time response should be computed. + Defaults to ``T``. + + return_x : bool, optional + If True, return the state vector when assigning to a tuple (default = + False). See :func:`forced_response` for more details. + If True, return the values of the state at each time (default = False). + + squeeze : bool, optional + If True and if the system has a single output, return the system + output as a 1D array rather than a 2D array. If False, return the + system output as a 2D array even if the system is SISO. Default value + set by 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 2D (indexed + by output and time). + + * states (array): Time evolution of the state vector, represented as + a 2D array indexed by state and time. + + * inputs (array): Input(s) to the system, indexed by input and time. + + The return value of the system can also be accessed by assigning the + function to a tuple of length 2 (time, output) or of length 3 (time, + output, state) if ``return_x`` is ``True``. If the input/output + system signals are named, these names will be used as labels for the + time response. + + Other parameters + ---------------- + solve_ivp_method : str, optional + Set the method used by :func:`scipy.integrate.solve_ivp`. Defaults + to 'RK45'. + solve_ivp_kwargs : dict, optional + Pass additional keywords to :func:`scipy.integrate.solve_ivp`. + + Raises + ------ + TypeError + If the system is not an input/output system. + 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. + + 2. If discontinuous inputs are given, the underlying SciPy numerical + integration algorithms can sometimes produce erroneous results due + to the default tolerances that are used. The `ivp_method` and + `ivp_keywords` parameters can be used to tune the ODE solver and + produce better results. In particular, using 'LSODA' as the + `ivp_method` or setting the `rtol` parameter to a smaller value + (e.g. using `ivp_kwargs={'rtol': 1e-4}`) can provide more accurate + results. + + """ + from .timeresp import _check_convert_array, _process_time_response, \ + TimeResponseData + + # + # Process keyword arguments + # + + # Figure out the method to be used + solve_ivp_kwargs = solve_ivp_kwargs.copy() if solve_ivp_kwargs else {} + 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.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] + 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 + + 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 = [(ntimepts,), (1, ntimepts)] + else: + 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 + X0 = _concatenate_list_elements(X0, 'X0') + + # 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)]) + + # Compute the number of states + nstates = _find_size(sys.nstates, X0) + + # create X0 if not given, test if X0 has correct shape + X0 = _check_convert_array(X0, [(nstates,), (nstates, 1)], + 'Parameter ``X0``: ', squeeze=True) + + # 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 + + # Update the parameter values + sys._update_params(params) + + # + # Define a function to evaluate the input at an arbitrary time + # + # This is equivalent to the function + # + # ufun = sp.interpolate.interp1d(T, U, fill_value='extrapolate') + # + # but has a lot less overhead => simulation runs much faster + def ufun(t): + # Find the value of the index using linear interpolation + # Use clip to allow for extrapolation if t is out of range + idx = np.clip(np.searchsorted(T, t, side='left'), 1, len(T)-1) + 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_labels, input_labels=sys.input_labels, + transpose=transpose, return_x=return_x, squeeze=squeeze) + + # Create a lambda function for the right hand side + def ivp_rhs(t, x): + return sys._rhs(t, x, ufun(t)) + + # Perform the simulation + if isctime(sys): + if not hasattr(sp.integrate, 'solve_ivp'): + 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_eval, + vectorized=False, **solve_ivp_kwargs) + if not soln.success: + raise RuntimeError("solve_ivp failed: " + soln.message) + + # 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_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: + # Make sure that the time increment is a multiple of sampling time + + # TODO: add back functionality for undersampling + # TODO: this test is brittle if dt = sys.dt + # First make sure that time increment is bigger than sampling time + # if dt < sys.dt: + # raise ValueError("Time steps ``T`` must match sampling time") + + # Check to make sure sampling time matches time increments + if not np.isclose(dt, sys.dt): + raise ValueError("Time steps ``T`` must be equal to " + "sampling time") + + # Compute the solution + soln = sp.optimize.OptimizeResult() + soln.t = t_eval # Store the time vector directly + x = np.array(X0) # State vector (store as floats) + soln.y = [] # Solution, following scipy convention + u, y = [], [] # System input, output + for t in t_eval: + # Store the current input, state, and output + soln.y.append(x) + u.append(ufun(t)) + y.append(sys._out(t, x, u[-1])) + + # Update the state for the next iteration + 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 + + else: # Neither ctime or dtime?? + raise TypeError("Can't determine system type") + + return TimeResponseData( + soln.t, y, soln.y, u, issiso=sys.issiso(), + output_labels=sys.output_labels, input_labels=sys.input_labels, + state_labels=sys.state_labels, + transpose=transpose, return_x=return_x, squeeze=squeeze) + + +def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None, + iu=None, iy=None, ix=None, idx=None, dx0=None, + 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 + either input value or desired output value for the equilibrium point. + + Parameters + ---------- + x0 : list of initial state values + Initial guess for the value of the state near the equilibrium point. + u0 : list of input values, optional + If `y0` is not specified, sets the equilibrium value of the input. If + `y0` is given, provides an initial guess for the value of the input. + Can be omitted if the system does not have any inputs. + y0 : list of output values, optional + If specified, sets the desired values of the outputs at the + equilibrium point. + t : float, optional + Evaluation time, for time-varying systems + params : dict, optional + Parameter values for the system. Passed to the evaluation functions + for the system as default values, overriding internal defaults. + iu : list of input indices, optional + If specified, only the inputs with the given indices will be fixed at + the specified values in solving for an equilibrium point. All other + inputs will be varied. Input indices can be listed in any order. + iy : list of output indices, optional + If specified, only the outputs with the given indices will be fixed at + the specified values in solving for an equilibrium point. All other + outputs will be varied. Output indices can be listed in any order. + ix : list of state indices, optional + If specified, states with the given indices will be fixed at the + specified values in solving for an equilibrium point. All other + states will be varied. State indices can be listed in any order. + dx0 : list of update values, optional + If specified, the value of update map must match the listed value + instead of the default value of 0. + idx : list of state indices, optional + If specified, state updates with the given indices will have their + update maps fixed at the values given in `dx0`. All other update + values will be ignored in solving for an equilibrium point. State + indices can be listed in any order. By default, all updates will be + fixed at `dx0` in searching for an equilibrium point. + return_y : bool, optional + If True, return the value of output at the equilibrium point. + return_result : bool, optional + If True, return the `result` option from the + :func:`scipy.optimize.root` function used to compute the equilibrium + point. + + Returns + ------- + xeq : array of states + Value of the states at the equilibrium point, or `None` if no + equilibrium point was found and `return_result` was False. + ueq : array of input values + Value of the inputs at the equilibrium point, or `None` if no + equilibrium point was found and `return_result` was False. + yeq : array of output values, optional + If `return_y` is True, returns the value of the outputs at the + equilibrium point, or `None` if no equilibrium point was found and + `return_result` was False. + result : :class:`scipy.optimize.OptimizeResult`, optional + If `return_result` is True, returns the `result` from the + :func:`scipy.optimize.root` function. + + Notes + ----- + For continuous time systems, equilibrium points are defined as points for + which the right hand side of the differential equation is zero: + :math:`f(t, x_e, u_e) = 0`. For discrete time systems, equilibrium points + are defined as points for which the right hand side of the difference + equation returns the current state: :math:`f(t, x_e, u_e) = x_e`. + + """ + from scipy.optimize import root + + # Figure out the number of states, inputs, and outputs + nstates = _find_size(sys.nstates, x0) + ninputs = _find_size(sys.ninputs, u0) + noutputs = _find_size(sys.noutputs, y0) + + # Convert x0, u0, y0 to arrays, if needed + if np.isscalar(x0): + x0 = np.ones((nstates,)) * x0 + if np.isscalar(u0): + u0 = np.ones((ninputs,)) * u0 + if np.isscalar(y0): + y0 = np.ones((ninputs,)) * y0 + + # Make sure the input arguments match the sizes of the system + if len(x0) != nstates or \ + (u0 is not None and len(u0) != ninputs) or \ + (y0 is not None and len(y0) != noutputs) or \ + (dx0 is not None and len(dx0) != nstates): + raise ValueError("Length of input arguments does not match system.") + + # Update the parameter values + sys._update_params(params) + + # Decide what variables to minimize + if all([x is None for x in (iu, iy, ix, idx)]): + # Special cases: either inputs or outputs are constrained + if y0 is None: + # Take u0 as fixed and minimize over x + if sys.isdtime(strict=True): + def state_rhs(z): return sys._rhs(t, z, u0) - z + else: + def state_rhs(z): return sys._rhs(t, z, u0) + + result = root(state_rhs, x0) + z = (result.x, u0, sys._out(t, result.x, u0)) + + else: + # Take y0 as fixed and minimize over x and u + if sys.isdtime(strict=True): + def rootfun(z): + x, u = np.split(z, [nstates]) + return np.concatenate( + (sys._rhs(t, x, u) - x, sys._out(t, x, u) - y0), + axis=0) + else: + def rootfun(z): + x, u = np.split(z, [nstates]) + 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) # Find the eq point + x, u = np.split(result.x, [nstates]) # Split result back in two + z = (x, u, sys._out(t, x, u)) + + else: + # General case: figure out what variables to constrain + # Verify the indices we are using are all in range + if iu is not None: + iu = np.unique(iu) + if any([not isinstance(x, int) for x in iu]) or \ + (len(iu) > 0 and (min(iu) < 0 or max(iu) >= ninputs)): + assert ValueError("One or more input indices is invalid") + else: + iu = [] + + if iy is not None: + iy = np.unique(iy) + if any([not isinstance(x, int) for x in iy]) or \ + min(iy) < 0 or max(iy) >= noutputs: + assert ValueError("One or more output indices is invalid") + else: + iy = list(range(noutputs)) + + if ix is not None: + ix = np.unique(ix) + if any([not isinstance(x, int) for x in ix]) or \ + min(ix) < 0 or max(ix) >= nstates: + assert ValueError("One or more state indices is invalid") + else: + ix = [] + + if idx is not None: + idx = np.unique(idx) + if any([not isinstance(x, int) for x in idx]) or \ + min(idx) < 0 or max(idx) >= nstates: + assert ValueError("One or more deriv indices is invalid") + else: + idx = list(range(nstates)) + + # Construct the index lists for mapping variables and constraints + # + # The mechanism by which we implement the root finding function is to + # map the subset of variables we are searching over into the inputs + # and states, and then return a function that represents the equations + # we are trying to solve. + # + # To do this, we need to carry out the following operations: + # + # 1. Given the current values of the free variables (z), map them into + # the portions of the state and input vectors that are not fixed. + # + # 2. Compute the update and output maps for the input/output system + # and extract the subset of equations that should be equal to zero. + # + # We perform these functions by computing four sets of index lists: + # + # * state_vars: indices of states that are allowed to vary + # * input_vars: indices of inputs that are allowed to vary + # * deriv_vars: indices of derivatives that must be constrained + # * output_vars: indices of outputs that must be constrained + # + # This index lists can all be precomputed based on the `iu`, `iy`, + # `ix`, and `idx` lists that were passed as arguments to `find_eqpt` + # and were processed above. + + # Get the states and inputs that were not listed as fixed + state_vars = (range(nstates) if not len(ix) + else np.delete(np.array(range(nstates)), ix)) + input_vars = (range(ninputs) if not len(iu) + else np.delete(np.array(range(ninputs)), iu)) + + # Set the outputs and derivs that will serve as constraints + output_vars = np.array(iy) + deriv_vars = np.array(idx) + + # Verify that the number of degrees of freedom all add up correctly + num_freedoms = len(state_vars) + len(input_vars) + num_constraints = len(output_vars) + len(deriv_vars) + if num_constraints != num_freedoms: + warn("Number of constraints (%d) does not match number of degrees " + "of freedom (%d). Results may be meaningless." % + (num_constraints, num_freedoms)) + + # Make copies of the state and input variables to avoid overwriting + # and convert to floats (in case ints were used for initial conditions) + x = np.array(x0, dtype=float) + u = np.array(u0, dtype=float) + dx0 = np.array(dx0, dtype=float) if dx0 is not None \ + else np.zeros(x.shape) + + # Keep track of the number of states in the set of free variables + nstate_vars = len(state_vars) + + def rootfun(z): + # Map the vector of values into the states and inputs + x[state_vars] = z[:nstate_vars] + u[input_vars] = z[nstate_vars:] + + # Compute the update and output maps + dx = sys._rhs(t, x, u) - dx0 + if sys.isdtime(strict=True): + dx -= x + + # If no y0 is given, don't evaluate the output function + if y0 is None: + return dx[deriv_vars] + else: + dy = sys._out(t, x, u) - y0 + + # Map the results into the constrained variables + return np.concatenate((dx[deriv_vars], dy[output_vars]), axis=0) + + # Set the initial condition for the root finding algorithm + z0 = np.concatenate((x[state_vars], u[input_vars]), axis=0) + + # Finally, call the root finding function + result = root(rootfun, z0) + + # Extract out the results and insert into x and u + x[state_vars] = result.x[:nstate_vars] + u[input_vars] = result.x[nstate_vars:] + z = (x, u, sys._out(t, x, u)) + + # Return the result based on what the user wants and what we found + if not return_y: + z = z[0:2] # Strip y from result if not desired + if return_result: + # Return whatever we got, along with the result dictionary + return z + (result,) + elif result.success: + # Return the result of the optimization + return z + else: + # Something went wrong, don't return anything + return (None, None, None) if return_y else (None, None) + + +# Linearize an input/output system +def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): + """Linearize an input/output system at a given state and input. + + This function computes the linearization of an input/output system at a + given state and input value and returns a :class:`~control.StateSpace` + object. The evaluation point need not be an equilibrium point. + + Parameters + ---------- + sys : InputOutputSystem + The system to be linearized + xeq : array + The state at which the linearization will be evaluated (does not need + to be an equilibrium state). + ueq : array + The input at which the linearization will be evaluated (does not need + to correspond to an equlibrium state). + t : float, optional + The time at which the linearization will be computed (for time-varying + systems). + 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 + Set the name of the linearized system. If not specified and + if `copy_names` is `False`, a generic name is generated + with a unique integer id. If `copy_names` is `True`, the new system + name is determined by adding the prefix and suffix strings in + config.defaults['namedio.linearized_system_name_prefix'] and + config.defaults['namedio.linearized_system_name_suffix'], with the + default being to add the suffix '$linearized'. + copy_names : bool, Optional + If True, Copy the names of the input signals, output signals, and + states to the linearized system. + + Returns + ------- + ss_sys : LinearIOSystem + The linearization of the system, as a :class:`~control.LinearIOSystem` + object (which is also a :class:`~control.StateSpace` object. + + Other 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) + + +def _find_size(sysval, vecval): + """Utility function to find the size of a system parameter + + If both parameters are not None, they must be consistent. + """ + if hasattr(vecval, '__len__'): + if sysval is not None and sysval != len(vecval): + raise ValueError("Inconsistent information to determine size " + "of system component") + return len(vecval) + # None or 0, which is a valid value for "a (sysval, ) vector of zeros". + if not vecval: + return 0 if sysval is None else sysval + elif sysval == 1: + # (1, scalar) is also a valid combination from legacy code + return 1 + raise ValueError("Can't determine size of system component.") + + +# Function to create an interconnected system +def interconnect( + syslist, connections=None, inplist=None, outlist=None, params=None, + check_unused=True, add_unused=False, ignore_inputs=None, + ignore_outputs=None, warn_duplicate=None, debug=False, **kwargs): + """Interconnect a set of input/output systems. + + This function creates a new system that is an interconnection of a set of + input/output systems. If all of the input systems are linear I/O systems + (type :class:`~control.LinearIOSystem`) then the resulting system will be + a linear interconnected I/O system (type :class:`~control.LinearICSystem`) + with the appropriate inputs, outputs, and states. Otherwise, an + interconnected I/O system (type :class:`~control.InterconnectedSystem`) + will be created. + + Parameters + ---------- + syslist : list of InputOutputSystems + The list of input/output systems to be connected + + connections : list of connections, optional + Description of the internal connections between the subsystems: + + [connection1, connection2, ...] + + Each connection is itself a list that describes an input to one of the + subsystems. The entries are of the form: + + [input-spec, output-spec1, output-spec2, ...] + + The input-spec can be in a number of different forms. The lowest + level representation is a tuple of the form `(subsys_i, inp_j)` + where `subsys_i` is the index into `syslist` and `inp_j` is the + index into the input vector for the subsystem. If the signal index + is omitted, then all subsystem inputs are used. If systems and + signals are given names, then the forms 'sys.sig' or ('sys', 'sig') + are also recognized. Finally, for multivariable systems the signal + index can be given as a list, for example '(subsys_i, [inp_j1, ..., + inp_jn])'; as a slice, for example, 'sys.sig[i:j]'; or as a base + name `sys.sig` (which matches `sys.sig[i]`). + + Similarly, each output-spec should describe an output signal from + one of the subsystems. The lowest level representation is a tuple + of the form `(subsys_i, out_j, gain)`. The input will be + constructed by summing the listed outputs after multiplying by the + gain term. If the gain term is omitted, it is assumed to be 1. If + the subsystem index `subsys_i` is omitted, then all outputs of the + subsystem are used. If systems and signals are given names, then + the form 'sys.sig', ('sys', 'sig') or ('sys', 'sig', gain) are also + recognized, and the special form '-sys.sig' can be used to specify + a signal with gain -1. Lists, slices, and base namess can also be + used, as long as the number of elements for each output spec + mataches the input spec. + + If omitted, the `interconnect` function will attempt to create the + interconnection map by connecting all signals with the same base names + (ignoring the system name). Specifically, for each input signal name + in the list of systems, if that signal name corresponds to the output + signal in any of the systems, it will be connected to that input (with + a summation across all signals if the output name occurs in more than + one system). + + The `connections` keyword can also be set to `False`, which will leave + the connection map empty and it can be specified instead using the + low-level :func:`~control.InterconnectedSystem.set_connect_map` + method. + + inplist : list of input connections, optional + List of connections for how the inputs for the overall system are + mapped to the subsystem inputs. The input specification is similar to + the form defined in the connection specification, except that + connections do not specify an input-spec, since these are the system + inputs. The entries for a connection are thus of the form: + + [input-spec1, input-spec2, ...] + + Each system input is added to the input for the listed subsystem. + If the system input connects to a subsystem with a single input, a + single input specification can be given (without the inner list). + + If omitted the `input` parameter will be used to identify the list + of input signals to the overall system. + + outlist : list of output connections, optional + List of connections for how the outputs from the subsystems are + mapped to overall system outputs. The output connection + description is the same as the form defined in the inplist + specification (including the optional gain term). Numbered outputs + must be chosen from the list of subsystem outputs, but named + outputs can also be contained in the list of subsystem inputs. + + If an output connection contains more than one signal specification, + then those signals are added together (multiplying by the any gain + term) to form the system output. + + If omitted, the output map can be specified using the + :func:`~control.InterconnectedSystem.set_output_map` method. + + inputs : int, list of str or None, optional + 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`). If this parameter + is not given or given as `None`, the relevant quantity will be + determined when possible based on other information provided to + functions using the system. + + 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`. The + default is `None`, in which case the states will be given names of the + form '.', for each subsys in syslist and each + state_name of each subsys. + + 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 following + values: + + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified + + name : string, optional + System name (used for specifying signals). If unspecified, a generic + name is generated with a unique integer id. + + check_unused : bool, optional + If True, check for unused sub-system signals. This check is + not done if connections is False, and neither input nor output + mappings are specified. + + add_unused : bool, optional + If True, subsystem signals that are not connected to other components + are added as inputs and outputs of the interconnected system. + + ignore_inputs : list of input-spec, optional + A list of sub-system inputs known not to be connected. This is + *only* used in checking for unused signals, and does not + disable use of the input. + + Besides the usual input-spec forms (see `connections`), an + input-spec can be just the signal base name, in which case all + signals from all sub-systems with that base name are + considered ignored. + + ignore_outputs : list of output-spec, optional + A list of sub-system outputs known not to be connected. This + is *only* used in checking for unused signals, and does not + disable use of the output. + + Besides the usual output-spec forms (see `connections`), an + output-spec can be just the signal base name, in which all + outputs from all sub-systems with that base name are + considered ignored. + + warn_duplicate : None, True, or False, optional + 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. + + debug : bool, default=False + Print out information about how signals are being processed that + may be useful in understanding why something is not working. + + + Examples + -------- + >>> P = ct.rss(2, 2, 2, strictly_proper=True, name='P') + >>> C = ct.rss(2, 2, 2, name='C') + >>> T = ct.interconnect( + ... [P, C], + ... connections=[ + ... ['P.u[0]', 'C.y[0]'], ['P.u[1]', 'C.y[1]'], + ... ['C.u[0]', '-P.y[0]'], ['C.u[1]', '-P.y[1]']], + ... inplist=['C.u[0]', 'C.u[1]'], + ... outlist=['P.y[0]', 'P.y[1]'], + ... ) + + This expression can be simplified using either slice notation or + just signal basenames: + + >>> T = ct.interconnect( + ... [P, C], connections=[['P.u[:]', 'C.y[:]'], ['C.u', '-P.y']], + ... inplist='C.u', outlist='P.y[:]') + + or further simplified by omitting the input and output signal + specifications (since all inputs and outputs are used): + + >>> T = ct.interconnect( + ... [P, C], connections=[['P', 'C'], ['C', '-P']], + ... inplist=['C'], outlist=['P']) + + A feedback system can also be constructed using the + :func:`~control.summing_block` function and the ability to + automatically interconnect signals with the same names: + + >>> P = ct.tf(1, [1, 0], inputs='u', outputs='y') + >>> C = ct.tf(10, [1, 1], inputs='e', outputs='u') + >>> sumblk = ct.summing_junction(inputs=['r', '-y'], output='e') + >>> T = ct.interconnect([P, C, sumblk], inputs='r', outputs='y') + + Notes + ----- + If a system is duplicated in the list of systems to be connected, + a warning is generated and a copy of the system is created with the + name of the new system determined by adding the prefix and suffix + strings in config.defaults['namedio.linearized_system_name_prefix'] + and config.defaults['namedio.linearized_system_name_suffix'], with the + default being to add the suffix '$copy' to the system name. + + In addition to explicit lists of system signals, it is possible to + lists vectors of signals, using one of the following forms:: + + (subsys, [i1, ..., iN], gain) signals with indices i1, ..., in + 'sysname.signal[i:j]' range of signal names, i through j-1 + 'sysname.signal[:]' all signals with given prefix + + While in many Python functions tuples can be used in place of lists, + for the interconnect() function the only use of tuples should be in the + specification of an input- or output-signal via the tuple notation + `(subsys_i, signal_j, gain)` (where `gain` is optional). If you get an + unexpected error message about a specification being of the wrong type + or not being found, check to make sure you are not using a tuple where + you should be using a list. + + In addition to its use for general nonlinear I/O systems, the + :func:`~control.interconnect` function allows linear systems to be + interconnected using named signals (compared with the + :func:`~control.connect` function, which uses signal indices) and to be + treated as both a :class:`~control.StateSpace` system as well as an + :class:`~control.InputOutputSystem`. + + The `input` and `output` keywords can be used instead of `inputs` and + `outputs`, for more natural naming of SISO systems. + + """ + from .statesp import StateSpace, LinearIOSystem, LinearICSystem, \ + _convert_to_statespace + from .xferfcn import TransferFunction + + dt = kwargs.pop('dt', None) # by pass normal 'dt' processing + name, inputs, outputs, states, _ = _process_namedio_keywords(kwargs) + + 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: + # user has disabled auto-connect, and supplied neither input + # nor output mappings; assume they know what they're doing + check_unused = False + + # If connections was not specified, set up default connection list + if connections is None: + # For each system input, look for outputs with the same name + connections = [] + for input_sys in syslist: + 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_labels: + connect.append(output_sys.name + "." + input_name) + if len(connect) > 1: + connections.append(connect) + + auto_connect = True + + elif connections is False: + check_unused = False + # Use an empty connections list + connections = [] + + elif isinstance(connections, list) and \ + all([isinstance(cnxn, (str, tuple)) for cnxn in connections]): + # Special case where there is a single connection + connections = [connections] + + # If inplist/outlist is not present, try using inputs/outputs instead + inplist_none, outlist_none = False, False + if inplist is None: + inplist = inputs or [] + inplist_none = True # use to rewrite inputs below + if outlist is None: + outlist = outputs or [] + outlist_none = True # use to rewrite outputs below + + # Define a local debugging function + dprint = lambda s: None if not debug else print(s) + + # + # Pre-process connecton list + # + # Support for various "vector" forms of specifications is handled here, + # by expanding any specifications that refer to more than one signal. + # This includes signal lists such as ('sysname', ['sig1', 'sig2', ...]) + # as well as slice-based specifications such as 'sysname.signal[i:j]'. + # + dprint(f"Pre-processing connections:") + new_connections = [] + for connection in connections: + dprint(f" parsing {connection=}") + if not isinstance(connection, list): + raise ValueError( + f"invalid connection {connection}: should be a list") + # Parse and expand the input specification + input_spec = _parse_spec(syslist, connection[0], 'input') + input_spec_list = [input_spec] + + # Parse and expand the output specifications + output_specs_list = [[]] * len(input_spec_list) + for spec in connection[1:]: + output_spec = _parse_spec(syslist, spec, 'output') + output_specs_list[0].append(output_spec) + + # Create the new connection entry + for input_spec, output_specs in zip(input_spec_list, output_specs_list): + new_connection = [input_spec] + output_specs + dprint(f" adding {new_connection=}") + new_connections.append(new_connection) + connections = new_connections + + # + # Pre-process input connections list + # + # Similar to the connections list, we now handle "vector" forms of + # specifications in the inplist parameter. This needs to be handled + # here because the InterconnectedSystem constructor assumes that the + # number of elements in `inplist` will match the number of inputs for + # the interconnected system. + # + # If inplist_none is True then inplist is a copy of inputs and so we + # also have to be careful that if we encounter any multivariable + # signals, we need to update the input list. + # + dprint(f"Pre-processing input connections: {inplist}") + if not isinstance(inplist, list): + dprint(f" converting inplist to list") + inplist = [inplist] + new_inplist, new_inputs = [], [] if inplist_none else inputs + + # Go through the list of inputs and process each one + for iinp, connection in enumerate(inplist): + # Check for system name or signal names without a system name + if isinstance(connection, str) and len(connection.split('.')) == 1: + # Create an empty connections list to store matching connections + new_connections = [] + + # Get the signal/system name + sname = connection[1:] if connection[0] == '-' else connection + gain = -1 if connection[0] == '-' else 1 + + # Look for the signal name as a system input + found_system, found_signal = False, False + for isys, sys in enumerate(syslist): + # Look for matching signals (returns None if no matches + indices = sys._find_signals(sname, sys.input_index) + + # See what types of matches we found + if sname == sys.name: + # System name matches => use all inputs + for isig in range(sys.ninputs): + dprint(f" adding input {(isys, isig, gain)}") + new_inplist.append((isys, isig, gain)) + found_system = True + elif indices: + # Signal name matches => store new connections + new_connection = [] + for isig in indices: + dprint(f" collecting input {(isys, isig, gain)}") + new_connection.append((isys, isig, gain)) + + if len(new_connections) == 0: + # First time we have seen this signal => initalize + for cnx in new_connection: + new_connections.append([cnx]) + if inplist_none: + # See if we need to rewrite the inputs + if len(new_connection) != 1: + new_inputs += [ + sys.input_labels[i] for i in indices] + else: + new_inputs.append(inputs[iinp]) + else: + # Additional signal match found =. add to the list + for i, cnx in enumerate(new_connection): + new_connections[i].append(cnx) + found_signal = True + + if found_system and found_signal: + raise ValueError( + f"signal '{sname}' is both signal and system name") + elif found_signal: + dprint(f" adding inputs {new_connections}") + new_inplist += new_connections + elif not found_system: + raise ValueError("could not find signal %s" % sname) + else: + # Regular signal specification + if not isinstance(connection, list): + dprint(f" converting item to list") + connection = [connection] + for spec in connection: + isys, indices, gain = _parse_spec(syslist, spec, 'input') + for isig in indices: + dprint(f" adding input {(isys, isig, gain)}") + new_inplist.append((isys, isig, gain)) + inplist, inputs = new_inplist, new_inputs + dprint(f" {inplist=}\n {inputs=}") + + # + # Pre-process output list + # + # This is similar to the processing of the input list, but we need to + # additionally take into account the fact that you can list subsystem + # inputs as system outputs. + # + dprint(f"Pre-processing output connections: {outlist}") + if not isinstance(outlist, list): + dprint(f" converting outlist to list") + outlist = [outlist] + new_outlist, new_outputs = [], [] if outlist_none else outputs + for iout, connection in enumerate(outlist): + # Create an empty connection list + new_connections = [] + + # Check for system name or signal names without a system name + if isinstance(connection, str) and len(connection.split('.')) == 1: + # Get the signal/system name + sname = connection[1:] if connection[0] == '-' else connection + gain = -1 if connection[0] == '-' else 1 + + # Look for the signal name as a system output + found_system, found_signal = False, False + for osys, sys in enumerate(syslist): + indices = sys._find_signals(sname, sys.output_index) + if sname == sys.name: + # Use all outputs + for osig in range(sys.noutputs): + dprint(f" adding output {(osys, osig, gain)}") + new_outlist.append((osys, osig, gain)) + found_system = True + elif indices: + new_connection = [] + for osig in indices: + dprint(f" collecting output {(osys, osig, gain)}") + new_connection.append((osys, osig, gain)) + if len(new_connections) == 0: + for cnx in new_connection: + new_connections.append([cnx]) + if outlist_none: + # See if we need to rewrite the outputs + if len(new_connection) != 1: + new_outputs += [ + sys.output_labels[i] for i in indices] + else: + new_outputs.append(outputs[iout]) + else: + # Additional signal match found =. add to the list + for i, cnx in enumerate(new_connection): + new_connections[i].append(cnx) + found_signal = True + + if found_system and found_signal: + raise ValueError( + f"signal '{sname}' is both signal and system name") + elif found_signal: + dprint(f" adding outputs {new_connections}") + new_outlist += new_connections + elif not found_system: + raise ValueError("could not find signal %s" % sname) + else: + # Regular signal specification + if not isinstance(connection, list): + dprint(f" converting item to list") + connection = [connection] + for spec in connection: + try: + # First trying looking in the output signals + osys, indices, gain = _parse_spec(syslist, spec, 'output') + for osig in indices: + dprint(f" adding output {(osys, osig, gain)}") + new_outlist.append((osys, osig, gain)) + except ValueError: + # If not, see if we can find it in inputs + isys, indices, gain = _parse_spec( + syslist, spec, 'input or output', + dictname='input_index') + for isig in indices: + # Use string form to allow searching input list + dprint(f" adding input {(isys, isig, gain)}") + new_outlist.append( + (syslist[isys].name, + syslist[isys].input_labels[isig], gain)) + outlist, outputs = new_outlist, new_outputs + dprint(f" {outlist=}\n {outputs=}") + + # Make sure inputs and outputs match inplist outlist, if specified + if inputs and ( + isinstance(inputs, (list, tuple)) and len(inputs) != len(inplist) + or isinstance(inputs, int) and inputs != len(inplist)): + raise ValueError("`inputs` incompatible with `inplist`") + if outputs and ( + isinstance(outputs, (list, tuple)) and len(outputs) != len(outlist) + or isinstance(outputs, int) and outputs != len(outlist)): + raise ValueError("`outputs` incompatible with `outlist`") + + newsys = InterconnectedSystem( + syslist, connections=connections, inplist=inplist, + outlist=outlist, inputs=inputs, outputs=outputs, states=states, + params=params, dt=dt, name=name, warn_duplicate=warn_duplicate, + **kwargs) + + # See if we should add any signals + if add_unused: + # Get all unused signals + dropped_inputs, dropped_outputs = newsys.check_unused_signals( + ignore_inputs, ignore_outputs, warning=False) + + # Add on any unused signals that we aren't ignoring + for isys, isig in dropped_inputs: + inplist.append((isys, isig)) + inputs.append(newsys.syslist[isys].input_labels[isig]) + for osys, osig in dropped_outputs: + outlist.append((osys, osig)) + outputs.append(newsys.syslist[osys].output_labels[osig]) + + # Rebuild the system with new inputs/outputs + newsys = InterconnectedSystem( + syslist, connections=connections, inplist=inplist, + outlist=outlist, inputs=inputs, outputs=outputs, states=states, + params=params, dt=dt, name=name, warn_duplicate=warn_duplicate, + **kwargs) + + # check for implicitly 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 newsys.syslist]): + return LinearICSystem(newsys, None) + + return newsys + + +# Utility function to allow lists states, inputs +def _concatenate_list_elements(X, name='X'): + # If we were passed a list, concatenate the elements together + if isinstance(X, (tuple, list)): + X_list = [] + for i, x in enumerate(X): + x = np.array(x).reshape(-1) # convert everyting to 1D array + X_list += x.tolist() # add elements to initial state + return np.array(X_list) + + # Otherwise, do nothing + return X diff --git a/control/optimal.py b/control/optimal.py index 50145324f..8b7a54713 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -24,7 +24,7 @@ from . import config from .exception import ControlNotImplemented -from .namedio import _process_indices, _process_labels, \ +from .iosys import _process_indices, _process_labels, \ _process_control_disturbance_indices diff --git a/control/pzmap.py b/control/pzmap.py index 09f58b79c..5ee3d37c7 100644 --- a/control/pzmap.py +++ b/control/pzmap.py @@ -42,7 +42,7 @@ from numpy import real, imag, linspace, exp, cos, sin, sqrt from math import pi from .lti import LTI -from .namedio import isdtime, isctime +from .iosys import isdtime, isctime from .grid import sgrid, zgrid, nogrid from . import config diff --git a/control/rlocus.py b/control/rlocus.py index 60565d48d..c92535101 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 .namedio import isdtime +from .iosys import isdtime from .xferfcn import _convert_to_transfer_function from .exception import ControlMIMONotImplemented from .sisotool import _SisotoolUpdate diff --git a/control/sisotool.py b/control/sisotool.py index e1cfbaf67..1a06ef60e 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -3,11 +3,11 @@ from control.exception import ControlMIMONotImplemented from .freqplot import bode_plot from .timeresp import step_response -from .namedio import common_timebase, isctime, isdtime +from .iosys import common_timebase, isctime, isdtime from .xferfcn import tf -from .iosys import ss +from .statesp import ss, tf2io, summing_junction from .bdalg import append, connect -from .iosys import ss, tf2io, summing_junction, interconnect +from .nlsys import interconnect from control.statesp import _convert_to_statespace from . import config import numpy as np diff --git a/control/statefbk.py b/control/statefbk.py index 50bad75c1..d2772fcb6 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -48,9 +48,9 @@ from .mateqn import care, dare, _check_shape from .statesp import StateSpace, _ssmatrix, _convert_to_statespace from .lti import LTI -from .namedio import isdtime, isctime, _process_indices, _process_labels -from .iosys import InputOutputSystem, NonlinearIOSystem, LinearIOSystem, \ - interconnect, ss +from .iosys import isdtime, isctime, _process_indices, _process_labels +from .nlsys import InputOutputSystem, NonlinearIOSystem, interconnect +from .statesp import LinearIOSystem, ss from .exception import ControlSlycot, ControlArgument, ControlDimension, \ ControlNotImplemented from .config import _process_legacy_keyword diff --git a/control/statesp.py b/control/statesp.py index ae2c32c50..30480f491 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -63,8 +63,9 @@ from .exception import ControlSlycot from .frdata import FrequencyResponseData from .lti import LTI, _process_frequency_response -from .namedio import common_timebase, isdtime, _process_namedio_keywords, \ - _process_dt_keyword, NamedIOSystem +from .iosys import NamedIOSystem, common_timebase, isdtime, \ + _process_namedio_keywords, _process_dt_keyword, _process_signal_list +from .nlsys import InputOutputSystem, NonlinearIOSystem, InterconnectedSystem from . import config from copy import deepcopy @@ -73,7 +74,9 @@ except ImportError: ab13dd = None -__all__ = ['StateSpace', 'tf2ss', 'ssdata', 'linfnorm'] +__all__ = ['StateSpace', 'LinearIOSystem', 'LinearICSystem', 'ss2io', + 'tf2io', 'tf2ss', 'ssdata', 'linfnorm', 'ss', 'rss', 'drss', + 'summing_junction'] # Define module default parameter values _statesp_defaults = { @@ -84,78 +87,6 @@ } -def _ssmatrix(data, axis=1): - """Convert argument to a (possibly empty) 2D state space matrix. - - The axis keyword argument makes it convenient to specify that if the input - is a vector, it is a row (axis=1) or column (axis=0) vector. - - Parameters - ---------- - data : array, list, or string - Input data defining the contents of the 2D array - axis : 0 or 1 - If input data is 1D, which axis to use for return object. The default - is 1, corresponding to a row matrix. - - Returns - ------- - arr : 2D array, with shape (0, 0) if a is empty - - """ - # Convert the data into an array - arr = np.array(data, dtype=float) - ndim = arr.ndim - shape = arr.shape - - # Change the shape of the array into a 2D array - if (ndim > 2): - raise ValueError("state-space matrix must be 2-dimensional") - - elif (ndim == 2 and shape == (1, 0)) or \ - (ndim == 1 and shape == (0, )): - # Passed an empty matrix or empty vector; change shape to (0, 0) - shape = (0, 0) - - elif ndim == 1: - # Passed a row or column vector - shape = (1, shape[0]) if axis == 1 else (shape[0], 1) - - elif ndim == 0: - # Passed a constant; turn into a matrix - shape = (1, 1) - - # Create the actual object used to store the result - return arr.reshape(shape) - - -def _f2s(f): - """Format floating point number f for StateSpace._repr_latex_. - - Numbers are converted to strings with statesp.latex_num_format. - - Inserts column separators, etc., as needed. - """ - fmt = "{:" + config.defaults['statesp.latex_num_format'] + "}" - sraw = fmt.format(f) - # significand-exponent - se = sraw.lower().split('e') - # whole-fraction - wf = se[0].split('.') - s = wf[0] - if wf[1:]: - s += r'.&\hspace{{-1em}}{frac}'.format(frac=wf[1]) - else: - s += r'\phantom{.}&\hspace{-1em}' - - if se[1:]: - s += r'&\hspace{{-1em}}\cdot10^{{{:d}}}'.format(int(se[1])) - else: - s += r'&\hspace{-1em}\phantom{\cdot}' - - return s - - class StateSpace(LTI): r"""StateSpace(A, B, C, D[, dt]) @@ -1503,322 +1434,397 @@ def output(self, t, x, u=None, params=None): + (self.D @ u).reshape((-1,)) # return as row vector -# TODO: add discrete time check -def _convert_to_statespace(sys, use_prefix_suffix=False): - """Convert a system to state space form (if needed). +class LinearIOSystem(InputOutputSystem, StateSpace): + """Input/output representation of a linear (state space) system. - If sys is already a state space, then it is returned. If sys is a - transfer function object, then it is converted to a state space and - returned. + This class is used to implement a system that is a linear state + space system (defined by the StateSpace system object). - Note: no renaming of inputs and outputs is performed; this should be done - by the calling function. + Parameters + ---------- + linsys : StateSpace or TransferFunction + LTI system to be converted. + inputs : int, list of str or None, optional + New system input labels (defaults to linsys input labels). + outputs : int, list of str or None, optional + New system output labels (defaults to linsys output labels). + states : int, list of str, or None, optional + New system input labels (defaults to linsys output labels). + 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. + params : dict, optional + Parameter values for the systems. Passed to the evaluation functions + for the system as default values, overriding internal defaults. + + Attributes + ---------- + ninputs, noutputs, nstates, dt, etc + See :class:`InputOutputSystem` for inherited attributes. + + A, B, C, D + See :class:`~control.StateSpace` for inherited attributes. + + See Also + -------- + InputOutputSystem : Input/output system class. """ - from .xferfcn import TransferFunction - import itertools + def __init__(self, linsys, **kwargs): + """Create an I/O system from a state space linear system. - if isinstance(sys, StateSpace): - return sys + Converts a :class:`~control.StateSpace` system into an + :class:`~control.InputOutputSystem` with the same inputs, outputs, and + states. The new system can be a continuous or discrete time system. - elif isinstance(sys, TransferFunction): - # Make sure the transfer function is proper - if any([[len(num) for num in col] for col in sys.num] > - [[len(num) for num in col] for col in sys.den]): - raise ValueError("Transfer function is non-proper; can't " - "convert to StateSpace system.") + """ + from .xferfcn import TransferFunction + + if isinstance(linsys, TransferFunction): + # Convert system to StateSpace + linsys = _convert_to_statespace(linsys) - try: - from slycot import td04ad + elif not isinstance(linsys, StateSpace): + raise TypeError("Linear I/O system must be a state space " + "or transfer function object") - # Change the numerator and denominator arrays so that the transfer - # function matrix has a common denominator. - # matrices are also sized/padded to fit td04ad - num, den, denorder = sys.minreal()._common_den() + # Process keyword arguments + name, inputs, outputs, states, dt = _process_namedio_keywords( + kwargs, linsys) - # transfer function to state space conversion now should work! - ssout = td04ad('C', sys.ninputs, sys.noutputs, - denorder, den, num, tol=0) + # Create the I/O system object + # Note: don't use super() to override StateSpace MRO + InputOutputSystem.__init__( + self, inputs=inputs, outputs=outputs, states=states, + params=None, dt=dt, name=name, **kwargs) - states = ssout[0] - newsys = StateSpace( - ssout[1][:states, :states], ssout[2][:states, :sys.ninputs], - ssout[3][:sys.noutputs, :states], ssout[4], sys.dt) + # Initalize additional state space variables + StateSpace.__init__( + self, linsys, remove_useless_states=False, init_namedio=False) - except ImportError: - # No Slycot. Scipy tf->ss can't handle MIMO, but static - # MIMO is an easy special case we can check for here - maxn = max(max(len(n) for n in nrow) - for nrow in sys.num) - maxd = max(max(len(d) for d in drow) - for drow in sys.den) - if 1 == maxn and 1 == maxd: - D = empty((sys.noutputs, sys.ninputs), dtype=float) - for i, j in itertools.product(range(sys.noutputs), - range(sys.ninputs)): - D[i, j] = sys.num[i][j][0] / sys.den[i][j][0] - newsys = StateSpace([], [], [], D, sys.dt) - else: - if sys.ninputs != 1 or sys.noutputs != 1: - raise TypeError("No support for MIMO without slycot") + # When sampling a LinearIO system, return a LinearIOSystem + def sample(self, *args, **kwargs): + return LinearIOSystem(StateSpace.sample(self, *args, **kwargs)) - # TODO: do we want to squeeze first and check dimenations? - # I think this will fail if num and den aren't 1-D after - # the squeeze - A, B, C, D = \ - sp.signal.tf2ss(squeeze(sys.num), squeeze(sys.den)) - newsys = StateSpace(A, B, C, D, sys.dt) + sample.__doc__ = StateSpace.sample.__doc__ - # Copy over the signal (and system) names - newsys._copy_names( - sys, - prefix_suffix_name='converted' if use_prefix_suffix else None) - return newsys + # The following text needs to be replicated from StateSpace in order for + # this entry to show up properly in sphinx doccumentation (not sure why, + # but it was the only way to get it to work). + # + #: Deprecated attribute; use :attr:`nstates` instead. + #: + #: The ``state`` attribute was used to store the number of states for : a + #: state space system. It is no longer used. If you need to access the + #: number of states, use :attr:`nstates`. + states = property(StateSpace._get_states, StateSpace._set_states) - elif isinstance(sys, FrequencyResponseData): - raise TypeError("Can't convert FRD to StateSpace system.") + def _update_params(self, params=None, warning=True): + # Parameters not supported; issue a warning + if params and warning: + warn("Parameters passed to LinearIOSystems are ignored.") - # If this is a matrix, try to create a constant feedthrough - try: - D = _ssmatrix(np.atleast_2d(sys)) - return StateSpace([], [], [], D, dt=None) + def _rhs(self, t, x, u): + # Convert input to column vector and then change output to 1D array + xdot = self.A @ np.reshape(x, (-1, 1)) \ + + self.B @ np.reshape(u, (-1, 1)) + return np.array(xdot).reshape((-1,)) - except Exception: - raise TypeError("Can't convert given type to StateSpace system.") + def _out(self, t, x, u): + # Convert input to column vector and then change output to 1D array + y = self.C @ np.reshape(x, (-1, 1)) \ + + self.D @ np.reshape(u, (-1, 1)) + return np.array(y).reshape((-1,)) -# TODO: add discrete time option -def _rss_generate( - states, inputs, outputs, cdtype, strictly_proper=False, name=None): - """Generate a random state space. + def __repr__(self): + # Need to define so that I/O system gets used instead of StateSpace + return InputOutputSystem.__repr__(self) - This does the actual random state space generation expected from rss and - drss. cdtype is 'c' for continuous systems and 'd' for discrete systems. + def __str__(self): + return InputOutputSystem.__str__(self) + "\n\n" \ + + StateSpace.__str__(self) - """ - # Probability of repeating a previous root. - pRepeat = 0.05 - # Probability of choosing a real root. Note that when choosing a complex - # root, the conjugate gets chosen as well. So the expected proportion of - # real roots is pReal / (pReal + 2 * (1 - pReal)). - pReal = 0.6 - # Probability that an element in B or C will not be masked out. - pBCmask = 0.8 - # Probability that an element in D will not be masked out. - pDmask = 0.3 - # Probability that D = 0. - pDzero = 0.5 +class LinearICSystem(InterconnectedSystem, LinearIOSystem): - # Check for valid input arguments. - if states < 1 or states % 1: - raise ValueError("states must be a positive integer. states = %g." % - states) - if inputs < 1 or inputs % 1: - raise ValueError("inputs must be a positive integer. inputs = %g." % - inputs) - if outputs < 1 or outputs % 1: - raise ValueError("outputs must be a positive integer. outputs = %g." % - outputs) - if cdtype not in ['c', 'd']: - raise ValueError("cdtype must be `c` or `d`") + """Interconnection of a set of linear input/output systems. - # Make some poles for A. Preallocate a complex array. - poles = zeros(states) + zeros(states) * 0.j - i = 0 + This class is used to implement a system that is an interconnection of + linear input/output systems. It has all of the structure of an + :class:`~control.InterconnectedSystem`, but also maintains the requirement + elements of :class:`~control.LinearIOSystem`, including the + :class:`StateSpace` class structure, allowing it to be passed to functions + that expect a :class:`StateSpace` system. - while i < states: - if rand() < pRepeat and i != 0 and i != states - 1: - # Small chance of copying poles, if we're not at the first or last - # element. - if poles[i-1].imag == 0: - # Copy previous real pole. - poles[i] = poles[i-1] - i += 1 - else: - # Copy previous complex conjugate pair of poles. - poles[i:i+2] = poles[i-2:i] - i += 2 - elif rand() < pReal or i == states - 1: - # No-oscillation pole. - if cdtype == 'c': - poles[i] = -exp(randn()) + 0.j - else: - poles[i] = 2. * rand() - 1. - i += 1 - else: - # Complex conjugate pair of oscillating poles. - if cdtype == 'c': - poles[i] = complex(-exp(randn()), 3. * exp(randn())) - else: - mag = rand() - phase = 2. * math.pi * rand() - poles[i] = complex(mag * cos(phase), mag * sin(phase)) - poles[i+1] = complex(poles[i].real, -poles[i].imag) - i += 2 + This class is generated using :func:`~control.interconnect` and + not called directly. - # Now put the poles in A as real blocks on the diagonal. - A = zeros((states, states)) - i = 0 - while i < states: - if poles[i].imag == 0: - A[i, i] = poles[i].real - i += 1 - else: - A[i, i] = A[i+1, i+1] = poles[i].real - A[i, i+1] = poles[i].imag - A[i+1, i] = -poles[i].imag - i += 2 - # Finally, apply a transformation so that A is not block-diagonal. - while True: - T = randn(states, states) - try: - A = solve(T, A) @ T # A = T \ A @ T - break - except LinAlgError: - # In the unlikely event that T is rank-deficient, iterate again. - pass + """ - # Make the remaining matrices. - B = randn(states, inputs) - C = randn(outputs, states) - D = randn(outputs, inputs) + 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 (essentially empty) I/O system object + InputOutputSystem.__init__( + self, name=io_sys.name, params=io_sys.params) + + # Copy over the named I/O system attributes + self.syslist = io_sys.syslist + 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 + self.syslist_index = io_sys.syslist_index + self.state_offset = io_sys.state_offset + self.input_offset = io_sys.input_offset + self.output_offset = io_sys.output_offset + self.connect_map = io_sys.connect_map + self.input_map = io_sys.input_map + self.output_map = io_sys.output_map + self.params = io_sys.params + + # If we didnt' get a state space system, linearize the full system + # TODO: this could be replaced with a direct computation (someday) + if ss_sys is None: + ss_sys = self.linearize(0, 0) + + # Initialize the state space attributes + if isinstance(ss_sys, StateSpace): + # 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_states=False, init_namedio=False) - # Make masks to zero out some of the elements. - while True: - Bmask = rand(states, inputs) < pBCmask - if any(Bmask): # Retry if we get all zeros. - break - while True: - Cmask = rand(outputs, states) < pBCmask - if any(Cmask): # Retry if we get all zeros. - break - if rand() < pDzero: - Dmask = zeros((outputs, inputs)) - else: - Dmask = rand(outputs, inputs) < pDmask + else: + raise TypeError("Second argument must be a state space system.") - # Apply masks. - B = B * Bmask - C = C * Cmask - D = D * Dmask if not strictly_proper else zeros(D.shape) + # The following text needs to be replicated from StateSpace in order for + # this entry to show up properly in sphinx doccumentation (not sure why, + # but it was the only way to get it to work). + # + #: Deprecated attribute; use :attr:`nstates` instead. + #: + #: The ``state`` attribute was used to store the number of states for : a + #: state space system. It is no longer used. If you need to access the + #: number of states, use :attr:`nstates`. + states = property(StateSpace._get_states, StateSpace._set_states) - if cdtype == 'c': - ss_args = (A, B, C, D) - else: - ss_args = (A, B, C, D, True) - return StateSpace(*ss_args, name=name) +# Define a state space object that is an I/O system +def ss(*args, **kwargs): + r"""ss(A, B, C, D[, dt]) -# Convert a MIMO system to a SISO system -# TODO: add discrete time check -def _mimo2siso(sys, input, output, warn_conversion=False): - # pylint: disable=W0622 - """ - Convert a MIMO system to a SISO system. (Convert a system with multiple - inputs and/or outputs, to a system with a single input and output.) + Create a state space system. - The input and output that are used in the SISO system can be selected - with the parameters ``input`` and ``output``. All other inputs are set - to 0, all other outputs are ignored. + The function accepts either 1, 2, 4 or 5 parameters: - If ``sys`` is already a SISO system, it will be returned unaltered. + ``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, outfcn)`` + 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:: + + dx/dt &= A x + B u \\ + y &= C x + D 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 x[k] + B u[k] \\ + y[k] &= C x[k] + D u[k] + + The matrices can be given as *array like* data types or strings. + + ``ss(args, inputs=['u1', ..., 'up'], outputs=['y1', ..., 'yq'], states=['x1', ..., 'xn'])`` + Create a system with named input, output, and state signals. Parameters ---------- - sys : StateSpace - Linear (MIMO) system that should be converted. - input : int - Index of the input that will become the SISO system's only input. - output : int - Index of the output that will become the SISO system's only output. - warn_conversion : bool, optional - If `True`, print a message when sys is a MIMO system, - warning that a conversion will take place. Default is False. + 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 - sys : StateSpace - The converted (SISO) system. + ------- + 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 matrices. + + >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]]) + + Convert a TransferFunction to a StateSpace object. + + >>> sys_tf = ct.tf([2.], [1., 3]) + >>> sys2 = ct.ss(sys_tf) + """ - if not (isinstance(input, int) and isinstance(output, int)): - raise TypeError("Parameters ``input`` and ``output`` must both " - "be integer numbers.") - if not (0 <= input < sys.ninputs): - raise ValueError("Selected input does not exist. " - "Selected input: {sel}, " - "number of system inputs: {ext}." - .format(sel=input, ext=sys.ninputs)) - if not (0 <= output < sys.noutputs): - raise ValueError("Selected output does not exist. " - "Selected output: {sel}, " - "number of system outputs: {ext}." - .format(sel=output, ext=sys.noutputs)) - # Convert sys to SISO if necessary - if sys.ninputs > 1 or sys.noutputs > 1: - if warn_conversion: - warn("Converting MIMO system to SISO system. " - "Only input {i} and output {o} are used." - .format(i=input, o=output)) - # $X = A*X + B*U - # Y = C*X + D*U - new_B = sys.B[:, input] - new_C = sys.C[output, :] - new_D = sys.D[output, input] - sys = StateSpace(sys.A, new_B, new_C, new_D, sys.dt, - name=sys.name, - inputs=sys.input_labels[input], outputs=sys.output_labels[output]) + # 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, + use_prefix_suffix=not sys._generic_name_check()), + **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 _mimo2simo(sys, input, warn_conversion=False): - # pylint: disable=W0622 - """ - Convert a MIMO system to a SIMO system. (Convert a system with multiple - inputs and/or outputs, to a system with a single input but possibly - multiple outputs.) +# Convert a state space system into an input/output system (wrapper) +def ss2io(*args, **kwargs): + return LinearIOSystem(*args, **kwargs) +ss2io.__doc__ = LinearIOSystem.__init__.__doc__ - The input that is used in the SIMO system can be selected with the - parameter ``input``. All other inputs are set to 0, all other - outputs are ignored. - If ``sys`` is already a SIMO system, it will be returned unaltered. +# Convert a transfer function into an input/output system (wrapper) +def tf2io(*args, **kwargs): + """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: StateSpace - Linear (MIMO) system that should be converted. - input: int - Index of the input that will become the SIMO system's only input. - warn_conversion: bool - If True: print a warning message when sys is a MIMO system. - Warn that a conversion will take place. + 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 ------- - sys: StateSpace - The converted (SIMO) system. + 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 = ct.tf2ss(num, den) + + >>> sys_tf = ct.tf(num, den) + >>> G = ct.tf2ss(sys_tf) + >>> G.ninputs, G.noutputs, G.nstates + (2, 2, 8) + """ - if not (isinstance(input, int)): - raise TypeError("Parameter ``input`` be an integer number.") - if not (0 <= input < sys.ninputs): - raise ValueError("Selected input does not exist. " - "Selected input: {sel}, " - "number of system inputs: {ext}." - .format(sel=input, ext=sys.ninputs)) - # Convert sys to SISO if necessary - if sys.ninputs > 1: - if warn_conversion: - warn("Converting MIMO system to SIMO system. " - "Only input {i} is used." .format(i=input)) - # $X = A*X + B*U - # Y = C*X + D*U - new_B = sys.B[:, input:input+1] - new_D = sys.D[:, input:input+1] - sys = StateSpace(sys.A, new_B, sys.C, new_D, sys.dt, - name=sys.name, - inputs=sys.input_labels[input], outputs=sys.output_labels) + # Convert the system to a state space system + linsys = tf2ss(*args) - return sys + # Now convert the state space system to an I/O system + return LinearIOSystem(linsys, **kwargs) def tf2ss(*args, **kwargs): @@ -1982,3 +1988,626 @@ def linfnorm(sys, tol=1e-10): fpeak /= sys.dt return gpeak, fpeak + + +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 : LinearIOSystem + 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) + + # 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, + **kwargs) + + +def drss(*args, **kwargs): + """ + drss([states, outputs, inputs, strictly_proper]) + + 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. + + Examples + -------- + >>> G = ct.drss(states=4, outputs=2, inputs=1) + >>> G.ninputs, G.noutputs, G.nstates + (1, 2, 4) + >>> G.isdtime() + True + + + """ + # 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 + + +# Summing junction +def summing_junction( + 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 + the inputs, potentially with a change in sign for each individual input. + The input/output system that is created by this function can be used as a + component in the :func:`~control.interconnect` function. + + Parameters + ---------- + inputs : int, string or list of strings + Description of the inputs to the summing junction. This can be given + as an integer count, a string, or a list of strings. If an integer + count is specified, the names of the input signals will be of the form + `u[i]`. + output : string, optional + Name of the system output. If not specified, the output will be 'y'. + dimension : int, optional + The dimension of the summing junction. If the dimension is set to a + positive integer, a multi-input, multi-output summing junction will be + created. The input and output signal names will be of the form + `[i]` where `signal` is the input/output signal name specified + by the `inputs` and `output` keywords. Default value is `None`. + name : string, optional + System name (used for specifying signals). If unspecified, a generic + name is generated with a unique integer id. + 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]`. + + Returns + ------- + sys : static LinearIOSystem + Linear input/output system object with no states and only a direct + term that implements the summing junction. + + Examples + -------- + >>> 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], inputs='r', outputs='y') + >>> T.ninputs, T.noutputs, T.nstates + (1, 1, 2) + + """ + # Utility function to parse input and output signal lists + def _parse_list(signals, signame='input', prefix='u'): + # Parse signals, including gains + if isinstance(signals, int): + nsignals = signals + names = ["%s[%d]" % (prefix, i) for i in range(nsignals)] + gains = np.ones((nsignals,)) + elif isinstance(signals, str): + nsignals = 1 + gains = [-1 if signals[0] == '-' else 1] + names = [signals[1:] if signals[0] == '-' else signals] + elif isinstance(signals, list) and \ + all([isinstance(x, str) for x in signals]): + nsignals = len(signals) + gains = np.ones((nsignals,)) + names = [] + for i in range(nsignals): + if signals[i][0] == '-': + gains[i] = -1 + names.append(signals[i][1:]) + else: + names.append(signals[i]) + else: + raise ValueError( + "could not parse %s description '%s'" + % (signame, str(signals))) + + # Return the parsed list + return nsignals, names, gains + + # 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") + + # Read the input list + ninputs, input_names, input_gains = _parse_list( + inputs, signame="input", prefix=prefix) + noutputs, output_names, output_gains = _parse_list( + output, signame="output", prefix='y') + if noutputs > 1: + raise NotImplementedError("vector outputs not yet supported") + + # If the dimension keyword is present, vectorize inputs and outputs + if isinstance(dimension, int) and dimension >= 1: + # Create a new list of input/output names and update parameters + input_names = ["%s[%d]" % (name, dim) + for name in input_names + for dim in range(dimension)] + ninputs = ninputs * dimension + + output_names = ["%s[%d]" % (name, dim) + for name in output_names + for dim in range(dimension)] + noutputs = noutputs * dimension + elif dimension is not None: + raise ValueError( + "unrecognized dimension value '%s'" % str(dimension)) + else: + dimension = 1 + + # Create the direct term + D = np.kron(input_gains * output_gains[0], np.eye(dimension)) + + # Create a linear system of the appropriate size + ss_sys = StateSpace( + np.zeros((0, 0)), np.ones((0, ninputs)), np.ones((noutputs, 0)), D) + + # Create a LinearIOSystem + return LinearIOSystem( + ss_sys, inputs=input_names, outputs=output_names, name=name) + + +def _ssmatrix(data, axis=1): + """Convert argument to a (possibly empty) 2D state space matrix. + + The axis keyword argument makes it convenient to specify that if the input + is a vector, it is a row (axis=1) or column (axis=0) vector. + + Parameters + ---------- + data : array, list, or string + Input data defining the contents of the 2D array + axis : 0 or 1 + If input data is 1D, which axis to use for return object. The default + is 1, corresponding to a row matrix. + + Returns + ------- + arr : 2D array, with shape (0, 0) if a is empty + + """ + # Convert the data into an array + arr = np.array(data, dtype=float) + ndim = arr.ndim + shape = arr.shape + + # Change the shape of the array into a 2D array + if (ndim > 2): + raise ValueError("state-space matrix must be 2-dimensional") + + elif (ndim == 2 and shape == (1, 0)) or \ + (ndim == 1 and shape == (0, )): + # Passed an empty matrix or empty vector; change shape to (0, 0) + shape = (0, 0) + + elif ndim == 1: + # Passed a row or column vector + shape = (1, shape[0]) if axis == 1 else (shape[0], 1) + + elif ndim == 0: + # Passed a constant; turn into a matrix + shape = (1, 1) + + # Create the actual object used to store the result + return arr.reshape(shape) + + +def _f2s(f): + """Format floating point number f for StateSpace._repr_latex_. + + Numbers are converted to strings with statesp.latex_num_format. + + Inserts column separators, etc., as needed. + """ + fmt = "{:" + config.defaults['statesp.latex_num_format'] + "}" + sraw = fmt.format(f) + # significand-exponent + se = sraw.lower().split('e') + # whole-fraction + wf = se[0].split('.') + s = wf[0] + if wf[1:]: + s += r'.&\hspace{{-1em}}{frac}'.format(frac=wf[1]) + else: + s += r'\phantom{.}&\hspace{-1em}' + + if se[1:]: + s += r'&\hspace{{-1em}}\cdot10^{{{:d}}}'.format(int(se[1])) + else: + s += r'&\hspace{-1em}\phantom{\cdot}' + + return s + + +# TODO: add discrete time check +def _convert_to_statespace(sys, use_prefix_suffix=False): + """Convert a system to state space form (if needed). + + If sys is already a state space, then it is returned. If sys is a + transfer function object, then it is converted to a state space and + returned. + + 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): + return sys + + elif isinstance(sys, TransferFunction): + # Make sure the transfer function is proper + if any([[len(num) for num in col] for col in sys.num] > + [[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 + + # Change the numerator and denominator arrays so that the transfer + # function matrix has a common denominator. + # matrices are also sized/padded to fit td04ad + num, den, denorder = sys.minreal()._common_den() + + # transfer function to state space conversion now should work! + ssout = td04ad('C', sys.ninputs, sys.noutputs, + denorder, den, num, tol=0) + + states = ssout[0] + newsys = StateSpace( + ssout[1][:states, :states], ssout[2][:states, :sys.ninputs], + ssout[3][:sys.noutputs, :states], ssout[4], sys.dt) + + except ImportError: + # No Slycot. Scipy tf->ss can't handle MIMO, but static + # MIMO is an easy special case we can check for here + maxn = max(max(len(n) for n in nrow) + for nrow in sys.num) + maxd = max(max(len(d) for d in drow) + for drow in sys.den) + if 1 == maxn and 1 == maxd: + D = empty((sys.noutputs, sys.ninputs), dtype=float) + for i, j in itertools.product(range(sys.noutputs), + range(sys.ninputs)): + D[i, j] = sys.num[i][j][0] / sys.den[i][j][0] + newsys = StateSpace([], [], [], D, sys.dt) + else: + if sys.ninputs != 1 or sys.noutputs != 1: + raise TypeError("No support for MIMO without slycot") + + # TODO: do we want to squeeze first and check dimenations? + # I think this will fail if num and den aren't 1-D after + # the squeeze + A, B, C, D = \ + sp.signal.tf2ss(squeeze(sys.num), squeeze(sys.den)) + newsys = StateSpace(A, B, C, D, sys.dt) + + # Copy over the signal (and system) names + newsys._copy_names( + sys, + prefix_suffix_name='converted' if use_prefix_suffix else None) + return newsys + + 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(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, name=None): + """Generate a random state space. + + This does the actual random state space generation expected from rss and + drss. cdtype is 'c' for continuous systems and 'd' for discrete systems. + + """ + + # Probability of repeating a previous root. + pRepeat = 0.05 + # Probability of choosing a real root. Note that when choosing a complex + # root, the conjugate gets chosen as well. So the expected proportion of + # real roots is pReal / (pReal + 2 * (1 - pReal)). + pReal = 0.6 + # Probability that an element in B or C will not be masked out. + pBCmask = 0.8 + # Probability that an element in D will not be masked out. + pDmask = 0.3 + # Probability that D = 0. + pDzero = 0.5 + + # Check for valid input arguments. + if states < 1 or states % 1: + raise ValueError("states must be a positive integer. states = %g." % + states) + if inputs < 1 or inputs % 1: + raise ValueError("inputs must be a positive integer. inputs = %g." % + inputs) + if outputs < 1 or outputs % 1: + raise ValueError("outputs must be a positive integer. outputs = %g." % + outputs) + if cdtype not in ['c', 'd']: + raise ValueError("cdtype must be `c` or `d`") + + # Make some poles for A. Preallocate a complex array. + poles = zeros(states) + zeros(states) * 0.j + i = 0 + + while i < states: + if rand() < pRepeat and i != 0 and i != states - 1: + # Small chance of copying poles, if we're not at the first or last + # element. + if poles[i-1].imag == 0: + # Copy previous real pole. + poles[i] = poles[i-1] + i += 1 + else: + # Copy previous complex conjugate pair of poles. + poles[i:i+2] = poles[i-2:i] + i += 2 + elif rand() < pReal or i == states - 1: + # No-oscillation pole. + if cdtype == 'c': + poles[i] = -exp(randn()) + 0.j + else: + poles[i] = 2. * rand() - 1. + i += 1 + else: + # Complex conjugate pair of oscillating poles. + if cdtype == 'c': + poles[i] = complex(-exp(randn()), 3. * exp(randn())) + else: + mag = rand() + phase = 2. * math.pi * rand() + poles[i] = complex(mag * cos(phase), mag * sin(phase)) + poles[i+1] = complex(poles[i].real, -poles[i].imag) + i += 2 + + # Now put the poles in A as real blocks on the diagonal. + A = zeros((states, states)) + i = 0 + while i < states: + if poles[i].imag == 0: + A[i, i] = poles[i].real + i += 1 + else: + A[i, i] = A[i+1, i+1] = poles[i].real + A[i, i+1] = poles[i].imag + A[i+1, i] = -poles[i].imag + i += 2 + # Finally, apply a transformation so that A is not block-diagonal. + while True: + T = randn(states, states) + try: + A = solve(T, A) @ T # A = T \ A @ T + break + except LinAlgError: + # In the unlikely event that T is rank-deficient, iterate again. + pass + + # Make the remaining matrices. + B = randn(states, inputs) + C = randn(outputs, states) + D = randn(outputs, inputs) + + # Make masks to zero out some of the elements. + while True: + Bmask = rand(states, inputs) < pBCmask + if any(Bmask): # Retry if we get all zeros. + break + while True: + Cmask = rand(outputs, states) < pBCmask + if any(Cmask): # Retry if we get all zeros. + break + if rand() < pDzero: + Dmask = zeros((outputs, inputs)) + else: + Dmask = rand(outputs, inputs) < pDmask + + # Apply masks. + B = B * Bmask + C = C * Cmask + D = D * Dmask if not strictly_proper else zeros(D.shape) + + if cdtype == 'c': + ss_args = (A, B, C, D) + else: + ss_args = (A, B, C, D, True) + return StateSpace(*ss_args, name=name) + + +# Convert a MIMO system to a SISO system +# TODO: add discrete time check +def _mimo2siso(sys, input, output, warn_conversion=False): + # pylint: disable=W0622 + """ + Convert a MIMO system to a SISO system. (Convert a system with multiple + inputs and/or outputs, to a system with a single input and output.) + + The input and output that are used in the SISO system can be selected + with the parameters ``input`` and ``output``. All other inputs are set + to 0, all other outputs are ignored. + + If ``sys`` is already a SISO system, it will be returned unaltered. + + Parameters + ---------- + sys : StateSpace + Linear (MIMO) system that should be converted. + input : int + Index of the input that will become the SISO system's only input. + output : int + Index of the output that will become the SISO system's only output. + warn_conversion : bool, optional + If `True`, print a message when sys is a MIMO system, + warning that a conversion will take place. Default is False. + + Returns + sys : StateSpace + The converted (SISO) system. + """ + if not (isinstance(input, int) and isinstance(output, int)): + raise TypeError("Parameters ``input`` and ``output`` must both " + "be integer numbers.") + if not (0 <= input < sys.ninputs): + raise ValueError("Selected input does not exist. " + "Selected input: {sel}, " + "number of system inputs: {ext}." + .format(sel=input, ext=sys.ninputs)) + if not (0 <= output < sys.noutputs): + raise ValueError("Selected output does not exist. " + "Selected output: {sel}, " + "number of system outputs: {ext}." + .format(sel=output, ext=sys.noutputs)) + # Convert sys to SISO if necessary + if sys.ninputs > 1 or sys.noutputs > 1: + if warn_conversion: + warn("Converting MIMO system to SISO system. " + "Only input {i} and output {o} are used." + .format(i=input, o=output)) + # $X = A*X + B*U + # Y = C*X + D*U + new_B = sys.B[:, input] + new_C = sys.C[output, :] + new_D = sys.D[output, input] + sys = StateSpace(sys.A, new_B, new_C, new_D, sys.dt, + name=sys.name, + inputs=sys.input_labels[input], outputs=sys.output_labels[output]) + + return sys + + +def _mimo2simo(sys, input, warn_conversion=False): + # pylint: disable=W0622 + """ + Convert a MIMO system to a SIMO system. (Convert a system with multiple + inputs and/or outputs, to a system with a single input but possibly + multiple outputs.) + + The input that is used in the SIMO system can be selected with the + parameter ``input``. All other inputs are set to 0, all other + outputs are ignored. + + If ``sys`` is already a SIMO system, it will be returned unaltered. + + Parameters + ---------- + sys: StateSpace + Linear (MIMO) system that should be converted. + input: int + Index of the input that will become the SIMO system's only input. + warn_conversion: bool + If True: print a warning message when sys is a MIMO system. + Warn that a conversion will take place. + + Returns + ------- + sys: StateSpace + The converted (SIMO) system. + """ + if not (isinstance(input, int)): + raise TypeError("Parameter ``input`` be an integer number.") + if not (0 <= input < sys.ninputs): + raise ValueError("Selected input does not exist. " + "Selected input: {sel}, " + "number of system inputs: {ext}." + .format(sel=input, ext=sys.ninputs)) + # Convert sys to SISO if necessary + if sys.ninputs > 1: + if warn_conversion: + warn("Converting MIMO system to SIMO system. " + "Only input {i} is used." .format(i=input)) + # $X = A*X + B*U + # Y = C*X + D*U + new_B = sys.B[:, input:input+1] + new_D = sys.D[:, input:input+1] + sys = StateSpace(sys.A, new_B, sys.C, new_D, sys.dt, + name=sys.name, + inputs=sys.input_labels[input], outputs=sys.output_labels) + + return sys diff --git a/control/stochsys.py b/control/stochsys.py index 85e108336..94dee3a95 100644 --- a/control/stochsys.py +++ b/control/stochsys.py @@ -20,13 +20,13 @@ import scipy as sp from math import sqrt -from .iosys import InputOutputSystem, LinearIOSystem, NonlinearIOSystem +from .nlsys import InputOutputSystem, NonlinearIOSystem from .lti import LTI -from .namedio import isctime, isdtime -from .namedio import _process_indices, _process_labels, \ +from .iosys import isctime, isdtime +from .iosys import _process_indices, _process_labels, \ _process_control_disturbance_indices from .mateqn import care, dare, _check_shape -from .statesp import StateSpace, _ssmatrix +from .statesp import StateSpace, LinearIOSystem, _ssmatrix from .exception import ControlArgument, ControlNotImplemented from .config import _process_legacy_keyword diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 1547b7e22..1d3abe2c2 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -273,7 +273,7 @@ def test_change_default_dt(self, dt): ct.set_defaults('control', default_dt=dt) assert ct.ss(1, 0, 0, 1).dt == dt assert ct.tf(1, [1, 1]).dt == dt - nlsys = ct.iosys.NonlinearIOSystem( + nlsys = ct.nlsys.NonlinearIOSystem( lambda t, x, u: u * x * x, lambda t, x, u: x, inputs=1, outputs=1) assert nlsys.dt == dt diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 1a383c2a7..db86a5e6b 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -492,7 +492,7 @@ def test_unrecognized_keyword(self): def test_named_signals(): - ct.namedio.NamedIOSystem._idCounter = 0 + ct.iosys.NamedIOSystem._idCounter = 0 h1 = TransferFunction([1], [1, 2, 2]) h2 = TransferFunction([1], [0.1, 1]) omega = np.logspace(-1, 2, 10) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 1fe57d577..649d38e66 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -16,7 +16,7 @@ from math import sqrt import control as ct -from control import iosys as ios +from control import nlsys as ios class TestIOSys: @@ -54,7 +54,7 @@ class TSys: def test_linear_iosys(self, tsys): # Create an input/output system from the linear system linsys = tsys.siso_linsys - iosys = ios.LinearIOSystem(linsys).copy() + iosys = ct.LinearIOSystem(linsys).copy() # Make sure that the right hand side matches linear system for x, u in (([0, 0], 0), ([1, 0], 0), ([0, 1], 0), ([0, 0], 1)): @@ -71,8 +71,8 @@ def test_linear_iosys(self, tsys): # Make sure that a static linear system has dt=None # and otherwise dt is as specified - assert ios.LinearIOSystem(tsys.staticgain).dt is None - assert ios.LinearIOSystem(tsys.staticgain, dt=.1).dt == .1 + assert ct.LinearIOSystem(tsys.staticgain).dt is None + assert ct.LinearIOSystem(tsys.staticgain, dt=.1).dt == .1 def test_tf2io(self, tsys): # Create a transfer function from the state space system @@ -194,7 +194,7 @@ def kincar_output(t, x, u, params): def test_linearize(self, tsys, kincar): # Create a single input/single output linear system linsys = tsys.siso_linsys - iosys = ios.LinearIOSystem(linsys) + iosys = ct.LinearIOSystem(linsys) # Linearize it and make sure we get back what we started with linearized = iosys.linearize([0, 0], 0) @@ -260,9 +260,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, name='iosys1') + iosys1 = ct.LinearIOSystem(linsys1, name='iosys1') linsys2 = tsys.siso_linsys - iosys2 = ios.LinearIOSystem(linsys2, name='iosys2') + iosys2 = ct.LinearIOSystem(linsys2, name='iosys2') # Connect systems in different ways and compare to StateSpace linsys_series = linsys2 * linsys1 @@ -285,7 +285,7 @@ def test_connect(self, tsys): # Connect systems with different timebases linsys2c = tsys.siso_linsys linsys2c.dt = 0 # Reset the timebase - iosys2c = ios.LinearIOSystem(linsys2c) + iosys2c = ct.LinearIOSystem(linsys2c) iosys_series = ios.InterconnectedSystem( [iosys1, iosys2c], # systems [[1, 0]], # interconnection (series) @@ -336,9 +336,9 @@ def test_connect(self, tsys): def test_connect_spec_variants(self, tsys, connections, inplist, outlist): # Define a couple of (linear) systems to interconnection linsys1 = tsys.siso_linsys - iosys1 = ios.LinearIOSystem(linsys1, name="sys1") + iosys1 = ct.LinearIOSystem(linsys1, name="sys1") linsys2 = tsys.siso_linsys - iosys2 = ios.LinearIOSystem(linsys2, name="sys2") + iosys2 = ct.LinearIOSystem(linsys2, name="sys2") # Simple series connection linsys_series = linsys2 * linsys1 @@ -371,9 +371,9 @@ def test_connect_spec_variants(self, tsys, connections, inplist, outlist): def test_connect_spec_warnings(self, tsys, connections, inplist, outlist): # Define a couple of (linear) systems to interconnection linsys1 = tsys.siso_linsys - iosys1 = ios.LinearIOSystem(linsys1, name="sys1") + iosys1 = ct.LinearIOSystem(linsys1, name="sys1") linsys2 = tsys.siso_linsys - iosys2 = ios.LinearIOSystem(linsys2, name="sys2") + iosys2 = ct.LinearIOSystem(linsys2, name="sys2") # Simple series connection linsys_series = linsys2 * linsys1 @@ -396,7 +396,7 @@ def test_connect_spec_warnings(self, tsys, connections, inplist, outlist): def test_static_nonlinearity(self, tsys): # Linear dynamical system linsys = tsys.siso_linsys - ioslin = ios.LinearIOSystem(linsys) + ioslin = ct.LinearIOSystem(linsys) # Nonlinear saturation sat = lambda u: u if abs(u) < 1 else np.sign(u) @@ -417,11 +417,11 @@ def test_static_nonlinearity(self, tsys): np.testing.assert_array_almost_equal(lti_y, ios_y, decimal=2) - @pytest.mark.filterwarnings("ignore:Duplicate name::control.iosys") + @pytest.mark.filterwarnings("ignore:Duplicate name::control.nlsys") def test_algebraic_loop(self, tsys): # Create some linear and nonlinear systems to play with linsys = tsys.siso_linsys - lnios = ios.LinearIOSystem(linsys) + lnios = ct.LinearIOSystem(linsys) nlios = ios.NonlinearIOSystem(None, \ lambda t, x, u, params: u*u, inputs=1, outputs=1) nlios1 = nlios.copy(name='nlios1') @@ -474,7 +474,7 @@ def test_algebraic_loop(self, tsys): # Algebraic loop due to feedthrough term linsys = ct.StateSpace( [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[1]]) - lnios = ios.LinearIOSystem(linsys) + lnios = ct.LinearIOSystem(linsys) iosys = ios.InterconnectedSystem( [nlios, lnios], # linear system w/ nonlinear feedback [[0, 1], # feedback interconnection @@ -489,8 +489,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, name='linio1') - linio2 = ios.LinearIOSystem(linsys, name='linio2') + linio1 = ct.LinearIOSystem(linsys, name='linio1') + linio2 = ct.LinearIOSystem(linsys, name='linio2') linsys_parallel = linsys + linsys iosys_parallel = linio1 + linio2 @@ -513,7 +513,7 @@ def test_rmul(self, tsys): # Linear system with input and output nonlinearities # Also creates a nested interconnected system - ioslin = ios.LinearIOSystem(tsys.siso_linsys) + ioslin = ct.LinearIOSystem(tsys.siso_linsys) nlios = ios.NonlinearIOSystem(None, \ lambda t, x, u, params: u*u, inputs=1, outputs=1) sys1 = nlios * ioslin @@ -538,7 +538,7 @@ def test_neg(self, tsys): # Linear system with input nonlinearity # Also creates a nested interconnected system - ioslin = ios.LinearIOSystem(tsys.siso_linsys) + ioslin = ct.LinearIOSystem(tsys.siso_linsys) sys = (ioslin) * (-nlios) # Make sure we got the right thing (via simulation comparison) @@ -551,7 +551,7 @@ def test_feedback(self, tsys): T, U, X0 = tsys.T, tsys.U, tsys.X0 # Linear system with constant feedback (via "nonlinear" mapping) - ioslin = ios.LinearIOSystem(tsys.siso_linsys) + ioslin = ct.LinearIOSystem(tsys.siso_linsys) nlios = ios.NonlinearIOSystem(None, \ lambda t, x, u, params: u, inputs=1, outputs=1) iosys = ct.feedback(ioslin, nlios) @@ -570,9 +570,9 @@ def test_bdalg_functions(self, tsys): # Set up systems to be composed linsys1 = tsys.mimo_linsys1 - linio1 = ios.LinearIOSystem(linsys1) + linio1 = ct.LinearIOSystem(linsys1) linsys2 = tsys.mimo_linsys2 - linio2 = ios.LinearIOSystem(linsys2) + linio2 = ct.LinearIOSystem(linsys2) # Series interconnection linsys_series = ct.series(linsys1, linsys2) @@ -616,9 +616,9 @@ def test_algebraic_functions(self, tsys): # Set up systems to be composed linsys1 = tsys.mimo_linsys1 - linio1 = ios.LinearIOSystem(linsys1) + linio1 = ct.LinearIOSystem(linsys1) linsys2 = tsys.mimo_linsys2 - linio2 = ios.LinearIOSystem(linsys2) + linio2 = ct.LinearIOSystem(linsys2) # Multiplication linsys_mul = linsys2 * linsys1 @@ -669,13 +669,13 @@ def test_nonsquare_bdalg(self, tsys): linsys_2i3o = ct.StateSpace( [[-1, 1, 0], [0, -2, 0], [0, 0, -3]], [[1, 0], [0, 1], [1, 1]], [[1, 0, 0], [0, 1, 0], [0, 0, 1]], np.zeros((3, 2))) - iosys_2i3o = ios.LinearIOSystem(linsys_2i3o) + iosys_2i3o = ct.LinearIOSystem(linsys_2i3o) linsys_3i2o = ct.StateSpace( [[-1, 1, 0], [0, -2, 0], [0, 0, -3]], [[1, 0, 0], [0, 1, 0], [0, 0, 1]], [[1, 0, 1], [0, 1, -1]], np.zeros((2, 3))) - iosys_3i2o = ios.LinearIOSystem(linsys_3i2o) + iosys_3i2o = ct.LinearIOSystem(linsys_3i2o) # Multiplication linsys_multiply = linsys_3i2o * linsys_2i3o @@ -713,7 +713,7 @@ def test_discrete(self, tsys): # Create some linear and nonlinear systems to play with linsys = ct.StateSpace( [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[0]], True) - lnios = ios.LinearIOSystem(linsys) + lnios = ct.LinearIOSystem(linsys) # Set up parameters for simulation T, U, X0 = tsys.T, tsys.U, tsys.X0 @@ -727,7 +727,7 @@ def test_discrete(self, tsys): # Test MIMO system, converted to discrete time linsys = ct.StateSpace(tsys.mimo_linsys1) linsys.dt = tsys.T[1] - tsys.T[0] - lnios = ios.LinearIOSystem(linsys) + lnios = ct.LinearIOSystem(linsys) # Set up parameters for simulation T = tsys.T @@ -875,7 +875,7 @@ def test_find_eqpts_dfan(self, tsys): # Unobservable system linsys = ct.StateSpace( [[-1, 1], [0, -2]], [[0], [1]], [[0, 0]], [[0]]) - lnios = ios.LinearIOSystem(linsys) + lnios = ct.LinearIOSystem(linsys) # If result is returned, user has to check xeq, ueq, result = ios.find_eqpt( @@ -938,7 +938,7 @@ def test_params(self, tsys): # Check for warning if we try to set params for LinearIOSystem linsys = tsys.siso_linsys - iosys = ios.LinearIOSystem(linsys) + iosys = ct.LinearIOSystem(linsys) T, U, X0 = tsys.T, tsys.U, tsys.X0 lti_t, lti_y = ct.forced_response(linsys, T, U, X0) with pytest.warns(UserWarning, match="LinearIOSystem.*ignored"): @@ -963,7 +963,7 @@ def test_named_signals(self, tsys): outputs = ['y[0]', 'y[1]'], states = tsys.mimo_linsys1.nstates, name = 'sys1') - sys2 = ios.LinearIOSystem(tsys.mimo_linsys2, + sys2 = ct.LinearIOSystem(tsys.mimo_linsys2, inputs = ['u[0]', 'u[1]'], outputs = ['y[0]', 'y[1]'], name = 'sys2') @@ -1063,7 +1063,7 @@ def test_sys_naming_convention(self, tsys): ct.config.use_legacy_defaults('0.8.4') # changed delims in 0.9.0 # Create a system with a known ID - ct.namedio.NamedIOSystem._idCounter = 0 + ct.iosys.NamedIOSystem._idCounter = 0 sys = ct.ss( tsys.mimo_linsys1.A, tsys.mimo_linsys1.B, tsys.mimo_linsys1.C, tsys.mimo_linsys1.D) @@ -1131,7 +1131,7 @@ def test_signals_naming_convention_0_8_4(self, tsys): ct.config.use_legacy_defaults('0.8.4') # changed delims in 0.9.0 # Create a system with a known ID - ct.namedio.NamedIOSystem._idCounter = 0 + ct.iosys.NamedIOSystem._idCounter = 0 sys = ct.ss( tsys.mimo_linsys1.A, tsys.mimo_linsys1.B, tsys.mimo_linsys1.C, tsys.mimo_linsys1.D) @@ -1471,10 +1471,10 @@ def test_duplicates(self, tsys): def test_linear_interconnection(): ss_sys1 = ct.rss(2, 2, 2, strictly_proper=True) ss_sys2 = ct.rss(2, 2, 2) - io_sys1 = ios.LinearIOSystem( + io_sys1 = ct.LinearIOSystem( ss_sys1, inputs = ('u[0]', 'u[1]'), outputs = ('y[0]', 'y[1]'), name = 'sys1') - io_sys2 = ios.LinearIOSystem( + io_sys2 = ct.LinearIOSystem( ss_sys2, inputs = ('u[0]', 'u[1]'), outputs = ('y[0]', 'y[1]'), name = 'sys2') nl_sys2 = ios.NonlinearIOSystem( @@ -1511,11 +1511,11 @@ def test_linear_interconnection(): ['sys2.y[1]'], ['sys2.u[1]']]) assert isinstance(nl_connect, ios.InterconnectedSystem) - assert not isinstance(nl_connect, ios.LinearICSystem) + assert not isinstance(nl_connect, ct.LinearICSystem) # Now take its linearization ss_connect = nl_connect.linearize(0, 0) - assert isinstance(ss_connect, ios.LinearIOSystem) + assert isinstance(ss_connect, ct.LinearIOSystem) io_connect = ios.interconnect( (io_sys1, io_sys2), @@ -1531,8 +1531,8 @@ def test_linear_interconnection(): ['sys2.y[1]'], ['sys2.u[1]']]) assert isinstance(io_connect, ios.InterconnectedSystem) - assert isinstance(io_connect, ios.LinearICSystem) - assert isinstance(io_connect, ios.LinearIOSystem) + assert isinstance(io_connect, ct.LinearICSystem) + assert isinstance(io_connect, ct.LinearIOSystem) assert isinstance(io_connect, ct.StateSpace) # Finally compare the linearization with the linear system @@ -1543,15 +1543,15 @@ def test_linear_interconnection(): # make sure interconnections of linear systems are linear and # if a nonlinear system is included then system is nonlinear - assert isinstance(ss_siso*ss_siso, ios.LinearIOSystem) - assert isinstance(tf_siso*ss_siso, ios.LinearIOSystem) - assert isinstance(ss_siso*tf_siso, ios.LinearIOSystem) - assert ~isinstance(ss_siso*nl_siso, ios.LinearIOSystem) - assert ~isinstance(nl_siso*ss_siso, ios.LinearIOSystem) - assert ~isinstance(nl_siso*nl_siso, ios.LinearIOSystem) - assert ~isinstance(tf_siso*nl_siso, ios.LinearIOSystem) - assert ~isinstance(nl_siso*tf_siso, ios.LinearIOSystem) - assert ~isinstance(nl_siso*nl_siso, ios.LinearIOSystem) + assert isinstance(ss_siso*ss_siso, ct.LinearIOSystem) + assert isinstance(tf_siso*ss_siso, ct.LinearIOSystem) + assert isinstance(ss_siso*tf_siso, ct.LinearIOSystem) + assert ~isinstance(ss_siso*nl_siso, ct.LinearIOSystem) + assert ~isinstance(nl_siso*ss_siso, ct.LinearIOSystem) + assert ~isinstance(nl_siso*nl_siso, ct.LinearIOSystem) + assert ~isinstance(tf_siso*nl_siso, ct.LinearIOSystem) + assert ~isinstance(nl_siso*tf_siso, ct.LinearIOSystem) + assert ~isinstance(nl_siso*nl_siso, ct.LinearIOSystem) def predprey(t, x, u, params={}): diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 83026391c..6dc46f7c4 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -239,8 +239,8 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): mutable_ok = { # initial and date control.flatsys.SystemTrajectory.__init__, # RMM, 18 Nov 2022 control.freqplot._add_arrows_to_line2D, # RMM, 18 Nov 2022 - control.namedio._process_dt_keyword, # RMM, 13 Nov 2022 - control.namedio._process_namedio_keywords, # RMM, 18 Nov 2022 + control.iosys._process_dt_keyword, # RMM, 13 Nov 2022 + control.iosys._process_namedio_keywords, # RMM, 18 Nov 2022 } @pytest.mark.parametrize("module", [control, control.flatsys]) diff --git a/control/tests/namedio_test.py b/control/tests/namedio_test.py index abd25f2d9..3139503aa 100644 --- a/control/tests/namedio_test.py +++ b/control/tests/namedio_test.py @@ -28,7 +28,7 @@ def test_named_ss(): 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 + ct.iosys.NamedIOSystem._idCounter = 0 sys = ct.ss(A, B, C, D) assert sys.name == 'sys[0]' assert sys.input_labels == ['u[0]', 'u[1]'] @@ -42,7 +42,7 @@ def test_named_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 ct.iosys.NamedIOSystem._idCounter == 1 assert sys.input_labels == ['u1', 'u2'] assert sys.output_labels == ['y1', 'y2'] assert sys.state_labels == ['x1', 'x2'] @@ -52,7 +52,7 @@ def test_named_ss(): # 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 ct.iosys.NamedIOSystem._idCounter == 1 assert sys.input_labels == ['u1'] assert sys.output_labels == ['y1', 'y2'] assert sys.state_labels == ['x1', 'x2', 'x3'] @@ -98,7 +98,7 @@ def test_named_ss(): ]) def test_io_naming(fun, args, kwargs): # Reset the ID counter to get uniform generic names - ct.namedio.NamedIOSystem._idCounter = 0 + ct.iosys.NamedIOSystem._idCounter = 0 # Create the system w/out any names sys_g = fun(*args, **kwargs) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 83dc58b49..cc1327e1f 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -20,7 +20,7 @@ from control.lti import evalfr from control.statesp import StateSpace, _convert_to_statespace, tf2ss, \ _statesp_defaults, _rss_generate, linfnorm -from control.iosys import ss, rss, drss +from control.statesp import ss, rss, drss from control.tests.conftest import slycotonly from control.xferfcn import TransferFunction, ss2tf diff --git a/control/tests/stochsys_test.py b/control/tests/stochsys_test.py index 91f4a1a08..8b846d4a0 100644 --- a/control/tests/stochsys_test.py +++ b/control/tests/stochsys_test.py @@ -467,12 +467,12 @@ def test_indices(ctrl_indices, dist_indices): # Create a system whose state we want to estimate if ctrl_indices is not None: - ctrl_idx = ct.namedio._process_indices( + ctrl_idx = ct.iosys._process_indices( ctrl_indices, 'control', sys.input_labels, sys.ninputs) dist_idx = [i for i in range(sys.ninputs) if i not in ctrl_idx] else: arg = -dist_indices if isinstance(dist_indices, int) else dist_indices - dist_idx = ct.namedio._process_indices( + dist_idx = ct.iosys._process_indices( arg, 'disturbance', sys.input_labels, sys.ninputs) ctrl_idx = [i for i in range(sys.ninputs) if i not in dist_idx] sysm = ct.ss(sys.A, sys.B[:, ctrl_idx], sys.C, sys.D[:, ctrl_idx]) diff --git a/control/timeresp.py b/control/timeresp.py index 2e25331d1..2fd8b22e5 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -80,7 +80,7 @@ from . import config from .exception import pandas_check -from .namedio import isctime, isdtime +from .iosys import isctime, isdtime from .statesp import StateSpace, _convert_to_statespace, _mimo2simo, _mimo2siso from .xferfcn import TransferFunction diff --git a/control/xferfcn.py b/control/xferfcn.py index 7303d5e73..895619b86 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -60,7 +60,7 @@ from itertools import chain from re import sub from .lti import LTI, _process_frequency_response -from .namedio import common_timebase, isdtime, _process_namedio_keywords +from .iosys import common_timebase, isdtime, _process_namedio_keywords from .exception import ControlMIMONotImplemented from .frdata import FrequencyResponseData from . import config