• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

python-control / python-control / 15817454629

23 Jun 2025 06:59AM UTC coverage: 94.759%. Remained the same
15817454629

push

github

web-flow
update documentation for input_output_response to address gh1152 (#1157)

resolves #1152 by updating the documentation to include information about setting max_step.

9890 of 10437 relevant lines covered (94.76%)

8.29 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.1
control/nlsys.py
1
# nlsys.py - input/output system module
2
# RMM, 28 April 2019
3
#
4
# Additional features to add:
5
#   * Allow constant inputs for MIMO input_output_response (w/out ones)
6
#   * Add unit tests (and example?) for time-varying systems
7
#   * Allow time vector for discrete-time simulations to be multiples of dt
8
#   * Check the way initial outputs for discrete-time systems are handled
9

10
"""This module contains the `NonlinearIOSystem` class that
11
represents (possibly nonlinear) input/output systems.  The
12
`NonlinearIOSystem` class is a general class that defines any
13
continuous- or discrete-time dynamical system.  Input/output systems
14
can be simulated and also used to compute operating points and
15
linearizations.
16

17
"""
18

19
from warnings import warn
9✔
20

21
import numpy as np
9✔
22
import scipy as sp
9✔
23

24
from . import config
9✔
25
from .config import _process_param, _process_kwargs
9✔
26
from .iosys import InputOutputSystem, _parse_spec, _process_iosys_keywords, \
9✔
27
    common_timebase, iosys_repr, isctime, isdtime
28
from .timeresp import TimeResponseData, TimeResponseList, \
9✔
29
    _check_convert_array, _process_time_response, _timeresp_aliases
30

31
__all__ = ['NonlinearIOSystem', 'InterconnectedSystem', 'nlsys',
9✔
32
           'input_output_response', 'find_eqpt', 'linearize',
33
           'interconnect', 'connection_table', 'OperatingPoint',
34
           'find_operating_point']
35

36

37
class NonlinearIOSystem(InputOutputSystem):
9✔
38
    """Nonlinear input/output system model.
39

40
    Creates an `InputOutputSystem` for a nonlinear system
41
    by specifying a state update function and an output function.  The new
42
    system can be a continuous or discrete-time system. Nonlinear I/O
43
    systems are usually created with the `nlsys` factory
44
    function.
45

46
    Parameters
47
    ----------
48
    updfcn : callable
49
        Function returning the state update function
50

51
            ``updfcn(t, x, u, params) -> array``
52

53
        where `t` is a float representing the current time, `x` is a 1-D
54
        array with shape (nstates,), `u` is a 1-D array with shape
55
        (ninputs,), and `params` is a dict containing the values of
56
        parameters used by the function.
57

58
    outfcn : callable
59
        Function returning the output at the given state
60

61
            `outfcn(t, x, u, params) -> array`
62

63
        where the arguments are the same as for `updfcn`.
64

65
    inputs, outputs, states : int, list of str or None, optional
66
        Description of the system inputs, outputs, and states.  See
67
        `control.nlsys` for more details.
68

69
    params : dict, optional
70
        Parameter values for the systems.  Passed to the evaluation functions
71
        for the system as default values, overriding internal defaults.
72

73
    dt : timebase, optional
74
        The timebase for the system, used to specify whether the system is
75
        operating in continuous or discrete time.  It can have the
76
        following values:
77

78
        * `dt` = 0: continuous-time system (default)
79
        * `dt` > 0: discrete-time system with sampling period `dt`
80
        * `dt` = True: discrete time with unspecified sampling period
81
        * `dt` = None: no timebase specified
82

83
    Attributes
84
    ----------
85
    ninputs, noutputs, nstates : int
86
        Number of input, output and state variables.
87
    shape : tuple
88
        2-tuple of I/O system dimension, (noutputs, ninputs).
89
    input_labels, output_labels, state_labels : list of str
90
        Names for the input, output, and state variables.
91
    name : string, optional
92
        System name.
93

94
    See Also
95
    --------
96
    nlsys, InputOutputSystem
97

98
    Notes
99
    -----
100
    The `InputOutputSystem` class (and its subclasses) makes use of two
101
    special methods for implementing much of the work of the class:
102

103
    * _rhs(t, x, u): compute the right hand side of the differential or
104
      difference equation for the system.  If not specified, the system
105
      has no state.
106

107
    * _out(t, x, u): compute the output for the current state of the system.
108
      The default is to return the entire system state.
109

110
    """
111
    def __init__(self, updfcn, outfcn=None, params=None, **kwargs):
9✔
112
        """Create a nonlinear I/O system given update and output functions."""
113
        # Process keyword arguments
114
        name, inputs, outputs, states, dt = _process_iosys_keywords(kwargs)
9✔
115

116
        # Initialize the rest of the structure
117
        super().__init__(
9✔
118
            inputs=inputs, outputs=outputs, states=states, dt=dt, name=name,
119
            **kwargs
120
        )
121
        self.params = {} if params is None else params.copy()
9✔
122

123
        # Store the update and output functions
124
        self.updfcn = updfcn
9✔
125
        self.outfcn = outfcn
9✔
126

127
        # Check to make sure arguments are consistent
128
        if updfcn is None:
9✔
129
            if self.nstates is None:
9✔
130
                self.nstates = 0
9✔
131
                self.updfcn = lambda t, x, u, params: np.zeros(0)
9✔
132
            else:
133
                raise ValueError(
×
134
                    "states specified but no update function given.")
135

136
        if outfcn is None:
9✔
137
            if self.noutputs == 0:
9✔
138
                self.outfcn = lambda t, x, u, params: np.zeros(0)
9✔
139
            elif self.noutputs is None and self.nstates is not None:
9✔
140
                self.noutputs = self.nstates
9✔
141
                if len(self.output_index) == 0:
9✔
142
                    # Use state names for outputs
143
                    self.output_index = self.state_index
9✔
144
            elif self.noutputs is not None and self.noutputs == self.nstates:
9✔
145
                # Number of outputs = number of states => all is OK
146
                pass
9✔
147
            elif self.noutputs is not None and self.noutputs != 0:
9✔
148
                raise ValueError("outputs specified but no output function "
×
149
                                 "(and nstates not known).")
150

151
        # Initialize current parameters to default parameters
152
        self._current_params = {} if params is None else params.copy()
9✔
153

154
    def __str__(self):
9✔
155
        out = f"{InputOutputSystem.__str__(self)}"
9✔
156
        if len(self.params) > 0:
9✔
157
            out += f"\nParameters: {[p for p in self.params.keys()]}"
9✔
158
        out += "\n\n" + \
9✔
159
            f"Update: {self.updfcn}\n" + \
160
            f"Output: {self.outfcn}"
161
        return out
9✔
162

163
    # Return the value of a static nonlinear system
164
    def __call__(sys, u, params=None, squeeze=None):
9✔
165
        """Evaluate a (static) nonlinearity at a given input value.
166

167
        If a nonlinear I/O system has no internal state, then evaluating
168
        the system at an input `u` gives the output ``y = F(u)``,
169
        determined by the output function.
170

171
        Parameters
172
        ----------
173
        params : dict, optional
174
            Parameter values for the system. Passed to the evaluation function
175
            for the system as default values, overriding internal defaults.
176
        squeeze : bool, optional
177
            If True and if the system has a single output, return the
178
            system output as a 1D array rather than a 2D array.  If
179
            False, return the system output as a 2D array even if the
180
            system is SISO.  Default value set by
181
            `config.defaults['control.squeeze_time_response']`.
182

183
        """
184
        # Make sure the call makes sense
185
        if sys.nstates != 0:
9✔
186
            raise TypeError(
×
187
                "function evaluation is only supported for static "
188
                "input/output systems")
189

190
        # If we received any parameters, update them before calling _out()
191
        if params is not None:
9✔
192
            sys._update_params(params)
×
193

194
        # Evaluate the function on the argument
195
        out = sys._out(0, np.array((0,)), np.asarray(u))
9✔
196
        out = _process_time_response(
9✔
197
            out, issiso=sys.issiso(), squeeze=squeeze)
198
        return out
9✔
199

200
    def __mul__(self, other):
9✔
201
        """Multiply two input/output systems (series interconnection)"""
202
        # Convert 'other' to an I/O system if needed
203
        other = _convert_to_iosystem(other)
9✔
204
        if not isinstance(other, InputOutputSystem):
9✔
205
            return NotImplemented
×
206

207
        # Make sure systems can be interconnected
208
        if other.noutputs != self.ninputs:
9✔
209
            raise ValueError(
9✔
210
                "can't multiply systems with incompatible inputs and outputs")
211

212
        # Make sure timebase are compatible
213
        common_timebase(other.dt, self.dt)
9✔
214

215
        # Create a new system to handle the composition
216
        inplist = [(0, i) for i in range(other.ninputs)]
9✔
217
        outlist = [(1, i) for i in range(self.noutputs)]
9✔
218
        newsys = InterconnectedSystem(
9✔
219
            (other, self), inplist=inplist, outlist=outlist)
220

221
        # Set up the connection map manually
222
        newsys.set_connect_map(np.block(
9✔
223
            [[np.zeros((other.ninputs, other.noutputs)),
224
              np.zeros((other.ninputs, self.noutputs))],
225
             [np.eye(self.ninputs, other.noutputs),
226
              np.zeros((self.ninputs, self.noutputs))]]
227
        ))
228

229
        # Return the newly created InterconnectedSystem
230
        return newsys
9✔
231

232
    def __rmul__(self, other):
9✔
233
        """Pre-multiply an input/output systems by a scalar/matrix"""
234
        # Convert other to an I/O system if needed
235
        other = _convert_to_iosystem(other)
9✔
236
        if not isinstance(other, InputOutputSystem):
9✔
237
            return NotImplemented
×
238

239
        # Make sure systems can be interconnected
240
        if self.noutputs != other.ninputs:
9✔
241
            raise ValueError("Can't multiply systems with incompatible "
9✔
242
                             "inputs and outputs")
243

244
        # Make sure timebase are compatible
245
        common_timebase(self.dt, other.dt)
9✔
246

247
        # Create a new system to handle the composition
248
        inplist = [(0, i) for i in range(self.ninputs)]
9✔
249
        outlist = [(1, i) for i in range(other.noutputs)]
9✔
250
        newsys = InterconnectedSystem(
9✔
251
            (self, other), inplist=inplist, outlist=outlist)
252

253
        # Set up the connection map manually
254
        newsys.set_connect_map(np.block(
9✔
255
            [[np.zeros((self.ninputs, self.noutputs)),
256
              np.zeros((self.ninputs, other.noutputs))],
257
             [np.eye(self.ninputs, self.noutputs),
258
              np.zeros((other.ninputs, other.noutputs))]]
259
        ))
260

261
        # Return the newly created InterconnectedSystem
262
        return newsys
9✔
263

264
    def __add__(self, other):
9✔
265
        """Add two input/output systems (parallel interconnection)"""
266
        # Convert other to an I/O system if needed
267
        other = _convert_to_iosystem(other)
9✔
268
        if not isinstance(other, InputOutputSystem):
9✔
269
            return NotImplemented
×
270

271
        # Make sure number of input and outputs match
272
        if self.ninputs != other.ninputs or self.noutputs != other.noutputs:
9✔
273
            raise ValueError("Can't add systems with incompatible numbers of "
9✔
274
                             "inputs or outputs")
275

276
        # Create a new system to handle the composition
277
        inplist = [[(0, i), (1, i)] for i in range(self.ninputs)]
9✔
278
        outlist = [[(0, i), (1, i)] for i in range(self.noutputs)]
9✔
279
        newsys = InterconnectedSystem(
9✔
280
            (self, other), inplist=inplist, outlist=outlist)
281

282
        # Return the newly created InterconnectedSystem
283
        return newsys
9✔
284

285
    def __radd__(self, other):
9✔
286
        """Parallel addition of input/output system to a compatible object."""
287
        # Convert other to an I/O system if needed
288
        other = _convert_to_iosystem(other)
9✔
289
        if not isinstance(other, InputOutputSystem):
9✔
290
            return NotImplemented
×
291

292
        # Make sure number of input and outputs match
293
        if self.ninputs != other.ninputs or self.noutputs != other.noutputs:
9✔
294
            raise ValueError("can't add systems with incompatible numbers of "
9✔
295
                             "inputs or outputs")
296

297
        # Create a new system to handle the composition
298
        inplist = [[(0, i), (1, i)] for i in range(other.ninputs)]
9✔
299
        outlist = [[(0, i), (1, i)] for i in range(other.noutputs)]
9✔
300
        newsys = InterconnectedSystem(
9✔
301
            (other, self), inplist=inplist, outlist=outlist)
302

303
        # Return the newly created InterconnectedSystem
304
        return newsys
9✔
305

306
    def __sub__(self, other):
9✔
307
        """Subtract two input/output systems (parallel interconnection)"""
308
        # Convert other to an I/O system if needed
309
        other = _convert_to_iosystem(other)
9✔
310
        if not isinstance(other, InputOutputSystem):
9✔
311
            return NotImplemented
×
312

313
        # Make sure number of input and outputs match
314
        if self.ninputs != other.ninputs or self.noutputs != other.noutputs:
9✔
315
            raise ValueError(
9✔
316
                "can't subtract systems with incompatible numbers of "
317
                "inputs or outputs")
318
        ninputs = self.ninputs
9✔
319
        noutputs = self.noutputs
9✔
320

321
        # Create a new system to handle the composition
322
        inplist = [[(0, i), (1, i)] for i in range(ninputs)]
9✔
323
        outlist = [[(0, i), (1, i, -1)] for i in range(noutputs)]
9✔
324
        newsys = InterconnectedSystem(
9✔
325
            (self, other), inplist=inplist, outlist=outlist)
326

327
        # Return the newly created InterconnectedSystem
328
        return newsys
9✔
329

330
    def __rsub__(self, other):
9✔
331
        """Parallel subtraction of I/O system to a compatible object."""
332
        # Convert other to an I/O system if needed
333
        other = _convert_to_iosystem(other)
9✔
334
        if not isinstance(other, InputOutputSystem):
9✔
335
            return NotImplemented
×
336
        return other - self
9✔
337

338
    def __neg__(self):
9✔
339
        """Negate an input/output system (rescale)"""
340
        if self.ninputs is None or self.noutputs is None:
9✔
341
            raise ValueError("Can't determine number of inputs or outputs")
9✔
342

343
        # Create a new system to hold the negation
344
        inplist = [(0, i) for i in range(self.ninputs)]
9✔
345
        outlist = [(0, i, -1) for i in range(self.noutputs)]
9✔
346
        newsys = InterconnectedSystem(
9✔
347
            (self,), dt=self.dt, inplist=inplist, outlist=outlist)
348

349
        # Return the newly created system
350
        return newsys
9✔
351

352
    def __truediv__(self, other):
9✔
353
        """Division of input/output system (by scalar or array)"""
354
        if not isinstance(other, InputOutputSystem):
9✔
355
            return self * (1/other)
9✔
356
        else:
357
            return NotImplemented
9✔
358

359
    # Determine if a system is static (memoryless)
360
    def _isstatic(self):
9✔
361
        return self.nstates == 0
9✔
362

363
    def _update_params(self, params):
9✔
364
        # Update the current parameter values
365
        self._current_params = self.params.copy()
9✔
366
        if params:
9✔
367
            self._current_params.update(params)
9✔
368

369
    def _rhs(self, t, x, u):
9✔
370
        """Evaluate right hand side of a differential or difference equation.
371

372
        Private function used to compute the right hand side of an
373
        input/output system model. Intended for fast evaluation; for a more
374
        user-friendly interface you may want to use `dynamics`.
375

376
        """
377
        return np.asarray(
9✔
378
            self.updfcn(t, x, u, self._current_params)).reshape(-1)
379

380
    def dynamics(self, t, x, u, params=None):
9✔
381
        """Dynamics of a differential or difference equation.
382

383
        Given time `t`, input `u` and state `x`, returns the value of the
384
        right hand side of the dynamical system. If the system is a
385
        continuous-time system, returns the time derivative::
386

387
            dx/dt = updfcn(t, x, u[, params])
388

389
        where `updfcn` is the system's (possibly nonlinear) update function.
390
        If the system is discrete time, returns the next value of `x`::
391

392
            x[t+dt] = updfcn(t, x[t], u[t][, params])
393

394
        where `t` is a scalar.
395

396
        The inputs `x` and `u` must be of the correct length.  The `params`
397
        argument is an optional dictionary of parameter values.
398

399
        Parameters
400
        ----------
401
        t : float
402
            Time at which to evaluate.
403
        x : array_like
404
            Current state.
405
        u : array_like
406
            Current input.
407
        params : dict, optional
408
            System parameter values.
409

410
        Returns
411
        -------
412
        dx/dt or x[t+dt] : ndarray
413

414
        """
415
        self._update_params(params)
9✔
416
        return self._rhs(
9✔
417
            t, np.asarray(x).reshape(-1), np.asarray(u).reshape(-1))
418

419
    def _out(self, t, x, u):
9✔
420
        """Evaluate the output of a system at a given state, input, and time
421

422
        Private function used to compute the output of of an input/output
423
        system model given the state, input, parameters. Intended for fast
424
        evaluation; for a more user-friendly interface you may want to use
425
        `output`.
426

427
        """
428
        #
429
        # To allow lazy evaluation of the system size, we allow for the
430
        # possibility that noutputs is left unspecified when the system
431
        # is created => we have to check for that case here (and return
432
        # the system state or a portion of it).
433
        #
434
        if self.outfcn is None:
9✔
435
            return x if self.noutputs is None else x[:self.noutputs]
9✔
436
        else:
437
            return np.asarray(
9✔
438
                self.outfcn(t, x, u, self._current_params)).reshape(-1)
439

440
    def output(self, t, x, u, params=None):
9✔
441
        """Compute the output of the system.
442

443
        Given time `t`, input `u` and state `x`, returns the output of the
444
        system::
445

446
            y = outfcn(t, x, u[, params])
447

448
        The inputs `x` and `u` must be of the correct length.
449

450
        Parameters
451
        ----------
452
        t : float
453
            The time at which to evaluate.
454
        x : array_like
455
            Current state.
456
        u : array_like
457
            Current input.
458
        params : dict, optional
459
            System parameter values.
460

461
        Returns
462
        -------
463
        y : ndarray
464

465
        """
466
        self._update_params(params)
9✔
467
        return self._out(
9✔
468
            t, np.asarray(x).reshape(-1), np.asarray(u).reshape(-1))
469

470
    def feedback(self, other=1, sign=-1, params=None):
9✔
471
        """Feedback interconnection between two I/O systems.
472

473
        Parameters
474
        ----------
475
        other : `InputOutputSystem`
476
            System in the feedback path.
477

478
        sign : float, optional
479
            Gain to use in feedback path.  Defaults to -1.
480

481
        params : dict, optional
482
            Parameter values for the overall system.  Passed to the
483
            evaluation functions for the system as default values,
484
            overriding defaults for the individual systems.
485

486
        Returns
487
        -------
488
        `NonlinearIOSystem`
489

490
        """
491
        # Convert sys2 to an I/O system if needed
492
        other = _convert_to_iosystem(other)
9✔
493

494
        # Make sure systems can be interconnected
495
        if self.noutputs != other.ninputs or other.noutputs != self.ninputs:
9✔
496
            raise ValueError("Can't connect systems with incompatible "
×
497
                             "inputs and outputs")
498

499
        # Make sure timebases are compatible
500
        dt = common_timebase(self.dt, other.dt)
9✔
501

502
        inplist = [(0, i) for i in range(self.ninputs)]
9✔
503
        outlist = [(0, i) for i in range(self.noutputs)]
9✔
504

505
        # Return the series interconnection between the systems
506
        newsys = InterconnectedSystem(
9✔
507
            (self, other), inplist=inplist, outlist=outlist,
508
            params=params, dt=dt)
509

510
        #  Set up the connection map manually
511
        newsys.set_connect_map(np.block(
9✔
512
            [[np.zeros((self.ninputs, self.noutputs)),
513
              sign * np.eye(self.ninputs, other.noutputs)],
514
             [np.eye(other.ninputs, self.noutputs),
515
              np.zeros((other.ninputs, other.noutputs))]]
516
        ))
517

518
        # Return the newly created system
519
        return newsys
9✔
520

521
    def linearize(self, x0, u0=None, t=0, params=None, eps=1e-6,
9✔
522
                  copy_names=False, **kwargs):
523
        """Linearize an input/output system at a given state and input.
524

525
        Return the linearization of an input/output system at a given
526
        operating point (or state and input value) as a `StateSpace` system.
527
        See `linearize` for complete documentation.
528

529
        """
530
        #
531
        # Default method: if the linearization is not defined by the
532
        # subclass, perform a numerical linearization use the `_rhs()` and
533
        # `_out()` member functions.
534
        #
535
        from .statesp import StateSpace
9✔
536

537
        # Allow first argument to be an operating point
538
        if isinstance(x0, OperatingPoint):
9✔
539
            u0 = x0.inputs if u0 is None else u0
9✔
540
            x0 = x0.states
9✔
541
        elif u0 is None:
9✔
542
            u0 = 0
9✔
543

544
        # Process nominal states and inputs
545
        x0, nstates = _process_vector_argument(x0, "x0", self.nstates)
9✔
546
        u0, ninputs = _process_vector_argument(u0, "u0", self.ninputs)
9✔
547

548
        # Update the current parameters (prior to calling _out())
549
        self._update_params(params)
9✔
550

551
        # Compute number of outputs by evaluating the output function
552
        noutputs = _find_size(self.noutputs, self._out(t, x0, u0), "outputs")
9✔
553

554
        # Compute the nominal value of the update law and output
555
        F0 = self._rhs(t, x0, u0)
9✔
556
        H0 = self._out(t, x0, u0)
9✔
557

558
        # Create empty matrices that we can fill up with linearizations
559
        A = np.zeros((nstates, nstates))        # Dynamics matrix
9✔
560
        B = np.zeros((nstates, ninputs))        # Input matrix
9✔
561
        C = np.zeros((noutputs, nstates))       # Output matrix
9✔
562
        D = np.zeros((noutputs, ninputs))       # Direct term
9✔
563

564
        # Perturb each of the state variables and compute linearization
565
        for i in range(nstates):
9✔
566
            dx = np.zeros((nstates,))
9✔
567
            dx[i] = eps
9✔
568
            A[:, i] = (self._rhs(t, x0 + dx, u0) - F0) / eps
9✔
569
            C[:, i] = (self._out(t, x0 + dx, u0) - H0) / eps
9✔
570

571
        # Perturb each of the input variables and compute linearization
572
        for i in range(ninputs):
9✔
573
            du = np.zeros((ninputs,))
9✔
574
            du[i] = eps
9✔
575
            B[:, i] = (self._rhs(t, x0, u0 + du) - F0) / eps
9✔
576
            D[:, i] = (self._out(t, x0, u0 + du) - H0) / eps
9✔
577

578
        # Create the state space system
579
        linsys = StateSpace(A, B, C, D, self.dt, remove_useless_states=False)
9✔
580

581
        # Set the system name, inputs, outputs, and states
582
        if copy_names:
9✔
583
            linsys._copy_names(self, prefix_suffix_name='linearized')
9✔
584

585
        # re-init to include desired signal names if names were provided
586
        return StateSpace(linsys, **kwargs)
9✔
587

588

589
class InterconnectedSystem(NonlinearIOSystem):
9✔
590
    """Interconnection of a set of input/output systems.
591

592
    This class is used to implement a system that is an interconnection of
593
    input/output systems.  The system consists of a collection of subsystems
594
    whose inputs and outputs are connected via a connection map.  The overall
595
    system inputs and outputs are subsets of the subsystem inputs and outputs.
596

597
    The `interconnect` factory function should be used to create an
598
    interconnected I/O system since it performs additional argument
599
    processing and checking.
600

601
    Parameters
602
    ----------
603
    syslist : list of `NonlinearIOSystem`
604
        List of state space systems to interconnect.
605
    connections : list of connections
606
        Description of the internal connections between the subsystem.  See
607
        `interconnect` for details.
608
    inplist, outlist : list of input and output connections
609
        Description of the inputs and outputs for the overall system.  See
610
        `interconnect` for details.
611
    inputs, outputs, states : int, list of str or None, optional
612
        Description of the system inputs, outputs, and states.  See
613
        `control.nlsys` for more details.
614
    params : dict, optional
615
        Parameter values for the systems.  Passed to the evaluation functions
616
        for the system as default values, overriding internal defaults.
617
    connection_type : str
618
        Type of connection: 'explicit' (or None) for explicitly listed
619
        set of connections, 'implicit' for connections made via signal names.
620

621
    Attributes
622
    ----------
623
    ninputs, noutputs, nstates : int
624
        Number of input, output and state variables.
625
    shape : tuple
626
        2-tuple of I/O system dimension, (noutputs, ninputs).
627
    name : string, optional
628
        System name.
629
    connect_map : 2D array
630
        Mapping of subsystem outputs to subsystem inputs.
631
    input_map : 2D array
632
        Mapping of system inputs to subsystem inputs.
633
    output_map : 2D array
634
        Mapping of (stacked) subsystem outputs and inputs to system outputs.
635
    input_labels, output_labels, state_labels : list of str
636
        Names for the input, output, and state variables.
637
    input_offset, output_offset, state_offset : list of int
638
        Offset to the subsystem inputs, outputs, and states in the overall
639
        system input, output, and state arrays.
640
    syslist_index : dict
641
        Index of the subsystem with key given by the name of the subsystem.
642

643
    See Also
644
    --------
645
    interconnect, NonlinearIOSystem, LinearICSystem
646

647
    """
648
    def __init__(self, syslist, connections=None, inplist=None, outlist=None,
9✔
649
                 params=None, warn_duplicate=None, connection_type=None,
650
                 **kwargs):
651
        """Create an I/O system from a list of systems + connection info."""
652
        from .statesp import _convert_to_statespace
9✔
653
        from .xferfcn import TransferFunction
9✔
654

655
        self.connection_type = connection_type # explicit, implicit, or None
9✔
656

657
        # Convert input and output names to lists if they aren't already
658
        if inplist is not None and not isinstance(inplist, list):
9✔
659
            inplist = [inplist]
9✔
660
        if outlist is not None and not isinstance(outlist, list):
9✔
661
            outlist = [outlist]
9✔
662

663
        # Check if dt argument was given; if not, pull from systems
664
        dt = kwargs.pop('dt', None)
9✔
665

666
        # Process keyword arguments (except dt)
667
        name, inputs, outputs, states, _ = _process_iosys_keywords(kwargs)
9✔
668

669
        # Initialize the system list and index
670
        self.syslist = list(syslist) # ensure modifications can be made
9✔
671
        self.syslist_index = {}
9✔
672

673
        # Initialize the input, output, and state counts, indices
674
        nstates, self.state_offset = 0, []
9✔
675
        ninputs, self.input_offset = 0, []
9✔
676
        noutputs, self.output_offset = 0, []
9✔
677

678
        # Keep track of system objects and names we have already seen
679
        sysobj_name_dct = {}
9✔
680
        sysname_count_dct = {}
9✔
681

682
        # Go through the system list and keep track of counts, offsets
683
        for sysidx, sys in enumerate(self.syslist):
9✔
684
            # Convert transfer functions to state space
685
            if isinstance(sys, TransferFunction):
9✔
686
                sys = _convert_to_statespace(sys)
9✔
687
                self.syslist[sysidx] = sys
9✔
688

689
            # Make sure time bases are consistent
690
            dt = common_timebase(dt, sys.dt)
9✔
691

692
            # Make sure number of inputs, outputs, states is given
693
            if sys.ninputs is None or sys.noutputs is None:
9✔
694
                raise TypeError("system '%s' must define number of inputs, "
×
695
                                "outputs, states in order to be connected" %
696
                                sys.name)
697
            elif sys.nstates is None:
9✔
698
                raise TypeError("can't interconnect systems with no state")
9✔
699

700
            # Keep track of the offsets into the states, inputs, outputs
701
            self.input_offset.append(ninputs)
9✔
702
            self.output_offset.append(noutputs)
9✔
703
            self.state_offset.append(nstates)
9✔
704

705
            # Keep track of the total number of states, inputs, outputs
706
            nstates += sys.nstates
9✔
707
            ninputs += sys.ninputs
9✔
708
            noutputs += sys.noutputs
9✔
709

710
            # Check for duplicate systems or duplicate names
711
            # Duplicates are renamed sysname_1, sysname_2, etc.
712
            if sys in sysobj_name_dct:
9✔
713
                # Make a copy of the object using a new name
714
                if warn_duplicate is None and sys._generic_name_check():
9✔
715
                    # Make a copy w/out warning, using generic format
716
                    sys = sys.copy(use_prefix_suffix=False)
9✔
717
                    warn_flag = False
9✔
718
                else:
719
                    sys = sys.copy()
9✔
720
                    warn_flag = warn_duplicate
9✔
721

722
                # Warn the user about the new object
723
                if warn_flag is not False:
9✔
724
                    warn("duplicate object found in system list; "
9✔
725
                         "created copy: %s" % str(sys.name), stacklevel=2)
726

727
            # Check to see if the system name shows up more than once
728
            if sys.name is not None and sys.name in sysname_count_dct:
9✔
729
                count = sysname_count_dct[sys.name]
9✔
730
                sysname_count_dct[sys.name] += 1
9✔
731
                sysname = sys.name + "_" + str(count)
9✔
732
                sysobj_name_dct[sys] = sysname
9✔
733
                self.syslist_index[sysname] = sysidx
9✔
734

735
                if warn_duplicate is not False:
9✔
736
                    warn("duplicate name found in system list; "
9✔
737
                         "renamed to {}".format(sysname), stacklevel=2)
738

739
            else:
740
                sysname_count_dct[sys.name] = 1
9✔
741
                sysobj_name_dct[sys] = sys.name
9✔
742
                self.syslist_index[sys.name] = sysidx
9✔
743

744
        if states is None:
9✔
745
            states = []
9✔
746
            state_name_delim = config.defaults['iosys.state_name_delim']
9✔
747
            for sys, sysname in sysobj_name_dct.items():
9✔
748
                states += [sysname + state_name_delim +
9✔
749
                           statename for statename in sys.state_index.keys()]
750

751
        # Make sure we the state list is the right length (internal check)
752
        if isinstance(states, list) and len(states) != nstates:
9✔
753
            raise RuntimeError(
×
754
                f"construction of state labels failed; found: "
755
                f"{len(states)} labels; expecting {nstates}")
756

757
        # Figure out what the inputs and outputs are
758
        if inputs is None and inplist is not None:
9✔
759
            inputs = len(inplist)
9✔
760

761
        if outputs is None and outlist is not None:
9✔
762
            outputs = len(outlist)
9✔
763

764
        if params is None:
9✔
765
            params = {}
9✔
766
            for sys in self.syslist:
9✔
767
                params = params | sys.params
9✔
768

769
        # Create updfcn and outfcn
770
        def updfcn(t, x, u, params):
9✔
771
            self._update_params(params)
9✔
772
            return self._rhs(t, x, u)
9✔
773
        def outfcn(t, x, u, params):
9✔
774
            self._update_params(params)
×
775
            return self._out(t, x, u)
×
776

777
        # Initialize NonlinearIOSystem object
778
        super().__init__(
9✔
779
            updfcn, outfcn, inputs=inputs, outputs=outputs,
780
            states=states, dt=dt, name=name, params=params, **kwargs)
781

782
        # Convert the list of interconnections to a connection map (matrix)
783
        self.connect_map = np.zeros((ninputs, noutputs))
9✔
784
        for connection in connections or []:
9✔
785
            input_indices = self._parse_input_spec(connection[0])
9✔
786
            for output_spec in connection[1:]:
9✔
787
                output_indices, gain = self._parse_output_spec(output_spec)
9✔
788
                if len(output_indices) != len(input_indices):
9✔
789
                    raise ValueError(
×
790
                        f"inconsistent number of signals in connecting"
791
                        f" '{output_spec}' to '{connection[0]}'")
792

793
                for input_index, output_index in zip(
9✔
794
                        input_indices, output_indices):
795
                    if self.connect_map[input_index, output_index] != 0:
9✔
796
                        warn("multiple connections given for input %d" %
9✔
797
                             input_index + "; combining with previous entries")
798
                    self.connect_map[input_index, output_index] += gain
9✔
799

800
        # Convert the input list to a matrix: maps system to subsystems
801
        self.input_map = np.zeros((ninputs, self.ninputs))
9✔
802
        for index, inpspec in enumerate(inplist or []):
9✔
803
            if isinstance(inpspec, (int, str, tuple)):
9✔
804
                inpspec = [inpspec]
9✔
805
            if not isinstance(inpspec, list):
9✔
806
                raise ValueError("specifications in inplist must be of type "
×
807
                                 "int, str, tuple or list")
808
            for spec in inpspec:
9✔
809
                ulist_indices = self._parse_input_spec(spec)
9✔
810
                for j, ulist_index in enumerate(ulist_indices):
9✔
811
                    if self.input_map[ulist_index, index] != 0:
9✔
812
                        warn("multiple connections given for input %d" %
9✔
813
                             index + "; combining with previous entries.")
814
                    self.input_map[ulist_index, index + j] += 1
9✔
815

816
        # Convert the output list to a matrix: maps subsystems to system
817
        self.output_map = np.zeros((self.noutputs, noutputs + ninputs))
9✔
818
        for index, outspec in enumerate(outlist or []):
9✔
819
            if isinstance(outspec, (int, str, tuple)):
9✔
820
                outspec = [outspec]
9✔
821
            if not isinstance(outspec, list):
9✔
822
                raise ValueError("specifications in outlist must be of type "
×
823
                                 "int, str, tuple or list")
824
            for spec in outspec:
9✔
825
                ylist_indices, gain = self._parse_output_spec(spec)
9✔
826
                for j, ylist_index in enumerate(ylist_indices):
9✔
827
                    if self.output_map[index, ylist_index] != 0:
9✔
828
                        warn("multiple connections given for output %d" %
9✔
829
                             index + "; combining with previous entries")
830
                    self.output_map[index + j, ylist_index] += gain
9✔
831

832
    def __str__(self):
9✔
833
        import textwrap
9✔
834
        out = InputOutputSystem.__str__(self)
9✔
835

836
        out += f"\n\nSubsystems ({len(self.syslist)}):\n"
9✔
837
        for sys in self.syslist:
9✔
838
            out += "\n".join(textwrap.wrap(
9✔
839
                iosys_repr(sys, format='info'), width=78,
840
                initial_indent=" * ", subsequent_indent="    ")) + "\n"
841

842
        # Build a list of input, output, and inpout signals
843
        input_list, output_list, inpout_list = [], [], []
9✔
844
        for sys in self.syslist:
9✔
845
            input_list += [sys.name + "." + lbl for lbl in sys.input_labels]
9✔
846
            output_list += [sys.name + "." + lbl for lbl in sys.output_labels]
9✔
847
        inpout_list = input_list + output_list
9✔
848

849
        # Define a utility function to generate the signal
850
        def cxn_string(signal, gain, first):
9✔
851
            if gain == 1:
9✔
852
                return (" + " if not first else "") + f"{signal}"
9✔
853
            elif gain == -1:
9✔
854
                return (" - " if not first else "-") + f"{signal}"
9✔
855
            elif gain > 0:
9✔
856
                return (" + " if not first else "") + f"{gain} * {signal}"
×
857
            elif gain < 0:
9✔
858
                return (" - " if not first else "-") + \
9✔
859
                    f"{abs(gain)} * {signal}"
860

861
        out += "\nConnections:\n"
9✔
862
        for i in range(len(input_list)):
9✔
863
            first = True
9✔
864
            cxn = f"{input_list[i]} <- "
9✔
865
            if np.any(self.connect_map[i]):
9✔
866
                for j in range(len(output_list)):
9✔
867
                    if self.connect_map[i, j]:
9✔
868
                        cxn += cxn_string(
9✔
869
                            output_list[j], self.connect_map[i,j], first)
870
                        first = False
9✔
871
            if np.any(self.input_map[i]):
9✔
872
                for j in range(len(self.input_labels)):
9✔
873
                    if self.input_map[i, j]:
9✔
874
                        cxn += cxn_string(
9✔
875
                            self.input_labels[j], self.input_map[i, j], first)
876
                        first = False
9✔
877
            out += "\n".join(textwrap.wrap(
9✔
878
                cxn, width=78, initial_indent=" * ",
879
                subsequent_indent="     ")) + "\n"
880

881
        out += "\nOutputs:"
9✔
882
        for i in range(len(self.output_labels)):
9✔
883
            first = True
9✔
884
            cxn = f"{self.output_labels[i]} <- "
9✔
885
            if np.any(self.output_map[i]):
9✔
886
                for j in range(len(inpout_list)):
9✔
887
                    if self.output_map[i, j]:
9✔
888
                        cxn += cxn_string(
9✔
889
                            output_list[j], self.output_map[i, j], first)
890
                        first = False
9✔
891
                out += "\n" + "\n".join(textwrap.wrap(
9✔
892
                    cxn, width=78, initial_indent=" * ",
893
                    subsequent_indent="     "))
894

895
        return out
9✔
896

897
    def _update_params(self, params):
9✔
898
        for sys in self.syslist:
9✔
899
            local = sys.params.copy()   # start with system parameters
9✔
900
            local.update(self.params)   # update with global params
9✔
901
            if params:
9✔
902
                local.update(params)    # update with locally passed parameters
9✔
903
            sys._update_params(local)
9✔
904

905
    def _rhs(self, t, x, u):
9✔
906
        # Make sure state and input are vectors
907
        x = np.array(x, ndmin=1)
9✔
908
        u = np.array(u, ndmin=1)
9✔
909

910
        # Compute the input and output vectors
911
        ulist, ylist = self._compute_static_io(t, x, u)
9✔
912

913
        # Go through each system and update the right hand side for that system
914
        xdot = np.zeros((self.nstates,))        # Array to hold results
9✔
915
        state_index, input_index = 0, 0         # Start at the beginning
9✔
916
        for sys in self.syslist:
9✔
917
            # Update the right hand side for this subsystem
918
            if sys.nstates != 0:
9✔
919
                xdot[state_index:state_index + sys.nstates] = sys._rhs(
9✔
920
                    t, x[state_index:state_index + sys.nstates],
921
                    ulist[input_index:input_index + sys.ninputs])
922

923
            # Update the state and input index counters
924
            state_index += sys.nstates
9✔
925
            input_index += sys.ninputs
9✔
926

927
        return xdot
9✔
928

929
    def _out(self, t, x, u):
9✔
930
        # Make sure state and input are vectors
931
        x = np.array(x, ndmin=1)
9✔
932
        u = np.array(u, ndmin=1)
9✔
933

934
        # Compute the input and output vectors
935
        ulist, ylist = self._compute_static_io(t, x, u)
9✔
936

937
        # Make the full set of subsystem outputs to system output
938
        return self.output_map @ ylist
9✔
939

940
    # Find steady state (static) inputs and outputs
941
    def _compute_static_io(self, t, x, u):
9✔
942
        # Figure out the total number of inputs and outputs
943
        (ninputs, noutputs) = self.connect_map.shape
9✔
944

945
        #
946
        # Get the outputs and inputs at the current system state
947
        #
948

949
        # Initialize the lists used to keep track of internal signals
950
        ulist = np.dot(self.input_map, u)
9✔
951
        ylist = np.zeros((noutputs + ninputs,))
9✔
952

953
        # To allow for feedthrough terms, iterate multiple times to allow
954
        # feedthrough elements to propagate.  For n systems, we could need to
955
        # cycle through n+1 times before reaching steady state
956
        # TODO (later): see if there is a more efficient way to compute
957
        cycle_count = len(self.syslist) + 1
9✔
958
        while cycle_count > 0:
9✔
959
            state_index, input_index, output_index = 0, 0, 0
9✔
960
            for sys in self.syslist:
9✔
961
                # Compute outputs for each system from current state
962
                ysys = sys._out(
9✔
963
                    t, x[state_index:state_index + sys.nstates],
964
                    ulist[input_index:input_index + sys.ninputs])
965

966
                # Store the outputs at the start of ylist
967
                ylist[output_index:output_index + sys.noutputs] = \
9✔
968
                    ysys.reshape((-1,))
969

970
                # Store the input in the second part of ylist
971
                ylist[noutputs + input_index:
9✔
972
                      noutputs + input_index + sys.ninputs] = \
973
                          ulist[input_index:input_index + sys.ninputs]
974

975
                # Increment the index pointers
976
                state_index += sys.nstates
9✔
977
                input_index += sys.ninputs
9✔
978
                output_index += sys.noutputs
9✔
979

980
            # Compute inputs based on connection map
981
            new_ulist = self.connect_map @ ylist[:noutputs] \
9✔
982
                + np.dot(self.input_map, u)
983

984
            # Check to see if any of the inputs changed
985
            if (ulist == new_ulist).all():
9✔
986
                break
9✔
987
            else:
988
                ulist = new_ulist
9✔
989

990
            # Decrease the cycle counter
991
            cycle_count -= 1
9✔
992

993
        # Make sure that we stopped before detecting an algebraic loop
994
        if cycle_count == 0:
9✔
995
            raise RuntimeError("algebraic loop detected")
9✔
996

997
        return ulist, ylist
9✔
998

999
    def _parse_input_spec(self, spec):
9✔
1000
        """Parse an input specification and returns the indices."""
1001
        # Parse the signal that we received
1002
        subsys_index, input_indices, gain = _parse_spec(
9✔
1003
            self.syslist, spec, 'input')
1004
        if gain != 1:
9✔
1005
            raise ValueError("gain not allowed in spec '%s'" % str(spec))
×
1006

1007
        # Return the indices into the input vector list (ylist)
1008
        return [self.input_offset[subsys_index] + i for i in input_indices]
9✔
1009

1010
    def _parse_output_spec(self, spec):
9✔
1011
        """Parse an output specification and returns the indices and gain."""
1012
        # Parse the rest of the spec with standard signal parsing routine
1013
        try:
9✔
1014
            # Start by looking in the set of subsystem outputs
1015
            subsys_index, output_indices, gain = \
9✔
1016
                _parse_spec(self.syslist, spec, 'output')
1017
            output_offset = self.output_offset[subsys_index]
9✔
1018

1019
        except ValueError:
9✔
1020
            # Try looking in the set of subsystem *inputs*
1021
            subsys_index, output_indices, gain = _parse_spec(
9✔
1022
                self.syslist, spec, 'input or output', dictname='input_index')
1023

1024
            # Return the index into the input vector list (ylist)
1025
            output_offset = sum(sys.noutputs for sys in self.syslist) + \
9✔
1026
                self.input_offset[subsys_index]
1027

1028
        return [output_offset + i for i in output_indices], gain
9✔
1029

1030
    def _find_system(self, name):
9✔
1031
        return self.syslist_index.get(name, None)
×
1032

1033
    def set_connect_map(self, connect_map):
9✔
1034
        """Set the connection map for an interconnected I/O system.
1035

1036
        Parameters
1037
        ----------
1038
        connect_map : 2D array
1039
             Specify the matrix that will be used to multiply the vector of
1040
             subsystem outputs to obtain the vector of subsystem inputs.
1041

1042
        """
1043
        # Make sure the connection map is the right size
1044
        if connect_map.shape != self.connect_map.shape:
9✔
1045
            ValueError("Connection map is not the right shape")
×
1046
        self.connect_map = connect_map
9✔
1047

1048
    def set_input_map(self, input_map):
9✔
1049
        """Set the input map for an interconnected I/O system.
1050

1051
        Parameters
1052
        ----------
1053
        input_map : 2D array
1054
             Specify the matrix that will be used to multiply the vector of
1055
             system inputs to obtain the vector of subsystem inputs.  These
1056
             values are added to the inputs specified in the connection map.
1057

1058
        """
1059
        # Figure out the number of internal inputs
1060
        ninputs = sum(sys.ninputs for sys in self.syslist)
×
1061

1062
        # Make sure the input map is the right size
1063
        if input_map.shape[0] != ninputs:
×
1064
            ValueError("Input map is not the right shape")
×
1065
        self.input_map = input_map
×
1066
        self.ninputs = input_map.shape[1]
×
1067

1068
    def set_output_map(self, output_map):
9✔
1069
        """Set the output map for an interconnected I/O system.
1070

1071
        Parameters
1072
        ----------
1073
        output_map : 2D array
1074
             Specify the matrix that will be used to multiply the vector of
1075
             subsystem outputs concatenated with subsystem inputs to obtain
1076
             the vector of system outputs.
1077

1078
        """
1079
        # Figure out the number of internal inputs and outputs
1080
        ninputs = sum(sys.ninputs for sys in self.syslist)
×
1081
        noutputs = sum(sys.noutputs for sys in self.syslist)
×
1082

1083
        # Make sure the output map is the right size
1084
        if output_map.shape[1] == noutputs:
×
1085
            # For backward compatibility, add zeros to the end of the array
1086
            output_map = np.concatenate(
×
1087
                (output_map,
1088
                 np.zeros((output_map.shape[0], ninputs))),
1089
                axis=1)
1090

1091
        if output_map.shape[1] != noutputs + ninputs:
×
1092
            ValueError("Output map is not the right shape")
×
1093
        self.output_map = output_map
×
1094
        self.noutputs = output_map.shape[0]
×
1095

1096
    def unused_signals(self):
9✔
1097
        """Find unused subsystem inputs and outputs.
1098

1099
        Returns
1100
        -------
1101
        unused_inputs : dict
1102
          A mapping from tuple of indices (isys, isig) to string
1103
          '{sys}.{sig}', for all unused subsystem inputs.
1104

1105
        unused_outputs : dict
1106
          A mapping from tuple of indices (osys, osig) to string
1107
          '{sys}.{sig}', for all unused subsystem outputs.
1108

1109
        """
1110
        used_sysinp_via_inp = np.nonzero(self.input_map)[0]
9✔
1111
        used_sysout_via_out = np.nonzero(self.output_map)[1]
9✔
1112
        used_sysinp_via_con, used_sysout_via_con = np.nonzero(self.connect_map)
9✔
1113

1114
        used_sysinp = set(used_sysinp_via_inp) | set(used_sysinp_via_con)
9✔
1115
        used_sysout = set(used_sysout_via_out) | set(used_sysout_via_con)
9✔
1116

1117
        nsubsysinp = sum(sys.ninputs for sys in self.syslist)
9✔
1118
        nsubsysout = sum(sys.noutputs for sys in self.syslist)
9✔
1119

1120
        unused_sysinp = sorted(set(range(nsubsysinp)) - used_sysinp)
9✔
1121
        unused_sysout = sorted(set(range(nsubsysout)) - used_sysout)
9✔
1122

1123
        inputs = [(isys, isig, f'{sys.name}.{sig}')
9✔
1124
                  for isys, sys in enumerate(self.syslist)
1125
                  for sig, isig in sys.input_index.items()]
1126

1127
        outputs = [(isys, isig, f'{sys.name}.{sig}')
9✔
1128
                   for isys, sys in enumerate(self.syslist)
1129
                   for sig, isig in sys.output_index.items()]
1130

1131
        return ({inputs[i][:2]: inputs[i][2] for i in unused_sysinp},
9✔
1132
                {outputs[i][:2]: outputs[i][2] for i in unused_sysout})
1133

1134
    def connection_table(self, show_names=False, column_width=32):
9✔
1135
        """Table of connections inside an interconnected system.
1136

1137
        Intended primarily for `InterconnectedSystem`'s that have been
1138
        connected implicitly using signal names.
1139

1140
        Parameters
1141
        ----------
1142
        show_names : bool, optional
1143
            Instead of printing out the system number, print out the name
1144
            of each system. Default is False because system name is not
1145
            usually specified when performing implicit interconnection
1146
            using `interconnect`.
1147
        column_width : int, optional
1148
            Character width of printed columns.
1149

1150
        Examples
1151
        --------
1152
        >>> P = ct.ss(1,1,1,0, inputs='u', outputs='y', name='P')
1153
        >>> C = ct.tf(10, [.1, 1], inputs='e', outputs='u', name='C')
1154
        >>> L = ct.interconnect([C, P], inputs='e', outputs='y')
1155
        >>> L.connection_table(show_names=True) # doctest: +SKIP
1156
        signal    | source                        | destination
1157
        --------------------------------------------------------------------
1158
        e         | input                         | C
1159
        u         | C                             | P
1160
        y         | P                             | output
1161

1162
        """
1163

1164
        print('signal'.ljust(10) + '| source'.ljust(column_width) + \
9✔
1165
            '| destination')
1166
        print('-'*(10 + column_width * 2))
9✔
1167

1168
        # TODO: update this method for explicitly-connected systems
1169
        if not self.connection_type == 'implicit':
9✔
1170
            warn('connection_table only gives useful output for implicitly-'\
×
1171
                'connected systems')
1172

1173
        # collect signal labels
1174
        signal_labels = []
9✔
1175
        for sys in self.syslist:
9✔
1176
            signal_labels += sys.input_labels + sys.output_labels
9✔
1177
        signal_labels = set(signal_labels)
9✔
1178

1179
        for signal_label in signal_labels:
9✔
1180
            print(signal_label.ljust(10), end='')
9✔
1181
            sources = '| '
9✔
1182
            dests = '| '
9✔
1183

1184
            #  overall interconnected system inputs and outputs
1185
            if self.find_input(signal_label) is not None:
9✔
1186
                sources += 'input'
9✔
1187
            if self.find_output(signal_label) is not None:
9✔
1188
                dests += 'output'
9✔
1189

1190
            # internal connections
1191
            for idx, sys in enumerate(self.syslist):
9✔
1192
                loc = sys.find_output(signal_label)
9✔
1193
                if loc is not None:
9✔
1194
                    if not sources.endswith(' '):
9✔
1195
                        sources += ', '
9✔
1196
                    sources += sys.name if show_names else 'system ' + str(idx)
9✔
1197
                loc = sys.find_input(signal_label)
9✔
1198
                if loc is not None:
9✔
1199
                    if not dests.endswith(' '):
9✔
1200
                        dests += ', '
9✔
1201
                    dests += sys.name if show_names else 'system ' + str(idx)
9✔
1202
            if len(sources) >= column_width:
9✔
1203
                sources = sources[:column_width - 3] + '.. '
×
1204
            print(sources.ljust(column_width), end='')
9✔
1205
            if len(dests) > column_width:
9✔
1206
                dests = dests[:column_width - 3] + '.. '
9✔
1207
            print(dests.ljust(column_width), end='\n')
9✔
1208

1209
    def _find_inputs_by_basename(self, basename):
9✔
1210
        """Find all subsystem inputs matching basename
1211

1212
        Returns
1213
        -------
1214
        Mapping from (isys, isig) to '{sys}.{sig}'
1215

1216
        """
1217
        return {(isys, isig): f'{sys.name}.{basename}'
9✔
1218
                for isys, sys in enumerate(self.syslist)
1219
                for sig, isig in sys.input_index.items()
1220
                if sig == (basename)}
1221

1222
    def _find_outputs_by_basename(self, basename):
9✔
1223
        """Find all subsystem outputs matching basename
1224

1225
        Returns
1226
        -------
1227
        Mapping from (isys, isig) to '{sys}.{sig}'
1228

1229
        """
1230
        return {(isys, isig): f'{sys.name}.{basename}'
9✔
1231
                for isys, sys in enumerate(self.syslist)
1232
                for sig, isig in sys.output_index.items()
1233
                if sig == (basename)}
1234

1235
    # TODO: change to internal function?  (not sure users need to see this)
1236
    def check_unused_signals(
9✔
1237
            self, ignore_inputs=None, ignore_outputs=None, print_warning=True):
1238
        """Check for unused subsystem inputs and outputs.
1239

1240
        Check to see if there are any unused signals and return a list of
1241
        unused input and output signal descriptions.  If `warning` is
1242
        True and any unused inputs or outputs are found, emit a warning.
1243

1244
        Parameters
1245
        ----------
1246
        ignore_inputs : list of input-spec
1247
          Subsystem inputs known to be unused.  input-spec can be any of:
1248
            'sig', 'sys.sig', (isys, isig), ('sys', isig)
1249

1250
          If the 'sig' form is used, all subsystem inputs with that
1251
          name are considered ignored.
1252

1253
        ignore_outputs : list of output-spec
1254
          Subsystem outputs known to be unused.  output-spec can be any of:
1255
            'sig', 'sys.sig', (isys, isig), ('sys', isig)
1256

1257
          If the 'sig' form is used, all subsystem outputs with that
1258
          name are considered ignored.
1259

1260
        print_warning : bool, optional
1261
            If True, print a warning listing any unused signals.
1262

1263
        Returns
1264
        -------
1265
        dropped_inputs : list of tuples
1266
            A list of the dropped input signals, with each element of the
1267
            list in the form of (isys, isig).
1268

1269
        dropped_outputs : list of tuples
1270
            A list of the dropped output signals, with each element of the
1271
            list in the form of (osys, osig).
1272

1273
        """
1274

1275
        if ignore_inputs is None:
9✔
1276
            ignore_inputs = []
9✔
1277

1278
        if ignore_outputs is None:
9✔
1279
            ignore_outputs = []
9✔
1280

1281
        unused_inputs, unused_outputs = self.unused_signals()
9✔
1282

1283
        # (isys, isig) -> signal-spec
1284
        ignore_input_map = {}
9✔
1285
        for ignore_input in ignore_inputs:
9✔
1286
            if isinstance(ignore_input, str) and '.' not in ignore_input:
9✔
1287
                ignore_idxs = self._find_inputs_by_basename(ignore_input)
9✔
1288
                if not ignore_idxs:
9✔
1289
                    raise ValueError("Couldn't find ignored input "
9✔
1290
                                     f"{ignore_input} in subsystems")
1291
                ignore_input_map.update(ignore_idxs)
9✔
1292
            else:
1293
                isys, isigs = _parse_spec(
9✔
1294
                    self.syslist, ignore_input, 'input')[:2]
1295
                for isig in isigs:
9✔
1296
                    ignore_input_map[(isys, isig)] = ignore_input
9✔
1297

1298
        # (osys, osig) -> signal-spec
1299
        ignore_output_map = {}
9✔
1300
        for ignore_output in ignore_outputs:
9✔
1301
            if isinstance(ignore_output, str) and '.' not in ignore_output:
9✔
1302
                ignore_found = self._find_outputs_by_basename(ignore_output)
9✔
1303
                if not ignore_found:
9✔
1304
                    raise ValueError("Couldn't find ignored output "
9✔
1305
                                     f"{ignore_output} in subsystems")
1306
                ignore_output_map.update(ignore_found)
9✔
1307
            else:
1308
                osys, osigs = _parse_spec(
9✔
1309
                    self.syslist, ignore_output, 'output')[:2]
1310
                for osig in osigs:
9✔
1311
                    ignore_output_map[(osys, osig)] = ignore_output
9✔
1312

1313
        dropped_inputs = set(unused_inputs) - set(ignore_input_map)
9✔
1314
        dropped_outputs = set(unused_outputs) - set(ignore_output_map)
9✔
1315

1316
        used_ignored_inputs = set(ignore_input_map) - set(unused_inputs)
9✔
1317
        used_ignored_outputs = set(ignore_output_map) - set(unused_outputs)
9✔
1318

1319
        if print_warning and dropped_inputs:
9✔
1320
            msg = ('Unused input(s) in InterconnectedSystem: '
9✔
1321
                   + '; '.join(f'{inp}={unused_inputs[inp]}'
1322
                               for inp in dropped_inputs))
1323
            warn(msg)
9✔
1324

1325
        if print_warning and dropped_outputs:
9✔
1326
            msg = ('Unused output(s) in InterconnectedSystem: '
9✔
1327
                   + '; '.join(f'{out} : {unused_outputs[out]}'
1328
                               for out in dropped_outputs))
1329
            warn(msg)
9✔
1330

1331
        if print_warning and used_ignored_inputs:
9✔
1332
            msg = ('Input(s) specified as ignored is (are) used: '
9✔
1333
                   + '; '.join(f'{inp} : {ignore_input_map[inp]}'
1334
                               for inp in used_ignored_inputs))
1335
            warn(msg)
9✔
1336

1337
        if print_warning and used_ignored_outputs:
9✔
1338
            msg = ('Output(s) specified as ignored is (are) used: '
9✔
1339
                   + '; '.join(f'{out}={ignore_output_map[out]}'
1340
                               for out in used_ignored_outputs))
1341
            warn(msg)
9✔
1342

1343
        return dropped_inputs, dropped_outputs
9✔
1344

1345

1346
def nlsys(updfcn, outfcn=None, **kwargs):
9✔
1347
    """Create a nonlinear input/output system.
1348

1349
    Creates an `InputOutputSystem` for a nonlinear system by specifying a
1350
    state update function and an output function.  The new system can be a
1351
    continuous or discrete-time system.
1352

1353
    Parameters
1354
    ----------
1355
    updfcn : callable (or `StateSpace`)
1356
        Function returning the state update function
1357

1358
            ``updfcn(t, x, u, params) -> array``
1359

1360
        where `x` is a 1-D array with shape (nstates,), `u` is a 1-D array
1361
        with shape (ninputs,), `t` is a float representing the current
1362
        time, and `params` is a dict containing the values of parameters
1363
        used by the function.
1364

1365
        If a `StateSpace` system is passed as the update function,
1366
        then a nonlinear I/O system is created that implements the linear
1367
        dynamics of the state space system.
1368

1369
    outfcn : callable
1370
        Function returning the output at the given state
1371

1372
            ``outfcn(t, x, u, params) -> array``
1373

1374
        where the arguments are the same as for `updfcn`.
1375

1376
    inputs : int, list of str or None, optional
1377
        Description of the system inputs.  This can be given as an integer
1378
        count or as a list of strings that name the individual signals.
1379
        If an integer count is specified, the names of the signal will be
1380
        of the form 's[i]' (where 's' is one of 'u', 'y', or 'x').  If
1381
        this parameter is not given or given as None, the relevant
1382
        quantity will be determined when possible based on other
1383
        information provided to functions using the system.
1384

1385
    outputs : int, list of str or None, optional
1386
        Description of the system outputs.  Same format as `inputs`.
1387

1388
    states : int, list of str, or None, optional
1389
        Description of the system states.  Same format as `inputs`.
1390

1391
    dt : timebase, optional
1392
        The timebase for the system, used to specify whether the system is
1393
        operating in continuous or discrete time.  It can have the
1394
        following values:
1395

1396
        * `dt` = 0: continuous-time system (default)
1397
        * `dt` > 0: discrete-time system with sampling period `dt`
1398
        * `dt` = True: discrete time with unspecified sampling period
1399
        * `dt` = None: no timebase specified
1400

1401
    name : string, optional
1402
        System name (used for specifying signals). If unspecified, a
1403
        generic name 'sys[id]' is generated with a unique integer id.
1404

1405
    params : dict, optional
1406
        Parameter values for the system.  Passed to the evaluation functions
1407
        for the system as default values, overriding internal defaults.
1408

1409
    Returns
1410
    -------
1411
    sys : `NonlinearIOSystem`
1412
        Nonlinear input/output system.
1413

1414
    Other Parameters
1415
    ----------------
1416
    input_prefix, output_prefix, state_prefix : string, optional
1417
        Set the prefix for input, output, and state signals.  Defaults =
1418
        'u', 'y', 'x'.
1419

1420
    See Also
1421
    --------
1422
    ss, tf
1423

1424
    Examples
1425
    --------
1426
    >>> def kincar_update(t, x, u, params):
1427
    ...     l = params['l']              # wheelbase
1428
    ...     return np.array([
1429
    ...         np.cos(x[2]) * u[0],     # x velocity
1430
    ...         np.sin(x[2]) * u[0],     # y velocity
1431
    ...         np.tan(u[1]) * u[0] / l  # angular velocity
1432
    ...     ])
1433
    >>>
1434
    >>> def kincar_output(t, x, u, params):
1435
    ...     return x[0:2]  # x, y position
1436
    >>>
1437
    >>> kincar = ct.nlsys(
1438
    ...     kincar_update, kincar_output, states=3, inputs=2, outputs=2,
1439
    ...     params={'l': 1})
1440
    >>>
1441
    >>> timepts = np.linspace(0, 10)
1442
    >>> response = ct.input_output_response(
1443
    ...     kincar, timepts, [10, 0.05 * np.sin(timepts)])
1444

1445
    """
1446
    from .iosys import _extended_system_name
9✔
1447
    from .statesp import StateSpace
9✔
1448

1449
    if isinstance(updfcn, StateSpace):
9✔
1450
        sys_ss = updfcn
9✔
1451
        kwargs['inputs'] = kwargs.get('inputs', sys_ss.input_labels)
9✔
1452
        kwargs['outputs'] = kwargs.get('outputs', sys_ss.output_labels)
9✔
1453
        kwargs['states'] = kwargs.get('states', sys_ss.state_labels)
9✔
1454
        kwargs['name'] = kwargs.get('name', _extended_system_name(
9✔
1455
            sys_ss.name, prefix_suffix_name='converted'))
1456

1457
        sys_nl = NonlinearIOSystem(
9✔
1458
            lambda t, x, u, params:
1459
                sys_ss.A @ np.atleast_1d(x) + sys_ss.B @ np.atleast_1d(u),
1460
            lambda t, x, u, params:
1461
                sys_ss.C @ np.atleast_1d(x) + sys_ss.D @ np.atleast_1d(u),
1462
            **kwargs)
1463

1464
        if sys_nl.nstates != sys_ss.nstates or sys_nl.shape != sys_ss.shape:
9✔
1465
            raise ValueError(
9✔
1466
                "new input, output, or state specification "
1467
                "doesn't match system size")
1468

1469
        return sys_nl
9✔
1470
    else:
1471
        return NonlinearIOSystem(updfcn, outfcn, **kwargs)
9✔
1472

1473

1474
def input_output_response(
9✔
1475
        sys, timepts=None, inputs=0., initial_state=0., params=None,
1476
        ignore_errors=False, transpose=False, return_states=False,
1477
        squeeze=None, solve_ivp_kwargs=None, evaluation_times='T', **kwargs):
1478
    """Compute the output response of a system to a given input.
1479

1480
    Simulate a dynamical system with a given input and return its output
1481
    and state values.
1482

1483
    Parameters
1484
    ----------
1485
    sys : `NonlinearIOSystem` or list of `NonlinearIOSystem`
1486
        I/O system(s) for which input/output response is simulated.
1487
    timepts (or T) : array_like
1488
        Time steps at which the input is defined; values must be evenly spaced.
1489
    inputs (or U) : array_like, list, or number, optional
1490
        Input array giving input at each time in `timepts` (default =
1491
        0). If a list is specified, each element in the list will be
1492
        treated as a portion of the input and broadcast (if necessary) to
1493
        match the time vector.
1494
    initial_state (or X0) : array_like, list, or number, optional
1495
        Initial condition (default = 0).  If a list is given, each element
1496
        in the list will be flattened and stacked into the initial
1497
        condition.  If a smaller number of elements are given that the
1498
        number of states in the system, the initial condition will be padded
1499
        with zeros.
1500
    evaluation_times (or t_eval) : array-list, optional
1501
        List of times at which the time response should be computed.
1502
        Defaults to `timepts`.
1503
    return_states (or return_x) : bool, optional
1504
        If True, return the state vector when assigning to a tuple.  See
1505
        `forced_response` for more details.  If True, return the values of
1506
        the state at each time Default is False.
1507
    params : dict, optional
1508
        Parameter values for the system.  Passed to the evaluation functions
1509
        for the system as default values, overriding internal defaults.
1510
    squeeze : bool, optional
1511
        If True and if the system has a single output, return the system
1512
        output as a 1D array rather than a 2D array.  If False, return the
1513
        system output as a 2D array even if the system is SISO.  Default
1514
        value set by `config.defaults['control.squeeze_time_response']`.
1515

1516
    Returns
1517
    -------
1518
    response : `TimeResponseData`
1519
        Time response data object representing the input/output response.
1520
        When accessed as a tuple, returns ``(time, outputs)`` or ``(time,
1521
        outputs, states`` if `return_x` is True.  If the input/output system
1522
        signals are named, these names will be used as labels for the time
1523
        response.  If `sys` is a list of systems, returns a `TimeResponseList`
1524
        object.  Results can be plotted using the `~TimeResponseData.plot`
1525
        method.  See `TimeResponseData` for more detailed information.
1526
    response.time : array
1527
        Time values of the output.
1528
    response.outputs : array
1529
        Response of the system.  If the system is SISO and `squeeze` is not
1530
        True, the array is 1D (indexed by time).  If the system is not SISO
1531
        or `squeeze` is False, the array is 2D (indexed by output and time).
1532
    response.states : array
1533
        Time evolution of the state vector, represented as a 2D array
1534
        indexed by state and time.
1535
    response.inputs : array
1536
        Input(s) to the system, indexed by input and time.
1537
    response.params : dict
1538
        Parameters values used for the simulation.
1539

1540
    Other Parameters
1541
    ----------------
1542
    ignore_errors : bool, optional
1543
        If False (default), errors during computation of the trajectory
1544
        will raise a `RuntimeError` exception.  If True, do not raise
1545
        an exception and instead set `response.success` to False and
1546
        place an error message in `response.message`.
1547
    solve_ivp_method : str, optional
1548
        Set the method used by `scipy.integrate.solve_ivp`.  Defaults
1549
        to 'RK45'.
1550
    solve_ivp_kwargs : dict, optional
1551
        Pass additional keywords to `scipy.integrate.solve_ivp`.
1552
    transpose : bool, default=False
1553
        If True, transpose all input and output arrays (for backward
1554
        compatibility with MATLAB and `scipy.signal.lsim`).
1555

1556
    Raises
1557
    ------
1558
    TypeError
1559
        If the system is not an input/output system.
1560
    ValueError
1561
        If time step does not match sampling time (for discrete-time systems).
1562

1563
    Notes
1564
    -----
1565
    If a smaller number of initial conditions are given than the number of
1566
    states in the system, the initial conditions will be padded with zeros.
1567
    This is often useful for interconnected control systems where the
1568
    process dynamics are the first system and all other components start
1569
    with zero initial condition since this can be specified as [xsys_0, 0].
1570
    A warning is issued if the initial conditions are padded and and the
1571
    final listed initial state is not zero.
1572

1573
    If discontinuous inputs are given, the underlying SciPy numerical
1574
    integration algorithms can sometimes produce erroneous results due to
1575
    the default tolerances that are used.  The `solve_ivp_method` and
1576
    `solve_ivp_keywords` parameters can be used to tune the ODE solver and
1577
    produce better results. In particular, using 'LSODA' as the
1578
    `solve_ivp_method`, setting the `rtol` parameter to a smaller value
1579
    (e.g. using ``solve_ivp_kwargs={'rtol': 1e-4}``), or setting the
1580
    maximum step size to a smaller value (e.g. ``solve_ivp_kwargs=
1581
    {'max_step': 0.01}``) can provide more accurate results.
1582

1583
    """
1584
    #
1585
    # Process keyword arguments
1586
    #
1587
    _process_kwargs(kwargs, _timeresp_aliases)
9✔
1588
    T = _process_param('timepts', timepts, kwargs, _timeresp_aliases)
9✔
1589
    U = _process_param('inputs', inputs, kwargs, _timeresp_aliases, sigval=0.)
9✔
1590
    X0 = _process_param(
9✔
1591
        'initial_state', initial_state, kwargs, _timeresp_aliases, sigval=0.)
1592
    return_x = _process_param(
9✔
1593
        'return_states', return_states, kwargs, _timeresp_aliases,
1594
        sigval=False)
1595
    # TODO: replace default value of evaluation_times with None?
1596
    t_eval = _process_param(
9✔
1597
        'evaluation_times', evaluation_times, kwargs, _timeresp_aliases,
1598
        sigval='T')
1599

1600
    # Figure out the method to be used
1601
    solve_ivp_kwargs = solve_ivp_kwargs.copy() if solve_ivp_kwargs else {}
9✔
1602
    if kwargs.get('solve_ivp_method', None):
9✔
1603
        if kwargs.get('method', None):
×
1604
            raise ValueError("ivp_method specified more than once")
×
1605
        solve_ivp_kwargs['method'] = kwargs.pop('solve_ivp_method')
×
1606
    elif kwargs.get('method', None):
9✔
1607
        # Allow method as an alternative to solve_ivp_method
1608
        solve_ivp_kwargs['method'] = kwargs.pop('method')
×
1609

1610
    # Set the default method to 'RK45'
1611
    if solve_ivp_kwargs.get('method', None) is None:
9✔
1612
        solve_ivp_kwargs['method'] = 'RK45'
9✔
1613

1614
    # Make sure there were no extraneous keywords
1615
    if kwargs:
9✔
1616
        raise TypeError("unrecognized keyword(s): ", str(kwargs))
9✔
1617

1618
    # If passed a list, recursively call individual responses with given T
1619
    if isinstance(sys, (list, tuple)):
9✔
1620
        sysdata, responses = sys, []
9✔
1621
        for sys in sysdata:
9✔
1622
            responses.append(input_output_response(
9✔
1623
                sys, timepts=T, inputs=U, initial_state=X0, params=params,
1624
                transpose=transpose, return_states=return_x, squeeze=squeeze,
1625
                evaluation_times=t_eval, solve_ivp_kwargs=solve_ivp_kwargs,
1626
                **kwargs))
1627
        return TimeResponseList(responses)
9✔
1628

1629
    # Sanity checking on the input
1630
    if not isinstance(sys, NonlinearIOSystem):
9✔
1631
        raise TypeError("System of type ", type(sys), " not valid")
×
1632

1633
    # Compute the time interval and number of steps
1634
    T0, Tf = T[0], T[-1]
9✔
1635
    ntimepts = len(T)
9✔
1636

1637
    # Figure out simulation times (t_eval)
1638
    if solve_ivp_kwargs.get('t_eval'):
9✔
1639
        if t_eval == 'T':
×
1640
            # Override the default with the solve_ivp keyword
1641
            t_eval = solve_ivp_kwargs.pop('t_eval')
×
1642
        else:
1643
            raise ValueError("t_eval specified more than once")
×
1644
    if isinstance(t_eval, str) and t_eval == 'T':
9✔
1645
        # Use the input time points as the output time points
1646
        t_eval = T
9✔
1647

1648
    #
1649
    # Process input argument
1650
    #
1651
    # The input argument is interpreted very flexibly, allowing the
1652
    # use of lists and/or tuples of mixed scalar and vector elements.
1653
    #
1654
    # Much of the processing here is similar to the processing in
1655
    # _process_vector_argument, but applied to a time series.
1656

1657
    # If we were passed a list of inputs, concatenate them (w/ broadcast)
1658
    if isinstance(U, (tuple, list)) and len(U) != ntimepts:
9✔
1659
        U_elements = []
9✔
1660
        for i, u in enumerate(U):
9✔
1661
            u = np.array(u)     # convert everything to an array
9✔
1662
            # Process this input
1663
            if u.ndim == 0 or (u.ndim == 1 and u.shape[0] != T.shape[0]):
9✔
1664
                # Broadcast array to the length of the time input
1665
                u = np.outer(u, np.ones_like(T))
9✔
1666

1667
            elif (u.ndim == 1 and u.shape[0] == T.shape[0]) or \
9✔
1668
                 (u.ndim == 2 and u.shape[1] == T.shape[0]):
1669
                # No processing necessary; just stack
1670
                pass
9✔
1671

1672
            else:
1673
                raise ValueError(f"Input element {i} has inconsistent shape")
9✔
1674

1675
            # Append this input to our list
1676
            U_elements.append(u)
9✔
1677

1678
        # Save the newly created input vector
1679
        U = np.vstack(U_elements)
9✔
1680

1681
    # Figure out the number of inputs
1682
    if sys.ninputs is None:
9✔
1683
        if isinstance(U, np.ndarray):
9✔
1684
            ninputs = U.shape[0] if U.size > 1 else U.size
9✔
1685
        else:
1686
            ninputs = 1
9✔
1687
    else:
1688
        ninputs = sys.ninputs
9✔
1689

1690
    # Make sure the input has the right shape
1691
    if ninputs is None or ninputs == 1:
9✔
1692
        legal_shapes = [(ntimepts,), (1, ntimepts)]
9✔
1693
    else:
1694
        legal_shapes = [(ninputs, ntimepts)]
9✔
1695

1696
    U = _check_convert_array(
9✔
1697
        U, legal_shapes, 'Parameter `U`: ', squeeze=False)
1698

1699
    # Always store the input as a 2D array
1700
    U = U.reshape(-1, ntimepts)
9✔
1701
    ninputs = U.shape[0]
9✔
1702

1703
    # Process initial states
1704
    X0, nstates = _process_vector_argument(X0, "X0", sys.nstates)
9✔
1705

1706
    # Update the parameter values (prior to evaluating outfcn)
1707
    sys._update_params(params)
9✔
1708

1709
    # Figure out the number of outputs
1710
    if sys.outfcn is None:
9✔
1711
        noutputs = nstates if sys.noutputs is None else sys.noutputs
9✔
1712
    else:
1713
        noutputs = np.shape(sys._out(T[0], X0, U[:, 0]))[0]
9✔
1714

1715
    if sys.noutputs is not None and sys.noutputs != noutputs:
9✔
1716
        raise RuntimeError(
9✔
1717
            f"inconsistent size of outputs; system specified {sys.noutputs}, "
1718
            f"output function returned {noutputs}")
1719

1720
    #
1721
    # Define a function to evaluate the input at an arbitrary time
1722
    #
1723
    # This is equivalent to the function
1724
    #
1725
    #   ufun = sp.interpolate.interp1d(T, U, fill_value='extrapolate')
1726
    #
1727
    # but has a lot less overhead => simulation runs much faster
1728
    def ufun(t):
9✔
1729
        # Find the value of the index using linear interpolation
1730
        # Use clip to allow for extrapolation if t is out of range
1731
        idx = np.clip(np.searchsorted(T, t, side='left'), 1, len(T)-1)
9✔
1732
        dt = (t - T[idx-1]) / (T[idx] - T[idx-1])
9✔
1733
        return U[..., idx-1] * (1. - dt) + U[..., idx] * dt
9✔
1734

1735
    # Check to make sure see if this is a static function
1736
    if sys.nstates == 0:
9✔
1737
        # Make sure the user gave a time vector for evaluation (or 'T')
1738
        if t_eval is None:
9✔
1739
            # User overrode t_eval with None, but didn't give us the times...
1740
            warn("t_eval set to None, but no dynamics; using T instead")
×
1741
            t_eval = T
×
1742

1743
        # Allocate space for the inputs and outputs
1744
        u = np.zeros((ninputs, len(t_eval)))
9✔
1745
        y = np.zeros((noutputs, len(t_eval)))
9✔
1746

1747
        # Compute the input and output at each point in time
1748
        for i, t in enumerate(t_eval):
9✔
1749
            u[:, i] = ufun(t)
9✔
1750
            y[:, i] = sys._out(t, [], u[:, i])
9✔
1751

1752
        return TimeResponseData(
9✔
1753
            t_eval, y, None, u, issiso=sys.issiso(),
1754
            output_labels=sys.output_labels, input_labels=sys.input_labels,
1755
            title="Input/output response for " + sys.name, sysname=sys.name,
1756
            transpose=transpose, return_x=return_x, squeeze=squeeze)
1757

1758
    # Create a lambda function for the right hand side
1759
    def ivp_rhs(t, x):
9✔
1760
        return sys._rhs(t, x, ufun(t))
9✔
1761

1762
    # Perform the simulation
1763
    if isctime(sys):
9✔
1764
        if not hasattr(sp.integrate, 'solve_ivp'):
9✔
1765
            raise NameError("scipy.integrate.solve_ivp not found; "
×
1766
                            "use SciPy 1.0 or greater")
1767
        soln = sp.integrate.solve_ivp(
9✔
1768
            ivp_rhs, (T0, Tf), X0, t_eval=t_eval,
1769
            vectorized=False, **solve_ivp_kwargs)
1770
        if not soln.success:
9✔
1771
            message = "solve_ivp failed: " + soln.message
9✔
1772
            if not ignore_errors:
9✔
1773
                raise RuntimeError(message)
×
1774
        else:
1775
            message = None
9✔
1776

1777
        # Compute inputs and outputs for each time point
1778
        u = np.zeros((ninputs, len(soln.t)))
9✔
1779
        y = np.zeros((noutputs, len(soln.t)))
9✔
1780
        for i, t in enumerate(soln.t):
9✔
1781
            u[:, i] = ufun(t)
9✔
1782
            y[:, i] = sys._out(t, soln.y[:, i], u[:, i])
9✔
1783

1784
    elif isdtime(sys):
9✔
1785
        # If t_eval was not specified, use the sampling time
1786
        if t_eval is None:
9✔
1787
            t_eval = np.arange(T[0], T[1] + sys.dt, sys.dt)
×
1788

1789
        # Make sure the time vector is uniformly spaced
1790
        dt = t_eval[1] - t_eval[0]
9✔
1791
        if not np.allclose(t_eval[1:] - t_eval[:-1], dt):
9✔
1792
            raise ValueError("parameter `t_eval`: time values must be "
×
1793
                             "equally spaced")
1794

1795
        # Make sure the sample time matches the given time
1796
        if sys.dt is not True:
9✔
1797
            # Make sure that the time increment is a multiple of sampling time
1798

1799
            # TODO: add back functionality for undersampling
1800
            # TODO: this test is brittle if dt =  sys.dt
1801
            # First make sure that time increment is bigger than sampling time
1802
            # if dt < sys.dt:
1803
            #     raise ValueError("Time steps `T` must match sampling time")
1804

1805
            # Check to make sure sampling time matches time increments
1806
            if not np.isclose(dt, sys.dt):
9✔
1807
                raise ValueError("Time steps `T` must be equal to "
×
1808
                                 "sampling time")
1809

1810
        # Compute the solution
1811
        soln = sp.optimize.OptimizeResult()
9✔
1812
        soln.t = t_eval                 # Store the time vector directly
9✔
1813
        x = np.array(X0)                # State vector (store as floats)
9✔
1814
        soln.y = []                     # Solution, following scipy convention
9✔
1815
        u, y = [], []                   # System input, output
9✔
1816
        for t in t_eval:
9✔
1817
            # Store the current input, state, and output
1818
            soln.y.append(x)
9✔
1819
            u.append(ufun(t))
9✔
1820
            y.append(sys._out(t, x, u[-1]))
9✔
1821

1822
            # Update the state for the next iteration
1823
            x = sys._rhs(t, x, u[-1])
9✔
1824

1825
        # Convert output to numpy arrays
1826
        soln.y = np.transpose(np.array(soln.y))
9✔
1827
        y = np.transpose(np.array(y))
9✔
1828
        u = np.transpose(np.array(u))
9✔
1829

1830
        # Mark solution as successful
1831
        soln.success, message = True, None      # No way to fail
9✔
1832

1833
    else:                       # Neither ctime or dtime??
1834
        raise TypeError("Can't determine system type")
×
1835

1836
    return TimeResponseData(
9✔
1837
        soln.t, y, soln.y, u, params=params, issiso=sys.issiso(),
1838
        output_labels=sys.output_labels, input_labels=sys.input_labels,
1839
        state_labels=sys.state_labels, sysname=sys.name,
1840
        title="Input/output response for " + sys.name,
1841
        transpose=transpose, return_x=return_x, squeeze=squeeze,
1842
        success=soln.success, message=message)
1843

1844

1845
class OperatingPoint():
9✔
1846
    """Operating point of nonlinear I/O system.
1847

1848
    The OperatingPoint class stores the operating point of a nonlinear
1849
    system, consisting of the state and input vectors for the system.  The
1850
    main use for this class is as the return object for the
1851
    `find_operating_point` function and as an input to the
1852
    `linearize` function.
1853

1854
    Parameters
1855
    ----------
1856
    states : array
1857
        State vector at the operating point.
1858
    inputs : array
1859
        Input vector at the operating point.
1860
    outputs : array, optional
1861
        Output vector at the operating point.
1862
    result : `scipy.optimize.OptimizeResult`, optional
1863
        Result from the `scipy.optimize.root` function, if available.
1864
    return_outputs, return_result : bool, optional
1865
        If set to True, then when accessed a tuple the output values
1866
        and/or result of the root finding function will be returned.
1867

1868
    Notes
1869
    -----
1870
    In addition to accessing the elements of the operating point as
1871
    attributes, if accessed as a list then the object will return ``(x0,
1872
    u0[, y0, res])``, where `y0` and `res` are returned depending on the
1873
    `return_outputs` and `return_result` parameters.
1874

1875
    """
1876
    def __init__(
9✔
1877
            self, states, inputs, outputs=None, result=None,
1878
            return_outputs=False, return_result=False):
1879
        self.states = states
9✔
1880
        self.inputs = inputs
9✔
1881

1882
        if outputs is None and return_outputs and not return_result:
9✔
1883
            raise ValueError("return_outputs specified but no outputs value")
×
1884
        self.outputs = outputs
9✔
1885
        self.return_outputs = return_outputs
9✔
1886

1887
        if result is None and return_result:
9✔
1888
            raise ValueError("return_result specified but no result value")
×
1889
        self.result = result
9✔
1890
        self.return_result = return_result
9✔
1891

1892
    # Implement iter to allow assigning to a tuple
1893
    def __iter__(self):
9✔
1894
        if self.return_outputs and self.return_result:
9✔
1895
            return iter((self.states, self.inputs, self.outputs, self.result))
9✔
1896
        elif self.return_outputs:
9✔
1897
            return iter((self.states, self.inputs, self.outputs))
9✔
1898
        elif self.return_result:
9✔
1899
            return iter((self.states, self.inputs, self.result))
9✔
1900
        else:
1901
            return iter((self.states, self.inputs))
9✔
1902

1903
    # Implement (thin) getitem to allow access via legacy indexing
1904
    def __getitem__(self, index):
9✔
1905
        return list(self.__iter__())[index]
9✔
1906

1907
    # Implement (thin) len to emulate legacy return value
1908
    def __len__(self):
9✔
1909
        return len(list(self.__iter__()))
9✔
1910

1911

1912
def find_operating_point(
9✔
1913
        sys, initial_state=0., inputs=None, outputs=None, t=0, params=None,
1914
        input_indices=None, output_indices=None, state_indices=None,
1915
        deriv_indices=None, derivs=None, root_method=None, root_kwargs=None,
1916
        return_outputs=None, return_result=None, **kwargs):
1917
    """Find an operating point for an input/output system.
1918

1919
    An operating point for a nonlinear system is a state and input around
1920
    which a nonlinear system operates.  This point is most commonly an
1921
    equilibrium point for the system, but in some cases a non-equilibrium
1922
    operating point can be used.
1923

1924
    This function attempts to find an operating point given a specification
1925
    for the desired inputs, outputs, states, or state updates of the system.
1926

1927
    In its simplest form, `find_operating_point` finds an equilibrium point
1928
    given either the desired input or desired output::
1929

1930
        xeq, ueq = find_operating_point(sys, x0, u0)
1931
        xeq, ueq = find_operating_point(sys, x0, u0, y0)
1932

1933
    The first form finds an equilibrium point for a given input u0 based on
1934
    an initial guess x0.  The second form fixes the desired output values
1935
    and uses x0 and u0 as an initial guess to find the equilibrium point.
1936
    If no equilibrium point can be found, the function returns the
1937
    operating point that minimizes the state update (state derivative for
1938
    continuous-time systems, state difference for discrete-time systems).
1939

1940
    More complex operating points can be found by specifying which states,
1941
    inputs, or outputs should be used in computing the operating point, as
1942
    well as desired values of the states, inputs, outputs, or state
1943
    updates.
1944

1945
    Parameters
1946
    ----------
1947
    sys : `NonlinearIOSystem`
1948
        I/O system for which the operating point is sought.
1949
    initial_state (or x0) : list of initial state values
1950
        Initial guess for the value of the state near the operating point.
1951
    inputs (or u0) : list of input values, optional
1952
        If `y0` is not specified, sets the value of the input.  If `y0` is
1953
        given, provides an initial guess for the value of the input.  Can
1954
        be omitted if the system does not have any inputs.
1955
    outputs (or y0) : list of output values, optional
1956
        If specified, sets the desired values of the outputs at the
1957
        operating point.
1958
    t : float, optional
1959
        Evaluation time, for time-varying systems.
1960
    params : dict, optional
1961
        Parameter values for the system.  Passed to the evaluation functions
1962
        for the system as default values, overriding internal defaults.
1963
    input_indices (or iu) : list of input indices, optional
1964
        If specified, only the inputs with the given indices will be fixed at
1965
        the specified values in solving for an operating point.  All other
1966
        inputs will be varied.  Input indices can be listed in any order.
1967
    output_indices (or iy) : list of output indices, optional
1968
        If specified, only the outputs with the given indices will be fixed
1969
        at the specified values in solving for an operating point.  All other
1970
        outputs will be varied.  Output indices can be listed in any order.
1971
    state_indices (or ix) : list of state indices, optional
1972
        If specified, states with the given indices will be fixed at the
1973
        specified values in solving for an operating point.  All other
1974
        states will be varied.  State indices can be listed in any order.
1975
    derivs (or dx0) : list of update values, optional
1976
        If specified, the value of update map must match the listed value
1977
        instead of the default value for an equilibrium point.
1978
    deriv_indices (or idx) : list of state indices, optional
1979
        If specified, state updates with the given indices will have their
1980
        update maps fixed at the values given in `dx0`.  All other update
1981
        values will be ignored in solving for an operating point.  State
1982
        indices can be listed in any order.  By default, all updates will be
1983
        fixed at `dx0` in searching for an operating point.
1984
    root_method : str, optional
1985
        Method to find the operating point.  If specified, this parameter
1986
        is passed to the `scipy.optimize.root` function.
1987
    root_kwargs : dict, optional
1988
        Additional keyword arguments to pass `scipy.optimize.root`.
1989
    return_outputs : bool, optional
1990
        If True, return the value of outputs at the operating point.
1991
    return_result : bool, optional
1992
        If True, return the `result` option from the
1993
        `scipy.optimize.root` function used to compute the
1994
        operating point.
1995

1996
    Returns
1997
    -------
1998
    op_point : `OperatingPoint`
1999
        The solution represented as an `OperatingPoint` object.  The main
2000
        attributes are `states` and `inputs`, which represent the state and
2001
        input arrays at the operating point.  If accessed as a tuple, returns
2002
        `states`, `inputs`, and optionally `outputs` and `result` based on the
2003
        `return_outputs` and `return_result` parameters.  See `OperatingPoint`
2004
        for a description of other attributes.
2005
    op_point.states : array
2006
        State vector at the operating point.
2007
    op_point.inputs : array
2008
        Input vector at the operating point.
2009
    op_point.outputs : array, optional
2010
        Output vector at the operating point.
2011

2012
    Notes
2013
    -----
2014
    For continuous-time systems, equilibrium points are defined as points
2015
    for which the right hand side of the differential equation is zero:
2016
    :math:`f(t, x_e, u_e) = 0`. For discrete-time systems, equilibrium
2017
    points are defined as points for which the right hand side of the
2018
    difference equation returns the current state: :math:`f(t, x_e, u_e) =
2019
    x_e`.
2020

2021
    Operating points are found using the `scipy.optimize.root`
2022
    function, which will attempt to find states and inputs that satisfy the
2023
    specified constraints.  If no solution is found and `return_result` is
2024
    False, the returned state and input for the operating point will be
2025
    None.  If `return_result` is True, then the return values from
2026
    `scipy.optimize.root` will be returned (but may not be valid).
2027
    If `root_method` is set to 'lm', then the least squares solution (in
2028
    the free variables) will be returned.
2029

2030
    """
2031
    from scipy.optimize import root
9✔
2032

2033
    # Process keyword arguments
2034
    aliases = {
9✔
2035
        'initial_state': (['x0', 'X0'], []),
2036
        'inputs': (['u0'], []),
2037
        'outputs': (['y0'], []),
2038
        'derivs': (['dx0'], []),
2039
        'input_indices': (['iu'], []),
2040
        'output_indices': (['iy'], []),
2041
        'state_indices': (['ix'], []),
2042
        'deriv_indices': (['idx'], []),
2043
        'return_outputs': ([], ['return_y']),
2044
    }
2045
    _process_kwargs(kwargs, aliases)
9✔
2046
    x0 = _process_param(
9✔
2047
        'initial_state', initial_state, kwargs, aliases, sigval=0.)
2048
    u0 = _process_param('inputs', inputs, kwargs, aliases)
9✔
2049
    y0 = _process_param('outputs', outputs, kwargs, aliases)
9✔
2050
    dx0 = _process_param('derivs', derivs, kwargs, aliases)
9✔
2051
    iu = _process_param('input_indices', input_indices, kwargs, aliases)
9✔
2052
    iy = _process_param('output_indices', output_indices, kwargs, aliases)
9✔
2053
    ix = _process_param('state_indices', state_indices, kwargs, aliases)
9✔
2054
    idx = _process_param('deriv_indices', deriv_indices, kwargs, aliases)
9✔
2055
    return_outputs = _process_param(
9✔
2056
        'return_outputs', return_outputs, kwargs, aliases)
2057
    if kwargs:
9✔
2058
        raise TypeError("unrecognized keyword(s): " + str(kwargs))
9✔
2059

2060
    # Process arguments for the root function
2061
    root_kwargs = dict() if root_kwargs is None else root_kwargs
9✔
2062
    if root_method:
9✔
2063
        root_kwargs['method'] = root_method
9✔
2064

2065
    # Figure out the number of states, inputs, and outputs
2066
    x0, nstates = _process_vector_argument(x0, "initial_states", sys.nstates)
9✔
2067
    u0, ninputs = _process_vector_argument(u0, "inputs", sys.ninputs)
9✔
2068
    y0, noutputs = _process_vector_argument(y0, "outputs", sys.noutputs)
9✔
2069

2070
    # Make sure the input arguments match the sizes of the system
2071
    if len(x0) != nstates or \
9✔
2072
       (u0 is not None and len(u0) != ninputs) or \
2073
       (y0 is not None and len(y0) != noutputs) or \
2074
       (dx0 is not None and len(dx0) != nstates):
2075
        raise ValueError("length of input arguments does not match system")
×
2076

2077
    # Update the parameter values
2078
    sys._update_params(params)
9✔
2079

2080
    # Decide what variables to minimize
2081
    if all([x is None for x in (iu, iy, ix, idx)]):
9✔
2082
        # Special cases: either inputs or outputs are constrained
2083
        if y0 is None:
9✔
2084
            # Take u0 as fixed and minimize over x
2085
            if sys.isdtime(strict=True):
9✔
2086
                def state_rhs(z): return sys._rhs(t, z, u0) - z
9✔
2087
            else:
2088
                def state_rhs(z): return sys._rhs(t, z, u0)
9✔
2089

2090
            result = root(state_rhs, x0, **root_kwargs)
9✔
2091
            z = (result.x, u0, sys._out(t, result.x, u0))
9✔
2092

2093
        else:
2094
            # Take y0 as fixed and minimize over x and u
2095
            if sys.isdtime(strict=True):
9✔
2096
                def rootfun(z):
9✔
2097
                    x, u = np.split(z, [nstates])
9✔
2098
                    return np.concatenate(
9✔
2099
                        (sys._rhs(t, x, u) - x, sys._out(t, x, u) - y0),
2100
                        axis=0)
2101
            else:
2102
                def rootfun(z):
9✔
2103
                    x, u = np.split(z, [nstates])
9✔
2104
                    return np.concatenate(
9✔
2105
                        (sys._rhs(t, x, u), sys._out(t, x, u) - y0), axis=0)
2106

2107
            # Find roots with (x, u) as free variables
2108
            z0 = np.concatenate((x0, u0), axis=0)
9✔
2109
            result = root(rootfun, z0, **root_kwargs)
9✔
2110
            x, u = np.split(result.x, [nstates])
9✔
2111
            z = (x, u, sys._out(t, x, u))
9✔
2112

2113
    else:
2114
        # General case: figure out what variables to constrain
2115
        # Verify the indices we are using are all in range
2116
        if iu is not None:
9✔
2117
            iu = np.unique(iu)
9✔
2118
            if any([not isinstance(x, int) for x in iu]) or \
9✔
2119
               (len(iu) > 0 and (min(iu) < 0 or max(iu) >= ninputs)):
2120
                assert ValueError("One or more input indices is invalid")
9✔
2121
        else:
2122
            iu = []
9✔
2123

2124
        if iy is not None:
9✔
2125
            iy = np.unique(iy)
9✔
2126
            if any([not isinstance(x, int) for x in iy]) or \
9✔
2127
               min(iy) < 0 or max(iy) >= noutputs:
2128
                assert ValueError("One or more output indices is invalid")
9✔
2129
        else:
2130
            iy = list(range(noutputs))
9✔
2131

2132
        if ix is not None:
9✔
2133
            ix = np.unique(ix)
9✔
2134
            if any([not isinstance(x, int) for x in ix]) or \
9✔
2135
               min(ix) < 0 or max(ix) >= nstates:
2136
                assert ValueError("One or more state indices is invalid")
9✔
2137
        else:
2138
            ix = []
9✔
2139

2140
        if idx is not None:
9✔
2141
            idx = np.unique(idx)
9✔
2142
            if any([not isinstance(x, int) for x in idx]) or \
9✔
2143
               min(idx) < 0 or max(idx) >= nstates:
2144
                assert ValueError("One or more deriv indices is invalid")
9✔
2145
        else:
2146
            idx = list(range(nstates))
9✔
2147

2148
        # Construct the index lists for mapping variables and constraints
2149
        #
2150
        # The mechanism by which we implement the root finding function is
2151
        # to map the subset of variables we are searching over into the
2152
        # inputs and states, and then return a function that represents the
2153
        # equations we are trying to solve.
2154
        #
2155
        # To do this, we need to carry out the following operations:
2156
        #
2157
        # 1. Given the current values of the free variables (z), map them into
2158
        #    the portions of the state and input vectors that are not fixed.
2159
        #
2160
        # 2. Compute the update and output maps for the input/output system
2161
        #    and extract the subset of equations that should be equal to zero.
2162
        #
2163
        # We perform these functions by computing four sets of index lists:
2164
        #
2165
        # * state_vars: indices of states that are allowed to vary
2166
        # * input_vars: indices of inputs that are allowed to vary
2167
        # * deriv_vars: indices of derivatives that must be constrained
2168
        # * output_vars: indices of outputs that must be constrained
2169
        #
2170
        # This index lists can all be precomputed based on the `iu`, `iy`,
2171
        # `ix`, and `idx` lists that were passed as arguments to
2172
        # `find_operating_point` and were processed above.
2173

2174
        # Get the states and inputs that were not listed as fixed
2175
        state_vars = (range(nstates) if not len(ix)
9✔
2176
                      else np.delete(np.array(range(nstates)), ix))
2177
        input_vars = (range(ninputs) if not len(iu)
9✔
2178
                      else np.delete(np.array(range(ninputs)), iu))
2179

2180
        # Set the outputs and derivs that will serve as constraints
2181
        output_vars = np.array(iy)
9✔
2182
        deriv_vars = np.array(idx)
9✔
2183

2184
        # Verify that the number of degrees of freedom all add up correctly
2185
        num_freedoms = len(state_vars) + len(input_vars)
9✔
2186
        num_constraints = len(output_vars) + len(deriv_vars)
9✔
2187
        if num_constraints != num_freedoms:
9✔
2188
            warn("number of constraints (%d) does not match number of degrees "
×
2189
                 "of freedom (%d); results may be meaningless" %
2190
                 (num_constraints, num_freedoms))
2191

2192
        # Make copies of the state and input variables to avoid overwriting
2193
        # and convert to floats (in case ints were used for initial conditions)
2194
        x = np.array(x0, dtype=float)
9✔
2195
        u = np.array(u0, dtype=float)
9✔
2196
        dx0 = np.array(dx0, dtype=float) if dx0 is not None \
9✔
2197
            else np.zeros(x.shape)
2198

2199
        # Keep track of the number of states in the set of free variables
2200
        nstate_vars = len(state_vars)
9✔
2201

2202
        def rootfun(z):
9✔
2203
            # Map the vector of values into the states and inputs
2204
            x[state_vars] = z[:nstate_vars]
9✔
2205
            u[input_vars] = z[nstate_vars:]
9✔
2206

2207
            # Compute the update and output maps
2208
            dx = sys._rhs(t, x, u) - dx0
9✔
2209
            if sys.isdtime(strict=True):
9✔
2210
                dx -= x
9✔
2211

2212
            # If no y0 is given, don't evaluate the output function
2213
            if y0 is None:
9✔
2214
                return dx[deriv_vars]
9✔
2215
            else:
2216
                dy = sys._out(t, x, u) - y0
9✔
2217

2218
                # Map the results into the constrained variables
2219
                return np.concatenate((dx[deriv_vars], dy[output_vars]), axis=0)
9✔
2220

2221
        # Set the initial condition for the root finding algorithm
2222
        z0 = np.concatenate((x[state_vars], u[input_vars]), axis=0)
9✔
2223

2224
        # Finally, call the root finding function
2225
        result = root(rootfun, z0, **root_kwargs)
9✔
2226

2227
        # Extract out the results and insert into x and u
2228
        x[state_vars] = result.x[:nstate_vars]
9✔
2229
        u[input_vars] = result.x[nstate_vars:]
9✔
2230
        z = (x, u, sys._out(t, x, u))
9✔
2231

2232
    # Return the result based on what the user wants and what we found
2233
    if return_result or result.success:
9✔
2234
        return OperatingPoint(
9✔
2235
            z[0], z[1], z[2], result, return_outputs, return_result)
2236
    else:
2237
        # Something went wrong, don't return anything
2238
        return OperatingPoint(
9✔
2239
            None, None, None, result, return_outputs, return_result)
2240

2241
    # TODO: remove code when ready
2242
    if not return_outputs:
2243
        z = z[0:2]              # Strip y from result if not desired
2244
    if return_result:
2245
        # Return whatever we got, along with the result dictionary
2246
        return z + (result,)
2247
    elif result.success:
2248
        # Return the result of the optimization
2249
        return z
2250
    else:
2251
        # Something went wrong, don't return anything
2252
        return (None, None, None) if return_outputs else (None, None)
2253

2254

2255
# Linearize an input/output system
2256
def linearize(sys, xeq, ueq=None, t=0, params=None, **kw):
9✔
2257
    """Linearize an input/output system at a given state and input.
2258

2259
    Compute the linearization of an I/O system at an operating point (state
2260
    and input) and returns a `StateSpace` object.  The
2261
    operating point need not be an equilibrium point.
2262

2263
    Parameters
2264
    ----------
2265
    sys : `InputOutputSystem`
2266
        The system to be linearized.
2267
    xeq : array or `OperatingPoint`
2268
        The state or operating point at which the linearization will be
2269
        evaluated (does not need to be an equilibrium state).
2270
    ueq : array, optional
2271
        The input at which the linearization will be evaluated (does not need
2272
        to correspond to an equilibrium state).  Can be omitted if `xeq` is
2273
        an `OperatingPoint`.  Defaults to 0.
2274
    t : float, optional
2275
        The time at which the linearization will be computed (for time-varying
2276
        systems).
2277
    params : dict, optional
2278
        Parameter values for the systems.  Passed to the evaluation functions
2279
        for the system as default values, overriding internal defaults.
2280
    name : string, optional
2281
        Set the name of the linearized system.  If not specified and
2282
        if `copy_names` is False, a generic name 'sys[id]' is generated
2283
        with a unique integer id.  If `copy_names` is True, the new system
2284
        name is determined by adding the prefix and suffix strings in
2285
        `config.defaults['iosys.linearized_system_name_prefix']` and
2286
        `config.defaults['iosys.linearized_system_name_suffix']`, with the
2287
        default being to add the suffix '$linearized'.
2288
    copy_names : bool, Optional
2289
        If True, Copy the names of the input signals, output signals, and
2290
        states to the linearized system.
2291

2292
    Returns
2293
    -------
2294
    ss_sys : `StateSpace`
2295
        The linearization of the system, as a `StateSpace`
2296
        object.
2297

2298
    Other Parameters
2299
    ----------------
2300
    inputs : int, list of str or None, optional
2301
        Description of the system inputs.  If not specified, the original
2302
        system inputs are used.  See `InputOutputSystem` for more
2303
        information.
2304
    outputs : int, list of str or None, optional
2305
        Description of the system outputs.  Same format as `inputs`.
2306
    states : int, list of str, or None, optional
2307
        Description of the system states.  Same format as `inputs`.
2308

2309
    """
2310
    if not isinstance(sys, InputOutputSystem):
9✔
2311
        raise TypeError("Can only linearize InputOutputSystem types")
×
2312
    return sys.linearize(xeq, ueq, t=t, params=params, **kw)
9✔
2313

2314

2315
def _find_size(sysval, vecval, name="system component"):
9✔
2316
    """Utility function to find the size of a system parameter
2317

2318
    If both parameters are not None, they must be consistent.
2319
    """
2320
    if hasattr(vecval, '__len__'):
9✔
2321
        if sysval is not None and sysval != len(vecval):
9✔
2322
            raise ValueError(
9✔
2323
                f"inconsistent information to determine size of {name}; "
2324
                f"expected {sysval} values, received {len(vecval)}")
2325
        return len(vecval)
9✔
2326
    # None or 0, which is a valid value for "a (sysval, ) vector of zeros".
2327
    if not vecval:
9✔
2328
        return 0 if sysval is None else sysval
9✔
2329
    elif sysval == 1:
×
2330
        # (1, scalar) is also a valid combination from legacy code
2331
        return 1
×
2332
    raise ValueError(f"can't determine size of {name}")
×
2333

2334

2335
# Function to create an interconnected system
2336
def interconnect(
9✔
2337
        syslist, connections=None, inplist=None, outlist=None, params=None,
2338
        check_unused=True, add_unused=False, ignore_inputs=None,
2339
        ignore_outputs=None, warn_duplicate=None, debug=False, **kwargs):
2340
    """Interconnect a set of input/output systems.
2341

2342
    This function creates a new system that is an interconnection of a set of
2343
    input/output systems.  If all of the input systems are linear I/O systems
2344
    (type `StateSpace`) then the resulting system will be
2345
    a linear interconnected I/O system (type `LinearICSystem`)
2346
    with the appropriate inputs, outputs, and states.  Otherwise, an
2347
    interconnected I/O system (type `InterconnectedSystem`)
2348
    will be created.
2349

2350
    Parameters
2351
    ----------
2352
    syslist : list of `NonlinearIOSystem`
2353
        The list of (state-based) input/output systems to be connected.
2354

2355
    connections : list of connections, optional
2356
        Description of the internal connections between the subsystems::
2357

2358
            [connection1, connection2, ...]
2359

2360
        Each connection is itself a list that describes an input to one of
2361
        the subsystems.  The entries are of the form::
2362

2363
            [input-spec, output-spec1, output-spec2, ...]
2364

2365
        The input-spec can be in a number of different forms.  The lowest
2366
        level representation is a tuple of the form ``(subsys_i, inp_j)``
2367
        where `subsys_i` is the index into `syslist` and `inp_j` is the
2368
        index into the input vector for the subsystem.  If the signal index
2369
        is omitted, then all subsystem inputs are used.  If systems and
2370
        signals are given names, then the forms 'sys.sig' or ('sys', 'sig')
2371
        are also recognized.  Finally, for multivariable systems the signal
2372
        index can be given as a list, for example '(subsys_i, [inp_j1, ...,
2373
        inp_jn])'; or as a slice, for example, 'sys.sig[i:j]'; or as a base
2374
        name 'sys.sig' (which matches 'sys.sig[i]').
2375

2376
        Similarly, each output-spec should describe an output signal from
2377
        one of the subsystems.  The lowest level representation is a tuple
2378
        of the form ``(subsys_i, out_j, gain)``.  The input will be
2379
        constructed by summing the listed outputs after multiplying by the
2380
        gain term.  If the gain term is omitted, it is assumed to be 1.  If
2381
        the subsystem index 'subsys_i' is omitted, then all outputs of the
2382
        subsystem are used.  If systems and signals are given names, then
2383
        the form 'sys.sig', ('sys', 'sig') or ('sys', 'sig', gain) are also
2384
        recognized, and the special form '-sys.sig' can be used to specify
2385
        a signal with gain -1.  Lists, slices, and base names can also be
2386
        used, as long as the number of elements for each output spec
2387
        matches the input spec.
2388

2389
        If omitted, the `interconnect` function will attempt to create the
2390
        interconnection map by connecting all signals with the same base
2391
        names (ignoring the system name).  Specifically, for each input
2392
        signal name in the list of systems, if that signal name corresponds
2393
        to the output signal in any of the systems, it will be connected to
2394
        that input (with a summation across all signals if the output name
2395
        occurs in more than one system).
2396

2397
        The `connections` keyword can also be set to False, which will leave
2398
        the connection map empty and it can be specified instead using the
2399
        low-level `InterconnectedSystem.set_connect_map`
2400
        method.
2401

2402
    inplist : list of input connections, optional
2403
        List of connections for how the inputs for the overall system are
2404
        mapped to the subsystem inputs.  The entries for a connection are
2405
        of the form::
2406

2407
            [input-spec1, input-spec2, ...]
2408

2409
        Each system input is added to the input for the listed subsystem.
2410
        If the system input connects to a subsystem with a single input, a
2411
        single input specification can be given (without the inner list).
2412

2413
        If omitted the `input` parameter will be used to identify the list
2414
        of input signals to the overall system.
2415

2416
    outlist : list of output connections, optional
2417
        List of connections for how the outputs from the subsystems are
2418
        mapped to overall system outputs.  The entries for a connection are
2419
        of the form::
2420

2421
            [output-spec1, output-spec2, ...]
2422

2423
        If an output connection contains more than one signal specification,
2424
        then those signals are added together (multiplying by the any gain
2425
        term) to form the system output.
2426

2427
        If omitted, the output map can be specified using the
2428
        `InterconnectedSystem.set_output_map` method.
2429

2430
    inputs : int, list of str or None, optional
2431
        Description of the system inputs.  This can be given as an integer
2432
        count or as a list of strings that name the individual signals.  If
2433
        an integer count is specified, the names of the signal will be of
2434
        the form 's[i]' (where 's' is one of 'u', 'y', or 'x').  If this
2435
        parameter is not given or given as None, the relevant quantity will
2436
        be determined when possible based on other information provided to
2437
        functions using the system.
2438

2439
    outputs : int, list of str or None, optional
2440
        Description of the system outputs.  Same format as `inputs`.
2441

2442
    states : int, list of str, or None, optional
2443
        Description of the system states.  Same format as `inputs`. The
2444
        default is None, in which case the states will be given names of
2445
        the form '<subsys_name><delim><state_name>', for each subsys in
2446
        syslist and each state_name of each subsys, where <delim> is the
2447
        value of `config.defaults['iosys.state_name_delim']`.
2448

2449
    params : dict, optional
2450
        Parameter values for the systems.  Passed to the evaluation functions
2451
        for the system as default values, overriding internal defaults.  If
2452
        not specified, defaults to parameters from subsystems.
2453

2454
    dt : timebase, optional
2455
        The timebase for the system, used to specify whether the system is
2456
        operating in continuous or discrete-time.  It can have the following
2457
        values:
2458

2459
        * `dt` = 0: continuous-time system (default)
2460
        * `dt` > 0`: discrete-time system with sampling period `dt`
2461
        * `dt` = True: discrete time with unspecified sampling period
2462
        * `dt` = None: no timebase specified
2463

2464
    name : string, optional
2465
        System name (used for specifying signals). If unspecified, a generic
2466
        name 'sys[id]' is generated with a unique integer id.
2467

2468
    Returns
2469
    -------
2470
    sys : `InterconnectedSystem`
2471
        `NonlinearIOSystem` consisting of the interconnected subsystems.
2472

2473
    Other Parameters
2474
    ----------------
2475
    input_prefix, output_prefix, state_prefix : string, optional
2476
        Set the prefix for input, output, and state signals.  Defaults =
2477
        'u', 'y', 'x'.
2478

2479
    check_unused : bool, optional
2480
        If True, check for unused sub-system signals.  This check is
2481
        not done if connections is False, and neither input nor output
2482
        mappings are specified.
2483

2484
    add_unused : bool, optional
2485
        If True, subsystem signals that are not connected to other components
2486
        are added as inputs and outputs of the interconnected system.
2487

2488
    ignore_inputs : list of input-spec, optional
2489
        A list of sub-system inputs known not to be connected.  This is
2490
        *only* used in checking for unused signals, and does not
2491
        disable use of the input.
2492

2493
        Besides the usual input-spec forms (see `connections`), an
2494
        input-spec can be just the signal base name, in which case all
2495
        signals from all sub-systems with that base name are
2496
        considered ignored.
2497

2498
    ignore_outputs : list of output-spec, optional
2499
        A list of sub-system outputs known not to be connected.  This
2500
        is *only* used in checking for unused signals, and does not
2501
        disable use of the output.
2502

2503
        Besides the usual output-spec forms (see `connections`), an
2504
        output-spec can be just the signal base name, in which all
2505
        outputs from all sub-systems with that base name are
2506
        considered ignored.
2507

2508
    warn_duplicate : None, True, or False, optional
2509
        Control how warnings are generated if duplicate objects or names are
2510
        detected.  In None (default), then warnings are generated for
2511
        systems that have non-generic names.  If False, warnings are not
2512
        generated and if True then warnings are always generated.
2513

2514
    debug : bool, default=False
2515
        Print out information about how signals are being processed that
2516
        may be useful in understanding why something is not working.
2517

2518
    Examples
2519
    --------
2520
    >>> P = ct.rss(2, 2, 2, strictly_proper=True, name='P')
2521
    >>> C = ct.rss(2, 2, 2, name='C')
2522
    >>> T = ct.interconnect(
2523
    ...     [P, C],
2524
    ...     connections=[
2525
    ...         ['P.u[0]', 'C.y[0]'], ['P.u[1]', 'C.y[1]'],
2526
    ...         ['C.u[0]', '-P.y[0]'], ['C.u[1]', '-P.y[1]']],
2527
    ...     inplist=['C.u[0]', 'C.u[1]'],
2528
    ...     outlist=['P.y[0]', 'P.y[1]'],
2529
    ... )
2530

2531
    This expression can be simplified using either slice notation or
2532
    just signal basenames:
2533

2534
    >>> T = ct.interconnect(
2535
    ...     [P, C], connections=[['P.u[:]', 'C.y[:]'], ['C.u', '-P.y']],
2536
    ...     inplist='C.u', outlist='P.y[:]')
2537

2538
    or further simplified by omitting the input and output signal
2539
    specifications (since all inputs and outputs are used):
2540

2541
    >>> T = ct.interconnect(
2542
    ...     [P, C], connections=[['P', 'C'], ['C', '-P']],
2543
    ...     inplist=['C'], outlist=['P'])
2544

2545
    A feedback system can also be constructed using the
2546
    `summing_junction` function and the ability to
2547
    automatically interconnect signals with the same names:
2548

2549
    >>> P = ct.tf(1, [1, 0], inputs='u', outputs='y')
2550
    >>> C = ct.tf(10, [1, 1], inputs='e', outputs='u')
2551
    >>> sumblk = ct.summing_junction(inputs=['r', '-y'], output='e')
2552
    >>> T = ct.interconnect([P, C, sumblk], inputs='r', outputs='y')
2553

2554
    Notes
2555
    -----
2556
    If a system is duplicated in the list of systems to be connected,
2557
    a warning is generated and a copy of the system is created with the
2558
    name of the new system determined by adding the prefix and suffix
2559
    strings in `config.defaults['iosys.duplicate_system_name_prefix']`
2560
    and `config.defaults['iosys.duplicate_system_name_suffix']`, with the
2561
    default being to add the suffix '$copy' to the system name.
2562

2563
    In addition to explicit lists of system signals, it is possible to
2564
    lists vectors of signals, using one of the following forms::
2565

2566
      (subsys, [i1, ..., iN], gain)   # signals with indices i1, ..., in
2567
      'sysname.signal[i:j]'           # range of signal names, i through j-1
2568
      'sysname.signal[:]'             # all signals with given prefix
2569

2570
    While in many Python functions tuples can be used in place of lists,
2571
    for the interconnect() function the only use of tuples should be in the
2572
    specification of an input- or output-signal via the tuple notation
2573
    ``(subsys_i, signal_j, gain)`` (where `gain` is optional).  If you get an
2574
    unexpected error message about a specification being of the wrong type
2575
    or not being found, check to make sure you are not using a tuple where
2576
    you should be using a list.
2577

2578
    In addition to its use for general nonlinear I/O systems, the
2579
    `interconnect` function allows linear systems to be
2580
    interconnected using named signals (compared with the
2581
    legacy `connect` function, which uses signal indices) and to be
2582
    treated as both a `StateSpace` system as well as an
2583
    `InputOutputSystem`.
2584

2585
    The `input` and `output` keywords can be used instead of `inputs` and
2586
    `outputs`, for more natural naming of SISO systems.
2587

2588
    """
2589
    from .statesp import LinearICSystem, StateSpace
9✔
2590

2591
    dt = kwargs.pop('dt', None)         # bypass normal 'dt' processing
9✔
2592
    name, inputs, outputs, states, _ = _process_iosys_keywords(kwargs)
9✔
2593
    connection_type = None # explicit, implicit, or None
9✔
2594

2595
    if not check_unused and (ignore_inputs or ignore_outputs):
9✔
2596
        raise ValueError('check_unused is False, but either '
×
2597
                         + 'ignore_inputs or ignore_outputs non-empty')
2598

2599
    if connections is False and not any((inplist, outlist, inputs, outputs)):
9✔
2600
        # user has disabled auto-connect, and supplied neither input
2601
        # nor output mappings; assume they know what they're doing
2602
        check_unused = False
9✔
2603

2604
    # If connections was not specified, assume implicit interconnection.
2605
    # set up default connection list
2606
    if connections is None:
9✔
2607
        connection_type = 'implicit'
9✔
2608
        # For each system input, look for outputs with the same name
2609
        connections = []
9✔
2610
        for input_sys in syslist:
9✔
2611
            for input_name in input_sys.input_labels:
9✔
2612
                connect = [input_sys.name + "." + input_name]
9✔
2613
                for output_sys in syslist:
9✔
2614
                    if input_name in output_sys.output_labels:
9✔
2615
                        connect.append(output_sys.name + "." + input_name)
9✔
2616
                if len(connect) > 1:
9✔
2617
                    connections.append(connect)
9✔
2618

2619
    elif connections is False:
9✔
2620
        check_unused = False
9✔
2621
        # Use an empty connections list
2622
        connections = []
9✔
2623

2624
    else:
2625
        connection_type = 'explicit'
9✔
2626
        if isinstance(connections, list) and \
9✔
2627
                all([isinstance(cnxn, (str, tuple)) for cnxn in connections]):
2628
            # Special case where there is a single connection
2629
            connections = [connections]
9✔
2630

2631
    # If inplist/outlist is not present, try using inputs/outputs instead
2632
    inplist_none, outlist_none = False, False
9✔
2633
    if inplist is None:
9✔
2634
        inplist = inputs or []
9✔
2635
        inplist_none = True     # use to rewrite inputs below
9✔
2636
    if outlist is None:
9✔
2637
        outlist = outputs or []
9✔
2638
        outlist_none = True     # use to rewrite outputs below
9✔
2639

2640
    # Define a local debugging function
2641
    dprint = lambda s: None if not debug else print(s)
9✔
2642

2643
    #
2644
    # Pre-process connecton list
2645
    #
2646
    # Support for various "vector" forms of specifications is handled here,
2647
    # by expanding any specifications that refer to more than one signal.
2648
    # This includes signal lists such as ('sysname', ['sig1', 'sig2', ...])
2649
    # as well as slice-based specifications such as 'sysname.signal[i:j]'.
2650
    #
2651
    dprint("Pre-processing connections:")
9✔
2652
    new_connections = []
9✔
2653
    for connection in connections:
9✔
2654
        dprint(f"  parsing {connection=}")
9✔
2655
        if not isinstance(connection, list):
9✔
2656
            raise ValueError(
×
2657
                f"invalid connection {connection}: should be a list")
2658
        # Parse and expand the input specification
2659
        input_spec = _parse_spec(syslist, connection[0], 'input')
9✔
2660
        input_spec_list = [input_spec]
9✔
2661

2662
        # Parse and expand the output specifications
2663
        output_specs_list = [[]] * len(input_spec_list)
9✔
2664
        for spec in connection[1:]:
9✔
2665
            output_spec = _parse_spec(syslist, spec, 'output')
9✔
2666
            output_specs_list[0].append(output_spec)
9✔
2667

2668
        # Create the new connection entry
2669
        for input_spec, output_specs in zip(input_spec_list, output_specs_list):
9✔
2670
            new_connection = [input_spec] + output_specs
9✔
2671
            dprint(f"    adding {new_connection=}")
9✔
2672
            new_connections.append(new_connection)
9✔
2673
    connections = new_connections
9✔
2674

2675
    #
2676
    # Pre-process input connections list
2677
    #
2678
    # Similar to the connections list, we now handle "vector" forms of
2679
    # specifications in the inplist parameter.  This needs to be handled
2680
    # here because the InterconnectedSystem constructor assumes that the
2681
    # number of elements in `inplist` will match the number of inputs for
2682
    # the interconnected system.
2683
    #
2684
    # If inplist_none is True then inplist is a copy of inputs and so we
2685
    # also have to be careful that if we encounter any multivariable
2686
    # signals, we need to update the input list.
2687
    #
2688
    dprint(f"Pre-processing input connections: {inplist}")
9✔
2689
    if not isinstance(inplist, list):
9✔
2690
        dprint("  converting inplist to list")
9✔
2691
        inplist = [inplist]
9✔
2692
    new_inplist, new_inputs = [], [] if inplist_none else inputs
9✔
2693

2694
    # Go through the list of inputs and process each one
2695
    for iinp, connection in enumerate(inplist):
9✔
2696
        # Check for system name or signal names without a system name
2697
        if isinstance(connection, str) and len(connection.split('.')) == 1:
9✔
2698
            # Create an empty connections list to store matching connections
2699
            new_connections = []
9✔
2700

2701
            # Get the signal/system name
2702
            sname = connection[1:] if connection[0] == '-' else connection
9✔
2703
            gain = -1 if connection[0] == '-' else 1
9✔
2704

2705
            # Look for the signal name as a system input
2706
            found_system, found_signal = False, False
9✔
2707
            for isys, sys in enumerate(syslist):
9✔
2708
                # Look for matching signals (returns None if no matches
2709
                indices = sys._find_signals(sname, sys.input_index)
9✔
2710

2711
                # See what types of matches we found
2712
                if sname == sys.name:
9✔
2713
                    # System name matches => use all inputs
2714
                    for isig in range(sys.ninputs):
9✔
2715
                        dprint(f"  adding input {(isys, isig, gain)}")
9✔
2716
                        new_inplist.append((isys, isig, gain))
9✔
2717
                    found_system = True
9✔
2718
                elif indices:
9✔
2719
                    # Signal name matches => store new connections
2720
                    new_connection = []
9✔
2721
                    for isig in indices:
9✔
2722
                        dprint(f"  collecting input {(isys, isig, gain)}")
9✔
2723
                        new_connection.append((isys, isig, gain))
9✔
2724

2725
                    if len(new_connections) == 0:
9✔
2726
                        # First time we have seen this signal => initialize
2727
                        for cnx in new_connection:
9✔
2728
                            new_connections.append([cnx])
9✔
2729
                        if inplist_none:
9✔
2730
                            # See if we need to rewrite the inputs
2731
                            if len(new_connection) != 1:
9✔
2732
                                new_inputs += [
9✔
2733
                                    sys.input_labels[i] for i in indices]
2734
                            else:
2735
                                new_inputs.append(inputs[iinp])
9✔
2736
                    else:
2737
                        # Additional signal match found =. add to the list
2738
                        for i, cnx in enumerate(new_connection):
9✔
2739
                            new_connections[i].append(cnx)
9✔
2740
                    found_signal = True
9✔
2741

2742
            if found_system and found_signal:
9✔
2743
                raise ValueError(
×
2744
                    f"signal '{sname}' is both signal and system name")
2745
            elif found_signal:
9✔
2746
                dprint(f"  adding inputs {new_connections}")
9✔
2747
                new_inplist += new_connections
9✔
2748
            elif not found_system:
9✔
2749
                raise ValueError("could not find signal %s" % sname)
9✔
2750
        else:
2751
            if isinstance(connection, list):
9✔
2752
                # Passed a list => create input map
2753
                dprint("  detected input list")
9✔
2754
                signal_list = []
9✔
2755
                for spec in connection:
9✔
2756
                    isys, indices, gain = _parse_spec(syslist, spec, 'input')
9✔
2757
                    for isig in indices:
9✔
2758
                        signal_list.append((isys, isig, gain))
9✔
2759
                        dprint(f"    adding input {(isys, isig, gain)} to list")
9✔
2760
                new_inplist.append(signal_list)
9✔
2761
            else:
2762
                # Passed a single signal name => add individual input(s)
2763
                isys, indices, gain = _parse_spec(syslist, connection, 'input')
9✔
2764
                for isig in indices:
9✔
2765
                    new_inplist.append((isys, isig, gain))
9✔
2766
                    dprint(f"  adding input {(isys, isig, gain)}")
9✔
2767
    inplist, inputs = new_inplist, new_inputs
9✔
2768
    dprint(f"  {inplist=}\n  {inputs=}")
9✔
2769

2770
    #
2771
    # Pre-process output list
2772
    #
2773
    # This is similar to the processing of the input list, but we need to
2774
    # additionally take into account the fact that you can list subsystem
2775
    # inputs as system outputs.
2776
    #
2777
    dprint(f"Pre-processing output connections: {outlist}")
9✔
2778
    if not isinstance(outlist, list):
9✔
2779
        dprint("  converting outlist to list")
9✔
2780
        outlist = [outlist]
9✔
2781
    new_outlist, new_outputs = [], [] if outlist_none else outputs
9✔
2782
    for iout, connection in enumerate(outlist):
9✔
2783
        # Create an empty connection list
2784
        new_connections = []
9✔
2785

2786
        # Check for system name or signal names without a system name
2787
        if isinstance(connection, str) and len(connection.split('.')) == 1:
9✔
2788
            # Get the signal/system name
2789
            sname = connection[1:] if connection[0] == '-' else connection
9✔
2790
            gain = -1 if connection[0] == '-' else 1
9✔
2791

2792
            # Look for the signal name as a system output
2793
            found_system, found_signal = False, False
9✔
2794
            for osys, sys in enumerate(syslist):
9✔
2795
                indices = sys._find_signals(sname, sys.output_index)
9✔
2796
                if sname == sys.name:
9✔
2797
                    # Use all outputs
2798
                    for osig in range(sys.noutputs):
9✔
2799
                        dprint(f"  adding output {(osys, osig, gain)}")
9✔
2800
                        new_outlist.append((osys, osig, gain))
9✔
2801
                    found_system = True
9✔
2802
                elif indices:
9✔
2803
                    new_connection = []
9✔
2804
                    for osig in indices:
9✔
2805
                        dprint(f"  collecting output {(osys, osig, gain)}")
9✔
2806
                        new_connection.append((osys, osig, gain))
9✔
2807
                    if len(new_connections) == 0:
9✔
2808
                        for cnx in new_connection:
9✔
2809
                            new_connections.append([cnx])
9✔
2810
                        if outlist_none:
9✔
2811
                            # See if we need to rewrite the outputs
2812
                            if len(new_connection) != 1:
9✔
2813
                                new_outputs += [
9✔
2814
                                    sys.output_labels[i] for i in indices]
2815
                            else:
2816
                                new_outputs.append(outputs[iout])
9✔
2817
                    else:
2818
                        # Additional signal match found =. add to the list
2819
                        for i, cnx in enumerate(new_connection):
9✔
2820
                            new_connections[i].append(cnx)
9✔
2821
                    found_signal = True
9✔
2822

2823
            if found_system and found_signal:
9✔
2824
                raise ValueError(
×
2825
                    f"signal '{sname}' is both signal and system name")
2826
            elif found_signal:
9✔
2827
                dprint(f"  adding outputs {new_connections}")
9✔
2828
                new_outlist += new_connections
9✔
2829
            elif not found_system:
9✔
2830
                raise ValueError("could not find signal %s" % sname)
9✔
2831
        else:
2832
            # Utility function to find named output or input signal
2833
            def _find_output_or_input_signal(spec):
9✔
2834
                signal_list = []
9✔
2835
                try:
9✔
2836
                    # First trying looking in the output signals
2837
                    osys, indices, gain = _parse_spec(syslist, spec, 'output')
9✔
2838
                    for osig in indices:
9✔
2839
                        dprint(f"  adding output {(osys, osig, gain)}")
9✔
2840
                        signal_list.append((osys, osig, gain))
9✔
2841
                except ValueError:
9✔
2842
                    # If not, see if we can find it in inputs
2843
                    isys, indices, gain = _parse_spec(
9✔
2844
                        syslist, spec, 'input or output',
2845
                        dictname='input_index')
2846
                    for isig in indices:
9✔
2847
                        # Use string form to allow searching input list
2848
                        dprint(f"  adding input {(isys, isig, gain)}")
9✔
2849
                        signal_list.append(
9✔
2850
                            (syslist[isys].name,
2851
                             syslist[isys].input_labels[isig], gain))
2852
                return signal_list
9✔
2853

2854
            if isinstance(connection, list):
9✔
2855
                # Passed a list => create input map
2856
                dprint("  detected output list")
9✔
2857
                signal_list = []
9✔
2858
                for spec in connection:
9✔
2859
                    signal_list += _find_output_or_input_signal(spec)
9✔
2860
                new_outlist.append(signal_list)
9✔
2861
            else:
2862
                new_outlist += _find_output_or_input_signal(connection)
9✔
2863

2864
    outlist, outputs = new_outlist, new_outputs
9✔
2865
    dprint(f"  {outlist=}\n  {outputs=}")
9✔
2866

2867
    # Make sure inputs and outputs match inplist outlist, if specified
2868
    if inputs and (
9✔
2869
            isinstance(inputs, (list, tuple)) and len(inputs) != len(inplist)
2870
            or isinstance(inputs, int) and inputs != len(inplist)):
2871
        raise ValueError("`inputs` incompatible with `inplist`")
×
2872
    if outputs and (
9✔
2873
            isinstance(outputs, (list, tuple)) and len(outputs) != len(outlist)
2874
            or isinstance(outputs, int) and outputs != len(outlist)):
2875
        raise ValueError("`outputs` incompatible with `outlist`")
×
2876

2877
    newsys = InterconnectedSystem(
9✔
2878
        syslist, connections=connections, inplist=inplist,
2879
        outlist=outlist, inputs=inputs, outputs=outputs, states=states,
2880
        params=params, dt=dt, name=name, warn_duplicate=warn_duplicate,
2881
        connection_type=connection_type, **kwargs)
2882

2883
    # See if we should add any signals
2884
    if add_unused:
9✔
2885
        # Get all unused signals
2886
        dropped_inputs, dropped_outputs = newsys.check_unused_signals(
9✔
2887
            ignore_inputs, ignore_outputs, print_warning=False)
2888

2889
        # Add on any unused signals that we aren't ignoring
2890
        for isys, isig in dropped_inputs:
9✔
2891
            inplist.append((isys, isig))
9✔
2892
            inputs.append(newsys.syslist[isys].input_labels[isig])
9✔
2893
        for osys, osig in dropped_outputs:
9✔
2894
            outlist.append((osys, osig))
9✔
2895
            outputs.append(newsys.syslist[osys].output_labels[osig])
9✔
2896

2897
        # Rebuild the system with new inputs/outputs
2898
        newsys = InterconnectedSystem(
9✔
2899
            syslist, connections=connections, inplist=inplist,
2900
            outlist=outlist, inputs=inputs, outputs=outputs, states=states,
2901
            params=params, dt=dt, name=name, warn_duplicate=warn_duplicate,
2902
            connection_type=connection_type, **kwargs)
2903

2904
    # check for implicitly dropped signals
2905
    if check_unused:
9✔
2906
        newsys.check_unused_signals(ignore_inputs, ignore_outputs)
9✔
2907

2908
    # If all subsystems are linear systems, maintain linear structure
2909
    if all([isinstance(sys, StateSpace) for sys in newsys.syslist]):
9✔
2910
        newsys = LinearICSystem(newsys, None, connection_type=connection_type)
9✔
2911

2912
    return newsys
9✔
2913

2914

2915
def _process_vector_argument(arg, name, size):
9✔
2916
    """Utility function to process vector elements (states, inputs)
2917

2918
    Process state and input arguments to turn them into lists of the
2919
    appropriate length.
2920

2921
    Parameters
2922
    ----------
2923
    arg : array_like
2924
        Value of the parameter passed to the function.  Can be a list,
2925
        tuple, ndarray, scalar, or None.
2926
    name : string
2927
        Name of the argument being processed.  Used in errors/warnings.
2928
    size : int or None
2929
        Size of the element.  If None, size is determined by arg.
2930

2931
    Returns
2932
    -------
2933
    val : array or None
2934
        Value of the element, zero-padded to proper length.
2935
    nelem : int or None
2936
        Number of elements in the returned value.
2937

2938
    Warns
2939
    -----
2940
    UserWarning : "{name} too short; padding with zeros"
2941
        If argument is too short and last value in arg is not 0.
2942

2943
    """
2944
    # Allow and expand list
2945
    if isinstance(arg, (tuple, list)):
9✔
2946
        val_list = []
9✔
2947
        for i, v in enumerate(arg):
9✔
2948
            v = np.array(v).reshape(-1)             # convert to 1D array
9✔
2949
            val_list += v.tolist()                  # add elements to list
9✔
2950
        val = np.array(val_list)
9✔
2951
    elif np.isscalar(arg) and size is not None:     # extend scalars
9✔
2952
        val = np.ones((size, )) * arg
9✔
2953
    elif np.isscalar(arg) and size is None:         # single scalar
9✔
2954
        val = np.array([arg])
×
2955
    elif isinstance(arg, np.ndarray):
9✔
2956
        val = arg.reshape(-1)                       # convert to 1D array
9✔
2957
    else:
2958
        val = arg                                   # return what we were given
9✔
2959

2960
    if size is not None and isinstance(val, np.ndarray) and val.size < size:
9✔
2961
        # If needed, extend the size of the vector to match desired size
2962
        if val[-1] != 0:
9✔
2963
            warn(f"{name} too short; padding with zeros")
9✔
2964
        val = np.hstack([val, np.zeros(size - val.size)])
9✔
2965

2966
    nelem = _find_size(size, val, name)                 # determine size
9✔
2967
    return val, nelem
9✔
2968

2969

2970
# Utility function to create an I/O system (from number or array)
2971
def _convert_to_iosystem(sys):
9✔
2972
    # If we were given an I/O system, do nothing
2973
    if isinstance(sys, InputOutputSystem):
9✔
2974
        return sys
9✔
2975

2976
    # Convert sys1 to an I/O system if needed
2977
    if isinstance(sys, (int, float, np.number)):
9✔
2978
        return NonlinearIOSystem(
9✔
2979
            None, lambda t, x, u, params: sys * u,
2980
            outputs=1, inputs=1, dt=None)
2981

2982
    elif isinstance(sys, np.ndarray):
9✔
2983
        sys = np.atleast_2d(sys)
9✔
2984
        return NonlinearIOSystem(
9✔
2985
            None, lambda t, x, u, params: sys @ u,
2986
            outputs=sys.shape[0], inputs=sys.shape[1], dt=None)
2987

2988
def connection_table(sys, show_names=False, column_width=32):
9✔
2989
    """Print table of connections inside interconnected system.
2990

2991
    Intended primarily for `InterconnectedSystem`'s that have been
2992
    connected implicitly using signal names.
2993

2994
    Parameters
2995
    ----------
2996
    sys : `InterconnectedSystem`
2997
        Interconnected system object.
2998
    show_names : bool, optional
2999
        Instead of printing out the system number, print out the name of
3000
        each system. Default is False because system name is not usually
3001
        specified when performing implicit interconnection using
3002
        `interconnect`.
3003
    column_width : int, optional
3004
        Character width of printed columns.
3005

3006
    Examples
3007
    --------
3008
    >>> P = ct.ss(1,1,1,0, inputs='u', outputs='y', name='P')
3009
    >>> C = ct.tf(10, [.1, 1], inputs='e', outputs='u', name='C')
3010
    >>> L = ct.interconnect([C, P], inputs='e', outputs='y')
3011
    >>> L.connection_table(show_names=True) # doctest: +SKIP
3012
    signal    | source                  | destination
3013
    --------------------------------------------------------------
3014
    e         | input                   | C
3015
    u         | C                       | P
3016
    y         | P                       | output
3017

3018
    """
3019
    assert isinstance(sys, InterconnectedSystem), "system must be"\
9✔
3020
        "an InterconnectedSystem."
3021

3022
    sys.connection_table(show_names=show_names, column_width=column_width)
9✔
3023

3024

3025
# Short versions of function call
3026
find_eqpt = find_operating_point
9✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc