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

python-control / python-control / 10312443515

09 Aug 2024 02:07AM UTC coverage: 94.694% (+0.04%) from 94.65%
10312443515

push

github

web-flow
Merge pull request #1034 from murrayrm/ctrlplot_updates-27Jun2024

Control plot refactoring for consistent functionality

9137 of 9649 relevant lines covered (94.69%)

8.27 hits per line

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

96.81
control/timeplot.py
1
# timeplot.py - time plotting functions
2
# RMM, 20 Jun 2023
3
#
4
# This file contains routines for plotting out time responses.  These
5
# functions can be called either as standalone functions or access from the
6
# TimeDataResponse class.
7
#
8
# Note: It might eventually make sense to put the functions here
9
# directly into timeresp.py.
10

11
import itertools
9✔
12
from warnings import warn
9✔
13

14
import matplotlib as mpl
9✔
15
import matplotlib.pyplot as plt
9✔
16
import numpy as np
9✔
17

18
from . import config
9✔
19
from .ctrlplot import ControlPlot, _make_legend_labels,\
9✔
20
    _process_legend_keywords, _update_plot_title
21

22
__all__ = ['time_response_plot', 'combine_time_responses']
9✔
23

24
# Default values for module parameter variables
25
_timeplot_defaults = {
9✔
26
    'timeplot.trace_props': [
27
        {'linestyle': s} for s in ['-', '--', ':', '-.']],
28
    'timeplot.output_props': [
29
        {'color': c} for c in [
30
            'tab:blue', 'tab:orange', 'tab:green', 'tab:pink', 'tab:gray']],
31
    'timeplot.input_props': [
32
        {'color': c} for c in [
33
            'tab:red', 'tab:purple', 'tab:brown', 'tab:olive', 'tab:cyan']],
34
    'timeplot.time_label': "Time [s]",
35
}
36

37

38
# Plot the input/output response of a system
39
def time_response_plot(
9✔
40
        data, *fmt, ax=None, plot_inputs=None, plot_outputs=True,
41
        transpose=False, overlay_traces=False, overlay_signals=False,
42
        legend=None, add_initial_zero=True, label=None,
43
        trace_labels=None, title=None, relabel=True, **kwargs):
44
    """Plot the time response of an input/output system.
45

46
    This function creates a standard set of plots for the input/output
47
    response of a system, with the data provided via a `TimeResponseData`
48
    object, which is the standard output for python-control simulation
49
    functions.
50

51
    Parameters
52
    ----------
53
    data : TimeResponseData
54
        Data to be plotted.
55
    plot_inputs : bool or str, optional
56
        Sets how and where to plot the inputs:
57
            * False: don't plot the inputs
58
            * None: use value from time response data (default)
59
            * 'overlay`: plot inputs overlaid with outputs
60
            * True: plot the inputs on their own axes
61
    plot_outputs : bool, optional
62
        If False, suppress plotting of the outputs.
63
    overlay_traces : bool, optional
64
        If set to True, combine all traces onto a single row instead of
65
        plotting a separate row for each trace.
66
    overlay_signals : bool, optional
67
        If set to True, combine all input and output signals onto a single
68
        plot (for each).
69
    transpose : bool, optional
70
        If transpose is False (default), signals are plotted from top to
71
        bottom, starting with outputs (if plotted) and then inputs.
72
        Multi-trace plots are stacked horizontally.  If transpose is True,
73
        signals are plotted from left to right, starting with the inputs
74
        (if plotted) and then the outputs.  Multi-trace responses are
75
        stacked vertically.
76
    *fmt : :func:`matplotlib.pyplot.plot` format string, optional
77
        Passed to `matplotlib` as the format string for all lines in the plot.
78
    **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
79
        Additional keywords passed to `matplotlib` to specify line properties.
80

81
    Returns
82
    -------
83
    cplt : :class:`ControlPlot` object
84
        Object containing the data that were plotted:
85

86
          * cplt.lines: Array of :class:`matplotlib.lines.Line2D` objects
87
            for each line in the plot.  The shape of the array matches the
88
            subplots shape and the value of the array is a list of Line2D
89
            objects in that subplot.
90

91
          * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot.
92

93
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
94

95
          * cplt.legend: legend object(s) contained in the plot
96

97
        See :class:`ControlPlot` for more detailed information.
98

99
    Other Parameters
100
    ----------------
101
    add_initial_zero : bool
102
        Add an initial point of zero at the first time point for all
103
        inputs with type 'step'.  Default is True.
104
    ax : array of matplotlib.axes.Axes, optional
105
        The matplotlib axes to draw the figure on.  If not specified, the
106
        axes for the current figure are used or, if there is no current
107
        figure with the correct number and shape of axes, a new figure is
108
        created.  The shape of the array must match the shape of the
109
        plotted data.
110
    input_props : array of dicts
111
        List of line properties to use when plotting combined inputs.  The
112
        default values are set by config.defaults['timeplot.input_props'].
113
    label : str or array_like of str, optional
114
        If present, replace automatically generated label(s) with the given
115
        label(s).  If more than one line is being generated, an array of
116
        labels should be provided with label[trace, :, 0] representing the
117
        output labels and label[trace, :, 1] representing the input labels.
118
    legend_map : array of str, optional
119
        Location of the legend for multi-axes plots.  Specifies an array
120
        of legend location strings matching the shape of the subplots, with
121
        each entry being either None (for no legend) or a legend location
122
        string (see :func:`~matplotlib.pyplot.legend`).
123
    legend_loc : int or str, optional
124
        Include a legend in the given location. Default is 'center right',
125
        with no legend for a single response.  Use False to suppress legend.
126
    output_props : array of dicts, optional
127
        List of line properties to use when plotting combined outputs.  The
128
        default values are set by config.defaults['timeplot.output_props'].
129
    relabel : bool, optional
130
        [deprecated] By default, existing figures and axes are relabeled
131
        when new data are added.  If set to `False`, just plot new data on
132
        existing axes.
133
    show_legend : bool, optional
134
        Force legend to be shown if ``True`` or hidden if ``False``.  If
135
        ``None``, then show legend when there is more than one line on an
136
        axis or ``legend_loc`` or ``legend_map`` has been specified.
137
    time_label : str, optional
138
        Label to use for the time axis.
139
    title : str, optional
140
        Set the title of the plot.  Defaults to plot type and system name(s).
141
    trace_labels : list of str, optional
142
        Replace the default trace labels with the given labels.
143
    trace_props : array of dicts
144
        List of line properties to use when plotting combined outputs.  The
145
        default values are set by config.defaults['timeplot.trace_props'].
146

147
    Notes
148
    -----
149
    1. A new figure will be generated if there is no current figure or
150
       the current figure has an incompatible number of axes.  To
151
       force the creation of a new figures, use `plt.figure()`.  To reuse
152
       a portion of an existing figure, use the `ax` keyword.
153

154
    2. The line properties (color, linestyle, etc) can be set for the
155
       entire plot using the `fmt` and/or `kwargs` parameter, which
156
       are passed on to `matplotlib`.  When combining signals or
157
       traces, the `input_props`, `output_props`, and `trace_props`
158
       parameters can be used to pass a list of dictionaries
159
       containing the line properties to use.  These input/output
160
       properties are combined with the trace properties and finally
161
       the kwarg properties to determine the final line properties.
162

163
    3. The default plot properties, such as font sizes, can be set using
164
       config.defaults[''timeplot.rcParams'].
165

166
    """
167
    from .ctrlplot import _process_ax_keyword, _process_line_labels
9✔
168
    from .iosys import InputOutputSystem
9✔
169
    from .timeresp import TimeResponseData
9✔
170

171
    #
172
    # Process keywords and set defaults
173
    #
174
    # Set up defaults
175
    ax_user = ax
9✔
176
    time_label = config._get_param(
9✔
177
        'timeplot', 'time_label', kwargs, _timeplot_defaults, pop=True)
178
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
179

180
    if kwargs.get('input_props', None) and len(fmt) > 0:
9✔
181
        warn("input_props ignored since fmt string was present")
9✔
182
    input_props = config._get_param(
9✔
183
        'timeplot', 'input_props', kwargs, _timeplot_defaults, pop=True)
184
    iprop_len = len(input_props)
9✔
185

186
    if kwargs.get('output_props', None) and len(fmt) > 0:
9✔
187
        warn("output_props ignored since fmt string was present")
9✔
188
    output_props = config._get_param(
9✔
189
        'timeplot', 'output_props', kwargs, _timeplot_defaults, pop=True)
190
    oprop_len = len(output_props)
9✔
191

192
    if kwargs.get('trace_props', None) and len(fmt) > 0:
9✔
193
        warn("trace_props ignored since fmt string was present")
9✔
194
    trace_props = config._get_param(
9✔
195
        'timeplot', 'trace_props', kwargs, _timeplot_defaults, pop=True)
196
    tprop_len = len(trace_props)
9✔
197

198
    # Determine whether or not to plot the input data (and how)
199
    if plot_inputs is None:
9✔
200
        plot_inputs = data.plot_inputs
9✔
201
    if plot_inputs not in [True, False, 'overlay']:
9✔
202
        raise ValueError(f"unrecognized value: {plot_inputs=}")
9✔
203

204
    #
205
    # Find/create axes
206
    #
207
    # Data are plotted in a standard subplots array, whose size depends on
208
    # which signals are being plotted and how they are combined.  The
209
    # baseline layout for data is to plot everything separately, with
210
    # outputs and inputs making up the rows and traces making up the
211
    # columns:
212
    #
213
    # Trace 0        Trace q
214
    # +------+       +------+
215
    # | y[0] |  ...  | y[0] |
216
    # +------+       +------+
217
    #    :
218
    # +------+       +------+
219
    # | y[p] |  ...  | y[p] |
220
    # +------+       +------+
221
    #
222
    # +------+       +------+
223
    # | u[0] |  ...  | u[0] |
224
    # +------+       +------+
225
    #    :
226
    # +------+       +------+
227
    # | u[m] |  ...  | u[m] |
228
    # +------+       +------+
229
    #
230
    # A variety of options are available to modify this format:
231
    #
232
    # * Omitting: either the inputs or the outputs can be omitted.
233
    #
234
    # * Overlay: inputs, outputs, and traces can be combined onto a
235
    #   single set of axes using various keyword combinations
236
    #   (overlay_signals, overlay_traces, plot_inputs='overlay').  This
237
    #   basically collapses data along either the rows or columns, and a
238
    #   legend is generated.
239
    #
240
    # * Transpose: if the `transpose` keyword is True, then instead of
241
    #   plotting the data vertically (outputs over inputs), we plot left to
242
    #   right (inputs, outputs):
243
    #
244
    #         +------+       +------+     +------+       +------+
245
    # Trace 0 | u[0] |  ...  | u[m] |     | y[0] |  ...  | y[p] |
246
    #         +------+       +------+     +------+       +------+
247
    #    :
248
    #    :
249
    #         +------+       +------+     +------+       +------+
250
    # Trace q | u[0] |  ...  | u[m] |     | y[0] |  ...  | y[p] |
251
    #         +------+       +------+     +------+       +------+
252
    #
253
    # This also affects the way in which legends and labels are generated.
254

255
    # Decide on the number of inputs and outputs
256
    ninputs = data.ninputs if plot_inputs else 0
9✔
257
    noutputs = data.noutputs if plot_outputs else 0
9✔
258
    ntraces = max(1, data.ntraces)      # treat data.ntraces == 0 as 1 trace
9✔
259
    if ninputs == 0 and noutputs == 0:
9✔
260
        raise ValueError(
9✔
261
            "plot_inputs and plot_outputs both False; no data to plot")
262
    elif plot_inputs == 'overlay' and noutputs == 0:
9✔
263
        raise ValueError(
9✔
264
            "can't overlay inputs with no outputs")
265
    elif plot_inputs in [True, 'overlay'] and data.ninputs == 0:
9✔
266
        raise ValueError(
9✔
267
            "input plotting requested but no inputs in time response data")
268

269
    # Figure how how many rows and columns to use + offsets for inputs/outputs
270
    if plot_inputs == 'overlay' and not overlay_signals:
9✔
271
        nrows = max(ninputs, noutputs)          # Plot inputs on top of outputs
9✔
272
        noutput_axes = 0                        # No offset required
9✔
273
        ninput_axes = 0                         # No offset required
9✔
274
    elif overlay_signals:
9✔
275
        nrows = int(plot_outputs)               # Start with outputs
9✔
276
        nrows += int(plot_inputs == True)       # Add plot for inputs if needed
9✔
277
        noutput_axes = 1 if plot_outputs and plot_inputs is True else 0
9✔
278
        ninput_axes = 1 if plot_inputs is True else 0
9✔
279
    else:
280
        nrows = noutputs + ninputs              # Plot inputs separately
9✔
281
        noutput_axes = noutputs if plot_outputs else 0
9✔
282
        ninput_axes = ninputs if plot_inputs else 0
9✔
283

284
    ncols = ntraces if not overlay_traces else 1
9✔
285
    if transpose:
9✔
286
        nrows, ncols = ncols, nrows
9✔
287

288
    # See if we can use the current figure axes
289
    fig, ax_array = _process_ax_keyword(ax, (nrows, ncols), rcParams=rcParams)
9✔
290
    legend_loc, legend_map, show_legend = _process_legend_keywords(
9✔
291
        kwargs, (nrows, ncols), 'center right')
292

293
    #
294
    # Map inputs/outputs and traces to axes
295
    #
296
    # This set of code takes care of all of the various options for how to
297
    # plot the data.  The arrays output_map and input_map are used to map
298
    # the different signals that are plotted onto the axes created above.
299
    # This code is complicated because it has to handle lots of different
300
    # variations.
301
    #
302

303
    # Create the map from trace, signal to axes, accounting for overlay_*
304
    output_map = np.empty((noutputs, ntraces), dtype=tuple)
9✔
305
    input_map = np.empty((ninputs, ntraces), dtype=tuple)
9✔
306

307
    for i in range(noutputs):
9✔
308
        for j in range(ntraces):
9✔
309
            signal_index = i if not overlay_signals else 0
9✔
310
            trace_index = j if not overlay_traces else 0
9✔
311
            if transpose:
9✔
312
                output_map[i, j] = (trace_index, signal_index + ninput_axes)
9✔
313
            else:
314
                output_map[i, j] = (signal_index, trace_index)
9✔
315

316
    for i in range(ninputs):
9✔
317
        for j in range(ntraces):
9✔
318
            signal_index = noutput_axes + (i if not overlay_signals else 0)
9✔
319
            trace_index = j if not overlay_traces else 0
9✔
320
            if transpose:
9✔
321
                input_map[i, j] = (trace_index, signal_index - noutput_axes)
9✔
322
            else:
323
                input_map[i, j] = (signal_index, trace_index)
9✔
324

325
    #
326
    # Plot the data
327
    #
328
    # The ax_output and ax_input arrays have the axes needed for making the
329
    # plots.  Labels are used on each axes for later creation of legends.
330
    # The generic labels if of the form:
331
    #
332
    #     signal name, trace label, system name
333
    #
334
    # The signal name or trace label can be omitted if they will appear on
335
    # the axes title or ylabel.  The system name is always included, since
336
    # multiple calls to plot() will require a legend that distinguishes
337
    # which system signals are plotted.  The system name is stripped off
338
    # later (in the legend-handling code) if it is not needed, but must be
339
    # included here since a plot may be built up by multiple calls to plot().
340
    #
341

342
    # Reshape the inputs and outputs for uniform indexing
343
    outputs = data.y.reshape(data.noutputs, ntraces, -1)
9✔
344
    if data.u is None or not plot_inputs:
9✔
345
        inputs = None
9✔
346
    else:
347
        inputs = data.u.reshape(data.ninputs, ntraces, -1)
9✔
348

349
    # Create a list of lines for the output
350
    out = np.empty((nrows, ncols), dtype=object)
9✔
351
    for i in range(nrows):
9✔
352
        for j in range(ncols):
9✔
353
            out[i, j] = []      # unique list in each element
9✔
354

355
    # Utility function for creating line label
356
    # TODO: combine with freqplot version?
357
    def _make_line_label(signal_index, signal_labels, trace_index):
9✔
358
        label = ""              # start with an empty label
9✔
359

360
        # Add the signal name if it won't appear as an axes label
361
        if overlay_signals or plot_inputs == 'overlay':
9✔
362
            label += signal_labels[signal_index]
9✔
363

364
        # Add the trace label if this is a multi-trace figure
365
        if overlay_traces and ntraces > 1 or trace_labels:
9✔
366
            label += ", " if label != "" else ""
9✔
367
            if trace_labels:
9✔
368
                label += trace_labels[trace_index]
×
369
            elif data.trace_labels:
9✔
370
                label += data.trace_labels[trace_index]
9✔
371
            else:
372
                label += f"trace {trace_index}"
×
373

374
        # Add the system name (will strip off later if redundant)
375
        label += ", " if label != "" else ""
9✔
376
        label += f"{data.sysname}"
9✔
377

378
        return label
9✔
379

380
    #
381
    # Store the color offsets with the figure to allow color/style cycling
382
    #
383
    # To allow repeated calls to time_response_plot() to cycle through
384
    # colors, we store an offset in the figure object that we can
385
    # retrieve in a later call, if needed.
386
    #
387
    output_offset = fig._output_offset = getattr(fig, '_output_offset', 0)
9✔
388
    input_offset = fig._input_offset = getattr(fig, '_input_offset', 0)
9✔
389

390
    #
391
    # Plot the lines for the response
392
    #
393

394
    # Process labels
395
    line_labels = _process_line_labels(
9✔
396
        label, ntraces, max(ninputs, noutputs), 2)
397

398
    # Go through each trace and each input/output
399
    for trace in range(ntraces):
9✔
400
        # Plot the output
401
        for i in range(noutputs):
9✔
402
            if line_labels is None:
9✔
403
                label = _make_line_label(i, data.output_labels, trace)
9✔
404
            else:
405
                label = line_labels[trace, i, 0]
9✔
406

407
            # Set up line properties for this output, trace
408
            if len(fmt) == 0:
9✔
409
                line_props = output_props[
9✔
410
                    (i + output_offset) % oprop_len if overlay_signals
411
                    else output_offset].copy()
412
                line_props.update(
9✔
413
                    trace_props[trace % tprop_len if overlay_traces else 0])
414
                line_props.update(kwargs)
9✔
415
            else:
416
                line_props = kwargs
9✔
417

418
            out[output_map[i, trace]] += ax_array[output_map[i, trace]].plot(
9✔
419
                data.time, outputs[i][trace], *fmt, label=label, **line_props)
420

421
        # Plot the input
422
        for i in range(ninputs):
9✔
423
            if line_labels is None:
9✔
424
                label = _make_line_label(i, data.input_labels, trace)
9✔
425
            else:
426
                label = line_labels[trace, i, 1]
9✔
427

428
            if add_initial_zero and data.ntraces > i \
9✔
429
               and data.trace_types[i] == 'step':
430
                x = np.hstack([np.array([data.time[0]]), data.time])
9✔
431
                y = np.hstack([np.array([0]), inputs[i][trace]])
9✔
432
            else:
433
                x, y = data.time, inputs[i][trace]
9✔
434

435
            # Set up line properties for this output, trace
436
            if len(fmt) == 0:
9✔
437
                line_props = input_props[
9✔
438
                    (i + input_offset) % iprop_len if overlay_signals
439
                    else input_offset].copy()
440
                line_props.update(
9✔
441
                    trace_props[trace % tprop_len if overlay_traces else 0])
442
                line_props.update(kwargs)
9✔
443
            else:
444
                line_props = kwargs
9✔
445

446
            out[input_map[i, trace]] += ax_array[input_map[i, trace]].plot(
9✔
447
                x, y, *fmt, label=label, **line_props)
448

449
    # Update the offsets so that we start at a new color/style the next time
450
    fig._output_offset = (
9✔
451
        output_offset + (noutputs if overlay_signals else 1)) % oprop_len
452
    fig._input_offset = (
9✔
453
        input_offset + (ninputs if overlay_signals else 1)) % iprop_len
454

455
    # Stop here if the user wants to control everything
456
    if not relabel:
9✔
457
        warn("relabel keyword is deprecated", FutureWarning)
9✔
458
        return ControlPlot(out, ax_array, fig)
9✔
459

460
    #
461
    # Label the axes (including trace labels)
462
    #
463
    # Once the data are plotted, we label the axes.  The horizontal axes is
464
    # always time and this is labeled only on the bottom most row.  The
465
    # vertical axes can consist either of a single signal or a combination
466
    # of signals (when overlay_signal is True or plot+inputs = 'overlay'.
467
    #
468
    # Traces are labeled at the top of the first row of plots (regular) or
469
    # the left edge of rows (tranpose).
470
    #
471

472
    # Time units on the bottom
473
    for col in range(ncols):
9✔
474
        ax_array[-1, col].set_xlabel(time_label)
9✔
475

476
    # Keep track of whether inputs are overlaid on outputs
477
    overlaid = plot_inputs == 'overlay'
9✔
478
    overlaid_title = "Inputs, Outputs"
9✔
479

480
    if transpose:               # inputs on left, outputs on right
9✔
481
        # Label the inputs
482
        if overlay_signals and plot_inputs:
9✔
483
            label = overlaid_title if overlaid else "Inputs"
9✔
484
            for trace in range(ntraces):
9✔
485
                ax_array[input_map[0, trace]].set_ylabel(label)
9✔
486
        else:
487
            for i in range(ninputs):
9✔
488
                label = overlaid_title if overlaid else data.input_labels[i]
9✔
489
                for trace in range(ntraces):
9✔
490
                    ax_array[input_map[i, trace]].set_ylabel(label)
9✔
491

492
        # Label the outputs
493
        if overlay_signals and plot_outputs:
9✔
494
            label = overlaid_title if overlaid else "Outputs"
9✔
495
            for trace in range(ntraces):
9✔
496
                ax_array[output_map[0, trace]].set_ylabel(label)
9✔
497
        else:
498
            for i in range(noutputs):
9✔
499
                label = overlaid_title if overlaid else data.output_labels[i]
9✔
500
                for trace in range(ntraces):
9✔
501
                    ax_array[output_map[i, trace]].set_ylabel(label)
9✔
502

503
        # Set the trace titles, if needed
504
        if ntraces > 1 and not overlay_traces:
9✔
505
            for trace in range(ntraces):
5✔
506
                # Get the existing ylabel for left column
507
                label = ax_array[trace, 0].get_ylabel()
5✔
508

509
                # Add on the trace title
510
                if trace_labels:
5✔
511
                    label = trace_labels[trace] + "\n" + label
×
512
                elif data.trace_labels:
5✔
513
                    label = data.trace_labels[trace] + "\n" + label
5✔
514
                else:
515
                    label = f"Trace {trace}" + "\n" + label
×
516

517
                ax_array[trace, 0].set_ylabel(label)
5✔
518

519
    else:                       # regular plot (outputs over inputs)
520
        # Set the trace titles, if needed
521
        if ntraces > 1 and not overlay_traces:
9✔
522
            for trace in range(ntraces):
9✔
523
                if trace_labels:
9✔
524
                    label = trace_labels[trace]
×
525
                elif data.trace_labels:
9✔
526
                    label = data.trace_labels[trace]
9✔
527
                else:
528
                    label = f"Trace {trace}"
×
529

530
                with plt.rc_context(rcParams):
9✔
531
                    ax_array[0, trace].set_title(label)
9✔
532

533
        # Label the outputs
534
        if overlay_signals and plot_outputs:
9✔
535
            ax_array[output_map[0, 0]].set_ylabel("Outputs")
9✔
536
        else:
537
            for i in range(noutputs):
9✔
538
                ax_array[output_map[i, 0]].set_ylabel(
9✔
539
                    overlaid_title if overlaid else data.output_labels[i])
540

541
        # Label the inputs
542
        if overlay_signals and plot_inputs:
9✔
543
            label = overlaid_title if overlaid else "Inputs"
9✔
544
            ax_array[input_map[0, 0]].set_ylabel(label)
9✔
545
        else:
546
            for i in range(ninputs):
9✔
547
                label = overlaid_title if overlaid else data.input_labels[i]
9✔
548
                ax_array[input_map[i, 0]].set_ylabel(label)
9✔
549

550
    #
551
    # Create legends
552
    #
553
    # Legends can be placed manually by passing a legend_map array that
554
    # matches the shape of the suplots, with each item being a string
555
    # indicating the location of the legend for that axes (or None for no
556
    # legend).
557
    #
558
    # If no legend spec is passed, a minimal number of legends are used so
559
    # that each line in each axis can be uniquely identified.  The details
560
    # depends on the various plotting parameters, but the general rule is
561
    # to place legends in the top row and right column.
562
    #
563
    # Because plots can be built up by multiple calls to plot(), the legend
564
    # strings are created from the line labels manually.  Thus an initial
565
    # call to plot() may not generate any legends (eg, if no signals are
566
    # combined nor overlaid), but subsequent calls to plot() will need a
567
    # legend for each different line (system).
568
    #
569

570
    # Figure out where to put legends
571
    if show_legend != False and legend_map is None:
9✔
572
        legend_map = np.full(ax_array.shape, None, dtype=object)
9✔
573

574
        if transpose:
9✔
575
            if (overlay_signals or plot_inputs == 'overlay') and overlay_traces:
9✔
576
                # Put a legend in each plot for inputs and outputs
577
                if plot_outputs is True:
9✔
578
                    legend_map[0, ninput_axes] = legend_loc
9✔
579
                if plot_inputs is True:
9✔
580
                    legend_map[0, 0] = legend_loc
9✔
581
            elif overlay_signals:
9✔
582
                # Put a legend in rightmost input/output plot
583
                if plot_inputs is True:
9✔
584
                    legend_map[0, 0] = legend_loc
9✔
585
                if plot_outputs is True:
9✔
586
                    legend_map[0, ninput_axes] = legend_loc
9✔
587
            elif plot_inputs == 'overlay':
9✔
588
                # Put a legend on the top of each column
589
                for i in range(ntraces):
9✔
590
                    legend_map[0, i] = legend_loc
9✔
591
            elif overlay_traces:
9✔
592
                # Put a legend topmost input/output plot
593
                legend_map[0, -1] = legend_loc
9✔
594
            else:
595
                # Put legend in the upper right
596
                legend_map[0, -1] = legend_loc
×
597

598
        else:                   # regular layout
599
            if (overlay_signals or plot_inputs == 'overlay') and overlay_traces:
9✔
600
                # Put a legend in each plot for inputs and outputs
601
                if plot_outputs is True:
9✔
602
                    legend_map[0, -1] = legend_loc
9✔
603
                if plot_inputs is True:
9✔
604
                    legend_map[noutput_axes, -1] = legend_loc
9✔
605
            elif overlay_signals:
9✔
606
                # Put a legend in rightmost input/output plot
607
                if plot_outputs is True:
9✔
608
                    legend_map[0, -1] = legend_loc
9✔
609
                if plot_inputs is True:
9✔
610
                    legend_map[noutput_axes, -1] = legend_loc
9✔
611
            elif plot_inputs == 'overlay':
9✔
612
                # Put a legend on the right of each row
613
                for i in range(max(ninputs, noutputs)):
9✔
614
                    legend_map[i, -1] = legend_loc
9✔
615
            elif overlay_traces:
9✔
616
                # Put a legend topmost input/output plot
617
                legend_map[0, -1] = legend_loc
9✔
618
            else:
619
                # Put legend in the upper right
620
                legend_map[0, -1] = legend_loc
9✔
621

622
    if show_legend != False:
9✔
623
        # Create axis legends
624
        legend_array = np.full(ax_array.shape, None, dtype=object)
9✔
625
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
626
            if legend_map[i, j] is None:
9✔
627
                continue
9✔
628
            ax = ax_array[i, j]
9✔
629
            labels = [line.get_label() for line in ax.get_lines()]
9✔
630
            if line_labels is None:
9✔
631
                labels = _make_legend_labels(labels, plot_inputs == 'overlay')
9✔
632

633
            # Update the labels to remove common strings
634
            if show_legend == True or len(labels) > 1:
9✔
635
                with plt.rc_context(rcParams):
9✔
636
                    legend_array[i, j] = ax.legend(
9✔
637
                        labels, loc=legend_map[i, j])
638
    else:
639
        legend_array = None
9✔
640

641
    #
642
    # Update the plot title (= figure suptitle)
643
    #
644
    # If plots are built up by multiple calls to plot() and the title is
645
    # not given, then the title is updated to provide a list of unique text
646
    # items in each successive title.  For data generated by the I/O
647
    # response functions this will generate a common prefix followed by a
648
    # list of systems (e.g., "Step response for sys[1], sys[2]").
649
    #
650

651
    if ax_user is None and title is None:
9✔
652
        title = data.title if title == None else title
9✔
653
        _update_plot_title(title, fig, rcParams=rcParams)
9✔
654
    elif ax_user is None:
9✔
655
        _update_plot_title(title, fig, rcParams=rcParams, use_existing=False)
9✔
656

657
    return ControlPlot(out, ax_array, fig, legend=legend_map)
9✔
658

659

660
def combine_time_responses(response_list, trace_labels=None, title=None):
9✔
661
    """Combine multiple individual time responses into a multi-trace response.
662

663
    This function combines multiple instances of :class:`TimeResponseData`
664
    into a multi-trace :class:`TimeResponseData` object.
665

666
    Parameters
667
    ----------
668
    response_list : list of :class:`TimeResponseData` objects
669
        Reponses to be combined.
670
    trace_labels : list of str, optional
671
        List of labels for each trace.  If not specified, trace names are
672
        taken from the input data or set to None.
673
    title : str, optional
674
        Set the title to use when plotting.  Defaults to plot type and
675
        system name(s).
676

677
    Returns
678
    -------
679
    data : :class:`TimeResponseData`
680
        Multi-trace input/output data.
681

682
    """
683
    from .timeresp import TimeResponseData
9✔
684

685
    # Save the first trace as the base case
686
    base = response_list[0]
9✔
687

688
    # Process keywords
689
    title = base.title if title is None else title
9✔
690

691
    # Figure out the size of the data (and check for consistency)
692
    ntraces = max(1, base.ntraces)
9✔
693

694
    # Initial pass through trace list to count things up and do error checks
695
    nstates = base.nstates
9✔
696
    for response in response_list[1:]:
9✔
697
        # Make sure the time vector is the same
698
        if not np.allclose(base.t, response.t):
9✔
699
            raise ValueError("all responses must have the same time vector")
9✔
700

701
        # Make sure the dimensions are all the same
702
        if base.ninputs != response.ninputs or \
9✔
703
           base.noutputs != response.noutputs:
704
            raise ValueError("all responses must have the same number of "
9✔
705
                            "inputs, outputs, and states")
706

707
        if nstates != response.nstates:
9✔
708
            warn("responses have different state dimensions; dropping states")
×
709
            nstates = 0
×
710

711
        ntraces += max(1, response.ntraces)
9✔
712

713
    # Create data structures for the new time response data object
714
    inputs = np.empty((base.ninputs, ntraces, base.t.size))
9✔
715
    outputs = np.empty((base.noutputs, ntraces, base.t.size))
9✔
716
    states = np.empty((nstates, ntraces, base.t.size))
9✔
717

718
    # See whether we should create labels or not
719
    if trace_labels is None:
9✔
720
        generate_trace_labels = True
9✔
721
        trace_labels = []
9✔
722
    elif len(trace_labels) != ntraces:
9✔
723
        raise ValueError(
9✔
724
            "number of trace labels does not match number of traces")
725
    else:
726
        generate_trace_labels = False
9✔
727

728
    offset = 0
9✔
729
    trace_types = []
9✔
730
    for response in response_list:
9✔
731
        if response.ntraces == 0:
9✔
732
            # Single trace
733
            inputs[:, offset, :] = response.u
9✔
734
            outputs[:, offset, :] = response.y
9✔
735
            if nstates:
9✔
736
                states[:, offset, :] = response.x
9✔
737
            offset += 1
9✔
738

739
            # Add on trace label and trace type
740
            if generate_trace_labels:
9✔
741
                trace_labels.append(response.title)
9✔
742
            trace_types.append(
9✔
743
                None if response.trace_types is None
744
                else response.trace_types[0])
745

746
        else:
747
            # Save the data
748
            for i in range(response.ntraces):
9✔
749
                inputs[:, offset, :] = response.u[:, i, :]
9✔
750
                outputs[:, offset, :] = response.y[:, i, :]
9✔
751
                if nstates:
9✔
752
                    states[:, offset, :] = response.x[:, i, :]
9✔
753

754
                # Save the trace labels
755
                if generate_trace_labels:
9✔
756
                    if response.trace_labels is not None:
9✔
757
                        trace_labels.append(response.trace_labels[i])
9✔
758
                    else:
759
                        trace_labels.append(response.title + f", trace {i}")
9✔
760

761
                offset += 1
9✔
762

763
            # Save the trace types
764
            if response.trace_types is not None:
9✔
765
                trace_types += response.trace_types
9✔
766
            else:
767
                trace_types += [None] * response.ntraces
9✔
768

769
    return TimeResponseData(
9✔
770
        base.t, outputs, states if nstates else None, inputs,
771
        output_labels=base.output_labels, input_labels=base.input_labels,
772
        state_labels=base.state_labels if nstates else None,
773
        title=title, transpose=base.transpose, return_x=base.return_x,
774
        issiso=base.issiso, squeeze=base.squeeze, sysname=base.sysname,
775
        trace_labels=trace_labels, trace_types=trace_types,
776
        plot_inputs=base.plot_inputs)
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

© 2026 Coveralls, Inc