Repository: python-control/python-control
Pull request #1019 (2024-06-30 15:14:24)

Small improvements to nlsys, bdalg
This PR provides some small changes to nonlinear I/O systems process:

* Allows system name to be overridden in `linearize`, even if `copy_names` is `False`.
* Allows renaming of system/signal names in bdalg functions:
  ```
  sys = ct.series(sys1, sys2, inputs='u', outputs='y')
  ```
* New `update_names` method for I/O systems that allows signal and system names to be updated.
* Refactoring of code for processing `x0`, `u0` keywords in `linearize` and `input_output_response` to provide common functionality in allowing concatenation of lists and zero padding ("vector element processing").
* Improved error messages when `x0` and `u0` don't match the expected size.
* If no output function is given in `nlsys`, which provides full state output, the output signal names are set to match the state names.
* Updated unit tests and documentation (including a new section on "vector element processing").



Patch:
diff --git a/control/bdalg.py b/control/bdalg.py
index ce800853..7bfd327e 100644
--- a/control/bdalg.py
+++ b/control/bdalg.py
@@ -54,17 +54,19 @@ $Id$
 """
 
 from functools import reduce
-import numpy as np
 from warnings import warn
-from . import xferfcn as tf
-from . import statesp as ss
+
+import numpy as np
+
 from . import frdata as frd
+from . import statesp as ss
+from . import xferfcn as tf
 from .iosys import InputOutputSystem
 
 __all__ = ['series', 'parallel', 'negate', 'feedback', 'append', 'connect']
 
 
-def series(sys1, *sysn):
+def series(sys1, *sysn, **kwargs):
     r"""series(sys1, sys2, [..., sysn])
 
     Return the series connection (`sysn` \* ...\  \*) `sys2` \* `sys1`.
@@ -79,6 +81,20 @@ def series(sys1, *sysn):
     out : scalar, array, or :class:`InputOutputSystem`
         Series interconnection of the systems.
 
+    Other Parameters
+    ----------------
+    inputs, outputs : str, or list of str, optional
+        List of strings that name the individual signals.  If not given,
+        signal names will be of the form `s[i]` (where `s` is one of `u`,
+        or `y`). See :class:`InputOutputSystem` for more information.
+    states : str, or list of str, optional
+        List of names for system states.  If not given, state names will be
+        of of the form `x[i]` for interconnections of linear systems or
+        '<subsys_name>.<state_name>' for interconnected nonlinear systems.
+    name : string, optional
+        System name (used for specifying signals). If unspecified, a generic
+        name <sys[id]> is generated with a unique integer id.
+
     Raises
     ------
     ValueError
@@ -117,10 +133,12 @@ def series(sys1, *sysn):
     (2, 1, 5)
 
     """
-    return reduce(lambda x, y: y * x, sysn, sys1)
+    sys = reduce(lambda x, y: y * x, sysn, sys1)
+    sys.update_names(**kwargs)
+    return sys
 
 
-def parallel(sys1, *sysn):
+def parallel(sys1, *sysn, **kwargs):
     r"""parallel(sys1, sys2, [..., sysn])
 
     Return the parallel connection `sys1` + `sys2` (+ ...\  + `sysn`).
@@ -135,6 +153,20 @@ def parallel(sys1, *sysn):
     out : scalar, array, or :class:`InputOutputSystem`
         Parallel interconnection of the systems.
 
+    Other Parameters
+    ----------------
+    inputs, outputs : str, or list of str, optional
+        List of strings that name the individual signals.  If not given,
+        signal names will be of the form `s[i]` (where `s` is one of `u`,
+        or `y`). See :class:`InputOutputSystem` for more information.
+    states : str, or list of str, optional
+        List of names for system states.  If not given, state names will be
+        of of the form `x[i]` for interconnections of linear systems or
+        '<subsys_name>.<state_name>' for interconnected nonlinear systems.
+    name : string, optional
+        System name (used for specifying signals). If unspecified, a generic
+        name <sys[id]> is generated with a unique integer id.
+
     Raises
     ------
     ValueError
@@ -171,10 +203,11 @@ def parallel(sys1, *sysn):
     (3, 4, 7)
 
     """
-    return reduce(lambda x, y: x + y, sysn, sys1)
-
+    sys = reduce(lambda x, y: x + y, sysn, sys1)
+    sys.update_names(**kwargs)
+    return sys
 
-def negate(sys):
+def negate(sys, **kwargs):
     """
     Return the negative of a system.
 
@@ -188,15 +221,29 @@ def negate(sys):
     out : scalar, array, or :class:`InputOutputSystem`
         Negated system.
 
-    Notes
-    -----
-    This function is a wrapper for the __neg__ function in the StateSpace and
-    TransferFunction classes.  The output type is the same as the input type.
+    Other Parameters
+    ----------------
+    inputs, outputs : str, or list of str, optional
+        List of strings that name the individual signals.  If not given,
+        signal names will be of the form `s[i]` (where `s` is one of `u`,
+        or `y`). See :class:`InputOutputSystem` for more information.
+    states : str, or list of str, optional
+        List of names for system states.  If not given, state names will be
+        of of the form `x[i]` for interconnections of linear systems or
+        '<subsys_name>.<state_name>' for interconnected nonlinear systems.
+    name : string, optional
+        System name (used for specifying signals). If unspecified, a generic
+        name <sys[id]> is generated with a unique integer id.
 
     See Also
     --------
     append, feedback, interconnect, parallel, series
 
+    Notes
+    -----
+    This function is a wrapper for the __neg__ function in the StateSpace and
+    TransferFunction classes.  The output type is the same as the input type.
+
     Examples
     --------
     >>> G = ct.tf([2], [1, 1])
@@ -208,11 +255,12 @@ def negate(sys):
     np.float64(-2.0)
 
     """
-    return -sys
+    sys = -sys
+    sys.update_names(**kwargs)
+    return sys
 
 #! TODO: expand to allow sys2 default to work in MIMO case?
-#! TODO: allow renaming of signals (for all bdalg operations)
-def feedback(sys1, sys2=1, sign=-1):
+def feedback(sys1, sys2=1, sign=-1, **kwargs):
     """Feedback interconnection between two I/O systems.
 
     Parameters
@@ -229,6 +277,20 @@ def feedback(sys1, sys2=1, sign=-1):
     out : scalar, array, or :class:`InputOutputSystem`
         Feedback interconnection of the systems.
 
+    Other Parameters
+    ----------------
+    inputs, outputs : str, or list of str, optional
+        List of strings that name the individual signals.  If not given,
+        signal names will be of the form `s[i]` (where `s` is one of `u`,
+        or `y`). See :class:`InputOutputSystem` for more information.
+    states : str, or list of str, optional
+        List of names for system states.  If not given, state names will be
+        of of the form `x[i]` for interconnections of linear systems or
+        '<subsys_name>.<state_name>' for interconnected nonlinear systems.
+    name : string, optional
+        System name (used for specifying signals). If unspecified, a generic
+        name <sys[id]> is generated with a unique integer id.
+
     Raises
     ------
     ValueError
@@ -261,7 +323,7 @@ def feedback(sys1, sys2=1, sign=-1):
     # Allow anything with a feedback function to call that function
     # TODO: rewrite to allow __rfeedback__
     try:
-        return sys1.feedback(sys2, sign)
+        return sys1.feedback(sys2, sign, **kwargs)
     except (AttributeError, TypeError):
         pass
 
@@ -284,9 +346,11 @@ def feedback(sys1, sys2=1, sign=-1):
         else:
             sys1 = ss._convert_to_statespace(sys1)
 
-    return sys1.feedback(sys2, sign)
+    sys = sys1.feedback(sys2, sign)
+    sys.update_names(**kwargs)
+    return sys
 
-def append(*sys):
+def append(*sys, **kwargs):
     """append(sys1, sys2, [..., sysn])
 
     Group LTI state space models by appending their inputs and outputs.
@@ -299,6 +363,20 @@ def append(*sys):
     sys1, sys2, ..., sysn: scalar, array, or :class:`StateSpace`
         I/O systems to combine.
 
+    Other Parameters
+    ----------------
+    inputs, outputs : str, or list of str, optional
+        List of strings that name the individual signals.  If not given,
+        signal names will be of the form `s[i]` (where `s` is one of `u`,
+        or `y`). See :class:`InputOutputSystem` for more information.
+    states : str, or list of str, optional
+        List of names for system states.  If not given, state names will be
+        of of the form `x[i]` for interconnections of linear systems or
+        '<subsys_name>.<state_name>' for interconnected nonlinear systems.
+    name : string, optional
+        System name (used for specifying signals). If unspecified, a generic
+        name <sys[id]> is generated with a unique integer id.
+
     Returns
     -------
     out: :class:`StateSpace`
@@ -327,6 +405,7 @@ def append(*sys):
     s1 = ss._convert_to_statespace(sys[0])
     for s in sys[1:]:
         s1 = s1.append(s)
+    s1.update_names(**kwargs)
     return s1
 
 def connect(sys, Q, inputv, outputv):
@@ -370,6 +449,12 @@ def connect(sys, Q, inputv, outputv):
     --------
     append, feedback, interconnect, negate, parallel, series
 
+    Notes
+    -----
+    The :func:`~control.interconnect` function in the :ref:`input/output
+    systems <iosys-module>` module allows the use of named signals and
+    provides an alternative method for interconnecting multiple systems.
+
     Examples
     --------
     >>> G = ct.rss(7, inputs=2, outputs=2)
@@ -378,12 +463,6 @@ def connect(sys, Q, inputv, outputv):
     >>> T.ninputs, T.noutputs, T.nstates
     (1, 2, 7)
 
-    Notes
-    -----
-    The :func:`~control.interconnect` function in the :ref:`input/output
-    systems <iosys-module>` module allows the use of named signals and
-    provides an alternative method for interconnecting multiple systems.
-
     """
     # TODO: maintain `connect` for use in MATLAB submodule (?)
     warn("`connect` is deprecated; use `interconnect`", DeprecationWarning)
diff --git a/control/iosys.py b/control/iosys.py
index fbd5c1db..d00dade6 100644
--- a/control/iosys.py
+++ b/control/iosys.py
@@ -6,10 +6,12 @@
 # FrequencyResponseData, InterconnectedSystem and other similar classes
 # that allow naming of signals.
 
-import numpy as np
+import re
 from copy import deepcopy
 from warnings import warn
-import re
+
+import numpy as np
+
 from . import config
 
 __all__ = ['InputOutputSystem', 'issiso', 'timebase', 'common_timebase',
@@ -366,6 +368,51 @@ class InputOutputSystem(object):
         lambda self: list(self.state_index.keys()),     # getter
         set_states)                                     # setter
 
+    # TODO: add dict as a means to selective change names?  [GH #1019]
+    def update_names(self, **kwargs):
+        """update_names([name, inputs, outputs, states])
+
+        Update signal and system names for an I/O system.
+
+        Parameters
+        ----------
+        name : str, optional
+            New system name.
+        inputs : list of str, int, or None, optional
+            List of strings that name the individual input signals.  If
+            given as an integer or None, signal names default to the form
+            `u[i]`.  See :class:`InputOutputSystem` for more information.
+        outputs : list of str, int, or None, optional
+            Description of output signals; defaults to `y[i]`.
+        states : int, list of str, int, or None, optional
+            Description of system states; defaults to `x[i]`.
+
+        """
+        self.name = kwargs.pop('name', self.name)
+        if 'inputs' in kwargs:
+            ninputs, input_index = _process_signal_list(
+                kwargs.pop('inputs'), prefix=kwargs.pop('input_prefix', 'u'))
+            if self.ninputs and self.ninputs != ninputs:
+                raise ValueError("number of inputs does not match system size")
+            self.input_index = input_index
+        if 'outputs' in kwargs:
+            noutputs, output_index = _process_signal_list(
+                kwargs.pop('outputs'), prefix=kwargs.pop('output_prefix', 'y'))
+            if self.noutputs and self.noutputs != noutputs:
+                raise ValueError("number of outputs does not match system size")
+            self.output_index = output_index
+        if 'states' in kwargs:
+            nstates, state_index = _process_signal_list(
+                kwargs.pop('states'), prefix=kwargs.pop('state_prefix', 'x'))
+            if self.nstates != nstates:
+                raise ValueError("number of states does not match system size")
+            self.state_index = state_index
+
+        # Make sure we processed all of the arguments
+        if kwargs:
+            raise TypeError("unrecognized keywords: ", str(kwargs))
+
+
     def isctime(self, strict=False):
         """
         Check to see if a system is a continuous-time system.
@@ -823,7 +870,6 @@ def _process_labels(labels, name, default):
 # This function returns the subsystem index, a list of indices for the
 # system signals, and the gain to use for that set of signals.
 #
-import re
 
 def _parse_spec(syslist, spec, signame, dictname=None):
     """Parse a signal specification, returning system and signal index."""
diff --git a/control/nlsys.py b/control/nlsys.py
index 358c4b12..139853fe 100644
--- a/control/nlsys.py
+++ b/control/nlsys.py
@@ -143,6 +143,9 @@ class NonlinearIOSystem(InputOutputSystem):
                 self.outfcn = lambda t, x, u, params: np.zeros(0)
             elif self.noutputs is None and self.nstates is not None:
                 self.noutputs = self.nstates
+                if len(self.output_index) == 0:
+                    # Use state names for outputs
+                    self.output_index = self.state_index
             elif self.noutputs is not None and self.noutputs == self.nstates:
                 # Number of outputs = number of states => all is OK
                 pass
@@ -514,7 +517,7 @@ class NonlinearIOSystem(InputOutputSystem):
         return newsys
 
     def linearize(self, x0, u0, t=0, params=None, eps=1e-6,
-                  name=None, copy_names=False, **kwargs):
+                  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
@@ -529,26 +532,16 @@ class NonlinearIOSystem(InputOutputSystem):
         # 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')
+        # Process nominal states and inputs
+        x0, nstates = _process_vector_argument(x0, "x0", self.nstates)
+        u0, ninputs = _process_vector_argument(u0, "u0", self.ninputs)
 
-        # Figure out dimensions if they were not specified.
-        nstates = _find_size(self.nstates, x0, "states")
-        ninputs = _find_size(self.ninputs, u0, "inputs")
-
-        # 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
+        # Update the current parameters (prior to calling _out())
+        self._update_params(params)
 
         # Compute number of outputs by evaluating the output function
         noutputs = _find_size(self.noutputs, self._out(t, x0, u0), "outputs")
 
-        # 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)
@@ -579,8 +572,6 @@ class NonlinearIOSystem(InputOutputSystem):
         # Set the system name, inputs, outputs, and states
         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 StateSpace(linsys, **kwargs)
@@ -1467,7 +1458,16 @@ def input_output_response(
         # 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)
+    #
+    # Process input argument
+    #
+    # The input argument is interpreted very flexibly, allowing the
+    # use of lists and/or tuples of mixed scalar and vector elements.
+    #
+    # Much of the processing here is similar to the processing in
+    # _process_vector_argument, but applied to a time series.
+
+    # If we were passed a list of inputs, concatenate them (w/ broadcast)
     if isinstance(U, (tuple, list)) and len(U) != ntimepts:
         U_elements = []
         for i, u in enumerate(U):
@@ -1491,62 +1491,44 @@ def input_output_response(
         # Save the newly created input vector
         U = np.vstack(U_elements)
 
+    # Figure out the number of inputs
+    if sys.ninputs is None:
+        if isinstance(U, np.ndarray):
+            ninputs = U.shape[0] if U.size > 1 else U.size
+        else:
+            ninputs = 1
+    else:
+        ninputs = sys.ninputs
+
     # Make sure the input has the right shape
-    if sys.ninputs is None or sys.ninputs == 1:
+    if ninputs is None or ninputs == 1:
         legal_shapes = [(ntimepts,), (1, ntimepts)]
     else:
-        legal_shapes = [(sys.ninputs, ntimepts)]
+        legal_shapes = [(ninputs, ntimepts)]
 
-    U = _check_convert_array(U, legal_shapes,
-                             'Parameter ``U``: ', squeeze=False)
+    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)])
+    # Process initial states
+    X0, nstates = _process_vector_argument(X0, "X0", sys.nstates)
 
-    # 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, "states")
-
-    # create X0 if not given, test if X0 has correct shape
-    X0 = _check_convert_array(X0, [(nstates,), (nstates, 1)],
-                              'Parameter ``X0``: ', squeeze=True)
+    # Update the parameter values (prior to evaluating outfcn)
+    sys._update_params(params)
 
     # Figure out the number of outputs
-    if sys.noutputs is None:
-        # Evaluate the output function to find number of outputs
-        noutputs = np.shape(sys._out(T[0], X0, U[:, 0]))[0]
+    if sys.outfcn is None:
+        noutputs = nstates if sys.noutputs is None else sys.noutputs
     else:
-        noutputs = sys.noutputs
+        noutputs = np.shape(sys._out(T[0], X0, U[:, 0]))[0]
 
-    # Update the parameter values
-    sys._update_params(params)
+    if sys.noutputs is not None and sys.noutputs != noutputs:
+        raise RuntimeError(
+            f"inconsistent size of outputs; system specified {sys.noutputs}, "
+            f"output function returned {noutputs}")
 
     #
     # Define a function to evaluate the input at an arbitrary time
@@ -1753,17 +1735,9 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None,
     from scipy.optimize import root
 
     # Figure out the number of states, inputs, and outputs
-    nstates = _find_size(sys.nstates, x0, "states")
-    ninputs = _find_size(sys.ninputs, u0, "inputs")
-    noutputs = _find_size(sys.noutputs, y0, "outputs")
-
-    # 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
+    x0, nstates = _process_vector_argument(x0, "x0", sys.nstates)
+    u0, ninputs = _process_vector_argument(u0, "u0", sys.ninputs)
+    y0, noutputs = _process_vector_argument(y0, "y0", sys.noutputs)
 
     # Make sure the input arguments match the sizes of the system
     if len(x0) != nstates or \
@@ -1998,7 +1972,7 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw):
     return sys.linearize(xeq, ueq, t=t, params=params, **kw)
 
 
-def _find_size(sysval, vecval, label):
+def _find_size(sysval, vecval, name="system component"):
     """Utility function to find the size of a system parameter
 
     If both parameters are not None, they must be consistent.
@@ -2006,7 +1980,8 @@ def _find_size(sysval, vecval, label):
     if hasattr(vecval, '__len__'):
         if sysval is not None and sysval != len(vecval):
             raise ValueError(
-                f"inconsistent information for number of {label}")
+                f"inconsistent information to determine size of {name}; "
+                f"expected {sysval} values, received {len(vecval)}")
         return len(vecval)
     # None or 0, which is a valid value for "a (sysval, ) vector of zeros".
     if not vecval:
@@ -2014,7 +1989,7 @@ def _find_size(sysval, vecval, label):
     elif sysval == 1:
         # (1, scalar) is also a valid combination from legacy code
         return 1
-    raise ValueError(f"can't determine number of {label}")
+    raise ValueError(f"can't determine size of {name}")
 
 
 # Function to create an interconnected system
@@ -2572,18 +2547,59 @@ def interconnect(
     return newsys
 
 
-# Utility function to allow lists of 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)
+def _process_vector_argument(arg, name, size):
+    """Utility function to process vector elements (states, inputs)
+
+    Process state and input arguments to turn them into lists of the
+    appropriate length.
+
+    Parameters
+    ----------
+    arg : array_like
+        Value of the parameter passed to the function.  Can be a list,
+        tuple, ndarray, scalar, or None.
+    name : string
+        Name of the argument being processed.  Used in errors/warnings.
+    size : int or None
+        Size of the element.  If None, size is determined by arg.
+
+    Returns
+    -------
+    val : array or None
+        Value of the element, zero-padded to proper length.
+    nelem : int or None
+        Number of elements in the returned value.
+
+    Warns
+    -----
+    UserWarning : "{name} too short; padding with zeros"
+        If argument is too short and last value in arg is not 0.
+
+    """
+    # Allow and expand list
+    if isinstance(arg, (tuple, list)):
+        val_list = []
+        for i, v in enumerate(arg):
+            v = np.array(v).reshape(-1)             # convert to 1D array
+            val_list += v.tolist()                  # add elements to list
+        val = np.array(val_list)
+    elif np.isscalar(arg) and size is not None:     # extend scalars
+        val = np.ones((size, )) * arg
+    elif np.isscalar(arg) and size is None:         # single scalar
+        val = np.array([arg])
+    elif isinstance(arg, np.ndarray):
+        val = arg.reshape(-1)                       # convert to 1D array
+    else:
+        val = arg                                   # return what we were given
+
+    if size is not None and isinstance(val, np.ndarray) and val.size < size:
+        # If needed, extend the size of the vector to match desired size
+        if val[-1] != 0:
+            warn(f"{name} too short; padding with zeros")
+        val = np.hstack([val, np.zeros(size - val.size)])
 
-    # Otherwise, do nothing
-    return X
+    nelem = _find_size(size, val, name)                 # determine size
+    return val, nelem
 
 
 # Utility function to create an I/O system from a static gain
diff --git a/doc/iosys.rst b/doc/iosys.rst
index c0c2cca3..eb4311e0 100644
--- a/doc/iosys.rst
+++ b/doc/iosys.rst
@@ -191,7 +191,86 @@ 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.
+simplify the creation and use of interconnected input/output systems.
+
+Vector elements processing
+--------------------------
+
+Several I/O system commands perform processing of vector elements
+(such as initial states or input vectors) and broadcast these to the
+proper shape.
+
+For static elements, such as the initial state in a simulation or the
+nominal state and input for a linearization), the following processing
+is done:
+
+* Scalars are automatically converted to a vector of the appropriate
+  size consisting of the scalar value. This is commonly used when
+  specifying the origin ('0') or a step input ('1').
+
+* Lists of values are concatenated into a single vector.  This is
+  often used when you have an interconnected system and you need to
+  specify the initial condition or input value for each subsystem
+  (e.g., [X1eq, X2eq, ...]).
+
+* Vector elements are zero padded to the required length. If you
+  specify only a portion of the values for states or inputs, the
+  remaining values are taken as zero.  (If the final element in the
+  given vector is non-zero, a warning is issued.)
+
+Similar processing is done for input time series, used for the
+:func:`~control.input_output_response` and
+:func:`~control.forced_response` commands, with the following
+additional feature:
+
+* Time series elements are broadcast to match the number of time points
+  specified.  If a list of time series and static elements are given (as a
+  list), static elements are broadcast to the proper number of time points,
+  and the overall list of elements concatenated to provide the full input
+  vector.
+
+As an example, suppose we have an interconnected system consisting of three
+subsystems, a controlled process, an estimator, and a (static) controller::
+
+  proc = ct.nlsys(...,
+      states=2, inputs=['u1', 'u2', 'd'], outputs='y')
+  estim = ct.nlsys(...,
+      states=2, inputs='y', outputs=['xhat[0]', 'xhat[1]')
+  ctrl = ct.nlsys(...,
+      states=0, inputs=['r', 'xhat[0]', 'xhat[1]'], outputs=['u1', 'u2'])
+
+  clsys = ct.interconnect(
+      [proc, estim, ctrl], inputs=['r', 'd'], outputs=['y', 'u1', 'u2'])
+
+To linearize the system around the origin, we can utilize the scalar
+processing feature of vector elements::
+
+  P = proc.linearize(0, 0)
+
+In this command, the states and the inputs are broadcast to the size of the
+state and input vectors, respectively.
+
+If we want to linearize the closed loop system around a process state
+``x0`` (with two elements) and an estimator state ``0`` (for both states),
+we can use the list processing feature::
+
+  H = clsys.linearize([x0, 0], 0)
+
+Note that this also utilizes the zero-padding functionality, since the
+second argument in the list ``[x0, 0]`` is a scalar and so the vector
+``[x0, 0]`` only has three elements instead of the required four.
+
+To run an input/output simulation with a sinusoidal signal for the first
+input, a constant for the second input, and no external disturbance, we can
+use the list processing feature combined with time series broadcasting::
+
+  timepts = np.linspace(0, 10)
+  u1 = np.sin(timepts)
+  u2 = 1
+  resp = ct.input_output_response(clsys, timepts, [u1, u2, 0])
+
+In this command, the second and third arguments will be broadcast to match
+the number of time points.
 
 Summing junction
 ----------------


Test patch:
diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py
index 2ed793ef..b9e26e8c 100644
--- a/control/tests/bdalg_test.py
+++ b/control/tests/bdalg_test.py
@@ -316,3 +316,49 @@ class TestFeedback:
                 connect(sys, Q, [2], [1, 0])
             with pytest.raises(IndexError):
                 connect(sys, Q, [2], [1, -1])
+
+
+@pytest.mark.parametrize(
+    "op, nsys, ninputs, noutputs, nstates", [
+        (ctrl.series, 2, 1, 1, 4),
+        (ctrl.parallel, 2, 1, 1, 4),
+        (ctrl.feedback, 2, 1, 1, 4),
+        (ctrl.append, 2, 2, 2, 4),
+        (ctrl.negate, 1, 1, 1, 2),
+    ])
+def test_bdalg_update_names(op, nsys, ninputs, noutputs, nstates):
+    syslist = [ctrl.rss(2, 1, 1), ctrl.rss(2, 1, 1)]
+    inputs = ['in1', 'in2']
+    outputs = ['out1', 'out2']
+    states = ['x1', 'x2', 'x3', 'x4']
+
+    newsys = op(
+        *syslist[:nsys], name='newsys', inputs=inputs[:ninputs],
+        outputs=outputs[:noutputs], states=states[:nstates])
+    assert newsys.name == 'newsys'
+    assert newsys.ninputs == ninputs
+    assert newsys.input_labels == inputs[:ninputs]
+    assert newsys.noutputs == noutputs
+    assert newsys.output_labels == outputs[:noutputs]
+    assert newsys.nstates == nstates
+    assert newsys.state_labels == states[:nstates]
+
+
+def test_bdalg_udpate_names_errors():
+    sys1 = ctrl.rss(2, 1, 1)
+    sys2 = ctrl.rss(2, 1, 1)
+
+    with pytest.raises(ValueError, match="number of inputs does not match"):
+        sys = ctrl.series(sys1, sys2, inputs=2)
+
+    with pytest.raises(ValueError, match="number of outputs does not match"):
+        sys = ctrl.series(sys1, sys2, outputs=2)
+
+    with pytest.raises(ValueError, match="number of states does not match"):
+        sys = ctrl.series(sys1, sys2, states=2)
+
+    with pytest.raises(ValueError, match="number of states does not match"):
+        sys = ctrl.series(ctrl.tf(sys1), ctrl.tf(sys2), states=2)
+
+    with pytest.raises(TypeError, match="unrecognized keywords"):
+        sys = ctrl.series(sys1, sys2, dt=1)
diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py
index f3693cf0..cf4e3dd4 100644
--- a/control/tests/iosys_test.py
+++ b/control/tests/iosys_test.py
@@ -231,11 +231,20 @@ class TestIOSys:
             linearized.C, [[1, 0, 0], [0, 1, 0]])
         np.testing.assert_array_almost_equal(linearized.D, np.zeros((2,2)))
 
+        # Pass fewer than the required elements
+        padded = iosys.linearize([0, 0], np.array([0]))
+        assert padded.nstates == linearized.nstates
+        assert padded.ninputs == linearized.ninputs
+
+        # Check for warning if last element before padding is nonzero
+        with pytest.warns(UserWarning, match="x0 too short; padding"):
+            padded = iosys.linearize([0, 1], np.array([0]))
+
     @pytest.mark.usefixtures("editsdefaults")
     def test_linearize_named_signals(self, kincar):
         # Full form of the call
-        linearized = kincar.linearize([0, 0, 0], [0, 0], copy_names=True,
-                                      name='linearized')
+        linearized = kincar.linearize(
+            [0, 0, 0], [0, 0], copy_names=True, name='linearized')
         assert linearized.name == 'linearized'
         assert linearized.find_input('v') == 0
         assert linearized.find_input('phi') == 1
@@ -256,8 +265,8 @@ class TestIOSys:
         assert lin_nocopy.find_state('x') is None
 
         # if signal names are provided, they should override those of kincar
-        linearized_newnames = kincar.linearize([0, 0, 0], [0, 0],
-            name='linearized',
+        linearized_newnames = kincar.linearize(
+            [0, 0, 0], [0, 0], name='linearized',
             copy_names=True, inputs=['v2', 'phi2'], outputs=['x2','y2'])
         assert linearized_newnames.name == 'linearized'
         assert linearized_newnames.find_input('v2') == 0
@@ -269,6 +278,11 @@ class TestIOSys:
         assert linearized_newnames.find_output('x') is None
         assert linearized_newnames.find_output('y') is None
 
+        # if system name is provided but copy_names is false, override name
+        linearized_newsysname = kincar.linearize(
+            [0, 0, 0], [0, 0], name='newname', copy_names=False)
+        assert linearized_newsysname.name == 'newname'
+
         # Test legacy version as well
         with pytest.warns(UserWarning, match="NumPy matrix class no longer"):
             ct.use_legacy_defaults('0.8.4')
@@ -1417,7 +1431,7 @@ class TestIOSys:
     def test_neg_badsize(self):
         # Create a system of unspecified size
         sys = ct.NonlinearIOSystem(lambda t, x, u, params: -x)
-        with pytest.raises(ValueError, match="Can't determine"):
+        with pytest.raises(ValueError, match="Can't determine number"):
             -sys
 
     def test_bad_signal_list(self):
@@ -1881,7 +1895,7 @@ def test_input_output_broadcasting():
     np.testing.assert_equal(resp_cov0.states, resp_init.states)
 
     # Specify only some of the initial conditions
-    with pytest.warns(UserWarning, match="initial state too short; padding"):
+    with pytest.warns(UserWarning, match="X0 too short; padding"):
         resp_short = ct.input_output_response(sys, T, [U[0], [0, 1]], [X0, 1])
 
     # Make sure that inconsistent settings don't work
@@ -2072,6 +2086,7 @@ def test_find_eqpt(x0, ix, u0, iu, y0, iy, dx0, idx, dt, x_expect, u_expect):
     np.testing.assert_allclose(np.array(xeq), x_expect, atol=1e-6)
     np.testing.assert_allclose(np.array(ueq), u_expect, atol=1e-6)
 
+
 def test_iosys_sample():
     csys = ct.rss(2, 1, 1)
     dsys = csys.sample(0.1)
@@ -2082,3 +2097,74 @@ def test_iosys_sample():
     dsys = ct.sample_system(csys, 0.1)
     assert isinstance(dsys, ct.StateSpace)
     assert dsys.dt == 0.1
+
+
+# Make sure that we can determine system sizes automatically
+def test_find_size():
+    # Create a nonlinear system with no size information
+    sys = ct.nlsys(
+        lambda t, x, u, params: -x + u,
+        lambda t, x, u, params: x[:1])
+
+    # Run a simulation with size set by parameters
+    timepts = np.linspace(0, 1)
+    resp = ct.input_output_response(sys, timepts, [0, 1], X0=[0, 0])
+    assert resp.states.shape[0] == 2
+    assert resp.inputs.shape[0] == 2
+    assert resp.outputs.shape[0] == 1
+
+    #
+    # Make sure we get warnings if things are inconsistent
+    #
+
+    # Define a system of fixed size
+    sys = ct.nlsys(
+        lambda t, x, u, params: -x + u,
+        lambda t, x, u, params: x[:1],
+        inputs=2, states=2)
+
+    with pytest.raises(ValueError, match="inconsistent .* size of X0"):
+        resp = ct.input_output_response(sys, timepts, [0, 1], X0=[0, 0, 1])
+
+    with pytest.raises(ValueError, match=".*U.* Wrong shape"):
+        resp = ct.input_output_response(sys, timepts, [0, 1, 2], X0=[0, 0])
+
+    with pytest.raises(RuntimeError, match="inconsistent size of outputs"):
+        sys = ct.nlsys(
+            lambda t, x, u, params: -x + u,
+            lambda t, x, u, params: x[:1],
+            inputs=2, states=2, outputs=2)
+        resp = ct.input_output_response(sys, timepts, [0, 1], X0=[0, 0])
+
+
+def test_update_names():
+    sys = ct.rss(['x1', 'x2'], 2, 2)
+    sys.update_names(
+        name='new', states=2, inputs=['u1', 'u2'],
+        outputs=2, output_prefix='yy')
+    assert sys.name == 'new'
+    assert sys.ninputs == 2
+    assert sys.input_labels == ['u1', 'u2']
+    assert sys.ninputs == 2
+    assert sys.output_labels == ['yy[0]', 'yy[1]']
+    assert sys.state_labels == ['x[0]', 'x[1]']
+
+    # Generate some error conditions
+    with pytest.raises(ValueError, match="number of inputs does not match"):
+        sys.update_names(inputs=3)
+
+    with pytest.raises(ValueError, match="number of outputs does not match"):
+        sys.update_names(outputs=3)
+
+    with pytest.raises(ValueError, match="number of states does not match"):
+        sys.update_names(states=3)
+
+    with pytest.raises(ValueError, match="number of states does not match"):
+        siso = ct.tf([1], [1, 2, 1])
+        ct.tf(siso).update_names(states=2)
+
+    with pytest.raises(TypeError, match="unrecognized keywords"):
+        sys.update_names(dt=1)
+
+    with pytest.raises(TypeError, match=".* takes 1 positional argument"):
+        sys.update_names(5)
diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py
index d6bd0648..73dbe322 100644
--- a/control/tests/kwargs_test.py
+++ b/control/tests/kwargs_test.py
@@ -92,20 +92,25 @@ def test_kwarg_search(module, prefix):
 
 @pytest.mark.parametrize(
     "function, nsssys, ntfsys, moreargs, kwargs",
-    [(control.dlqe, 1, 0, ([[1]], [[1]]), {}),
+    [(control.append, 2, 0, (), {}),
+     (control.dlqe, 1, 0, ([[1]], [[1]]), {}),
      (control.dlqr, 1, 0, ([[1, 0], [0, 1]], [[1]]), {}),
      (control.drss, 0, 0, (2, 1, 1), {}),
+     (control.feedback, 2, 0, (), {}),
      (control.flatsys.flatsys, 1, 0, (), {}),
      (control.input_output_response, 1, 0, ([0, 1, 2], [1, 1, 1]), {}),
      (control.lqe, 1, 0, ([[1]], [[1]]), {}),
      (control.lqr, 1, 0, ([[1, 0], [0, 1]], [[1]]), {}),
      (control.linearize, 1, 0, (0, 0), {}),
+     (control.negate, 1, 0, (), {}),
      (control.nlsys, 0, 0, (lambda t, x, u, params: np.array([0]),), {}),
+     (control.parallel, 2, 0, (), {}),
      (control.pzmap, 1, 0, (), {}),
      (control.rlocus, 0, 1, (), {}),
      (control.root_locus, 0, 1, (), {}),
      (control.root_locus_plot, 0, 1, (), {}),
      (control.rss, 0, 0, (2, 1, 1), {}),
+     (control.series, 2, 0, (), {}),
      (control.set_defaults, 0, 0, ('control',), {'default_dt': True}),
      (control.ss, 0, 0, (0, 0, 0, 0), {'dt': 1}),
      (control.ss2io, 1, 0,  (), {}),
@@ -122,6 +127,7 @@ def test_kwarg_search(module, prefix):
      (control.LTI, 0, 0, (),
       {'inputs': 1, 'outputs': 1, 'states': 1}),
      (control.flatsys.LinearFlatSystem, 1, 0, (), {}),
+     (control.InputOutputSystem.update_names, 1, 0, (), {}),
      (control.NonlinearIOSystem.linearize, 1, 0, (0, 0), {}),
      (control.StateSpace.sample, 1, 0, (0.1,), {}),
      (control.StateSpace, 0, 0,
@@ -232,6 +238,7 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo):
 #
 
 kwarg_unittest = {
+    'append': test_unrecognized_kwargs,
     'bode': test_response_plot_kwargs,
     'bode_plot': test_response_plot_kwargs,
     'create_estimator_iosystem': stochsys_test.test_estimator_errors,
@@ -242,6 +249,7 @@ kwarg_unittest = {
     'dlqe': test_unrecognized_kwargs,
     'dlqr': test_unrecognized_kwargs,
     'drss': test_unrecognized_kwargs,
+    'feedback': test_unrecognized_kwargs,
     'flatsys.flatsys': test_unrecognized_kwargs,
     'frd': frd_test.TestFRD.test_unrecognized_keyword,
     'gangof4': test_matplotlib_kwargs,
@@ -252,6 +260,7 @@ kwarg_unittest = {
     'linearize': test_unrecognized_kwargs,
     'lqe': test_unrecognized_kwargs,
     'lqr': test_unrecognized_kwargs,
+    'negate': test_unrecognized_kwargs,
     'nichols_plot': test_matplotlib_kwargs,
     'nichols': test_matplotlib_kwargs,
     'nlsys': test_unrecognized_kwargs,
@@ -259,12 +268,14 @@ kwarg_unittest = {
     'nyquist_response': test_response_plot_kwargs,
     'nyquist_plot': test_matplotlib_kwargs,
     'phase_plane_plot': test_matplotlib_kwargs,
+    'parallel': test_unrecognized_kwargs,
     'pole_zero_plot': test_unrecognized_kwargs,
     'pzmap': test_unrecognized_kwargs,
     'rlocus': test_unrecognized_kwargs,
     'root_locus': test_unrecognized_kwargs,
     'root_locus_plot': test_unrecognized_kwargs,
     'rss': test_unrecognized_kwargs,
+    'series': test_unrecognized_kwargs,
     'set_defaults': test_unrecognized_kwargs,
     'singular_values_plot': test_matplotlib_kwargs,
     'ss': test_unrecognized_kwargs,
@@ -292,6 +303,7 @@ kwarg_unittest = {
     'DescribingFunctionResponse.plot':
         descfcn_test.test_describing_function_exceptions,
     'InputOutputSystem.__init__': test_unrecognized_kwargs,
+    'InputOutputSystem.update_names': test_unrecognized_kwargs,
     'LTI.__init__': test_unrecognized_kwargs,
     'flatsys.LinearFlatSystem.__init__': test_unrecognized_kwargs,
     'NonlinearIOSystem.linearize': test_unrecognized_kwargs,
diff --git a/control/tests/nlsys_test.py b/control/tests/nlsys_test.py
index 80baa646..7f649e0c 100644
--- a/control/tests/nlsys_test.py
+++ b/control/tests/nlsys_test.py
@@ -46,7 +46,7 @@ def test_nlsys_basic():
     ])
 def test_lti_nlsys_response(nin, nout, input, output):
     sys_ss = ct.rss(4, nin, nout, strictly_proper=True)
-    sys_ss.A = np.diag([-1, -2, -3, -4])        # avoid random noise errors
+    sys_ss.A = np.diag([-1, -2, -3, -4])        # avoid random numerical errors
     sys_nl = ct.nlsys(
         lambda t, x, u, params: sys_ss.A @ x + sys_ss.B @ u,
         lambda t, x, u, params: sys_ss.C @ x + sys_ss.D @ u,