diff --git a/control/iosys.py b/control/iosys.py index 66ca20ebb..afee5088c 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -3,16 +3,11 @@ # RMM, 28 April 2019 # # Additional features to add -# * Improve support for signal names, specially in operator overloads -# - Figure out how to handle "nested" names (icsys.sys[1].x[1]) -# - Use this to implement signal names for operators? # * 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 -# * Rename 'connections' as 'conlist' to match 'inplist' and 'outlist'? -# * Allow signal summation in InterconnectedSystem diagrams (via new output?) # """The :mod:`~control.iosys` module contains the @@ -36,14 +31,15 @@ import copy from warnings import warn -from .statesp import StateSpace, tf2ss +from .statesp import StateSpace, tf2ss, _convert_to_statespace from .timeresp import _check_convert_array, _process_time_response from .lti import isctime, isdtime, common_timebase from . import config __all__ = ['InputOutputSystem', 'LinearIOSystem', 'NonlinearIOSystem', 'InterconnectedSystem', 'LinearICSystem', 'input_output_response', - 'find_eqpt', 'linearize', 'ss2io', 'tf2io', 'interconnect'] + 'find_eqpt', 'linearize', 'ss2io', 'tf2io', 'interconnect', + 'summing_junction'] # Define module default parameter values _iosys_defaults = { @@ -481,9 +477,14 @@ def feedback(self, other=1, sign=-1, params={}): """ # TODO: add conversion to I/O system when needed if not isinstance(other, InputOutputSystem): - raise TypeError("Feedback around I/O system must be I/O system.") - - return new_io_sys + # 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: @@ -1846,7 +1847,7 @@ def tf2io(*args, **kwargs): # Function to create an interconnected system -def interconnect(syslist, connections=[], inplist=[], outlist=[], +def interconnect(syslist, connections=None, inplist=[], outlist=[], inputs=None, outputs=None, states=None, params={}, dt=None, name=None): """Interconnect a set of input/output systems. @@ -1893,8 +1894,18 @@ def interconnect(syslist, connections=[], inplist=[], outlist=[], and the special form '-sys.sig' can be used to specify a signal with gain -1. - If omitted, the connection map (matrix) can be specified using the - :func:`~control.InterconnectedSystem.set_connect_map` method. + 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 @@ -1966,24 +1977,33 @@ def interconnect(syslist, connections=[], inplist=[], outlist=[], Example ------- >>> P = control.LinearIOSystem( - >>> ct.rss(2, 2, 2, strictly_proper=True), name='P') + >>> control.rss(2, 2, 2, strictly_proper=True), name='P') >>> C = control.LinearIOSystem(control.rss(2, 2, 2), name='C') - >>> S = control.InterconnectedSystem( + >>> T = control.interconnect( >>> [P, C], >>> connections = [ - >>> ['P.u[0]', 'C.y[0]'], ['P.u[1]', 'C.y[0]'], + >>> ['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]'], >>> ) + For a SISO system, this example can be simplified by using the + :func:`~control.summing_block` function and the ability to automatically + interconnect signals with the same names: + + >>> P = control.tf2io(control.tf(1, [1, 0]), inputs='u', outputs='y') + >>> C = control.tf2io(control.tf(10, [1, 1]), inputs='e', outputs='u') + >>> sumblk = control.summing_junction(inputs=['r', '-y'], output='e') + >>> T = control.interconnect([P, C, sumblk], inplist='r', outlist='y') + Notes ----- If a system is duplicated in the list of systems to be connected, a warning is generated 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['iosys.linearized_system_name_prefix'] - and config.defaults['iosys.linearized_system_name_suffix'], with the + and config.defaults['iosys.linearized_system_name_suffix'], with the default being to add the suffix '$copy'$ to the system name. It is possible to replace lists in most of arguments with tuples instead, @@ -2001,6 +2021,78 @@ def interconnect(syslist, connections=[], inplist=[], outlist=[], :class:`~control.InputOutputSystem`. """ + # 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_index.keys(): + connect = [input_sys.name + "." + input_name] + for output_sys in syslist: + if input_name in output_sys.output_index.keys(): + connect.append(output_sys.name + "." + input_name) + if len(connect) > 1: + connections.append(connect) + elif connections is False: + # Use an empty connections list + connections = [] + + # Process input list + if not isinstance(inplist, (list, tuple)): + inplist = [inplist] + new_inplist = [] + for signal in inplist: + # Check for signal names without a system name + if isinstance(signal, str) and len(signal.split('.')) == 1: + # Get the signal name + name = signal[1:] if signal[0] == '-' else signal + sign = '-' if signal[0] == '-' else "" + + # Look for the signal name as a system input + new_name = None + for sys in syslist: + if name in sys.input_index.keys(): + if new_name is not None: + raise ValueError("signal %s is not unique" % name) + new_name = sign + sys.name + "." + name + + # Make sure we found the name + if new_name is None: + raise ValueError("could not find signal %s" % name) + else: + new_inplist.append(new_name) + else: + new_inplist.append(signal) + inplist = new_inplist + + # Process output list + if not isinstance(outlist, (list, tuple)): + outlist = [outlist] + new_outlist = [] + for signal in outlist: + # Check for signal names without a system name + if isinstance(signal, str) and len(signal.split('.')) == 1: + # Get the signal name + name = signal[1:] if signal[0] == '-' else signal + sign = '-' if signal[0] == '-' else "" + + # Look for the signal name as a system output + new_name = None + for sys in syslist: + if name in sys.output_index.keys(): + if new_name is not None: + raise ValueError("signal %s is not unique" % name) + new_name = sign + sys.name + "." + name + + # Make sure we found the name + if new_name is None: + raise ValueError("could not find signal %s" % name) + else: + new_outlist.append(new_name) + else: + new_outlist.append(signal) + outlist = new_outlist + newsys = InterconnectedSystem( syslist, connections=connections, inplist=inplist, outlist=outlist, inputs=inputs, outputs=outputs, states=states, @@ -2011,3 +2103,117 @@ def interconnect(syslist, connections=[], inplist=[], outlist=[], return LinearICSystem(newsys, None) return newsys + + +# Summing junction +def summing_junction(inputs, output='y', dimension=None, name=None, prefix='u'): + """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. + + Example + ------- + >>> P = control.tf2io(ct.tf(1, [1, 0]), inputs='u', outputs='y') + >>> C = control.tf2io(ct.tf(10, [1, 1]), inputs='e', outputs='u') + >>> sumblk = control.summing_junction(inputs=['r', '-y'], output='e') + >>> T = control.interconnect((P, C, sumblk), inplist='r', outlist='y') + + """ + # 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 + + # 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) diff --git a/control/statesp.py b/control/statesp.py index 0ee83cb7d..cac3ecb39 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -223,7 +223,7 @@ def __init__(self, *args, **kwargs): The default constructor is StateSpace(A, B, C, D), where A, B, C, D are matrices or equivalent objects. To create a discrete time system, - use StateSpace(A, B, C, D, dt) where 'dt' is the sampling time (or + use StateSpace(A, B, C, D, dt) where `dt` is the sampling time (or True for unspecified sampling time). To call the copy constructor, call StateSpace(sys), where sys is a StateSpace object. @@ -231,7 +231,7 @@ def __init__(self, *args, **kwargs): C matrices for rows or columns of zeros. If the zeros are such that a particular state has no effect on the input-output dynamics, then that state is removed from the A, B, and C matrices. If not specified, the - value is read from `config.defaults['statesp.remove_useless_states'] + value is read from `config.defaults['statesp.remove_useless_states']` (default = False). """ diff --git a/control/tests/interconnect_test.py b/control/tests/interconnect_test.py new file mode 100644 index 000000000..34daffb75 --- /dev/null +++ b/control/tests/interconnect_test.py @@ -0,0 +1,174 @@ +"""interconnect_test.py - test input/output interconnect function + +RMM, 22 Jan 2021 + +This set of unit tests covers the various operatons of the interconnect() +function, as well as some of the support functions associated with +interconnect(). + +Note: additional tests are available in iosys_test.py, which focuses on the +raw InterconnectedSystem constructor. This set of unit tests focuses on +functionality implemented in the interconnect() function itself. + +""" + +import pytest + +import numpy as np +import scipy as sp + +import control as ct + +@pytest.mark.parametrize("inputs, output, dimension, D", [ + [1, 1, None, [[1]] ], + ['u', 'y', None, [[1]] ], + [['u'], ['y'], None, [[1]] ], + [2, 1, None, [[1, 1]] ], + [['r', '-y'], ['e'], None, [[1, -1]] ], + [5, 1, None, np.ones((1, 5)) ], + ['u', 'y', 1, [[1]] ], + ['u', 'y', 2, [[1, 0], [0, 1]] ], + [['r', '-y'], ['e'], 2, [[1, 0, -1, 0], [0, 1, 0, -1]] ], +]) +def test_summing_junction(inputs, output, dimension, D): + ninputs = 1 if isinstance(inputs, str) else \ + inputs if isinstance(inputs, int) else len(inputs) + sum = ct.summing_junction( + inputs=inputs, output=output, dimension=dimension) + dim = 1 if dimension is None else dimension + np.testing.assert_array_equal(sum.A, np.ndarray((0, 0))) + np.testing.assert_array_equal(sum.B, np.ndarray((0, ninputs*dim))) + np.testing.assert_array_equal(sum.C, np.ndarray((dim, 0))) + np.testing.assert_array_equal(sum.D, D) + + +def test_summation_exceptions(): + # Bad input description + with pytest.raises(ValueError, match="could not parse input"): + sumblk = ct.summing_junction(None, 'y') + + # Bad output description + with pytest.raises(ValueError, match="could not parse output"): + sumblk = ct.summing_junction('u', None) + + # Bad input dimension + with pytest.raises(ValueError, match="unrecognized dimension"): + sumblk = ct.summing_junction('u', 'y', dimension=False) + + +def test_interconnect_implicit(): + """Test the use of implicit connections in interconnect()""" + import random + + # System definition + P = ct.ss2io( + ct.rss(2, 1, 1, strictly_proper=True), + inputs='u', outputs='y', name='P') + kp = ct.tf(random.uniform(1, 10), [1]) + ki = ct.tf(random.uniform(1, 10), [1, 0]) + C = ct.tf2io(kp + ki, inputs='e', outputs='u', name='C') + + # Block diagram computation + Tss = ct.feedback(P * C, 1) + + # Construct the interconnection explicitly + Tio_exp = ct.interconnect( + (C, P), + connections = [['P.u', 'C.u'], ['C.e', '-P.y']], + inplist='C.e', outlist='P.y') + + # Compare to bdalg computation + np.testing.assert_almost_equal(Tio_exp.A, Tss.A) + np.testing.assert_almost_equal(Tio_exp.B, Tss.B) + np.testing.assert_almost_equal(Tio_exp.C, Tss.C) + np.testing.assert_almost_equal(Tio_exp.D, Tss.D) + + # Construct the interconnection via a summing junction + sumblk = ct.summing_junction(inputs=['r', '-y'], output='e', name="sum") + Tio_sum = ct.interconnect( + (C, P, sumblk), inplist=['r'], outlist=['y']) + + np.testing.assert_almost_equal(Tio_sum.A, Tss.A) + np.testing.assert_almost_equal(Tio_sum.B, Tss.B) + np.testing.assert_almost_equal(Tio_sum.C, Tss.C) + np.testing.assert_almost_equal(Tio_sum.D, Tss.D) + + # Setting connections to False should lead to an empty connection map + empty = ct.interconnect( + (C, P, sumblk), connections=False, inplist=['r'], outlist=['y']) + np.testing.assert_array_equal(empty.connect_map, np.zeros((4, 3))) + + # Implicit summation across repeated signals + kp_io = ct.tf2io(kp, inputs='e', outputs='u', name='kp') + ki_io = ct.tf2io(ki, inputs='e', outputs='u', name='ki') + Tio_sum = ct.interconnect( + (kp_io, ki_io, P, sumblk), inplist=['r'], outlist=['y']) + np.testing.assert_almost_equal(Tio_sum.A, Tss.A) + np.testing.assert_almost_equal(Tio_sum.B, Tss.B) + np.testing.assert_almost_equal(Tio_sum.C, Tss.C) + np.testing.assert_almost_equal(Tio_sum.D, Tss.D) + + # TODO: interconnect a MIMO system using implicit connections + # P = control.ss2io( + # control.rss(2, 2, 2, strictly_proper=True), + # input_prefix='u', output_prefix='y', name='P') + # C = control.ss2io( + # control.rss(2, 2, 2), + # input_prefix='e', output_prefix='u', name='C') + # sumblk = control.summing_junction( + # inputs=['r', '-y'], output='e', dimension=2) + # S = control.interconnect([P, C, sumblk], inplist='r', outlist='y') + + # Make sure that repeated inplist/outlist names generate an error + # Input not unique + Cbad = ct.tf2io(ct.tf(10, [1, 1]), inputs='r', outputs='x', name='C') + with pytest.raises(ValueError, match="not unique"): + Tio_sum = ct.interconnect( + (Cbad, P, sumblk), inplist=['r'], outlist=['y']) + + # Output not unique + Cbad = ct.tf2io(ct.tf(10, [1, 1]), inputs='e', outputs='y', name='C') + with pytest.raises(ValueError, match="not unique"): + Tio_sum = ct.interconnect( + (Cbad, P, sumblk), inplist=['r'], outlist=['y']) + + # Signal not found + with pytest.raises(ValueError, match="could not find"): + Tio_sum = ct.interconnect( + (C, P, sumblk), inplist=['x'], outlist=['y']) + + with pytest.raises(ValueError, match="could not find"): + Tio_sum = ct.interconnect( + (C, P, sumblk), inplist=['r'], outlist=['x']) + +def test_interconnect_docstring(): + """Test the examples from the interconnect() docstring""" + + # MIMO interconnection (note: use [C, P] instead of [P, C] for state order) + P = ct.LinearIOSystem( + ct.rss(2, 2, 2, strictly_proper=True), name='P') + C = ct.LinearIOSystem(ct.rss(2, 2, 2), name='C') + T = ct.interconnect( + [C, P], + 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]'], + ) + T_ss = ct.feedback(P * C, ct.ss([], [], [], np.eye(2))) + np.testing.assert_almost_equal(T.A, T_ss.A) + np.testing.assert_almost_equal(T.B, T_ss.B) + np.testing.assert_almost_equal(T.C, T_ss.C) + np.testing.assert_almost_equal(T.D, T_ss.D) + + # Implicit interconnection (note: use [C, P, sumblk] for proper state order) + P = ct.tf2io(ct.tf(1, [1, 0]), inputs='u', outputs='y') + C = ct.tf2io(ct.tf(10, [1, 1]), inputs='e', outputs='u') + sumblk = ct.summing_junction(inputs=['r', '-y'], output='e') + T = ct.interconnect([C, P, sumblk], inplist='r', outlist='y') + T_ss = ct.feedback(P * C, 1) + np.testing.assert_almost_equal(T.A, T_ss.A) + np.testing.assert_almost_equal(T.B, T_ss.B) + np.testing.assert_almost_equal(T.C, T_ss.C) + np.testing.assert_almost_equal(T.D, T_ss.D) diff --git a/doc/control.rst b/doc/control.rst index 80119f691..500f6db3c 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -144,6 +144,7 @@ Nonlinear system support linearize input_output_response ss2io + summing_junction tf2io flatsys.point_to_point diff --git a/doc/iosys.rst b/doc/iosys.rst index b2ac752af..1b160bad1 100644 --- a/doc/iosys.rst +++ b/doc/iosys.rst @@ -156,7 +156,8 @@ The input to the controller is `u`, consisting of the vector of hare and lynx populations followed by the desired lynx population. To connect the controller to the predatory-prey model, we create an -:class:`~control.InterconnectedSystem`: +:class:`~control.InterconnectedSystem` using the :func:`~control.interconnect` +function: .. code-block:: python @@ -189,13 +190,83 @@ Finally, we simulate the closed loop system: plt.legend(['input']) plt.show(block=False) +Additional features +=================== + +The I/O systems module has a number of other features that can be used to +simplify the creation of interconnected input/output systems. + +Summing junction +---------------- + +The :func:`~control.summing_junction` function can be used to create an +input/output system that takes the sum of an arbitrary number of inputs. For +ezample, to create an input/output system that takes the sum of three inputs, +use the command + +.. code-block:: python + + sumblk = ct.summing_junction(3) + +By default, the name of the inputs will be of the form ``u[i]`` and the output +will be ``y``. This can be changed by giving an explicit list of names:: + + sumblk = ct.summing_junction(inputs=['a', 'b', 'c'], output='d') + +A more typical usage would be to define an input/output system that compares a +reference signal to the output of the process and computes the error:: + + sumblk = ct.summing_junction(inputs=['r', '-y'], output='e') + +Note the use of the minus sign as a means of setting the sign of the input 'y' +to be negative instead of positive. + +It is also possible to define "vector" summing blocks that take +multi-dimensional inputs and produce a multi-dimensional output. For example, +the command + +.. code-block:: python + + sumblk = ct.summing_junction(inputs=['r', '-y'], output='e', dimension=2) + +will produce an input/output block that implements ``e[0] = r[0] - y[0]`` and +``e[1] = r[1] - y[1]``. + +Automatic connections using signal names +---------------------------------------- + +The :func:`~control.interconnect` function allows the interconnection of +multiple systems by using signal names of the form ``sys.signal``. In many +situations, it can be cumbersome to explicitly connect all of the appropriate +inputs and outputs. As an alternative, if the ``connections`` keyword is +omitted, the :func:`~control.interconnect` function will connect all signals +of the same name to each other. This can allow for simplified methods of +interconnecting systems, especially when combined with the +:func:`~control.summing_junction` function. For example, the following code +will create a unity gain, negative feedback system:: + + P = control.tf2io(control.tf(1, [1, 0]), inputs='u', outputs='y') + C = control.tf2io(control.tf(10, [1, 1]), inputs='e', outputs='u') + sumblk = control.summing_junction(inputs=['r', '-y'], output='e') + T = control.interconnect([P, C, sumblk], inplist='r', outlist='y') + +If a signal name appears in multiple outputs then that signal will be summed +when it is interconnected. Similarly, if a signal name appears in multiple +inputs then all systems using that signal name will receive the same input. +The :func:`~control.interconnect` function will generate an error if an signal +listed in ``inplist`` or ``outlist`` (corresponding to the inputs and outputs +of the interconnected system) is not found, but inputs and outputs of +individual systems that are not connected to other systems are left +unconnected (so be careful!). + + Module classes and functions ============================ Input/output system classes --------------------------- .. autosummary:: - + ~control.InputOutputSystem ~control.InterconnectedSystem ~control.LinearICSystem @@ -211,5 +282,5 @@ Input/output system functions ~control.input_output_response ~control.interconnect ~control.ss2io + ~control.summing_junction ~control.tf2io -