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

python-control / python-control / 17209086284

25 Aug 2025 12:39PM UTC coverage: 94.153% (-0.6%) from 94.734%
17209086284

Pull #1148

github

web-flow
Merge e9cf123f6 into abeb0e46a
Pull Request #1148: Add support for continuous delay systems

10435 of 11083 relevant lines covered (94.15%)

8.25 hits per line

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

94.91
control/freqplot.py
1
# freqplot.py - frequency domain plots for control systems
2
#
3
# Initial author: Richard M. Murray
4
# Creation date: 24 May 2009
5

6
"""Frequency domain plots for control systems.
7

8
This module contains some standard control system plots: Bode plots,
9
Nyquist plots and other frequency response plots.  The code for
10
Nichols charts is in nichols.py.  The code for pole-zero diagrams is
11
in pzmap.py and rlocus.py.
12

13
"""
14

15
import itertools
9✔
16
import math
9✔
17
import warnings
9✔
18

19
import matplotlib as mpl
9✔
20
import matplotlib.pyplot as plt
9✔
21
import numpy as np
9✔
22

23
from . import config
9✔
24
from .bdalg import feedback
9✔
25
from .ctrlplot import ControlPlot, _add_arrows_to_line2D, _find_axes_center, \
9✔
26
    _get_color, _get_color_offset, _get_line_labels, _make_legend_labels, \
27
    _process_ax_keyword, _process_legend_keywords, _process_line_labels, \
28
    _update_plot_title
29
from .ctrlutil import unwrap
9✔
30
from .exception import ControlMIMONotImplemented
9✔
31
from .frdata import FrequencyResponseData
9✔
32
from .lti import LTI, _process_frequency_response, frequency_response
9✔
33
from .margins import stability_margins
9✔
34
from .statesp import StateSpace
9✔
35
from .xferfcn import TransferFunction
9✔
36
from .delaylti import DelayLTI
9✔
37

38
__all__ = ['bode_plot', 'NyquistResponseData', 'nyquist_response',
9✔
39
           'nyquist_plot', 'singular_values_response',
40
           'singular_values_plot', 'gangof4_plot', 'gangof4_response',
41
           'bode', 'nyquist', 'gangof4', 'FrequencyResponseList',
42
           'NyquistResponseList']
43

44
# Default values for module parameter variables
45
_freqplot_defaults = {
9✔
46
    'freqplot.feature_periphery_decades': 1,
47
    'freqplot.number_of_samples': 1000,
48
    'freqplot.dB': False,  # Plot gain in dB
49
    'freqplot.deg': True,  # Plot phase in degrees
50
    'freqplot.Hz': False,  # Plot frequency in Hertz
51
    'freqplot.grid': True,  # Turn on grid for gain and phase
52
    'freqplot.wrap_phase': False,  # Wrap the phase plot at a given value
53
    'freqplot.freq_label': "Frequency [{units}]",
54
    'freqplot.magnitude_label': "Magnitude",
55
    'freqplot.share_magnitude': 'row',
56
    'freqplot.share_phase': 'row',
57
    'freqplot.share_frequency': 'col',
58
    'freqplot.title_frame': 'axes',
59
}
60

61
#
62
# Frequency response data list class
63
#
64
# This class is a subclass of list that adds a plot() method, enabling
65
# direct plotting from routines returning a list of FrequencyResponseData
66
# objects.
67
#
68

69
class FrequencyResponseList(list):
9✔
70
    """List of FrequencyResponseData objects with plotting capability.
71

72
    This class consists of a list of `FrequencyResponseData` objects.
73
    It is a subclass of the Python `list` class, with a `plot` method that
74
    plots the individual `FrequencyResponseData` objects.
75

76
    """
77
    def plot(self, *args, plot_type=None, **kwargs):
9✔
78
        """Plot a list of frequency responses.
79

80
        See `FrequencyResponseData.plot` for details.
81

82
        """
83
        if plot_type == None:
9✔
84
            for response in self:
9✔
85
                if plot_type is not None and response.plot_type != plot_type:
9✔
86
                    raise TypeError(
×
87
                        "inconsistent plot_types in data; set plot_type "
88
                        "to 'bode', 'nichols', or 'svplot'")
89
                plot_type = response.plot_type
9✔
90

91
        # Use FRD plot method, which can handle lists via plot functions
92
        return FrequencyResponseData.plot(
9✔
93
            self, plot_type=plot_type, *args, **kwargs)
94

95
#
96
# Bode plot
97
#
98
# This is the default method for plotting frequency responses.  There are
99
# lots of options available for tuning the format of the plot, (hopefully)
100
# covering most of the common use cases.
101
#
102

103
def bode_plot(
9✔
104
        data, omega=None, *fmt, ax=None, omega_limits=None, omega_num=None,
105
        plot=None, plot_magnitude=True, plot_phase=None,
106
        overlay_outputs=None, overlay_inputs=None, phase_label=None,
107
        magnitude_label=None, label=None, display_margins=None,
108
        margins_method='best', title=None, sharex=None, sharey=None, **kwargs):
109
    """Bode plot for a system.
110

111
    Plot the magnitude and phase of the frequency response over a
112
    (optional) frequency range.
113

114
    Parameters
115
    ----------
116
    data : list of `FrequencyResponseData` or `LTI`
117
        List of LTI systems or `FrequencyResponseData` objects.  A
118
        single system or frequency response can also be passed.
119
    omega : array_like, optional
120
        Set of frequencies in rad/sec to plot over.  If not specified, this
121
        will be determined from the properties of the systems.  Ignored if
122
        `data` is not a list of systems.
123
    *fmt : `matplotlib.pyplot.plot` format string, optional
124
        Passed to `matplotlib` as the format string for all lines in the plot.
125
        The `omega` parameter must be present (use omega=None if needed).
126
    dB : bool
127
        If True, plot result in dB.  Default is False.
128
    Hz : bool
129
        If True, plot frequency in Hz (omega must be provided in rad/sec).
130
        Default value (False) set by `config.defaults['freqplot.Hz']`.
131
    deg : bool
132
        If True, plot phase in degrees (else radians).  Default
133
        value (True) set by `config.defaults['freqplot.deg']`.
134
    display_margins : bool or str
135
        If True, draw gain and phase margin lines on the magnitude and phase
136
        graphs and display the margins at the top of the graph.  If set to
137
        'overlay', the values for the gain and phase margin are placed on
138
        the graph.  Setting `display_margins` turns off the axes grid, unless
139
        `grid` is explicitly set to True.
140
    **kwargs : `matplotlib.pyplot.plot` keyword properties, optional
141
        Additional keywords passed to `matplotlib` to specify line properties.
142

143
    Returns
144
    -------
145
    cplt : `ControlPlot` object
146
        Object containing the data that were plotted.  See `ControlPlot`
147
        for more detailed information.
148
    cplt.lines : Array of `matplotlib.lines.Line2D` objects
149
        Array containing information on each line in the plot.  The shape
150
        of the array matches the subplots shape and the value of the array
151
        is a list of Line2D objects in that subplot.
152
    cplt.axes : 2D ndarray of `matplotlib.axes.Axes`
153
        Axes for each subplot.
154
    cplt.figure : `matplotlib.figure.Figure`
155
        Figure containing the plot.
156
    cplt.legend : 2D array of `matplotlib.legend.Legend`
157
        Legend object(s) contained in the plot.
158

159
    Other Parameters
160
    ----------------
161
    ax : array of `matplotlib.axes.Axes`, optional
162
        The matplotlib axes to draw the figure on.  If not specified, the
163
        axes for the current figure are used or, if there is no current
164
        figure with the correct number and shape of axes, a new figure is
165
        created.  The shape of the array must match the shape of the
166
        plotted data.
167
    freq_label, magnitude_label, phase_label : str, optional
168
        Labels to use for the frequency, magnitude, and phase axes.
169
        Defaults are set by `config.defaults['freqplot.<keyword>']`.
170
    grid : bool, optional
171
        If True, plot grid lines on gain and phase plots.  Default is set by
172
        `config.defaults['freqplot.grid']`.
173
    initial_phase : float, optional
174
        Set the reference phase to use for the lowest frequency.  If set, the
175
        initial phase of the Bode plot will be set to the value closest to the
176
        value specified.  Units are in either degrees or radians, depending on
177
        the `deg` parameter. Default is -180 if wrap_phase is False, 0 if
178
        wrap_phase is True.
179
    label : str or array_like of str, optional
180
        If present, replace automatically generated label(s) with the given
181
        label(s).  If sysdata is a list, strings should be specified for each
182
        system.  If MIMO, strings required for each system, output, and input.
183
    legend_map : array of str, optional
184
        Location of the legend for multi-axes plots.  Specifies an array
185
        of legend location strings matching the shape of the subplots, with
186
        each entry being either None (for no legend) or a legend location
187
        string (see `~matplotlib.pyplot.legend`).
188
    legend_loc : int or str, optional
189
        Include a legend in the given location. Default is 'center right',
190
        with no legend for a single response.  Use False to suppress legend.
191
    margins_method : str, optional
192
        Method to use in computing margins (see `stability_margins`).
193
    omega_limits : array_like of two values
194
        Set limits for plotted frequency range. If Hz=True the limits are
195
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
196
        elements is equivalent to providing `omega_limits`. Ignored if
197
        data is not a list of systems.
198
    omega_num : int
199
        Number of samples to use for the frequency range.  Defaults to
200
        `config.defaults['freqplot.number_of_samples']`.  Ignored if data is
201
        not a list of systems.
202
    overlay_inputs, overlay_outputs : bool, optional
203
        If set to True, combine input and/or output signals onto a single
204
        plot and use line colors, labels, and a legend to distinguish them.
205
    plot : bool, optional
206
        (legacy) If given, `bode_plot` returns the legacy return values
207
        of magnitude, phase, and frequency.  If False, just return the
208
        values with no plot.
209
    plot_magnitude, plot_phase : bool, optional
210
        If set to False, do not plot the magnitude or phase, respectively.
211
    rcParams : dict
212
        Override the default parameters used for generating plots.
213
        Default is set by `config.defaults['ctrlplot.rcParams']`.
214
    share_frequency, share_magnitude, share_phase : str or bool, optional
215
        Determine whether and how axis limits are shared between the
216
        indicated variables.  Can be set set to 'row' to share across all
217
        subplots in a row, 'col' to set across all subplots in a column, or
218
        False to allow independent limits.  Note: if `sharex` is given,
219
        it sets the value of `share_frequency`; if `sharey` is given, it
220
        sets the value of both `share_magnitude` and `share_phase`.
221
        Default values are 'row' for `share_magnitude` and `share_phase`,
222
        'col', for `share_frequency`, and can be set using
223
        `config.defaults['freqplot.share_<axis>']`.
224
    show_legend : bool, optional
225
        Force legend to be shown if True or hidden if False.  If
226
        None, then show legend when there is more than one line on an
227
        axis or `legend_loc` or `legend_map` has been specified.
228
    title : str, optional
229
        Set the title of the plot.  Defaults to plot type and system name(s).
230
    title_frame : str, optional
231
        Set the frame of reference used to center the plot title. If set to
232
        'axes' (default), the horizontal position of the title will be
233
        centered relative to the axes.  If set to 'figure', it will be
234
        centered with respect to the figure (faster execution).  The default
235
        value can be set using `config.defaults['freqplot.title_frame']`.
236
    wrap_phase : bool or float
237
        If wrap_phase is False (default), then the phase will be unwrapped
238
        so that it is continuously increasing or decreasing.  If wrap_phase is
239
        True the phase will be restricted to the range [-180, 180) (or
240
        [:math:`-\\pi`, :math:`\\pi`) radians). If `wrap_phase` is specified
241
        as a float, the phase will be offset by 360 degrees if it falls below
242
        the specified value. Default value is False and can be set using
243
        `config.defaults['freqplot.wrap_phase']`.
244

245
    See Also
246
    --------
247
    frequency_response
248

249
    Notes
250
    -----
251
    Starting with python-control version 0.10, `bode_plot` returns a
252
    `ControlPlot` object instead of magnitude, phase, and
253
    frequency. To recover the old behavior, call `bode_plot` with
254
    `plot` = True, which will force the legacy values (mag, phase, omega) to
255
    be returned (with a warning).  To obtain just the frequency response of
256
    a system (or list of systems) without plotting, use the
257
    `frequency_response` command.
258

259
    If a discrete-time model is given, the frequency response is plotted
260
    along the upper branch of the unit circle, using the mapping ``z =
261
    exp(1j * omega * dt)`` where `omega` ranges from 0 to pi/`dt` and `dt`
262
    is the discrete timebase.  If timebase not specified (`dt` = True),
263
    `dt` is set to 1.
264

265
    The default values for Bode plot configuration parameters can be reset
266
    using the `config.defaults` dictionary, with module name 'bode'.
267

268
    Examples
269
    --------
270
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
271
    >>> out = ct.bode_plot(G)
272

273
    """
274
    #
275
    # Process keywords and set defaults
276
    #
277

278
    # Make a copy of the kwargs dictionary since we will modify it
279
    kwargs = dict(kwargs)
9✔
280

281
    # Legacy keywords for margins
282
    display_margins = config._process_legacy_keyword(
9✔
283
        kwargs, 'margins', 'display_margins', display_margins)
284
    if kwargs.pop('margin_info', False):
9✔
285
        warnings.warn(
×
286
            "keyword 'margin_info' is deprecated; "
287
            "use 'display_margins='overlay'")
288
        if display_margins is False:
×
289
            raise ValueError(
×
290
                "conflicting_keywords: `display_margins` and `margin_info`")
291

292
    # Turn off grid if display margins, unless explicitly overridden
293
    if display_margins and 'grid' not in kwargs:
9✔
294
        kwargs['grid'] = False
9✔
295

296
    margins_method = config._process_legacy_keyword(
9✔
297
        kwargs, 'method', 'margins_method', margins_method)
298

299
    # Get values for params (and pop from list to allow keyword use in plot)
300
    dB = config._get_param(
9✔
301
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
302
    deg = config._get_param(
9✔
303
        'freqplot', 'deg', kwargs, _freqplot_defaults, pop=True)
304
    Hz = config._get_param(
9✔
305
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
306
    grid = config._get_param(
9✔
307
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
308
    wrap_phase = config._get_param(
9✔
309
        'freqplot', 'wrap_phase', kwargs, _freqplot_defaults, pop=True)
310
    initial_phase = config._get_param(
9✔
311
        'freqplot', 'initial_phase', kwargs, None, pop=True)
312
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
313
    title_frame = config._get_param(
9✔
314
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
315

316
    # Set the default labels
317
    freq_label = config._get_param(
9✔
318
        'freqplot', 'freq_label', kwargs, _freqplot_defaults, pop=True)
319
    if magnitude_label is None:
9✔
320
        magnitude_label = config._get_param(
9✔
321
            'freqplot', 'magnitude_label', kwargs,
322
            _freqplot_defaults, pop=True) + (" [dB]" if dB else "")
323
    if phase_label is None:
9✔
324
        phase_label = "Phase [deg]" if deg else "Phase [rad]"
9✔
325

326
    # Use sharex and sharey as proxies for share_{magnitude, phase, frequency}
327
    if sharey is not None:
9✔
328
        if 'share_magnitude' in kwargs or 'share_phase' in kwargs:
9✔
329
            ValueError(
×
330
                "sharey cannot be present with share_magnitude/share_phase")
331
        kwargs['share_magnitude'] = sharey
9✔
332
        kwargs['share_phase'] = sharey
9✔
333
    if sharex is not None:
9✔
334
        if 'share_frequency' in kwargs:
9✔
335
            ValueError(
×
336
                "sharex cannot be present with share_frequency")
337
        kwargs['share_frequency'] = sharex
9✔
338

339
    if not isinstance(data, (list, tuple)):
9✔
340
        data = [data]
9✔
341

342
    #
343
    # Pre-process the data to be plotted (unwrap phase, limit frequencies)
344
    #
345
    # To maintain compatibility with legacy uses of bode_plot(), we do some
346
    # initial processing on the data, specifically phase unwrapping and
347
    # setting the initial value of the phase.  If bode_plot is called with
348
    # plot == False, then these values are returned to the user (instead of
349
    # the list of lines created, which is the new output for _plot functions.
350
    #
351

352
    # If we were passed a list of systems, convert to data
353
    if any([isinstance(
9✔
354
            sys, (StateSpace, TransferFunction, DelayLTI)) for sys in data]):
355
        data = frequency_response(
9✔
356
            data, omega=omega, omega_limits=omega_limits,
357
            omega_num=omega_num, Hz=Hz)
358
    else:
359
        # Generate warnings if frequency keywords were given
360
        if omega_num is not None:
9✔
361
            warnings.warn("`omega_num` ignored when passed response data")
9✔
362
        elif omega is not None:
9✔
363
            warnings.warn("`omega` ignored when passed response data")
9✔
364

365
        # Check to make sure omega_limits is sensible
366
        if omega_limits is not None and \
9✔
367
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
368
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
369

370
    # If plot_phase is not specified, check the data first, otherwise true
371
    if plot_phase is None:
9✔
372
        plot_phase = True if data[0].plot_phase is None else data[0].plot_phase
9✔
373

374
    if not plot_magnitude and not plot_phase:
9✔
375
        raise ValueError(
9✔
376
            "plot_magnitude and plot_phase both False; no data to plot")
377

378
    mag_data, phase_data, omega_data = [], [], []
9✔
379
    for response in data:
9✔
380
        noutputs, ninputs = response.noutputs, response.ninputs
9✔
381

382
        if initial_phase is None:
9✔
383
            # Start phase in the range 0 to -360 w/ initial phase = 0
384
            # TODO: change this to 0 to 270 (?)
385
            # If wrap_phase is true, use 0 instead (phase \in (-pi, pi])
386
            initial_phase_value = -math.pi if wrap_phase is not True else 0
9✔
387
        elif isinstance(initial_phase, (int, float)):
9✔
388
            # Allow the user to override the default calculation
389
            if deg:
9✔
390
                initial_phase_value = initial_phase/180. * math.pi
9✔
391
            else:
392
                initial_phase_value = initial_phase
9✔
393
        else:
394
            raise ValueError("initial_phase must be a number.")
×
395

396
        # Shift and wrap the phase
397
        phase = np.angle(response.frdata)               # 3D array
9✔
398
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
399
            # Shift the phase if needed
400
            if abs(phase[i, j, 0] - initial_phase_value) > math.pi:
9✔
401
                phase[i, j] -= 2*math.pi * round(
9✔
402
                    (phase[i, j, 0] - initial_phase_value) / (2*math.pi))
403

404
            # Phase wrapping
405
            if wrap_phase is False:
9✔
406
                phase[i, j] = unwrap(phase[i, j]) # unwrap the phase
9✔
407
            elif wrap_phase is True:
9✔
408
                pass                                    # default calc OK
9✔
409
            elif isinstance(wrap_phase, (int, float)):
9✔
410
                phase[i, j] = unwrap(phase[i, j]) # unwrap phase first
9✔
411
                if deg:
9✔
412
                    wrap_phase *= math.pi/180.
9✔
413

414
                # Shift the phase if it is below the wrap_phase
415
                phase[i, j] += 2*math.pi * np.maximum(
9✔
416
                    0, np.ceil((wrap_phase - phase[i, j])/(2*math.pi)))
417
            else:
418
                raise ValueError("wrap_phase must be bool or float.")
×
419

420
        # Save the data for later use
421
        mag_data.append(np.abs(response.frdata))
9✔
422
        phase_data.append(phase)
9✔
423
        omega_data.append(response.omega)
9✔
424

425
    #
426
    # Process `plot` keyword
427
    #
428
    # We use the `plot` keyword to track legacy usage of `bode_plot`.
429
    # Prior to v0.10, the `bode_plot` command returned mag, phase, and
430
    # omega.  Post v0.10, we return an array with the same shape as the
431
    # axes we use for plotting, with each array element containing a list
432
    # of lines drawn on that axes.
433
    #
434
    # There are three possibilities at this stage in the code:
435
    #
436
    # * plot == True: set explicitly by the user. Return mag, phase, omega,
437
    #   with a warning.
438
    #
439
    # * plot == False: set explicitly by the user. Return mag, phase,
440
    #   omega, with a warning.
441
    #
442
    # * plot == None: this is the new default setting.  Return an array of
443
    #   lines that were drawn.
444
    #
445
    # If `bode_plot` was called with no `plot` argument and the return
446
    # values were used, the new code will cause problems (you get an array
447
    # of lines instead of magnitude, phase, and frequency).  To recover the
448
    # old behavior, call `bode_plot` with `plot=True`.
449
    #
450
    # All of this should be removed in v0.11+ when we get rid of deprecated
451
    # code.
452
    #
453

454
    if plot is not None:
9✔
455
        warnings.warn(
9✔
456
            "bode_plot() return value of mag, phase, omega is deprecated; "
457
            "use frequency_response()", FutureWarning)
458

459
    if plot is False:
9✔
460
        # Process the data to match what we were sent
461
        for i in range(len(mag_data)):
9✔
462
            mag_data[i] = _process_frequency_response(
9✔
463
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
464
            phase_data[i] = _process_frequency_response(
9✔
465
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
466

467
        if len(data) == 1:
9✔
468
            return mag_data[0], phase_data[0], omega_data[0]
9✔
469
        else:
470
            return mag_data, phase_data, omega_data
9✔
471
    #
472
    # Find/create axes
473
    #
474
    # Data are plotted in a standard subplots array, whose size depends on
475
    # which signals are being plotted and how they are combined.  The
476
    # baseline layout for data is to plot everything separately, with
477
    # the magnitude and phase for each output making up the rows and the
478
    # columns corresponding to the different inputs.
479
    #
480
    #      Input 0                 Input m
481
    # +---------------+       +---------------+
482
    # |  mag H_y0,u0  |  ...  |  mag H_y0,um  |
483
    # +---------------+       +---------------+
484
    # +---------------+       +---------------+
485
    # | phase H_y0,u0 |  ...  | phase H_y0,um |
486
    # +---------------+       +---------------+
487
    #         :                       :
488
    # +---------------+       +---------------+
489
    # |  mag H_yp,u0  |  ...  |  mag H_yp,um  |
490
    # +---------------+       +---------------+
491
    # +---------------+       +---------------+
492
    # | phase H_yp,u0 |  ...  | phase H_yp,um |
493
    # +---------------+       +---------------+
494
    #
495
    # Several operations are available that change this layout.
496
    #
497
    # * Omitting: either the magnitude or the phase plots can be omitted
498
    #   using the plot_magnitude and plot_phase keywords.
499
    #
500
    # * Overlay: inputs and/or outputs can be combined onto a single set of
501
    #   axes using the overlay_inputs and overlay_outputs keywords.  This
502
    #   basically collapses data along either the rows or columns, and a
503
    #   legend is generated.
504
    #
505

506
    # Decide on the maximum number of inputs and outputs
507
    ninputs, noutputs = 0, 0
9✔
508
    for response in data:       # TODO: make more pythonic/numpic
9✔
509
        ninputs = max(ninputs, response.ninputs)
9✔
510
        noutputs = max(noutputs, response.noutputs)
9✔
511

512
    # Figure how how many rows and columns to use + offsets for inputs/outputs
513
    if overlay_outputs and overlay_inputs:
9✔
514
        nrows = plot_magnitude + plot_phase
9✔
515
        ncols = 1
9✔
516
    elif overlay_outputs:
9✔
517
        nrows = plot_magnitude + plot_phase
9✔
518
        ncols = ninputs
9✔
519
    elif overlay_inputs:
9✔
520
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
521
            (noutputs if plot_phase else 0)
522
        ncols = 1
9✔
523
    else:
524
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
525
            (noutputs if plot_phase else 0)
526
        ncols = ninputs
9✔
527

528
    if ax is None:
9✔
529
        # Set up default sharing of axis limits if not specified
530
        for kw in ['share_magnitude', 'share_phase', 'share_frequency']:
9✔
531
            if kw not in kwargs or kwargs[kw] is None:
9✔
532
                kwargs[kw] = config.defaults['freqplot.' + kw]
9✔
533

534
    fig, ax_array = _process_ax_keyword(
9✔
535
        ax, (nrows, ncols), squeeze=False, rcParams=rcParams, clear_text=True)
536
    legend_loc, legend_map, show_legend = _process_legend_keywords(
9✔
537
        kwargs, (nrows,ncols), 'center right')
538

539
    # Get the values for sharing axes limits
540
    share_magnitude = kwargs.pop('share_magnitude', None)
9✔
541
    share_phase = kwargs.pop('share_phase', None)
9✔
542
    share_frequency = kwargs.pop('share_frequency', None)
9✔
543

544
    # Set up axes variables for easier access below
545
    if plot_magnitude and not plot_phase:
9✔
546
        mag_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
547
        for i in range(noutputs):
9✔
548
            for j in range(ninputs):
9✔
549
                if overlay_outputs and overlay_inputs:
9✔
550
                    mag_map[i, j] = (0, 0)
9✔
551
                elif overlay_outputs:
9✔
552
                    mag_map[i, j] = (0, j)
9✔
553
                elif overlay_inputs:
9✔
554
                    mag_map[i, j] = (i, 0)
×
555
                else:
556
                    mag_map[i, j] = (i, j)
9✔
557
        phase_map = np.full((noutputs, ninputs), None)
9✔
558
        share_phase = False
9✔
559

560
    elif plot_phase and not plot_magnitude:
9✔
561
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
562
        for i in range(noutputs):
9✔
563
            for j in range(ninputs):
9✔
564
                if overlay_outputs and overlay_inputs:
9✔
565
                    phase_map[i, j] = (0, 0)
×
566
                elif overlay_outputs:
9✔
567
                    phase_map[i, j] = (0, j)
×
568
                elif overlay_inputs:
9✔
569
                    phase_map[i, j] = (i, 0)
9✔
570
                else:
571
                    phase_map[i, j] = (i, j)
9✔
572
        mag_map = np.full((noutputs, ninputs), None)
9✔
573
        share_magnitude = False
9✔
574

575
    else:
576
        mag_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
577
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
578
        for i in range(noutputs):
9✔
579
            for j in range(ninputs):
9✔
580
                if overlay_outputs and overlay_inputs:
9✔
581
                    mag_map[i, j] = (0, 0)
×
582
                    phase_map[i, j] = (1, 0)
×
583
                elif overlay_outputs:
9✔
584
                    mag_map[i, j] = (0, j)
×
585
                    phase_map[i, j] = (1, j)
×
586
                elif overlay_inputs:
9✔
587
                    mag_map[i, j] = (i*2, 0)
×
588
                    phase_map[i, j] = (i*2 + 1, 0)
×
589
                else:
590
                    mag_map[i, j] = (i*2, j)
9✔
591
                    phase_map[i, j] = (i*2 + 1, j)
9✔
592

593
    # Identity map needed for setting up shared axes
594
    ax_map = np.empty((nrows, ncols), dtype=tuple)
9✔
595
    for i, j in itertools.product(range(nrows), range(ncols)):
9✔
596
        ax_map[i, j] = (i, j)
9✔
597

598
    #
599
    # Set up axes limit sharing
600
    #
601
    # This code uses the share_magnitude, share_phase, and share_frequency
602
    # keywords to decide which axes have shared limits and what ticklabels
603
    # to include.  The sharing code needs to come before the plots are
604
    # generated, but additional code for removing tick labels needs to come
605
    # *during* and *after* the plots are generated (see below).
606
    #
607
    # Note: if the various share_* keywords are None then a previous set of
608
    # axes are available and no updates should be made.
609
    #
610

611
    # Utility function to turn on sharing
612
    def _share_axes(ref, share_map, axis):
9✔
613
        ref_ax = ax_array[ref]
9✔
614
        for index in np.nditer(share_map, flags=["refs_ok"]):
9✔
615
            if index.item() == ref:
9✔
616
                continue
9✔
617
            if axis == 'x':
9✔
618
                ax_array[index.item()].sharex(ref_ax)
9✔
619
            elif axis == 'y':
9✔
620
                ax_array[index.item()].sharey(ref_ax)
9✔
621
            else:
622
                raise ValueError("axis must be 'x' or 'y'")
×
623

624
    # Process magnitude, phase, and frequency axes
625
    for name, value, map, axis in zip(
9✔
626
            ['share_magnitude', 'share_phase', 'share_frequency'],
627
            [ share_magnitude,   share_phase,   share_frequency],
628
            [ mag_map,           phase_map,     ax_map],
629
            [ 'y',               'y',           'x']):
630
        if value in [True, 'all']:
9✔
631
            _share_axes(map[0 if axis == 'y' else -1, 0], map, axis)
9✔
632
        elif axis == 'y' and value in ['row']:
9✔
633
            for i in range(noutputs if not overlay_outputs else 1):
9✔
634
                _share_axes(map[i, 0], map[i], 'y')
9✔
635
        elif axis == 'x' and value in ['col']:
9✔
636
            for j in range(ncols):
9✔
637
                _share_axes(map[-1, j], map[:, j], 'x')
9✔
638
        elif value in [False, 'none']:
9✔
639
            # TODO: turn off any sharing that is on
640
            pass
9✔
641
        elif value is not None:
9✔
642
            raise ValueError(
×
643
                f"unknown value for `{name}`: '{value}'")
644

645
    #
646
    # Plot the data
647
    #
648
    # The mag_map and phase_map arrays have the indices axes needed for
649
    # making the plots.  Labels are used on each axes for later creation of
650
    # legends.  The generic labels if of the form:
651
    #
652
    #     To output label, From input label, system name
653
    #
654
    # The input and output labels are omitted if overlay_inputs or
655
    # overlay_outputs is False, respectively.  The system name is always
656
    # included, since multiple calls to plot() will require a legend that
657
    # distinguishes which system signals are plotted.  The system name is
658
    # stripped off later (in the legend-handling code) if it is not needed.
659
    #
660
    # Note: if we are building on top of an existing plot, tick labels
661
    # should be preserved from the existing axes.  For log scale axes the
662
    # tick labels seem to appear no matter what => we have to detect if
663
    # they are present at the start and, it not, remove them after calling
664
    # loglog or semilogx.
665
    #
666

667
    # Create a list of lines for the output
668
    out = np.empty((nrows, ncols), dtype=object)
9✔
669
    for i in range(nrows):
9✔
670
        for j in range(ncols):
9✔
671
            out[i, j] = []      # unique list in each element
9✔
672

673
    # Process label keyword
674
    line_labels = _process_line_labels(label, len(data), ninputs, noutputs)
9✔
675

676
    # Utility function for creating line label
677
    def _make_line_label(response, output_index, input_index):
9✔
678
        label = ""              # start with an empty label
9✔
679

680
        # Add the output name if it won't appear as an axes label
681
        if noutputs > 1 and overlay_outputs:
9✔
682
            label += response.output_labels[output_index]
9✔
683

684
        # Add the input name if it won't appear as a column label
685
        if ninputs > 1 and overlay_inputs:
9✔
686
            label += ", " if label != "" else ""
9✔
687
            label += response.input_labels[input_index]
9✔
688

689
        # Add the system name (will strip off later if redundant)
690
        label += ", " if label != "" else ""
9✔
691
        label += f"{response.sysname}"
9✔
692

693
        return label
9✔
694

695
    for index, response in enumerate(data):
9✔
696
        # Get the (pre-processed) data in fully indexed form
697
        mag = mag_data[index]
9✔
698
        phase = phase_data[index]
9✔
699
        omega_sys, sysname = omega_data[index], response.sysname
9✔
700

701
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
702
            # Get the axes to use for magnitude and phase
703
            ax_mag = ax_array[mag_map[i, j]]
9✔
704
            ax_phase = ax_array[phase_map[i, j]]
9✔
705

706
            # Get the frequencies and convert to Hz, if needed
707
            omega_plot = omega_sys / (2 * math.pi) if Hz else omega_sys
9✔
708
            if response.isdtime(strict=True):
9✔
709
                nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
710

711
            # Save the magnitude and phase to plot
712
            mag_plot = 20 * np.log10(mag[i, j]) if dB else mag[i, j]
9✔
713
            phase_plot = phase[i, j] * 180. / math.pi if deg else phase[i, j]
9✔
714

715
            # Generate a label
716
            if line_labels is None:
9✔
717
                label = _make_line_label(response, i, j)
9✔
718
            else:
719
                label = line_labels[index, i, j]
9✔
720

721
            # Magnitude
722
            if plot_magnitude:
9✔
723
                pltfcn = ax_mag.semilogx if dB else ax_mag.loglog
9✔
724

725
                # Plot the main data
726
                lines = pltfcn(
9✔
727
                    omega_plot, mag_plot, *fmt, label=label, **kwargs)
728
                out[mag_map[i, j]] += lines
9✔
729

730
                # Save the information needed for the Nyquist line
731
                if response.isdtime(strict=True):
9✔
732
                    ax_mag.axvline(
9✔
733
                        nyq_freq, color=lines[0].get_color(), linestyle='--',
734
                        label='_nyq_mag_' + sysname)
735

736
                # Add a grid to the plot
737
                ax_mag.grid(grid, which='both')
9✔
738

739
            # Phase
740
            if plot_phase:
9✔
741
                lines = ax_phase.semilogx(
9✔
742
                    omega_plot, phase_plot, *fmt, label=label, **kwargs)
743
                out[phase_map[i, j]] += lines
9✔
744

745
                # Save the information needed for the Nyquist line
746
                if response.isdtime(strict=True):
9✔
747
                    ax_phase.axvline(
9✔
748
                        nyq_freq, color=lines[0].get_color(), linestyle='--',
749
                        label='_nyq_phase_' + sysname)
750

751
                # Add a grid to the plot
752
                ax_phase.grid(grid, which='both')
9✔
753

754
        #
755
        # Display gain and phase margins (SISO only)
756
        #
757

758
        if display_margins:
9✔
759
            if ninputs > 1 or noutputs > 1:
9✔
760
                raise NotImplementedError(
761
                    "margins are not available for MIMO systems")
762

763
            if display_margins == 'overlay' and len(data) > 1:
9✔
764
                raise NotImplementedError(
765
                    f"{display_margins=} not supported for multi-trace plots")
766

767
            # Compute stability margins for the system
768
            margins = stability_margins(response, method=margins_method)
9✔
769
            gm, pm, Wcg, Wcp = (margins[i] for i in [0, 1, 3, 4])
9✔
770

771
            # Figure out sign of the phase at the first gain crossing
772
            # (needed if phase_wrap is True)
773
            phase_at_cp = phase[
9✔
774
                0, 0, (np.abs(omega_data[0] - Wcp)).argmin()]
775
            if phase_at_cp >= 0.:
9✔
776
                phase_limit = 180.
9✔
777
            else:
778
                phase_limit = -180.
9✔
779

780
            if Hz:
9✔
781
                Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi)
9✔
782

783
            # Draw lines at gain and phase limits
784
            if plot_magnitude:
9✔
785
                ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':',
9✔
786
                               zorder=-20)
787

788
            if plot_phase:
9✔
789
                ax_phase.axhline(y=phase_limit if deg else
9✔
790
                                 math.radians(phase_limit),
791
                                 color='k', linestyle=':', zorder=-20)
792

793
            # Annotate the phase margin (if it exists)
794
            if plot_phase and pm != float('inf') and Wcp != float('nan'):
9✔
795
                # Draw dotted lines marking the gain crossover frequencies
796
                if plot_magnitude:
9✔
797
                    ax_mag.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
798
                ax_phase.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
799

800
                # Draw solid segments indicating the margins
801
                if deg:
9✔
802
                    ax_phase.semilogx(
9✔
803
                        [Wcp, Wcp], [phase_limit + pm, phase_limit],
804
                        color='k', zorder=-20)
805
                else:
806
                    ax_phase.semilogx(
9✔
807
                        [Wcp, Wcp], [math.radians(phase_limit) +
808
                                     math.radians(pm),
809
                                     math.radians(phase_limit)],
810
                        color='k', zorder=-20)
811

812
            # Annotate the gain margin (if it exists)
813
            if plot_magnitude and gm != float('inf') and \
9✔
814
               Wcg != float('nan'):
815
                # Draw dotted lines marking the phase crossover frequencies
816
                ax_mag.axvline(Wcg, color='k', linestyle=':', zorder=-30)
9✔
817
                if plot_phase:
9✔
818
                    ax_phase.axvline(Wcg, color='k', linestyle=':', zorder=-30)
9✔
819

820
                # Draw solid segments indicating the margins
821
                if dB:
9✔
822
                    ax_mag.semilogx(
9✔
823
                        [Wcg, Wcg], [0, -20*np.log10(gm)],
824
                        color='k', zorder=-20)
825
                else:
826
                    ax_mag.loglog(
9✔
827
                        [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20)
828

829
            if display_margins == 'overlay':
9✔
830
                # TODO: figure out how to handle case of multiple lines
831
                # Put the margin information in the lower left corner
832
                if plot_magnitude:
9✔
833
                    ax_mag.text(
9✔
834
                        0.04, 0.06,
835
                        'G.M.: %.2f %s\nFreq: %.2f %s' %
836
                        (20*np.log10(gm) if dB else gm,
837
                         'dB ' if dB else '',
838
                         Wcg, 'Hz' if Hz else 'rad/s'),
839
                        horizontalalignment='left',
840
                        verticalalignment='bottom',
841
                        transform=ax_mag.transAxes,
842
                        fontsize=8 if int(mpl.__version__[0]) == 1 else 6)
843

844
                if plot_phase:
9✔
845
                    ax_phase.text(
9✔
846
                        0.04, 0.06,
847
                        'P.M.: %.2f %s\nFreq: %.2f %s' %
848
                        (pm if deg else math.radians(pm),
849
                         'deg' if deg else 'rad',
850
                         Wcp, 'Hz' if Hz else 'rad/s'),
851
                        horizontalalignment='left',
852
                        verticalalignment='bottom',
853
                        transform=ax_phase.transAxes,
854
                        fontsize=8 if int(mpl.__version__[0]) == 1 else 6)
855

856
            else:
857
                # Put the title underneath the suptitle (one line per system)
858
                ax_ = ax_mag if ax_mag else ax_phase
9✔
859
                axes_title = ax_.get_title()
9✔
860
                if axes_title is not None and axes_title != "":
9✔
861
                    axes_title += "\n"
9✔
862
                with plt.rc_context(rcParams):
9✔
863
                    ax_.set_title(
9✔
864
                        axes_title + f"{sysname}: "
865
                        "Gm = %.2f %s(at %.2f %s), "
866
                        "Pm = %.2f %s (at %.2f %s)" %
867
                        (20*np.log10(gm) if dB else gm,
868
                         'dB ' if dB else '',
869
                         Wcg, 'Hz' if Hz else 'rad/s',
870
                         pm if deg else math.radians(pm),
871
                         'deg' if deg else 'rad',
872
                         Wcp, 'Hz' if Hz else 'rad/s'))
873

874
    #
875
    # Finishing handling axes limit sharing
876
    #
877
    # This code handles labels on Bode plots and also removes tick labels
878
    # on shared axes.  It needs to come *after* the plots are generated,
879
    # in order to handle two things:
880
    #
881
    # * manually generated labels and grids need to reflect the limits for
882
    #   shared axes, which we don't know until we have plotted everything;
883
    #
884
    # * the loglog and semilog functions regenerate the labels (not quite
885
    #   sure why, since using sharex and sharey in subplots does not have
886
    #   this behavior).
887
    #
888
    # Note: as before, if the various share_* keywords are None then a
889
    # previous set of axes are available and no updates are made. (TODO: true?)
890
    #
891

892
    for i in range(noutputs):
9✔
893
        for j in range(ninputs):
9✔
894
            # Utility function to generate phase labels
895
            def gen_zero_centered_series(val_min, val_max, period):
9✔
896
                v1 = np.ceil(val_min / period - 0.2)
9✔
897
                v2 = np.floor(val_max / period + 0.2)
9✔
898
                return np.arange(v1, v2 + 1) * period
9✔
899

900
            # Label the phase axes using multiples of 45 degrees
901
            if plot_phase:
9✔
902
                ax_phase = ax_array[phase_map[i, j]]
9✔
903

904
                # Set the labels
905
                if deg:
9✔
906
                    ylim = ax_phase.get_ylim()
9✔
907
                    num = np.floor((ylim[1] - ylim[0]) / 45)
9✔
908
                    factor = max(1, np.round(num / (32 / nrows)) * 2)
9✔
909
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
910
                        ylim[0], ylim[1], 45 * factor))
911
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
912
                        ylim[0], ylim[1], 15 * factor), minor=True)
913
                else:
914
                    ylim = ax_phase.get_ylim()
9✔
915
                    num = np.ceil((ylim[1] - ylim[0]) / (math.pi/4))
9✔
916
                    factor = max(1, np.round(num / (36 / nrows)) * 2)
9✔
917
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
918
                        ylim[0], ylim[1], math.pi / 4. * factor))
919
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
920
                        ylim[0], ylim[1], math.pi / 12. * factor), minor=True)
921

922
    # Turn off y tick labels for shared axes
923
    for i in range(0, noutputs):
9✔
924
        for j in range(1, ncols):
9✔
925
            if share_magnitude in [True, 'all', 'row']:
9✔
926
                ax_array[mag_map[i, j]].tick_params(labelleft=False)
9✔
927
            if share_phase in [True, 'all', 'row']:
9✔
928
                ax_array[phase_map[i, j]].tick_params(labelleft=False)
9✔
929

930
    # Turn off x tick labels for shared axes
931
    for i in range(0, nrows-1):
9✔
932
        for j in range(0, ncols):
9✔
933
            if share_frequency in [True, 'all', 'col']:
9✔
934
                ax_array[i, j].tick_params(labelbottom=False)
9✔
935

936
    # If specific omega_limits were given, use them
937
    if omega_limits is not None:
9✔
938
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
939
            ax_array[i, j].set_xlim(omega_limits)
9✔
940

941
    #
942
    # Label the axes (including header labels)
943
    #
944
    # Once the data are plotted, we label the axes.  The horizontal axes is
945
    # always frequency and this is labeled only on the bottom most row.  The
946
    # vertical axes can consist either of a single signal or a combination
947
    # of signals (when overlay_inputs or overlay_outputs is True)
948
    #
949
    # Input/output signals are give at the top of columns and left of rows
950
    # when these are individually plotted.
951
    #
952

953
    # Label the columns (do this first to get row labels in the right spot)
954
    for j in range(ncols):
9✔
955
        # If we have more than one column, label the individual responses
956
        if (noutputs > 1 and not overlay_outputs or ninputs > 1) \
9✔
957
           and not overlay_inputs:
958
            with plt.rc_context(rcParams):
9✔
959
                ax_array[0, j].set_title(f"From {data[0].input_labels[j]}")
9✔
960

961
        # Label the frequency axis
962
        ax_array[-1, j].set_xlabel(
9✔
963
            freq_label.format(units="Hz" if Hz else "rad/s"))
964

965
    # Label the rows
966
    for i in range(noutputs if not overlay_outputs else 1):
9✔
967
        if plot_magnitude:
9✔
968
            ax_mag = ax_array[mag_map[i, 0]]
9✔
969
            ax_mag.set_ylabel(magnitude_label)
9✔
970
        if plot_phase:
9✔
971
            ax_phase = ax_array[phase_map[i, 0]]
9✔
972
            ax_phase.set_ylabel(phase_label)
9✔
973

974
        if (noutputs > 1 or ninputs > 1) and not overlay_outputs:
9✔
975
            if plot_magnitude and plot_phase:
9✔
976
                # Get existing ylabel for left column and add a blank line
977
                ax_mag.set_ylabel("\n" + ax_mag.get_ylabel())
9✔
978
                ax_phase.set_ylabel("\n" + ax_phase.get_ylabel())
9✔
979

980
                # Find the midpoint between the row axes (+ tight_layout)
981
                _, ypos = _find_axes_center(fig, [ax_mag, ax_phase])
9✔
982

983
                # Get the bounding box including the labels
984
                inv_transform = fig.transFigure.inverted()
9✔
985
                mag_bbox = inv_transform.transform(
9✔
986
                    ax_mag.get_tightbbox(fig.canvas.get_renderer()))
987

988
                # Figure out location for text (center left in figure frame)
989
                xpos = mag_bbox[0, 0]               # left edge
9✔
990

991
                # Put a centered label as text outside the box
992
                fig.text(
9✔
993
                    0.8 * xpos, ypos, f"To {data[0].output_labels[i]}\n",
994
                    rotation=90, ha='left', va='center',
995
                    fontsize=rcParams['axes.titlesize'])
996
            else:
997
                # Only a single axes => add label to the left
998
                ax_array[i, 0].set_ylabel(
9✔
999
                    f"To {data[0].output_labels[i]}\n" +
1000
                    ax_array[i, 0].get_ylabel())
1001

1002
    #
1003
    # Update the plot title (= figure suptitle)
1004
    #
1005
    # If plots are built up by multiple calls to plot() and the title is
1006
    # not given, then the title is updated to provide a list of unique text
1007
    # items in each successive title.  For data generated by the frequency
1008
    # response function this will generate a common prefix followed by a
1009
    # list of systems (e.g., "Step response for sys[1], sys[2]").
1010
    #
1011

1012
    # Set initial title for the data (unique system names, preserving order)
1013
    seen = set()
9✔
1014
    sysnames = [response.sysname for response in data if not
9✔
1015
                (response.sysname in seen or seen.add(response.sysname))]
1016

1017
    if ax is None and title is None:
9✔
1018
        if data[0].title is None:
9✔
1019
            title = "Bode plot for " + ", ".join(sysnames)
9✔
1020
        else:
1021
            # Allow data to set the title (used by gangof4)
1022
            title = data[0].title
9✔
1023
        _update_plot_title(title, fig, rcParams=rcParams, frame=title_frame)
9✔
1024
    elif ax is None:
9✔
1025
        _update_plot_title(
9✔
1026
            title, fig=fig, rcParams=rcParams, frame=title_frame,
1027
            use_existing=False)
1028

1029
    #
1030
    # Create legends
1031
    #
1032
    # Legends can be placed manually by passing a legend_map array that
1033
    # matches the shape of the sublots, with each item being a string
1034
    # indicating the location of the legend for that axes (or None for no
1035
    # legend).
1036
    #
1037
    # If no legend spec is passed, a minimal number of legends are used so
1038
    # that each line in each axis can be uniquely identified.  The details
1039
    # depends on the various plotting parameters, but the general rule is
1040
    # to place legends in the top row and right column.
1041
    #
1042
    # Because plots can be built up by multiple calls to plot(), the legend
1043
    # strings are created from the line labels manually.  Thus an initial
1044
    # call to plot() may not generate any legends (e.g., if no signals are
1045
    # overlaid), but subsequent calls to plot() will need a legend for each
1046
    # different response (system).
1047
    #
1048

1049
    # Create axis legends
1050
    if show_legend != False:
9✔
1051
        # Figure out where to put legends
1052
        if legend_map is None:
9✔
1053
            legend_map = np.full(ax_array.shape, None, dtype=object)
9✔
1054
            legend_map[0, -1] = legend_loc
9✔
1055

1056
        legend_array = np.full(ax_array.shape, None, dtype=object)
9✔
1057
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
1058
            if legend_map[i, j] is None:
9✔
1059
                continue
9✔
1060
            ax = ax_array[i, j]
9✔
1061

1062
            # Get the labels to use, removing common strings
1063
            lines = [line for line in ax.get_lines()
9✔
1064
                     if line.get_label()[0] != '_']
1065
            labels = _make_legend_labels(
9✔
1066
                [line.get_label() for line in lines],
1067
                ignore_common=line_labels is not None)
1068

1069
            # Generate the label, if needed
1070
            if show_legend == True or len(labels) > 1:
9✔
1071
                with plt.rc_context(rcParams):
9✔
1072
                    legend_array[i, j] = ax.legend(
9✔
1073
                        lines, labels, loc=legend_map[i, j])
1074
    else:
1075
        legend_array = None
9✔
1076

1077
    #
1078
    # Legacy return processing
1079
    #
1080
    if plot is True:            # legacy usage; remove in future release
9✔
1081
        # Process the data to match what we were sent
1082
        for i in range(len(mag_data)):
9✔
1083
            mag_data[i] = _process_frequency_response(
9✔
1084
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
1085
            phase_data[i] = _process_frequency_response(
9✔
1086
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
1087

1088
        if len(data) == 1:
9✔
1089
            return mag_data[0], phase_data[0], omega_data[0]
9✔
1090
        else:
1091
            return mag_data, phase_data, omega_data
9✔
1092

1093
    return ControlPlot(out, ax_array, fig, legend=legend_array)
9✔
1094

1095

1096
#
1097
# Nyquist plot
1098
#
1099

1100
# Default values for module parameter variables
1101
_nyquist_defaults = {
9✔
1102
    'nyquist.primary_style': ['-', '-.'],       # style for primary curve
1103
    'nyquist.mirror_style': ['--', ':'],        # style for mirror curve
1104
    'nyquist.arrows': 3,                        # number of arrows around curve
1105
    'nyquist.arrow_size': 8,                    # pixel size for arrows
1106
    'nyquist.encirclement_threshold': 0.05,     # warning threshold
1107
    'nyquist.indent_radius': 1e-4,              # indentation radius
1108
    'nyquist.indent_direction': 'right',        # indentation direction
1109
    'nyquist.indent_points': 200,               # number of points to insert
1110
    'nyquist.max_curve_magnitude': 15,          # rescale large values
1111
    'nyquist.blend_fraction': 0.15,             # when to start scaling
1112
    'nyquist.max_curve_offset': 0.02,           # offset of primary/mirror
1113
    'nyquist.start_marker': 'o',                # marker at start of curve
1114
    'nyquist.start_marker_size': 4,             # size of the marker
1115
    'nyquist.circle_style':                     # style for unit circles
1116
      {'color': 'black', 'linestyle': 'dashed', 'linewidth': 1}
1117
}
1118

1119

1120
class NyquistResponseData:
9✔
1121
    """Nyquist response data object.
1122

1123
    Nyquist contour analysis allows the stability and robustness of a
1124
    closed loop linear system to be evaluated using the open loop response
1125
    of the loop transfer function.  The NyquistResponseData class is used
1126
    by the `nyquist_response` function to return the
1127
    response of a linear system along the Nyquist 'D' contour.  The
1128
    response object can be used to obtain information about the Nyquist
1129
    response or to generate a Nyquist plot.
1130

1131
    Parameters
1132
    ----------
1133
    count : integer
1134
        Number of encirclements of the -1 point by the Nyquist curve for
1135
        a system evaluated along the Nyquist contour.
1136
    contour : complex array
1137
        The Nyquist 'D' contour, with appropriate indentations to avoid
1138
        open loop poles and zeros near/on the imaginary axis.
1139
    response : complex array
1140
        The value of the linear system under study along the Nyquist contour.
1141
    dt : None or float
1142
        The system timebase.
1143
    sysname : str
1144
        The name of the system being analyzed.
1145
    return_contour : bool
1146
        If True, when the object is accessed as an iterable return two
1147
        elements: `count` (number of encirclements) and `contour`.  If
1148
        False (default), then return only `count`.
1149

1150
    """
1151
    def __init__(
9✔
1152
            self, count, contour, response, dt, sysname=None,
1153
            return_contour=False):
1154
        self.count = count
9✔
1155
        self.contour = contour
9✔
1156
        self.response = response
9✔
1157
        self.dt = dt
9✔
1158
        self.sysname = sysname
9✔
1159
        self.return_contour = return_contour
9✔
1160

1161
    # Implement iter to allow assigning to a tuple
1162
    def __iter__(self):
9✔
1163
        if self.return_contour:
9✔
1164
            return iter((self.count, self.contour))
9✔
1165
        else:
1166
            return iter((self.count, ))
9✔
1167

1168
    # Implement (thin) getitem to allow access via legacy indexing
1169
    def __getitem__(self, index):
9✔
1170
        return list(self.__iter__())[index]
×
1171

1172
    # Implement (thin) len to emulate legacy testing interface
1173
    def __len__(self):
9✔
1174
        return 2 if self.return_contour else 1
9✔
1175

1176
    def plot(self, *args, **kwargs):
9✔
1177
        """Plot a list of Nyquist responses.
1178

1179
        See `nyquist_plot` for details.
1180

1181
        """
1182
        return nyquist_plot(self, *args, **kwargs)
9✔
1183

1184

1185
class NyquistResponseList(list):
9✔
1186
    """List of NyquistResponseData objects with plotting capability.
1187

1188
    This class consists of a list of `NyquistResponseData` objects.
1189
    It is a subclass of the Python `list` class, with a `plot` method that
1190
    plots the individual `NyquistResponseData` objects.
1191

1192
    """
1193
    def plot(self, *args, **kwargs):
9✔
1194
        """Plot a list of Nyquist responses.
1195

1196
        See `nyquist_plot` for details.
1197

1198
        """
1199
        return nyquist_plot(self, *args, **kwargs)
9✔
1200

1201

1202
def nyquist_response(
9✔
1203
        sysdata, omega=None, omega_limits=None, omega_num=None,
1204
        return_contour=False, warn_encirclements=True, warn_nyquist=True,
1205
        _kwargs=None, _check_kwargs=True, **kwargs):
1206
    """Nyquist response for a system.
1207

1208
    Computes a Nyquist contour for the system over a (optional) frequency
1209
    range and evaluates the number of net encirclements.  The curve is
1210
    computed by evaluating the Nyquist segment along the positive imaginary
1211
    axis, with a mirror image generated to reflect the negative imaginary
1212
    axis.  Poles on or near the imaginary axis are avoided using a small
1213
    indentation.  The portion of the Nyquist contour at infinity is not
1214
    explicitly computed (since it maps to a constant value for any system
1215
    with a proper transfer function).
1216

1217
    Parameters
1218
    ----------
1219
    sysdata : LTI or list of LTI
1220
        List of linear input/output systems (single system is OK). Nyquist
1221
        curves for each system are plotted on the same graph.
1222
    omega : array_like, optional
1223
        Set of frequencies to be evaluated, in rad/sec.
1224

1225
    Returns
1226
    -------
1227
    responses : list of `NyquistResponseData`
1228
        For each system, a Nyquist response data object is returned.  If
1229
        `sysdata` is a single system, a single element is returned (not a
1230
        list).
1231
    response.count : int
1232
        Number of encirclements of the point -1 by the Nyquist curve.  If
1233
        multiple systems are given, an array of counts is returned.
1234
    response.contour : ndarray
1235
        The contour used to create the primary Nyquist curve segment.  To
1236
        obtain the Nyquist curve values, evaluate system(s) along contour.
1237

1238
    Other Parameters
1239
    ----------------
1240
    encirclement_threshold : float, optional
1241
        Define the threshold for generating a warning if the number of net
1242
        encirclements is a non-integer value.  Default value is 0.05 and can
1243
        be set using `config.defaults['nyquist.encirclement_threshold']`.
1244
    indent_direction : str, optional
1245
        For poles on the imaginary axis, set the direction of indentation to
1246
        be 'right' (default), 'left', or 'none'.  The default value can
1247
        be set using `config.defaults['nyquist.indent_direction']`.
1248
    indent_points : int, optional
1249
        Number of points to insert in the Nyquist contour around poles that
1250
        are at or near the imaginary axis.
1251
    indent_radius : float, optional
1252
        Amount to indent the Nyquist contour around poles on or near the
1253
        imaginary axis. Portions of the Nyquist plot corresponding to
1254
        indented portions of the contour are plotted using a different line
1255
        style. The default value can be set using
1256
        `config.defaults['nyquist.indent_radius']`.
1257
    omega_limits : array_like of two values
1258
        Set limits for plotted frequency range. If Hz=True the limits are
1259
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
1260
        elements is equivalent to providing `omega_limits`.
1261
    omega_num : int, optional
1262
        Number of samples to use for the frequency range.  Defaults to
1263
        `config.defaults['freqplot.number_of_samples']`.
1264
    warn_nyquist : bool, optional
1265
        If set to False, turn off warnings about frequencies above Nyquist.
1266
    warn_encirclements : bool, optional
1267
        If set to False, turn off warnings about number of encirclements not
1268
        meeting the Nyquist criterion.
1269

1270
    Notes
1271
    -----
1272
    If a discrete-time model is given, the frequency response is computed
1273
    along the upper branch of the unit circle, using the mapping ``z =
1274
    exp(1j * omega * dt)`` where `omega` ranges from 0 to pi/`dt` and
1275
    `dt` is the discrete timebase.  If timebase not specified
1276
    (`dt` = True), `dt` is set to 1.
1277

1278
    If a continuous-time system contains poles on or near the imaginary
1279
    axis, a small indentation will be used to avoid the pole.  The radius
1280
    of the indentation is given by `indent_radius` and it is taken to the
1281
    right of stable poles and the left of unstable poles.  If a pole is
1282
    exactly on the imaginary axis, the `indent_direction` parameter can be
1283
    used to set the direction of indentation.  Setting `indent_direction`
1284
    to 'none' will turn off indentation.
1285

1286
    For those portions of the Nyquist plot in which the contour is indented
1287
    to avoid poles, resulting in a scaling of the Nyquist plot, the line
1288
    styles are according to the settings of the `primary_style` and
1289
    `mirror_style` keywords.  By default the scaled portions of the primary
1290
    curve use a dotted line style and the scaled portion of the mirror
1291
    image use a dashdot line style.
1292

1293
    If the legacy keyword `return_contour` is specified as True, the
1294
    response object can be iterated over to return ``(count, contour)``.
1295
    This behavior is deprecated and will be removed in a future release.
1296

1297
    See Also
1298
    --------
1299
    nyquist_plot
1300

1301
    Examples
1302
    --------
1303
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1304
    >>> response = ct.nyquist_response(G)
1305
    >>> count = response.count
1306
    >>> cplt = response.plot()
1307

1308
    """
1309
    # Create unified list of keyword arguments
1310
    if _kwargs is None:
9✔
1311
        _kwargs = kwargs
9✔
1312
    else:
1313
        # Use existing dictionary, to keep track of processed keywords
1314
        _kwargs |= kwargs
9✔
1315

1316
    # Get values for params
1317
    omega_num_given = omega_num is not None
9✔
1318
    omega_num = config._get_param('freqplot', 'number_of_samples', omega_num)
9✔
1319
    indent_radius = config._get_param(
9✔
1320
        'nyquist', 'indent_radius', _kwargs, _nyquist_defaults, pop=True)
1321
    encirclement_threshold = config._get_param(
9✔
1322
        'nyquist', 'encirclement_threshold', _kwargs,
1323
        _nyquist_defaults, pop=True)
1324
    indent_direction = config._get_param(
9✔
1325
        'nyquist', 'indent_direction', _kwargs, _nyquist_defaults, pop=True)
1326
    indent_points = config._get_param(
9✔
1327
        'nyquist', 'indent_points', _kwargs, _nyquist_defaults, pop=True)
1328

1329
    if _check_kwargs and _kwargs:
9✔
1330
        raise TypeError("unrecognized keywords: ", str(_kwargs))
9✔
1331

1332
    # Convert the first argument to a list
1333
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
1334

1335
    # Determine the range of frequencies to use, based on args/features
1336
    omega, omega_range_given = _determine_omega_vector(
9✔
1337
        syslist, omega, omega_limits, omega_num, feature_periphery_decades=2)
1338

1339
    # If omega was not specified explicitly, start at omega = 0
1340
    if not omega_range_given:
9✔
1341
        if omega_num_given:
9✔
1342
            # Just reset the starting point
1343
            omega[0] = 0.0
9✔
1344
        else:
1345
            # Insert points between the origin and the first frequency point
1346
            omega = np.concatenate((
9✔
1347
                np.linspace(0, omega[0], indent_points), omega[1:]))
1348

1349
    # Go through each system and keep track of the results
1350
    responses = []
9✔
1351
    for idx, sys in enumerate(syslist):
9✔
1352
        if not sys.issiso():
9✔
1353
            # TODO: Add MIMO nyquist plots.
1354
            raise ControlMIMONotImplemented(
9✔
1355
                "Nyquist plot currently only supports SISO systems.")
1356

1357
        # Figure out the frequency range
1358
        if isinstance(sys, FrequencyResponseData) and sys._ifunc is None \
9✔
1359
           and not omega_range_given:
1360
            omega_sys = sys.omega               # use system frequencies
9✔
1361
        else:
1362
            omega_sys = np.asarray(omega)       # use common omega vector
9✔
1363

1364
        # Determine the contour used to evaluate the Nyquist curve
1365
        if sys.isdtime(strict=True):
9✔
1366
            # Restrict frequencies for discrete-time systems
1367
            nyq_freq = math.pi / sys.dt
9✔
1368
            if not omega_range_given:
9✔
1369
                # limit up to and including Nyquist frequency
1370
                omega_sys = np.hstack((
9✔
1371
                    omega_sys[omega_sys < nyq_freq], nyq_freq))
1372

1373
            # Issue a warning if we are sampling above Nyquist
1374
            if np.any(omega_sys * sys.dt > np.pi) and warn_nyquist:
9✔
1375
                warnings.warn("evaluation above Nyquist frequency")
9✔
1376

1377
        # do indentations in s-plane where it is more convenient
1378
        splane_contour = 1j * omega_sys
9✔
1379

1380
        # Bend the contour around any poles on/near the imaginary axis
1381
        if isinstance(sys, (StateSpace, TransferFunction)) \
9✔
1382
                and indent_direction != 'none':
1383
            if sys.isctime():
9✔
1384
                splane_poles = sys.poles()
9✔
1385
                splane_cl_poles = sys.feedback().poles()
9✔
1386
            else:
1387
                # map z-plane poles to s-plane. We ignore any at the origin
1388
                # to avoid numerical warnings because we know we
1389
                # don't need to indent for them
1390
                zplane_poles = sys.poles()
9✔
1391
                zplane_poles = zplane_poles[~np.isclose(abs(zplane_poles), 0.)]
9✔
1392
                splane_poles = np.log(zplane_poles) / sys.dt
9✔
1393

1394
                zplane_cl_poles = sys.feedback().poles()
9✔
1395
                # eliminate z-plane poles at the origin to avoid warnings
1396
                zplane_cl_poles = zplane_cl_poles[
9✔
1397
                    ~np.isclose(abs(zplane_cl_poles), 0.)]
1398
                splane_cl_poles = np.log(zplane_cl_poles) / sys.dt
9✔
1399

1400
            #
1401
            # Check to make sure indent radius is small enough
1402
            #
1403
            # If there is a closed loop pole that is near the imaginary axis
1404
            # at a point that is near an open loop pole, it is possible that
1405
            # indentation might skip or create an extraneous encirclement.
1406
            # We check for that situation here and generate a warning if that
1407
            # could happen.
1408
            #
1409
            for p_cl in splane_cl_poles:
9✔
1410
                # See if any closed loop poles are near the imaginary axis
1411
                if abs(p_cl.real) <= indent_radius:
9✔
1412
                    # See if any open loop poles are close to closed loop poles
1413
                    if len(splane_poles) > 0:
9✔
1414
                        p_ol = splane_poles[
9✔
1415
                            (np.abs(splane_poles - p_cl)).argmin()]
1416

1417
                        if abs(p_ol - p_cl) <= indent_radius and \
9✔
1418
                                warn_encirclements:
1419
                            warnings.warn(
9✔
1420
                                "indented contour may miss closed loop pole; "
1421
                                "consider reducing indent_radius to below "
1422
                                f"{abs(p_ol - p_cl):5.2g}", stacklevel=2)
1423

1424
            #
1425
            # See if we should add some frequency points near imaginary poles
1426
            #
1427
            for p in splane_poles:
9✔
1428
                # See if we need to process this pole (skip if on the negative
1429
                # imaginary axis or not near imaginary axis + user override)
1430
                if p.imag < 0 or abs(p.real) > indent_radius or \
9✔
1431
                   omega_range_given:
1432
                    continue
9✔
1433

1434
                # Find the frequencies before the pole frequency
1435
                below_points = np.argwhere(
9✔
1436
                    splane_contour.imag - abs(p.imag) < -indent_radius)
1437
                if below_points.size > 0:
9✔
1438
                    first_point = below_points[-1].item()
9✔
1439
                    start_freq = p.imag - indent_radius
9✔
1440
                else:
1441
                    # Add the points starting at the beginning of the contour
1442
                    assert splane_contour[0] == 0
9✔
1443
                    first_point = 0
9✔
1444
                    start_freq = 0
9✔
1445

1446
                # Find the frequencies after the pole frequency
1447
                above_points = np.argwhere(
9✔
1448
                    splane_contour.imag - abs(p.imag) > indent_radius)
1449
                last_point = above_points[0].item()
9✔
1450

1451
                # Add points for half/quarter circle around pole frequency
1452
                # (these will get indented left or right below)
1453
                splane_contour = np.concatenate((
9✔
1454
                    splane_contour[0:first_point+1],
1455
                    (1j * np.linspace(
1456
                        start_freq, p.imag + indent_radius, indent_points)),
1457
                    splane_contour[last_point:]))
1458

1459
            # Indent points that are too close to a pole
1460
            if len(splane_poles) > 0: # accommodate no splane poles if dtime sys
9✔
1461
                for i, s in enumerate(splane_contour):
9✔
1462
                    # Find the nearest pole
1463
                    p = splane_poles[(np.abs(splane_poles - s)).argmin()]
9✔
1464

1465
                    # See if we need to indent around it
1466
                    if abs(s - p) < indent_radius:
9✔
1467
                        # Figure out how much to offset (simple trigonometry)
1468
                        offset = np.sqrt(
9✔
1469
                            indent_radius ** 2 - (s - p).imag ** 2) \
1470
                            - (s - p).real
1471

1472
                        # Figure out which way to offset the contour point
1473
                        if p.real < 0 or (p.real == 0 and
9✔
1474
                                        indent_direction == 'right'):
1475
                            # Indent to the right
1476
                            splane_contour[i] += offset
9✔
1477

1478
                        elif p.real > 0 or (p.real == 0 and
9✔
1479
                                            indent_direction == 'left'):
1480
                            # Indent to the left
1481
                            splane_contour[i] -= offset
9✔
1482

1483
                        else:
1484
                            raise ValueError(
9✔
1485
                                "unknown value for indent_direction")
1486

1487
        # change contour to z-plane if necessary
1488
        if sys.isctime():
9✔
1489
            contour = splane_contour
9✔
1490
        else:
1491
            contour = np.exp(splane_contour * sys.dt)
9✔
1492

1493
        # Make sure we don't try to evaluate at a pole
1494
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1495
            if any([pole in contour for pole in sys.poles()]):
9✔
1496
                raise RuntimeError(
9✔
1497
                    "attempt to evaluate at a pole; indent required")
1498

1499
        # Compute the primary curve
1500
        resp = sys(contour)
9✔
1501

1502
        # Compute CW encirclements of -1 by integrating the (unwrapped) angle
1503
        phase = -unwrap(np.angle(resp + 1))
9✔
1504
        encirclements = np.sum(np.diff(phase)) / np.pi
9✔
1505
        count = int(np.round(encirclements, 0))
9✔
1506

1507
        # Let the user know if the count might not make sense
1508
        if abs(encirclements - count) > encirclement_threshold and \
9✔
1509
           warn_encirclements:
1510
            warnings.warn(
9✔
1511
                "number of encirclements was a non-integer value; this can"
1512
                " happen is contour is not closed, possibly based on a"
1513
                " frequency range that does not include zero.")
1514

1515
        #
1516
        # Make sure that the encirclements match the Nyquist criterion
1517
        #
1518
        # If the user specifies the frequency points to use, it is possible
1519
        # to miss encirclements, so we check here to make sure that the
1520
        # Nyquist criterion is actually satisfied.
1521
        #
1522
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1523
            # Count the number of open/closed loop RHP poles
1524
            if sys.isctime():
9✔
1525
                if indent_direction == 'right':
9✔
1526
                    P = (sys.poles().real > 0).sum()
9✔
1527
                else:
1528
                    P = (sys.poles().real >= 0).sum()
9✔
1529
                Z = (sys.feedback().poles().real >= 0).sum()
9✔
1530
            else:
1531
                if indent_direction == 'right':
9✔
1532
                    P = (np.abs(sys.poles()) > 1).sum()
9✔
1533
                else:
1534
                    P = (np.abs(sys.poles()) >= 1).sum()
×
1535
                Z = (np.abs(sys.feedback().poles()) >= 1).sum()
9✔
1536

1537
            # Check to make sure the results make sense; warn if not
1538
            if Z != count + P and warn_encirclements:
9✔
1539
                warnings.warn(
9✔
1540
                    "number of encirclements does not match Nyquist criterion;"
1541
                    " check frequency range and indent radius/direction",
1542
                    UserWarning, stacklevel=2)
1543
            elif indent_direction == 'none' and any(sys.poles().real == 0) \
9✔
1544
                 and warn_encirclements:
1545
                warnings.warn(
×
1546
                    "system has pure imaginary poles but indentation is"
1547
                    " turned off; results may be meaningless",
1548
                    RuntimeWarning, stacklevel=2)
1549

1550
        # Decide on system name
1551
        sysname = sys.name if sys.name is not None else f"Unknown-{idx}"
9✔
1552

1553
        responses.append(NyquistResponseData(
9✔
1554
            count, contour, resp, sys.dt, sysname=sysname,
1555
            return_contour=return_contour))
1556

1557
    if isinstance(sysdata, (list, tuple)):
9✔
1558
        return NyquistResponseList(responses)
9✔
1559
    else:
1560
        return responses[0]
9✔
1561

1562

1563
def nyquist_plot(
9✔
1564
        data, omega=None, plot=None, label_freq=0, color=None, label=None,
1565
        return_contour=None, title=None, ax=None,
1566
        unit_circle=False, mt_circles=None, ms_circles=None, **kwargs):
1567
    """Nyquist plot for a system.
1568

1569
    Generates a Nyquist plot for the system over a (optional) frequency
1570
    range.  The curve is computed by evaluating the Nyquist segment along
1571
    the positive imaginary axis, with a mirror image generated to reflect
1572
    the negative imaginary axis.  Poles on or near the imaginary axis are
1573
    avoided using a small indentation.  The portion of the Nyquist contour
1574
    at infinity is not explicitly computed (since it maps to a constant
1575
    value for any system with a proper transfer function).
1576

1577
    Parameters
1578
    ----------
1579
    data : list of `LTI` or `NyquistResponseData`
1580
        List of linear input/output systems (single system is OK) or
1581
        Nyquist responses (computed using `nyquist_response`).
1582
        Nyquist curves for each system are plotted on the same graph.
1583
    omega : array_like, optional
1584
        Set of frequencies to be evaluated, in rad/sec. Specifying
1585
        `omega` as a list of two elements is equivalent to providing
1586
        `omega_limits`.
1587
    unit_circle : bool, optional
1588
        If True, display the unit circle, to read gain crossover
1589
        frequency.  The circle style is determined by
1590
        `config.defaults['nyquist.circle_style']`.
1591
    mt_circles : array_like, optional
1592
        Draw circles corresponding to the given magnitudes of sensitivity.
1593
    ms_circles : array_like, optional
1594
        Draw circles corresponding to the given magnitudes of complementary
1595
        sensitivity.
1596
    **kwargs : `matplotlib.pyplot.plot` keyword properties, optional
1597
        Additional keywords passed to `matplotlib` to specify line properties.
1598

1599
    Returns
1600
    -------
1601
    cplt : `ControlPlot` object
1602
        Object containing the data that were plotted.  See `ControlPlot`
1603
        for more detailed information.
1604
    cplt.lines : 2D array of `matplotlib.lines.Line2D`
1605
        Array containing information on each line in the plot.  The shape
1606
        of the array is given by (nsys, 4) where nsys is the number of
1607
        systems or Nyquist responses passed to the function.  The second
1608
        index specifies the segment type:
1609

1610
            - lines[idx, 0]: unscaled portion of the primary curve
1611
            - lines[idx, 1]: scaled portion of the primary curve
1612
            - lines[idx, 2]: unscaled portion of the mirror curve
1613
            - lines[idx, 3]: scaled portion of the mirror curve
1614

1615
    cplt.axes : 2D array of `matplotlib.axes.Axes`
1616
        Axes for each subplot.
1617
    cplt.figure : `matplotlib.figure.Figure`
1618
        Figure containing the plot.
1619
    cplt.legend : 2D array of `matplotlib.legend.Legend`
1620
        Legend object(s) contained in the plot.
1621

1622
    Other Parameters
1623
    ----------------
1624
    arrows : int or 1D/2D array of floats, optional
1625
        Specify the number of arrows to plot on the Nyquist curve.  If an
1626
        integer is passed. that number of equally spaced arrows will be
1627
        plotted on each of the primary segment and the mirror image.  If a
1628
        1D array is passed, it should consist of a sorted list of floats
1629
        between 0 and 1, indicating the location along the curve to plot an
1630
        arrow.  If a 2D array is passed, the first row will be used to
1631
        specify arrow locations for the primary curve and the second row
1632
        will be used for the mirror image.  Default value is 2 and can be
1633
        set using `config.defaults['nyquist.arrows']`.
1634
    arrow_size : float, optional
1635
        Arrowhead width and length (in display coordinates).  Default value is
1636
        8 and can be set using `config.defaults['nyquist.arrow_size']`.
1637
    arrow_style : matplotlib.patches.ArrowStyle, optional
1638
        Define style used for Nyquist curve arrows (overrides `arrow_size`).
1639
    ax : `matplotlib.axes.Axes`, optional
1640
        The matplotlib axes to draw the figure on.  If not specified and
1641
        the current figure has a single axes, that axes is used.
1642
        Otherwise, a new figure is created.
1643
    blend_fraction : float, optional
1644
        For portions of the Nyquist curve that are scaled to have a maximum
1645
        magnitude of `max_curve_magnitude`, begin a smooth rescaling at
1646
        this fraction of `max_curve_magnitude`. Default value is 0.15.
1647
    encirclement_threshold : float, optional
1648
        Define the threshold for generating a warning if the number of net
1649
        encirclements is a non-integer value.  Default value is 0.05 and can
1650
        be set using `config.defaults['nyquist.encirclement_threshold']`.
1651
    indent_direction : str, optional
1652
        For poles on the imaginary axis, set the direction of indentation to
1653
        be 'right' (default), 'left', or 'none'.
1654
    indent_points : int, optional
1655
        Number of points to insert in the Nyquist contour around poles that
1656
        are at or near the imaginary axis.
1657
    indent_radius : float, optional
1658
        Amount to indent the Nyquist contour around poles on or near the
1659
        imaginary axis. Portions of the Nyquist plot corresponding to indented
1660
        portions of the contour are plotted using a different line style.
1661
    label : str or array_like of str, optional
1662
        If present, replace automatically generated label(s) with the given
1663
        label(s).  If `data` is a list, strings should be specified for each
1664
        system.
1665
    label_freq : int, optional
1666
        Label every nth frequency on the plot.  If not specified, no labels
1667
        are generated.
1668
    legend_loc : int or str, optional
1669
        Include a legend in the given location. Default is 'upper right',
1670
        with no legend for a single response.  Use False to suppress legend.
1671
    max_curve_magnitude : float, optional
1672
        Restrict the maximum magnitude of the Nyquist plot to this value.
1673
        Portions of the Nyquist plot whose magnitude is restricted are
1674
        plotted using a different line style.  The default value is 20 and
1675
        can be set using `config.defaults['nyquist.max_curve_magnitude']`.
1676
    max_curve_offset : float, optional
1677
        When plotting scaled portion of the Nyquist plot, increase/decrease
1678
        the magnitude by this fraction of the max_curve_magnitude to allow
1679
        any overlaps between the primary and mirror curves to be avoided.
1680
        The default value is 0.02 and can be set using
1681
        `config.defaults['nyquist.max_curve_magnitude']`.
1682
    mirror_style : [str, str] or False
1683
        Linestyles for mirror image of the Nyquist curve.  The first element
1684
        is used for unscaled portions of the Nyquist curve, the second element
1685
        is used for portions that are scaled (using max_curve_magnitude).  If
1686
        False then omit completely.  Default linestyle (['--', ':']) is
1687
        determined by `config.defaults['nyquist.mirror_style']`.
1688
    omega_limits : array_like of two values
1689
        Set limits for plotted frequency range. If Hz=True the limits are
1690
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
1691
        elements is equivalent to providing `omega_limits`.
1692
    omega_num : int, optional
1693
        Number of samples to use for the frequency range.  Defaults to
1694
        `config.defaults['freqplot.number_of_samples']`.  Ignored if `data`
1695
        is not a system or list of systems.
1696
    plot : bool, optional
1697
        (legacy) If given, `nyquist_plot` returns the legacy return values
1698
        of (counts, contours).  If False, return the values with no plot.
1699
    primary_style : [str, str], optional
1700
        Linestyles for primary image of the Nyquist curve.  The first
1701
        element is used for unscaled portions of the Nyquist curve,
1702
        the second element is used for portions that are scaled (using
1703
        max_curve_magnitude).  Default linestyle (['-', '-.']) is
1704
        determined by `config.defaults['nyquist.mirror_style']`.
1705
    rcParams : dict
1706
        Override the default parameters used for generating plots.
1707
        Default is set by `config.defaults['ctrlplot.rcParams']`.
1708
    return_contour : bool, optional
1709
        (legacy) If True, return the encirclement count and Nyquist
1710
        contour used to generate the Nyquist plot.
1711
    show_legend : bool, optional
1712
        Force legend to be shown if True or hidden if False.  If
1713
        None, then show legend when there is more than one line on the
1714
        plot or `legend_loc` has been specified.
1715
    start_marker : str, optional
1716
        Matplotlib marker to use to mark the starting point of the Nyquist
1717
        plot.  Defaults value is 'o' and can be set using
1718
        `config.defaults['nyquist.start_marker']`.
1719
    start_marker_size : float, optional
1720
        Start marker size (in display coordinates).  Default value is
1721
        4 and can be set using `config.defaults['nyquist.start_marker_size']`.
1722
    title : str, optional
1723
        Set the title of the plot.  Defaults to plot type and system name(s).
1724
    title_frame : str, optional
1725
        Set the frame of reference used to center the plot title. If set to
1726
        'axes' (default), the horizontal position of the title will
1727
        centered relative to the axes.  If set to 'figure', it will be
1728
        centered with respect to the figure (faster execution).
1729
    warn_nyquist : bool, optional
1730
        If set to False, turn off warnings about frequencies above Nyquist.
1731
    warn_encirclements : bool, optional
1732
        If set to False, turn off warnings about number of encirclements not
1733
        meeting the Nyquist criterion.
1734

1735
    See Also
1736
    --------
1737
    nyquist_response
1738

1739
    Notes
1740
    -----
1741
    If a discrete-time model is given, the frequency response is computed
1742
    along the upper branch of the unit circle, using the mapping ``z =
1743
    exp(1j * omega * dt)`` where `omega` ranges from 0 to pi/`dt` and
1744
    `dt` is the discrete timebase.  If timebase not specified
1745
    (`dt` = True), `dt` is set to 1.
1746

1747
    If a continuous-time system contains poles on or near the imaginary
1748
    axis, a small indentation will be used to avoid the pole.  The radius
1749
    of the indentation is given by `indent_radius` and it is taken to the
1750
    right of stable poles and the left of unstable poles.  If a pole is
1751
    exactly on the imaginary axis, the `indent_direction` parameter can be
1752
    used to set the direction of indentation.  Setting `indent_direction`
1753
    to 'none' will turn off indentation.  If `return_contour` is True,
1754
    the exact contour used for evaluation is returned.
1755

1756
    For those portions of the Nyquist plot in which the contour is indented
1757
    to avoid poles, resulting in a scaling of the Nyquist plot, the line
1758
    styles are according to the settings of the `primary_style` and
1759
    `mirror_style` keywords.  By default the scaled portions of the primary
1760
    curve use a dashdot line style and the scaled portions of the mirror
1761
    image use a dotted line style.
1762

1763
    Examples
1764
    --------
1765
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1766
    >>> out = ct.nyquist_plot(G)
1767

1768
    """
1769
    #
1770
    # Keyword processing
1771
    #
1772
    # Keywords for the nyquist_plot function can either be keywords that
1773
    # are unique to this function, keywords that are intended for use by
1774
    # nyquist_response (if data is a list of systems), or keywords that
1775
    # are intended for the plotting commands.
1776
    #
1777
    # We first pop off all keywords that are used directly by this
1778
    # function.  If data is a list of systems, when then pop off keywords
1779
    # that correspond to nyquist_response() keywords.  The remaining
1780
    # keywords are passed to matplotlib (and will generate an error if
1781
    # unrecognized).
1782
    #
1783

1784
    # Get values for params (and pop from list to allow keyword use in plot)
1785
    arrows = config._get_param(
9✔
1786
        'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True)
1787
    arrow_size = config._get_param(
9✔
1788
        'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True)
1789
    arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None)
9✔
1790
    ax_user = ax
9✔
1791
    max_curve_magnitude = config._get_param(
9✔
1792
        'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True)
1793
    blend_fraction = config._get_param(
9✔
1794
        'nyquist', 'blend_fraction', kwargs, _nyquist_defaults, pop=True)
1795
    max_curve_offset = config._get_param(
9✔
1796
        'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True)
1797
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
1798
    start_marker = config._get_param(
9✔
1799
        'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True)
1800
    start_marker_size = config._get_param(
9✔
1801
        'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True)
1802
    title_frame = config._get_param(
9✔
1803
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
1804

1805
    # Set line styles for the curves
1806
    def _parse_linestyle(style_name, allow_false=False):
9✔
1807
        style = config._get_param(
9✔
1808
            'nyquist', style_name, kwargs, _nyquist_defaults, pop=True)
1809
        if isinstance(style, str):
9✔
1810
            # Only one style provided, use the default for the other
1811
            style = [style, _nyquist_defaults['nyquist.' + style_name][1]]
9✔
1812
            warnings.warn(
9✔
1813
                "use of a single string for linestyle will be deprecated "
1814
                " in a future release", PendingDeprecationWarning)
1815
        if (allow_false and style is False) or \
9✔
1816
           (isinstance(style, list) and len(style) == 2):
1817
            return style
9✔
1818
        else:
1819
            raise ValueError(f"invalid '{style_name}': {style}")
9✔
1820

1821
    primary_style = _parse_linestyle('primary_style')
9✔
1822
    mirror_style = _parse_linestyle('mirror_style', allow_false=True)
9✔
1823

1824
    # Parse the arrows keyword
1825
    if not arrows:
9✔
1826
        arrow_pos = []
9✔
1827
    elif isinstance(arrows, int):
9✔
1828
        N = arrows
9✔
1829
        # Space arrows out, starting midway along each "region"
1830
        arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False)
9✔
1831
    elif isinstance(arrows, (list, np.ndarray)):
9✔
1832
        arrow_pos = np.sort(np.atleast_1d(arrows))
9✔
1833
    else:
1834
        raise ValueError("unknown or unsupported arrow location")
9✔
1835

1836
    # Set the arrow style
1837
    if arrow_style is None:
9✔
1838
        arrow_style = mpl.patches.ArrowStyle(
9✔
1839
            'simple', head_width=arrow_size, head_length=arrow_size)
1840

1841
    # If argument was a singleton, turn it into a tuple
1842
    if not isinstance(data, (list, tuple)):
9✔
1843
        data = [data]
9✔
1844

1845
    # Process label keyword
1846
    line_labels = _process_line_labels(label, len(data))
9✔
1847

1848
    # If we are passed a list of systems, compute response first
1849
    if all([isinstance(
9✔
1850
            sys, (StateSpace, TransferFunction, FrequencyResponseData))
1851
            for sys in data]):
1852
        # Get the response; pop explicit keywords here, kwargs in _response()
1853
        nyquist_responses = nyquist_response(
9✔
1854
            data, omega=omega, return_contour=return_contour,
1855
            omega_limits=kwargs.pop('omega_limits', None),
1856
            omega_num=kwargs.pop('omega_num', None),
1857
            warn_encirclements=kwargs.pop('warn_encirclements', True),
1858
            warn_nyquist=kwargs.pop('warn_nyquist', True),
1859
            _kwargs=kwargs, _check_kwargs=False)
1860
    else:
1861
        nyquist_responses = data
9✔
1862

1863
    # Legacy return value processing
1864
    if plot is not None or return_contour is not None:
9✔
1865
        warnings.warn(
9✔
1866
            "nyquist_plot() return value of count[, contour] is deprecated; "
1867
            "use nyquist_response()", FutureWarning)
1868

1869
        # Extract out the values that we will eventually return
1870
        counts = [response.count for response in nyquist_responses]
9✔
1871
        contours = [response.contour for response in nyquist_responses]
9✔
1872

1873
    if plot is False:
9✔
1874
        # Make sure we used all of the keywords
1875
        if kwargs:
9✔
1876
            raise TypeError("unrecognized keywords: ", str(kwargs))
×
1877

1878
        if len(data) == 1:
9✔
1879
            counts, contours = counts[0], contours[0]
9✔
1880

1881
        # Return counts and (optionally) the contour we used
1882
        return (counts, contours) if return_contour else counts
9✔
1883

1884
    fig, ax = _process_ax_keyword(
9✔
1885
        ax_user, shape=(1, 1), squeeze=True, rcParams=rcParams)
1886
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
1887
        kwargs, None, 'upper right')
1888

1889
    # Figure out where the blended curve should start
1890
    if blend_fraction < 0 or blend_fraction > 1:
9✔
1891
        raise ValueError("blend_fraction must be between 0 and 1")
9✔
1892
    blend_curve_start = (1 - blend_fraction) * max_curve_magnitude
9✔
1893

1894
    # Create a list of lines for the output
1895
    out = np.empty((len(nyquist_responses), 4), dtype=object)
9✔
1896
    for i in range(len(nyquist_responses)):
9✔
1897
        for j in range(4):
9✔
1898
            out[i, j] = []      # unique list in each element
9✔
1899

1900
    for idx, response in enumerate(nyquist_responses):
9✔
1901
        resp = response.response
9✔
1902
        if response.dt in [0, None]:
9✔
1903
            splane_contour = response.contour
9✔
1904
        else:
1905
            splane_contour = np.log(response.contour) / response.dt
9✔
1906

1907
        # Find the different portions of the curve (with scaled pts marked)
1908
        reg_mask = np.logical_or(
9✔
1909
            np.abs(resp) > blend_curve_start,
1910
            np.logical_not(np.isclose(splane_contour.real, 0)))
1911

1912
        scale_mask = ~reg_mask \
9✔
1913
            & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \
1914
            & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1]))
1915

1916
        # Rescale the points with large magnitude
1917
        rescale_idx = (np.abs(resp) > blend_curve_start)
9✔
1918

1919
        if np.any(rescale_idx):  # Only process if rescaling is needed
9✔
1920
            subset = resp[rescale_idx]
9✔
1921
            abs_subset = np.abs(subset)
9✔
1922
            unit_vectors = subset / abs_subset  # Preserve phase/direction
9✔
1923

1924
            if blend_curve_start == max_curve_magnitude:
9✔
1925
                # Clip at max_curve_magnitude
1926
                resp[rescale_idx] = max_curve_magnitude * unit_vectors
9✔
1927
            else:
1928
                # Logistic scaling
1929
                newmag = blend_curve_start + \
9✔
1930
                    (max_curve_magnitude - blend_curve_start) * \
1931
                    (abs_subset - blend_curve_start) / \
1932
                    (abs_subset + max_curve_magnitude - 2 * blend_curve_start)
1933
                resp[rescale_idx] = newmag * unit_vectors
9✔
1934

1935
        # Get the label to use for the line
1936
        label = response.sysname if line_labels is None else line_labels[idx]
9✔
1937

1938
        # Plot the regular portions of the curve (and grab the color)
1939
        x_reg = np.ma.masked_where(reg_mask, resp.real)
9✔
1940
        y_reg = np.ma.masked_where(reg_mask, resp.imag)
9✔
1941
        p = ax.plot(
9✔
1942
            x_reg, y_reg, primary_style[0], color=color, label=label, **kwargs)
1943
        c = p[0].get_color()
9✔
1944
        out[idx, 0] += p
9✔
1945

1946
        # Figure out how much to offset the curve: the offset goes from
1947
        # zero at the start of the scaled section to max_curve_offset as
1948
        # we move along the curve
1949
        curve_offset = _compute_curve_offset(
9✔
1950
            resp, scale_mask, max_curve_offset)
1951

1952
        # Plot the scaled sections of the curve (changing linestyle)
1953
        x_scl = np.ma.masked_where(scale_mask, resp.real)
9✔
1954
        y_scl = np.ma.masked_where(scale_mask, resp.imag)
9✔
1955
        if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1956
            out[idx, 1] += ax.plot(
9✔
1957
                x_scl * (1 + curve_offset),
1958
                y_scl * (1 + curve_offset),
1959
                primary_style[1], color=c, **kwargs)
1960
        else:
1961
            out[idx, 1] += [None]
9✔
1962

1963
        # Plot the primary curve (invisible) for setting arrows
1964
        x, y = resp.real.copy(), resp.imag.copy()
9✔
1965
        x[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1966
        y[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1967
        p = ax.plot(x, y, linestyle='None', color=c)
9✔
1968

1969
        # Add arrows
1970
        _add_arrows_to_line2D(
9✔
1971
            ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1)
1972

1973
        # Plot the mirror image
1974
        if mirror_style is not False:
9✔
1975
            # Plot the regular and scaled segments
1976
            out[idx, 2] += ax.plot(
9✔
1977
                x_reg, -y_reg, mirror_style[0], color=c, **kwargs)
1978
            if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1979
                out[idx, 3] += ax.plot(
9✔
1980
                    x_scl * (1 - curve_offset),
1981
                    -y_scl * (1 - curve_offset),
1982
                    mirror_style[1], color=c, **kwargs)
1983
            else:
1984
                out[idx, 3] += [None]
9✔
1985

1986
            # Add the arrows (on top of an invisible contour)
1987
            x, y = resp.real.copy(), resp.imag.copy()
9✔
1988
            x[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1989
            y[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1990
            p = ax.plot(x, -y, linestyle='None', color=c, **kwargs)
9✔
1991
            _add_arrows_to_line2D(
9✔
1992
                ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1)
1993
        else:
1994
            out[idx, 2] += [None]
9✔
1995
            out[idx, 3] += [None]
9✔
1996

1997
        # Mark the start of the curve
1998
        if start_marker:
9✔
1999
            segment = 0 if 0 in rescale_idx else 1      # regular vs scaled
9✔
2000
            out[idx, segment] += ax.plot(
9✔
2001
                resp[0].real, resp[0].imag, start_marker,
2002
                color=c, markersize=start_marker_size)
2003

2004
        # Mark the -1 point
2005
        ax.plot([-1], [0], 'r+')
9✔
2006

2007
        #
2008
        # Draw circles for gain crossover and sensitivity functions
2009
        #
2010
        theta = np.linspace(0, 2*np.pi, 100)
9✔
2011
        cos = np.cos(theta)
9✔
2012
        sin = np.sin(theta)
9✔
2013
        label_pos = 15
9✔
2014

2015
        # Display the unit circle, to read gain crossover frequency
2016
        if unit_circle:
9✔
2017
            ax.plot(cos, sin, **config.defaults['nyquist.circle_style'])
9✔
2018

2019
        # Draw circles for given magnitudes of sensitivity
2020
        if ms_circles is not None:
9✔
2021
            for ms in ms_circles:
9✔
2022
                pos_x = -1 + (1/ms)*cos
9✔
2023
                pos_y = (1/ms)*sin
9✔
2024
                ax.plot(
9✔
2025
                    pos_x, pos_y, **config.defaults['nyquist.circle_style'])
2026
                ax.text(pos_x[label_pos], pos_y[label_pos], ms)
9✔
2027

2028
        # Draw circles for given magnitudes of complementary sensitivity
2029
        if mt_circles is not None:
9✔
2030
            for mt in mt_circles:
9✔
2031
                if mt != 1:
9✔
2032
                    ct = -mt**2/(mt**2-1)  # Mt center
9✔
2033
                    rt = mt/(mt**2-1)  # Mt radius
9✔
2034
                    pos_x = ct+rt*cos
9✔
2035
                    pos_y = rt*sin
9✔
2036
                    ax.plot(
9✔
2037
                        pos_x, pos_y,
2038
                        **config.defaults['nyquist.circle_style'])
2039
                    ax.text(pos_x[label_pos], pos_y[label_pos], mt)
9✔
2040
                else:
2041
                    _, _, ymin, ymax = ax.axis()
9✔
2042
                    pos_y = np.linspace(ymin, ymax, 100)
9✔
2043
                    ax.vlines(
9✔
2044
                        -0.5, ymin=ymin, ymax=ymax,
2045
                        **config.defaults['nyquist.circle_style'])
2046
                    ax.text(-0.5, pos_y[label_pos], 1)
9✔
2047

2048
        # Label the frequencies of the points on the Nyquist curve
2049
        if label_freq:
9✔
2050
            ind = slice(None, None, label_freq)
9✔
2051
            omega_sys = np.imag(splane_contour[np.real(splane_contour) == 0])
9✔
2052
            for xpt, ypt, omegapt in zip(x[ind], y[ind], omega_sys[ind]):
9✔
2053
                # Convert to Hz
2054
                f = omegapt / (2 * np.pi)
9✔
2055

2056
                # Factor out multiples of 1000 and limit the
2057
                # result to the range [-8, 8].
2058
                pow1000 = max(min(get_pow1000(f), 8), -8)
9✔
2059

2060
                # Get the SI prefix.
2061
                prefix = gen_prefix(pow1000)
9✔
2062

2063
                # Apply the text. (Use a space before the text to
2064
                # prevent overlap with the data.)
2065
                #
2066
                # np.round() is used because 0.99... appears
2067
                # instead of 1.0, and this would otherwise be
2068
                # truncated to 0.
2069
                ax.text(xpt, ypt, ' ' +
9✔
2070
                         str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' +
2071
                         prefix + 'Hz')
2072

2073
    # Label the axes
2074
    ax.set_xlabel("Real axis")
9✔
2075
    ax.set_ylabel("Imaginary axis")
9✔
2076
    ax.grid(color="lightgray")
9✔
2077

2078
    # List of systems that are included in this plot
2079
    lines, labels = _get_line_labels(ax)
9✔
2080

2081
    # Add legend if there is more than one system plotted
2082
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2083
        with plt.rc_context(rcParams):
9✔
2084
            legend = ax.legend(lines, labels, loc=legend_loc)
9✔
2085
    else:
2086
        legend = None
9✔
2087

2088
    # Add the title
2089
    sysnames = [response.sysname for response in nyquist_responses]
9✔
2090
    if ax_user is None and title is None:
9✔
2091
        title = "Nyquist plot for " + ", ".join(sysnames)
9✔
2092
        _update_plot_title(
9✔
2093
            title, fig=fig, rcParams=rcParams, frame=title_frame)
2094
    elif ax_user is None:
9✔
2095
        _update_plot_title(
9✔
2096
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2097
            use_existing=False)
2098

2099
    # Legacy return processing
2100
    if plot is True or return_contour is not None:
9✔
2101
        if len(data) == 1:
9✔
2102
            counts, contours = counts[0], contours[0]
9✔
2103

2104
        # Return counts and (optionally) the contour we used
2105
        return (counts, contours) if return_contour else counts
9✔
2106

2107
    return ControlPlot(out, ax, fig, legend=legend)
9✔
2108

2109

2110
#
2111
# Function to compute Nyquist curve offsets
2112
#
2113
# This function computes a smoothly varying offset that starts and ends at
2114
# zero at the ends of a scaled segment.
2115
#
2116
def _compute_curve_offset(resp, mask, max_offset):
9✔
2117
    # Compute the arc length along the curve
2118
    s_curve = np.cumsum(
9✔
2119
        np.sqrt(np.diff(resp.real) ** 2 + np.diff(resp.imag) ** 2))
2120

2121
    # Initialize the offset
2122
    offset = np.zeros(resp.size)
9✔
2123
    arclen = np.zeros(resp.size)
9✔
2124

2125
    # Walk through the response and keep track of each continuous component
2126
    i, nsegs = 0, 0
9✔
2127
    while i < resp.size:
9✔
2128
        # Skip the regular segment
2129
        while i < resp.size and mask[i]:
9✔
2130
            i += 1              # Increment the counter
9✔
2131
            if i == resp.size:
9✔
2132
                break
9✔
2133
            # Keep track of the arclength
2134
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2135

2136
        nsegs += 0.5
9✔
2137
        if i == resp.size:
9✔
2138
            break
9✔
2139

2140
        # Save the starting offset of this segment
2141
        seg_start = i
9✔
2142

2143
        # Walk through the scaled segment
2144
        while i < resp.size and not mask[i]:
9✔
2145
            i += 1
9✔
2146
            if i == resp.size:  # See if we are done with this segment
9✔
2147
                break
×
2148
            # Keep track of the arclength
2149
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2150

2151
        nsegs += 0.5
9✔
2152
        if i == resp.size:
9✔
2153
            break
×
2154

2155
        # Save the ending offset of this segment
2156
        seg_end = i
9✔
2157

2158
        # Now compute the scaling for this segment
2159
        s_segment = arclen[seg_end-1] - arclen[seg_start]
9✔
2160
        offset[seg_start:seg_end] = max_offset * s_segment/s_curve[-1] * \
9✔
2161
            np.sin(np.pi * (arclen[seg_start:seg_end]
2162
                            - arclen[seg_start])/s_segment)
2163

2164
    return offset
9✔
2165

2166

2167
#
2168
# Gang of Four plot
2169
#
2170
def gangof4_response(
9✔
2171
        P, C, omega=None, omega_limits=None, omega_num=None, Hz=False):
2172
    """Compute response of "Gang of 4" transfer functions.
2173

2174
    Generates a 2x2 frequency response for the "Gang of 4" sensitivity
2175
    functions [T, PS; CS, S].
2176

2177
    Parameters
2178
    ----------
2179
    P, C : LTI
2180
        Linear input/output systems (process and control).
2181
    omega : array
2182
        Range of frequencies (list or bounds) in rad/sec.
2183
    omega_limits : array_like of two values
2184
        Set limits for plotted frequency range. If Hz=True the limits are
2185
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2186
        elements is equivalent to providing `omega_limits`. Ignored if
2187
        data is not a list of systems.
2188
    omega_num : int
2189
        Number of samples to use for the frequency range.  Defaults to
2190
        `config.defaults['freqplot.number_of_samples']`.  Ignored if data is
2191
        not a list of systems.
2192
    Hz : bool, optional
2193
        If True, when computing frequency limits automatically set
2194
        limits to full decades in Hz instead of rad/s.
2195

2196
    Returns
2197
    -------
2198
    response : `FrequencyResponseData`
2199
        Frequency response with inputs 'r' and 'd' and outputs 'y', and 'u'
2200
        representing the 2x2 matrix of transfer functions in the Gang of 4.
2201

2202
    Examples
2203
    --------
2204
    >>> P = ct.tf([1], [1, 1])
2205
    >>> C = ct.tf([2], [1])
2206
    >>> response = ct.gangof4_response(P, C)
2207
    >>> cplt = response.plot()
2208

2209
    """
2210
    if not P.issiso() or not C.issiso():
9✔
2211
        # TODO: Add MIMO go4 plots.
2212
        raise ControlMIMONotImplemented(
×
2213
            "Gang of four is currently only implemented for SISO systems.")
2214

2215
    # Compute the sensitivity functions
2216
    L = P * C
9✔
2217
    S = feedback(1, L)
9✔
2218
    T = L * S
9✔
2219

2220
    # Select a default range if none is provided
2221
    # TODO: This needs to be made more intelligent
2222
    omega, _ = _determine_omega_vector(
9✔
2223
        [P, C, S], omega, omega_limits, omega_num, Hz=Hz)
2224

2225
    #
2226
    # bode_plot based implementation
2227
    #
2228

2229
    # Compute the response of the Gang of 4
2230
    resp_T = T(1j * omega)
9✔
2231
    resp_PS = (P * S)(1j * omega)
9✔
2232
    resp_CS = (C * S)(1j * omega)
9✔
2233
    resp_S = S(1j * omega)
9✔
2234

2235
    # Create a single frequency response data object with the underlying data
2236
    data = np.empty((2, 2, omega.size), dtype=complex)
9✔
2237
    data[0, 0, :] = resp_T
9✔
2238
    data[0, 1, :] = resp_PS
9✔
2239
    data[1, 0, :] = resp_CS
9✔
2240
    data[1, 1, :] = resp_S
9✔
2241

2242
    return FrequencyResponseData(
9✔
2243
        data, omega, outputs=['y', 'u'], inputs=['r', 'd'],
2244
        title=f"Gang of Four for P={P.name}, C={C.name}",
2245
        sysname=f"P={P.name}, C={C.name}", plot_phase=False)
2246

2247

2248
def gangof4_plot(
9✔
2249
        *args, omega=None, omega_limits=None, omega_num=None,
2250
        Hz=False, **kwargs):
2251
    """gangof4_plot(response) \
2252
    gangof4_plot(P, C, omega)
2253

2254
    Plot response of "Gang of 4" transfer functions.
2255

2256
    Plots a 2x2 frequency response for the "Gang of 4" sensitivity
2257
    functions [T, PS; CS, S].  Can be called in one of two ways:
2258

2259
        gangof4_plot(response[, ...])
2260
        gangof4_plot(P, C[, ...])
2261

2262
    Parameters
2263
    ----------
2264
    response : FrequencyPlotData
2265
        Gang of 4 frequency response from `gangof4_response`.
2266
    P, C : LTI
2267
        Linear input/output systems (process and control).
2268
    omega : array
2269
        Range of frequencies (list or bounds) in rad/sec.
2270
    omega_limits : array_like of two values
2271
        Set limits for plotted frequency range. If Hz=True the limits are
2272
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2273
        elements is equivalent to providing `omega_limits`. Ignored if
2274
        data is not a list of systems.
2275
    omega_num : int
2276
        Number of samples to use for the frequency range.  Defaults to
2277
        `config.defaults['freqplot.number_of_samples']`.  Ignored if data is
2278
        not a list of systems.
2279
    Hz : bool, optional
2280
        If True, when computing frequency limits automatically set
2281
        limits to full decades in Hz instead of rad/s.
2282

2283
    Returns
2284
    -------
2285
    cplt : `ControlPlot` object
2286
        Object containing the data that were plotted.  See `ControlPlot`
2287
        for more detailed information.
2288
    cplt.lines : 2x2 array of `matplotlib.lines.Line2D`
2289
        Array containing information on each line in the plot.  The value
2290
        of each array entry is a list of Line2D objects in that subplot.
2291
    cplt.axes : 2D array of `matplotlib.axes.Axes`
2292
        Axes for each subplot.
2293
    cplt.figure : `matplotlib.figure.Figure`
2294
        Figure containing the plot.
2295
    cplt.legend : 2D array of `matplotlib.legend.Legend`
2296
        Legend object(s) contained in the plot.
2297

2298
    """
2299
    if len(args) == 1 and isinstance(args[0], FrequencyResponseData):
9✔
2300
        if any([kw is not None
×
2301
                for kw in [omega, omega_limits, omega_num, Hz]]):
2302
            raise ValueError(
×
2303
                "omega, omega_limits, omega_num, Hz not allowed when "
2304
                "given a Gang of 4 response as first argument")
2305
        return args[0].plot(kwargs)
×
2306
    else:
2307
        if len(args) > 3:
9✔
2308
            raise TypeError(
×
2309
                f"expecting 2 or 3 positional arguments; received {len(args)}")
2310
        omega = omega if len(args) < 3 else args[2]
9✔
2311
        args = args[0:2]
9✔
2312
        return gangof4_response(
9✔
2313
            *args, omega=omega, omega_limits=omega_limits,
2314
            omega_num=omega_num, Hz=Hz).plot(**kwargs)
2315

2316

2317
#
2318
# Singular values plot
2319
#
2320
def singular_values_response(
9✔
2321
        sysdata, omega=None, omega_limits=None, omega_num=None, Hz=False):
2322
    """Singular value response for a system.
2323

2324
    Computes the singular values for a system or list of systems over
2325
    a (optional) frequency range.
2326

2327
    Parameters
2328
    ----------
2329
    sysdata : LTI or list of LTI
2330
        List of linear input/output systems (single system is OK).
2331
    omega : array_like
2332
        List of frequencies in rad/sec to be used for frequency response.
2333
    Hz : bool, optional
2334
        If True, when computing frequency limits automatically set
2335
        limits to full decades in Hz instead of rad/s.
2336

2337
    Returns
2338
    -------
2339
    response : `FrequencyResponseData`
2340
        Frequency response with the number of outputs equal to the
2341
        number of singular values in the response, and a single input.
2342

2343
    Other Parameters
2344
    ----------------
2345
    omega_limits : array_like of two values
2346
        Set limits for plotted frequency range. If Hz=True the limits are
2347
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2348
        elements is equivalent to providing `omega_limits`.
2349
    omega_num : int, optional
2350
        Number of samples to use for the frequency range.  Defaults to
2351
        `config.defaults['freqplot.number_of_samples']`.
2352

2353
    See Also
2354
    --------
2355
    singular_values_plot
2356

2357
    Examples
2358
    --------
2359
    >>> omegas = np.logspace(-4, 1, 1000)
2360
    >>> den = [75, 1]
2361
    >>> G = ct.tf([[[87.8], [-86.4]], [[108.2], [-109.6]]],
2362
    ...           [[den, den], [den, den]])
2363
    >>> response = ct.singular_values_response(G, omega=omegas)
2364

2365
    """
2366
    # Convert the first argument to a list
2367
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
2368

2369
    if any([not isinstance(sys, LTI) for sys in syslist]):
9✔
2370
        ValueError("singular values can only be computed for LTI systems")
×
2371

2372
    # Compute the frequency responses for the systems
2373
    responses = frequency_response(
9✔
2374
        syslist, omega=omega, omega_limits=omega_limits,
2375
        omega_num=omega_num, Hz=Hz, squeeze=False)
2376

2377
    # Calculate the singular values for each system in the list
2378
    svd_responses = []
9✔
2379
    for response in responses:
9✔
2380
        # Compute the singular values (permute indices to make things work)
2381
        fresp_permuted = response.frdata.transpose((2, 0, 1))
9✔
2382
        sigma = np.linalg.svd(fresp_permuted, compute_uv=False).transpose()
9✔
2383
        sigma_fresp = sigma.reshape(sigma.shape[0], 1, sigma.shape[1])
9✔
2384

2385
        # Save the singular values as an FRD object
2386
        svd_responses.append(
9✔
2387
            FrequencyResponseData(
2388
                sigma_fresp, response.omega, _return_singvals=True,
2389
                outputs=[f'$\\sigma_{{{k+1}}}$' for k in range(sigma.shape[0])],
2390
                inputs='inputs', dt=response.dt, plot_phase=False,
2391
                sysname=response.sysname, plot_type='svplot',
2392
                title=f"Singular values for {response.sysname}"))
2393

2394
    if isinstance(sysdata, (list, tuple)):
9✔
2395
        return FrequencyResponseList(svd_responses)
9✔
2396
    else:
2397
        return svd_responses[0]
9✔
2398

2399

2400
def singular_values_plot(
9✔
2401
        data, omega=None, *fmt, plot=None, omega_limits=None, omega_num=None,
2402
        ax=None, label=None, title=None, **kwargs):
2403
    """Plot the singular values for a system.
2404

2405
    Plot the singular values as a function of frequency for a system or
2406
    list of systems.  If multiple systems are plotted, each system in the
2407
    list is plotted in a different color.
2408

2409
    Parameters
2410
    ----------
2411
    data : list of `FrequencyResponseData`
2412
        List of `FrequencyResponseData` objects.  For backward
2413
        compatibility, a list of LTI systems can also be given.
2414
    omega : array_like
2415
        List of frequencies in rad/sec over to plot over.
2416
    *fmt : `matplotlib.pyplot.plot` format string, optional
2417
        Passed to `matplotlib` as the format string for all lines in the plot.
2418
        The `omega` parameter must be present (use omega=None if needed).
2419
    dB : bool
2420
        If True, plot result in dB.  Default is False.
2421
    Hz : bool
2422
        If True, plot frequency in Hz (omega must be provided in rad/sec).
2423
        Default value (False) set by `config.defaults['freqplot.Hz']`.
2424
    **kwargs : `matplotlib.pyplot.plot` keyword properties, optional
2425
        Additional keywords passed to `matplotlib` to specify line properties.
2426

2427
    Returns
2428
    -------
2429
    cplt : `ControlPlot` object
2430
        Object containing the data that were plotted.  See `ControlPlot`
2431
        for more detailed information.
2432
    cplt.lines : array of `matplotlib.lines.Line2D`
2433
        Array containing information on each line in the plot.  The size of
2434
        the array matches the number of systems and the value of the array
2435
        is a list of Line2D objects for that system.
2436
    cplt.axes : 2D array of `matplotlib.axes.Axes`
2437
        Axes for each subplot.
2438
    cplt.figure : `matplotlib.figure.Figure`
2439
        Figure containing the plot.
2440
    cplt.legend : 2D array of `matplotlib.legend.Legend`
2441
        Legend object(s) contained in the plot.
2442

2443
    Other Parameters
2444
    ----------------
2445
    ax : `matplotlib.axes.Axes`, optional
2446
        The matplotlib axes to draw the figure on.  If not specified and
2447
        the current figure has a single axes, that axes is used.
2448
        Otherwise, a new figure is created.
2449
    color : matplotlib color spec
2450
        Color to use for singular values (or None for matplotlib default).
2451
    grid : bool
2452
        If True, plot grid lines on gain and phase plots.  Default is
2453
        set by `config.defaults['freqplot.grid']`.
2454
    label : str or array_like of str, optional
2455
        If present, replace automatically generated label(s) with the given
2456
        label(s).  If sysdata is a list, strings should be specified for each
2457
        system.
2458
    legend_loc : int or str, optional
2459
        Include a legend in the given location. Default is 'center right',
2460
        with no legend for a single response.  Use False to suppress legend.
2461
    omega_limits : array_like of two values
2462
        Set limits for plotted frequency range. If Hz=True the limits are
2463
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2464
        elements is equivalent to providing `omega_limits`.
2465
    omega_num : int, optional
2466
        Number of samples to use for the frequency range.  Defaults to
2467
        `config.defaults['freqplot.number_of_samples']`.  Ignored if data is
2468
        not a list of systems.
2469
    plot : bool, optional
2470
        (legacy) If given, `singular_values_plot` returns the legacy return
2471
        values of magnitude, phase, and frequency.  If False, just return
2472
        the values with no plot.
2473
    rcParams : dict
2474
        Override the default parameters used for generating plots.
2475
        Default is set up `config.defaults['ctrlplot.rcParams']`.
2476
    show_legend : bool, optional
2477
        Force legend to be shown if True or hidden if False.  If
2478
        None, then show legend when there is more than one line on an
2479
        axis or `legend_loc` or `legend_map` has been specified.
2480
    title : str, optional
2481
        Set the title of the plot.  Defaults to plot type and system name(s).
2482
    title_frame : str, optional
2483
        Set the frame of reference used to center the plot title. If set to
2484
        'axes' (default), the horizontal position of the title will
2485
        centered relative to the axes.  If set to 'figure', it will be
2486
        centered with respect to the figure (faster execution).
2487

2488
    See Also
2489
    --------
2490
    singular_values_response
2491

2492
    Notes
2493
    -----
2494
    If `plot` = False, the following legacy values are returned:
2495
       * `mag` : ndarray (or list of ndarray if len(data) > 1))
2496
           Magnitude of the response (deprecated).
2497
       * `phase` : ndarray (or list of ndarray if len(data) > 1))
2498
           Phase in radians of the response (deprecated).
2499
       * `omega` : ndarray (or list of ndarray if len(data) > 1))
2500
           Frequency in rad/sec (deprecated).
2501

2502
    """
2503
    # Keyword processing
2504
    color = kwargs.pop('color', None)
9✔
2505
    dB = config._get_param(
9✔
2506
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
2507
    Hz = config._get_param(
9✔
2508
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
2509
    grid = config._get_param(
9✔
2510
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
2511
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
2512
    title_frame = config._get_param(
9✔
2513
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
2514

2515
    # If argument was a singleton, turn it into a tuple
2516
    data = data if isinstance(data, (list, tuple)) else (data,)
9✔
2517

2518
    # Convert systems into frequency responses
2519
    if any([isinstance(response, (StateSpace, TransferFunction))
9✔
2520
            for response in data]):
2521
        responses = singular_values_response(
9✔
2522
                    data, omega=omega, omega_limits=omega_limits,
2523
                    omega_num=omega_num)
2524
    else:
2525
        # Generate warnings if frequency keywords were given
2526
        if omega_num is not None:
9✔
2527
            warnings.warn("`omega_num` ignored when passed response data")
9✔
2528
        elif omega is not None:
9✔
2529
            warnings.warn("`omega` ignored when passed response data")
9✔
2530

2531
        # Check to make sure omega_limits is sensible
2532
        if omega_limits is not None and \
9✔
2533
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
2534
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
2535

2536
        responses = data
9✔
2537

2538
    # Process label keyword
2539
    line_labels = _process_line_labels(label, len(data))
9✔
2540

2541
    # Process (legacy) plot keyword
2542
    if plot is not None:
9✔
2543
        warnings.warn(
×
2544
            "`singular_values_plot` return values of sigma, omega is "
2545
            "deprecated; use singular_values_response()", FutureWarning)
2546

2547
    # Warn the user if we got past something that is not real-valued
2548
    if any([not np.allclose(np.imag(response.frdata[:, 0, :]), 0)
9✔
2549
            for response in responses]):
2550
        warnings.warn("data has non-zero imaginary component")
×
2551

2552
    # Extract the data we need for plotting
2553
    sigmas = [np.real(response.frdata[:, 0, :]) for response in responses]
9✔
2554
    omegas = [response.omega for response in responses]
9✔
2555

2556
    # Legacy processing for no plotting case
2557
    if plot is False:
9✔
2558
        if len(data) == 1:
×
2559
            return sigmas[0], omegas[0]
×
2560
        else:
2561
            return sigmas, omegas
×
2562

2563
    fig, ax_sigma = _process_ax_keyword(
9✔
2564
        ax, shape=(1, 1), squeeze=True, rcParams=rcParams)
2565
    ax_sigma.set_label('control-sigma')         # TODO: deprecate?
9✔
2566
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
2567
        kwargs, None, 'center right')
2568

2569
    # Get color offset for first (new) line to be drawn
2570
    color_offset, color_cycle = _get_color_offset(ax_sigma)
9✔
2571

2572
    # Create a list of lines for the output
2573
    out = np.empty(len(data), dtype=object)
9✔
2574

2575
    # Plot the singular values for each response
2576
    for idx_sys, response in enumerate(responses):
9✔
2577
        sigma = sigmas[idx_sys].transpose()     # frequency first for plotting
9✔
2578
        omega = omegas[idx_sys] / (2 * math.pi) if Hz else  omegas[idx_sys]
9✔
2579

2580
        if response.isdtime(strict=True):
9✔
2581
            nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
2582
        else:
2583
            nyq_freq = None
9✔
2584

2585
        # Determine the color to use for this response
2586
        current_color = _get_color(
9✔
2587
            color, fmt=fmt, offset=color_offset + idx_sys,
2588
            color_cycle=color_cycle)
2589

2590
        # To avoid conflict with *fmt, only pass color kw if non-None
2591
        color_arg = {} if current_color is None else {'color': current_color}
9✔
2592

2593
        # Decide on the system name
2594
        sysname = response.sysname if response.sysname is not None \
9✔
2595
            else f"Unknown-{idx_sys}"
2596

2597
        # Get the label to use for the line
2598
        label = sysname if line_labels is None else line_labels[idx_sys]
9✔
2599

2600
        # Plot the data
2601
        if dB:
9✔
2602
            out[idx_sys] = ax_sigma.semilogx(
9✔
2603
                omega, 20 * np.log10(sigma), *fmt,
2604
                label=label, **color_arg, **kwargs)
2605
        else:
2606
            out[idx_sys] = ax_sigma.loglog(
9✔
2607
                omega, sigma, label=label, *fmt, **color_arg, **kwargs)
2608

2609
        # Plot the Nyquist frequency
2610
        if nyq_freq is not None:
9✔
2611
            ax_sigma.axvline(
9✔
2612
                nyq_freq, linestyle='--', label='_nyq_freq_' + sysname,
2613
                **color_arg)
2614

2615
    # If specific omega_limits were given, use them
2616
    if omega_limits is not None:
9✔
2617
        ax_sigma.set_xlim(omega_limits)
9✔
2618

2619
    # Add a grid to the plot + labeling
2620
    if grid:
9✔
2621
        ax_sigma.grid(grid, which='both')
9✔
2622

2623
    ax_sigma.set_ylabel(
9✔
2624
        "Singular Values [dB]" if dB else "Singular Values")
2625
    ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]")
9✔
2626

2627
    # List of systems that are included in this plot
2628
    lines, labels = _get_line_labels(ax_sigma)
9✔
2629

2630
    # Add legend if there is more than one system plotted
2631
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2632
        with plt.rc_context(rcParams):
9✔
2633
            legend = ax_sigma.legend(lines, labels, loc=legend_loc)
9✔
2634
    else:
2635
        legend = None
9✔
2636

2637
    # Add the title
2638
    if ax is None:
9✔
2639
        if title is None:
9✔
2640
            title = "Singular values for " + ", ".join(labels)
9✔
2641
        _update_plot_title(
9✔
2642
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2643
            use_existing=False)
2644

2645
    # Legacy return processing
2646
    if plot is not None:
9✔
2647
        if len(responses) == 1:
×
2648
            return sigmas[0], omegas[0]
×
2649
        else:
2650
            return sigmas, omegas
×
2651

2652
    return ControlPlot(out, ax_sigma, fig, legend=legend)
9✔
2653

2654
#
2655
# Utility functions
2656
#
2657
# This section of the code contains some utility functions for
2658
# generating frequency domain plots.
2659
#
2660

2661

2662
# Determine the frequency range to be used
2663
def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
9✔
2664
                            Hz=None, feature_periphery_decades=None):
2665
    """Determine the frequency range for a frequency-domain plot
2666
    according to a standard logic.
2667

2668
    If `omega_in` and `omega_limits` are both None, then `omega_out` is
2669
    computed on `omega_num` points according to a default logic defined by
2670
    `_default_frequency_range` and tailored for the list of systems
2671
    syslist, and `omega_range_given` is set to False.
2672

2673
    If `omega_in` is None but `omega_limits` is a tuple of 2 elements, then
2674
    `omega_out` is computed with the function `numpy.logspace` on
2675
    `omega_num` points within the interval ``[min, max] = [omega_limits[0],
2676
    omega_limits[1]]``, and `omega_range_given` is set to True.
2677

2678
    If `omega_in` is a tuple of length 2, it is interpreted as a range and
2679
    handled like `omega_limits`.  If `omega_in` is a tuple of length 3, it
2680
    is interpreted a range plus number of points and handled like
2681
    `omega_limits` and `omega_num`.
2682

2683
    If `omega_in` is an array or a list/tuple of length greater than two,
2684
    then `omega_out` is set to `omega_in` (as an array), and
2685
    `omega_range_given` is set to True
2686

2687
    Parameters
2688
    ----------
2689
    syslist : list of LTI
2690
        List of linear input/output systems (single system is OK).
2691
    omega_in : 1D array_like or None
2692
        Frequency range specified by the user.
2693
    omega_limits : 1D array_like or None
2694
        Frequency limits specified by the user.
2695
    omega_num : int
2696
        Number of points to be used for the frequency range (if the
2697
        frequency range is not user-specified).
2698
    Hz : bool, optional
2699
        If True, the limits (first and last value) of the frequencies
2700
        are set to full decades in Hz so it fits plotting with logarithmic
2701
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2702

2703
    Returns
2704
    -------
2705
    omega_out : 1D array
2706
        Frequency range to be used.
2707
    omega_range_given : bool
2708
        True if the frequency range was specified by the user, either through
2709
        omega_in or through omega_limits. False if both omega_in
2710
        and omega_limits are None.
2711

2712
    """
2713
    # Handle the special case of a range of frequencies
2714
    if omega_in is not None and omega_limits is not None:
9✔
2715
        warnings.warn(
×
2716
            "omega and omega_limits both specified; ignoring limits")
2717
    elif isinstance(omega_in, (list, tuple)) and len(omega_in) == 2:
9✔
2718
        omega_limits = omega_in
9✔
2719
        omega_in = None
9✔
2720

2721
    omega_range_given = True
9✔
2722
    if omega_in is None:
9✔
2723
        if omega_limits is None:
9✔
2724
            omega_range_given = False
9✔
2725
            # Select a default range if none is provided
2726
            omega_out = _default_frequency_range(
9✔
2727
                syslist, number_of_samples=omega_num, Hz=Hz,
2728
                feature_periphery_decades=feature_periphery_decades)
2729
        else:
2730
            omega_limits = np.asarray(omega_limits)
9✔
2731
            if len(omega_limits) != 2:
9✔
2732
                raise ValueError("len(omega_limits) must be 2")
×
2733
            omega_out = np.logspace(np.log10(omega_limits[0]),
9✔
2734
                                    np.log10(omega_limits[1]),
2735
                                    num=omega_num, endpoint=True)
2736
    else:
2737
        omega_out = np.copy(omega_in)
9✔
2738

2739
    return omega_out, omega_range_given
9✔
2740

2741

2742
# Compute reasonable defaults for axes
2743
def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
9✔
2744
                             feature_periphery_decades=None):
2745
    """Compute a default frequency range for frequency domain plots.
2746

2747
    This code looks at the poles and zeros of all of the systems that
2748
    we are plotting and sets the frequency range to be one decade above
2749
    and below the min and max feature frequencies, rounded to the nearest
2750
    integer.  If no features are found, it returns logspace(-1, 1)
2751

2752
    Parameters
2753
    ----------
2754
    syslist : list of LTI
2755
        List of linear input/output systems (single system is OK)
2756
    Hz : bool, optional
2757
        If True, the limits (first and last value) of the frequencies
2758
        are set to full decades in Hz so it fits plotting with logarithmic
2759
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2760
    number_of_samples : int, optional
2761
        Number of samples to generate.  The default value is read from
2762
        `config.defaults['freqplot.number_of_samples']`.  If None,
2763
        then the default from `numpy.logspace` is used.
2764
    feature_periphery_decades : float, optional
2765
        Defines how many decades shall be included in the frequency range on
2766
        both sides of features (poles, zeros).  The default value is read from
2767
        `config.defaults['freqplot.feature_periphery_decades']`.
2768

2769
    Returns
2770
    -------
2771
    omega : array
2772
        Range of frequencies in rad/sec
2773

2774
    Examples
2775
    --------
2776
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
2777
    >>> omega = ct._default_frequency_range(G)
2778
    >>> omega.min(), omega.max()
2779
    (0.1, 100.0)
2780

2781
    """
2782
    # Set default values for options
2783
    number_of_samples = config._get_param(
9✔
2784
        'freqplot', 'number_of_samples', number_of_samples)
2785
    feature_periphery_decades = config._get_param(
9✔
2786
        'freqplot', 'feature_periphery_decades', feature_periphery_decades, 1)
2787

2788
    # Find the list of all poles and zeros in the systems
2789
    features = np.array(())
9✔
2790
    freq_interesting = []
9✔
2791

2792
    # detect if single sys passed by checking if it is sequence-like
2793
    if not hasattr(syslist, '__iter__'):
9✔
2794
        syslist = (syslist,)
9✔
2795

2796
    for sys in syslist:
9✔
2797
        # For FRD systems, just use the response frequencies
2798
        if isinstance(sys, FrequencyResponseData):
9✔
2799
            # Add the min and max frequency, minus periphery decades
2800
            # (keeps frequency ranges from artificially expanding)
2801
            features = np.concatenate([features, np.array([
9✔
2802
                np.min(sys.omega) * 10**feature_periphery_decades,
2803
                np.max(sys.omega) / 10**feature_periphery_decades])])
2804
            continue
9✔
2805

2806
        try:
9✔
2807
            # Add new features to the list
2808
            if sys.isctime():
9✔
2809
                features_ = np.concatenate(
9✔
2810
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2811
                # Get rid of poles and zeros at the origin
2812
                toreplace = np.isclose(features_, 0.0)
9✔
2813
                if np.any(toreplace):
9✔
2814
                    features_ = features_[~toreplace]
9✔
2815
            elif sys.isdtime(strict=True):
9✔
2816
                fn = math.pi / sys.dt
9✔
2817
                # TODO: What distance to the Nyquist frequency is appropriate?
2818
                freq_interesting.append(fn * 0.9)
9✔
2819

2820
                features_ = np.concatenate(
9✔
2821
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2822
                # Get rid of poles and zeros on the real axis (imag==0)
2823
                # * origin and real < 0
2824
                # * at 1.: would result in omega=0. (logarithmic plot!)
2825
                toreplace = np.isclose(features_.imag, 0.0) & (
9✔
2826
                                    (features_.real <= 0.) |
2827
                                    (np.abs(features_.real - 1.0) < 1.e-10))
2828
                if np.any(toreplace):
9✔
2829
                    features_ = features_[~toreplace]
9✔
2830
                # TODO: improve (mapping pack to continuous time)
2831
                features_ = np.abs(np.log(features_) / (1.j * sys.dt))
9✔
2832
            else:
2833
                # TODO
2834
                raise NotImplementedError(
2835
                    "type of system in not implemented now")
2836
            features = np.concatenate([features, features_])
9✔
2837
        except NotImplementedError:
9✔
2838
            # Don't add any features for anything we don't understand
2839
            pass
9✔
2840

2841
    # Make sure there is at least one point in the range
2842
    if features.shape[0] == 0:
9✔
2843
        features = np.array([1.])
9✔
2844

2845
    if Hz:
9✔
2846
        features /= 2. * math.pi
9✔
2847
    features = np.log10(features)
9✔
2848
    lsp_min = np.rint(np.min(features) - feature_periphery_decades)
9✔
2849
    lsp_max = np.rint(np.max(features) + feature_periphery_decades)
9✔
2850
    if Hz:
9✔
2851
        lsp_min += np.log10(2. * math.pi)
9✔
2852
        lsp_max += np.log10(2. * math.pi)
9✔
2853

2854
    if freq_interesting:
9✔
2855
        lsp_min = min(lsp_min, np.log10(min(freq_interesting)))
9✔
2856
        lsp_max = max(lsp_max, np.log10(max(freq_interesting)))
9✔
2857

2858
    # TODO: Add a check in discrete case to make sure we don't get aliasing
2859
    # (Attention: there is a list of system but only one omega vector)
2860

2861
    # Set the range to be an order of magnitude beyond any features
2862
    if number_of_samples:
9✔
2863
        omega = np.logspace(
9✔
2864
            lsp_min, lsp_max, num=number_of_samples, endpoint=True)
2865
    else:
2866
        omega = np.logspace(lsp_min, lsp_max, endpoint=True)
×
2867
    return omega
9✔
2868

2869

2870
#
2871
# Utility functions to create nice looking labels (KLD 5/23/11)
2872
#
2873

2874
def get_pow1000(num):
9✔
2875
    """Determine exponent for which significance of a number is within the
2876
    range [1, 1000).
2877
    """
2878
    # Based on algorithm from http://www.mail-archive.com/
2879
    # matplotlib-users@lists.sourceforge.net/msg14433.html, accessed 2010/11/7
2880
    # by Jason Heeris 2009/11/18
2881
    from decimal import Decimal
9✔
2882
    from math import floor
9✔
2883
    dnum = Decimal(str(num))
9✔
2884
    if dnum == 0:
9✔
2885
        return 0
9✔
2886
    elif dnum < 0:
9✔
2887
        dnum = -dnum
×
2888
    return int(floor(dnum.log10() / 3))
9✔
2889

2890

2891
def gen_prefix(pow1000):
9✔
2892
    """Return the SI prefix for a power of 1000.
2893
    """
2894
    # Prefixes according to Table 5 of [BIPM 2006] (excluding hecto,
2895
    # deca, deci, and centi).
2896
    if pow1000 < -8 or pow1000 > 8:
9✔
2897
        raise ValueError(
×
2898
            "Value is out of the range covered by the SI prefixes.")
2899
    return ['Y',  # yotta (10^24)
9✔
2900
            'Z',  # zetta (10^21)
2901
            'E',  # exa (10^18)
2902
            'P',  # peta (10^15)
2903
            'T',  # tera (10^12)
2904
            'G',  # giga (10^9)
2905
            'M',  # mega (10^6)
2906
            'k',  # kilo (10^3)
2907
            '',  # (10^0)
2908
            'm',  # milli (10^-3)
2909
            r'$\mu$',  # micro (10^-6)
2910
            'n',  # nano (10^-9)
2911
            'p',  # pico (10^-12)
2912
            'f',  # femto (10^-15)
2913
            'a',  # atto (10^-18)
2914
            'z',  # zepto (10^-21)
2915
            'y'][8 - pow1000]  # yocto (10^-24)
2916

2917

2918
# Function aliases
2919
bode = bode_plot
9✔
2920
nyquist = nyquist_plot
9✔
2921
gangof4 = gangof4_plot
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