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

python-control / python-control / 27166109799

08 Jun 2026 08:50PM UTC coverage: 94.73% (-0.006%) from 94.736%
27166109799

push

github

web-flow
Merge pull request #1223 from marko1olo/fix-nyquist-indent-direction

Honor explicit Nyquist indent direction near imaginary axis

9958 of 10512 relevant lines covered (94.73%)

8.29 hits per line

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

94.83
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

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

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

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

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

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

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

79
        See `FrequencyResponseData.plot` for details.
80

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

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

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

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

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

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

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

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

244
    See Also
245
    --------
246
    frequency_response
247

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

692
        return label
9✔
693

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1094

1095
#
1096
# Nyquist plot
1097
#
1098

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

1118

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

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

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

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

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

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

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

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

1178
        See `nyquist_plot` for details.
1179

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

1183

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

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

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

1195
        See `nyquist_plot` for details.
1196

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

1200

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

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

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

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

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

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

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

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

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

1296
    See Also
1297
    --------
1298
    nyquist_plot
1299

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

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

1315
    indent_direction_arg = 'indent_direction' in _kwargs
9✔
1316

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1473
                        # Figure out which way to offset the contour point
1474
                        if indent_direction_arg:
9✔
1475
                            if indent_direction == 'right':
9✔
1476
                                # Indent to the right
1477
                                splane_contour[i] += offset
9✔
1478

1479
                            elif indent_direction == 'left':
9✔
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
                        elif p.real < 0 or (p.real == 0 and
9✔
1488
                                            indent_direction == 'right'):
1489
                            # Indent to the right
1490
                            splane_contour[i] += offset
9✔
1491

1492
                        elif p.real > 0 or (p.real == 0 and
9✔
1493
                                            indent_direction == 'left'):
1494
                            # Indent to the left
1495
                            splane_contour[i] -= offset
9✔
1496

1497
                        else:
1498
                            raise ValueError(
×
1499
                                "unknown value for indent_direction")
1500

1501
        # change contour to z-plane if necessary
1502
        if sys.isctime():
9✔
1503
            contour = splane_contour
9✔
1504
        else:
1505
            contour = np.exp(splane_contour * sys.dt)
9✔
1506

1507
        # Make sure we don't try to evaluate at a pole
1508
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1509
            if any([pole in contour for pole in sys.poles()]):
9✔
1510
                raise RuntimeError(
9✔
1511
                    "attempt to evaluate at a pole; indent required")
1512

1513
        # Compute the primary curve
1514
        resp = sys(contour)
9✔
1515

1516
        # Compute CW encirclements of -1 by integrating the (unwrapped) angle
1517
        phase = -unwrap(np.angle(resp + 1))
9✔
1518
        encirclements = np.sum(np.diff(phase)) / np.pi
9✔
1519
        count = int(np.round(encirclements, 0))
9✔
1520

1521
        # Let the user know if the count might not make sense
1522
        if abs(encirclements - count) > encirclement_threshold and \
9✔
1523
           warn_encirclements:
1524
            warnings.warn(
9✔
1525
                "number of encirclements was a non-integer value; this can"
1526
                " happen is contour is not closed, possibly based on a"
1527
                " frequency range that does not include zero.")
1528

1529
        #
1530
        # Make sure that the encirclements match the Nyquist criterion
1531
        #
1532
        # If the user specifies the frequency points to use, it is possible
1533
        # to miss encirclements, so we check here to make sure that the
1534
        # Nyquist criterion is actually satisfied.
1535
        #
1536
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1537
            # Count the number of open/closed loop RHP poles
1538
            if sys.isctime():
9✔
1539
                if indent_direction == 'right':
9✔
1540
                    P = (sys.poles().real > 0).sum()
9✔
1541
                else:
1542
                    P = (sys.poles().real >= 0).sum()
9✔
1543
                Z = (sys.feedback().poles().real >= 0).sum()
9✔
1544
            else:
1545
                if indent_direction == 'right':
9✔
1546
                    P = (np.abs(sys.poles()) > 1).sum()
9✔
1547
                else:
1548
                    P = (np.abs(sys.poles()) >= 1).sum()
×
1549
                Z = (np.abs(sys.feedback().poles()) >= 1).sum()
9✔
1550

1551
            # Check to make sure the results make sense; warn if not
1552
            if Z != count + P and warn_encirclements:
9✔
1553
                warnings.warn(
9✔
1554
                    "number of encirclements does not match Nyquist criterion;"
1555
                    " check frequency range and indent radius/direction",
1556
                    UserWarning, stacklevel=2)
1557
            elif indent_direction == 'none' and any(sys.poles().real == 0) \
9✔
1558
                 and warn_encirclements:
1559
                warnings.warn(
×
1560
                    "system has pure imaginary poles but indentation is"
1561
                    " turned off; results may be meaningless",
1562
                    RuntimeWarning, stacklevel=2)
1563

1564
        # Decide on system name
1565
        sysname = sys.name if sys.name is not None else f"Unknown-{idx}"
9✔
1566

1567
        responses.append(NyquistResponseData(
9✔
1568
            count, contour, resp, sys.dt, sysname=sysname,
1569
            return_contour=return_contour))
1570

1571
    if isinstance(sysdata, (list, tuple)):
9✔
1572
        return NyquistResponseList(responses)
9✔
1573
    else:
1574
        return responses[0]
9✔
1575

1576

1577
def nyquist_plot(
9✔
1578
        data, omega=None, plot=None, label_freq=0, color=None, label=None,
1579
        return_contour=None, title=None, ax=None,
1580
        unit_circle=False, mt_circles=None, ms_circles=None, **kwargs):
1581
    """Nyquist plot for a system.
1582

1583
    Generates a Nyquist plot for the system over a (optional) frequency
1584
    range.  The curve is computed by evaluating the Nyquist segment along
1585
    the positive imaginary axis, with a mirror image generated to reflect
1586
    the negative imaginary axis.  Poles on or near the imaginary axis are
1587
    avoided using a small indentation.  The portion of the Nyquist contour
1588
    at infinity is not explicitly computed (since it maps to a constant
1589
    value for any system with a proper transfer function).
1590

1591
    Parameters
1592
    ----------
1593
    data : list of `LTI` or `NyquistResponseData`
1594
        List of linear input/output systems (single system is OK) or
1595
        Nyquist responses (computed using `nyquist_response`).
1596
        Nyquist curves for each system are plotted on the same graph.
1597
    omega : array_like, optional
1598
        Set of frequencies to be evaluated, in rad/sec. Specifying
1599
        `omega` as a list of two elements is equivalent to providing
1600
        `omega_limits`.
1601
    unit_circle : bool, optional
1602
        If True, display the unit circle, to read gain crossover
1603
        frequency.  The circle style is determined by
1604
        `config.defaults['nyquist.circle_style']`.
1605
    mt_circles : array_like, optional
1606
        Draw circles corresponding to the given magnitudes of sensitivity.
1607
    ms_circles : array_like, optional
1608
        Draw circles corresponding to the given magnitudes of complementary
1609
        sensitivity.
1610
    **kwargs : `matplotlib.pyplot.plot` keyword properties, optional
1611
        Additional keywords passed to `matplotlib` to specify line properties.
1612

1613
    Returns
1614
    -------
1615
    cplt : `ControlPlot` object
1616
        Object containing the data that were plotted.  See `ControlPlot`
1617
        for more detailed information.
1618
    cplt.lines : 2D array of `matplotlib.lines.Line2D`
1619
        Array containing information on each line in the plot.  The shape
1620
        of the array is given by (nsys, 4) where nsys is the number of
1621
        systems or Nyquist responses passed to the function.  The second
1622
        index specifies the segment type:
1623

1624
            - lines[idx, 0]: unscaled portion of the primary curve
1625
            - lines[idx, 1]: scaled portion of the primary curve
1626
            - lines[idx, 2]: unscaled portion of the mirror curve
1627
            - lines[idx, 3]: scaled portion of the mirror curve
1628

1629
    cplt.axes : 2D array of `matplotlib.axes.Axes`
1630
        Axes for each subplot.
1631
    cplt.figure : `matplotlib.figure.Figure`
1632
        Figure containing the plot.
1633
    cplt.legend : 2D array of `matplotlib.legend.Legend`
1634
        Legend object(s) contained in the plot.
1635

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

1749
    See Also
1750
    --------
1751
    nyquist_response
1752

1753
    Notes
1754
    -----
1755
    If a discrete-time model is given, the frequency response is computed
1756
    along the upper branch of the unit circle, using the mapping ``z =
1757
    exp(1j * omega * dt)`` where `omega` ranges from 0 to pi/`dt` and
1758
    `dt` is the discrete timebase.  If timebase not specified
1759
    (`dt` = True), `dt` is set to 1.
1760

1761
    If a continuous-time system contains poles on or near the imaginary
1762
    axis, a small indentation will be used to avoid the pole.  The radius
1763
    of the indentation is given by `indent_radius` and it is taken to the
1764
    right of stable poles and the left of unstable poles.  If a pole is
1765
    exactly on the imaginary axis, the `indent_direction` parameter can be
1766
    used to set the direction of indentation.  Setting `indent_direction`
1767
    to 'none' will turn off indentation.  If `return_contour` is True,
1768
    the exact contour used for evaluation is returned.
1769

1770
    For those portions of the Nyquist plot in which the contour is indented
1771
    to avoid poles, resulting in a scaling of the Nyquist plot, the line
1772
    styles are according to the settings of the `primary_style` and
1773
    `mirror_style` keywords.  By default the scaled portions of the primary
1774
    curve use a dashdot line style and the scaled portions of the mirror
1775
    image use a dotted line style.
1776

1777
    Examples
1778
    --------
1779
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1780
    >>> out = ct.nyquist_plot(G)
1781

1782
    """
1783
    #
1784
    # Keyword processing
1785
    #
1786
    # Keywords for the nyquist_plot function can either be keywords that
1787
    # are unique to this function, keywords that are intended for use by
1788
    # nyquist_response (if data is a list of systems), or keywords that
1789
    # are intended for the plotting commands.
1790
    #
1791
    # We first pop off all keywords that are used directly by this
1792
    # function.  If data is a list of systems, when then pop off keywords
1793
    # that correspond to nyquist_response() keywords.  The remaining
1794
    # keywords are passed to matplotlib (and will generate an error if
1795
    # unrecognized).
1796
    #
1797

1798
    # Get values for params (and pop from list to allow keyword use in plot)
1799
    arrows = config._get_param(
9✔
1800
        'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True)
1801
    arrow_size = config._get_param(
9✔
1802
        'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True)
1803
    arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None)
9✔
1804
    ax_user = ax
9✔
1805
    max_curve_magnitude = config._get_param(
9✔
1806
        'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True)
1807
    blend_fraction = config._get_param(
9✔
1808
        'nyquist', 'blend_fraction', kwargs, _nyquist_defaults, pop=True)
1809
    max_curve_offset = config._get_param(
9✔
1810
        'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True)
1811
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
1812
    start_marker = config._get_param(
9✔
1813
        'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True)
1814
    start_marker_size = config._get_param(
9✔
1815
        'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True)
1816
    title_frame = config._get_param(
9✔
1817
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
1818

1819
    # Set line styles for the curves
1820
    def _parse_linestyle(style_name, allow_false=False):
9✔
1821
        style = config._get_param(
9✔
1822
            'nyquist', style_name, kwargs, _nyquist_defaults, pop=True)
1823
        if isinstance(style, str):
9✔
1824
            # Only one style provided, use the default for the other
1825
            style = [style, _nyquist_defaults['nyquist.' + style_name][1]]
9✔
1826
            warnings.warn(
9✔
1827
                "use of a single string for linestyle will be deprecated "
1828
                " in a future release", PendingDeprecationWarning)
1829
        if (allow_false and style is False) or \
9✔
1830
           (isinstance(style, list) and len(style) == 2):
1831
            return style
9✔
1832
        else:
1833
            raise ValueError(f"invalid '{style_name}': {style}")
9✔
1834

1835
    primary_style = _parse_linestyle('primary_style')
9✔
1836
    mirror_style = _parse_linestyle('mirror_style', allow_false=True)
9✔
1837

1838
    # Parse the arrows keyword
1839
    if not arrows:
9✔
1840
        arrow_pos = []
9✔
1841
    elif isinstance(arrows, int):
9✔
1842
        N = arrows
9✔
1843
        # Space arrows out, starting midway along each "region"
1844
        arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False)
9✔
1845
    elif isinstance(arrows, (list, np.ndarray)):
9✔
1846
        arrow_pos = np.sort(np.atleast_1d(arrows))
9✔
1847
    else:
1848
        raise ValueError("unknown or unsupported arrow location")
9✔
1849

1850
    # Set the arrow style
1851
    if arrow_style is None:
9✔
1852
        arrow_style = mpl.patches.ArrowStyle(
9✔
1853
            'simple', head_width=arrow_size, head_length=arrow_size)
1854

1855
    # If argument was a singleton, turn it into a tuple
1856
    if not isinstance(data, (list, tuple)):
9✔
1857
        data = [data]
9✔
1858

1859
    # Process label keyword
1860
    line_labels = _process_line_labels(label, len(data))
9✔
1861

1862
    # If we are passed a list of systems, compute response first
1863
    if all([isinstance(
9✔
1864
            sys, (StateSpace, TransferFunction, FrequencyResponseData))
1865
            for sys in data]):
1866
        # Get the response; pop explicit keywords here, kwargs in _response()
1867
        nyquist_responses = nyquist_response(
9✔
1868
            data, omega=omega, return_contour=return_contour,
1869
            omega_limits=kwargs.pop('omega_limits', None),
1870
            omega_num=kwargs.pop('omega_num', None),
1871
            warn_encirclements=kwargs.pop('warn_encirclements', True),
1872
            warn_nyquist=kwargs.pop('warn_nyquist', True),
1873
            _kwargs=kwargs, _check_kwargs=False)
1874
    else:
1875
        nyquist_responses = data
9✔
1876

1877
    # Legacy return value processing
1878
    if plot is not None or return_contour is not None:
9✔
1879
        warnings.warn(
9✔
1880
            "nyquist_plot() return value of count[, contour] is deprecated; "
1881
            "use nyquist_response()", FutureWarning)
1882

1883
        # Extract out the values that we will eventually return
1884
        counts = [response.count for response in nyquist_responses]
9✔
1885
        contours = [response.contour for response in nyquist_responses]
9✔
1886

1887
    if plot is False:
9✔
1888
        # Make sure we used all of the keywords
1889
        if kwargs:
9✔
1890
            raise TypeError("unrecognized keywords: ", str(kwargs))
×
1891

1892
        if len(data) == 1:
9✔
1893
            counts, contours = counts[0], contours[0]
9✔
1894

1895
        # Return counts and (optionally) the contour we used
1896
        return (counts, contours) if return_contour else counts
9✔
1897

1898
    fig, ax = _process_ax_keyword(
9✔
1899
        ax_user, shape=(1, 1), squeeze=True, rcParams=rcParams)
1900
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
1901
        kwargs, None, 'upper right')
1902

1903
    # Figure out where the blended curve should start
1904
    if blend_fraction < 0 or blend_fraction > 1:
9✔
1905
        raise ValueError("blend_fraction must be between 0 and 1")
9✔
1906
    blend_curve_start = (1 - blend_fraction) * max_curve_magnitude
9✔
1907

1908
    # Create a list of lines for the output
1909
    out = np.empty((len(nyquist_responses), 4), dtype=object)
9✔
1910
    for i in range(len(nyquist_responses)):
9✔
1911
        for j in range(4):
9✔
1912
            out[i, j] = []      # unique list in each element
9✔
1913

1914
    for idx, response in enumerate(nyquist_responses):
9✔
1915
        resp = response.response
9✔
1916
        if response.dt in [0, None]:
9✔
1917
            splane_contour = response.contour
9✔
1918
        else:
1919
            splane_contour = np.log(response.contour) / response.dt
9✔
1920

1921
        # Find the different portions of the curve (with scaled pts marked)
1922
        reg_mask = np.logical_or(
9✔
1923
            np.abs(resp) > blend_curve_start,
1924
            np.logical_not(np.isclose(splane_contour.real, 0)))
1925

1926
        scale_mask = ~reg_mask \
9✔
1927
            & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \
1928
            & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1]))
1929

1930
        # Rescale the points with large magnitude
1931
        rescale_idx = (np.abs(resp) > blend_curve_start)
9✔
1932

1933
        if np.any(rescale_idx):  # Only process if rescaling is needed
9✔
1934
            subset = resp[rescale_idx]
9✔
1935
            abs_subset = np.abs(subset)
9✔
1936
            unit_vectors = subset / abs_subset  # Preserve phase/direction
9✔
1937

1938
            if blend_curve_start == max_curve_magnitude:
9✔
1939
                # Clip at max_curve_magnitude
1940
                resp[rescale_idx] = max_curve_magnitude * unit_vectors
9✔
1941
            else:
1942
                # Logistic scaling
1943
                newmag = blend_curve_start + \
9✔
1944
                    (max_curve_magnitude - blend_curve_start) * \
1945
                    (abs_subset - blend_curve_start) / \
1946
                    (abs_subset + max_curve_magnitude - 2 * blend_curve_start)
1947
                resp[rescale_idx] = newmag * unit_vectors
9✔
1948

1949
        # Get the label to use for the line
1950
        label = response.sysname if line_labels is None else line_labels[idx]
9✔
1951

1952
        # Plot the regular portions of the curve (and grab the color)
1953
        x_reg = np.ma.masked_where(reg_mask, resp.real)
9✔
1954
        y_reg = np.ma.masked_where(reg_mask, resp.imag)
9✔
1955
        p = ax.plot(
9✔
1956
            x_reg, y_reg, primary_style[0], color=color, label=label, **kwargs)
1957
        c = p[0].get_color()
9✔
1958
        out[idx, 0] += p
9✔
1959

1960
        # Figure out how much to offset the curve: the offset goes from
1961
        # zero at the start of the scaled section to max_curve_offset as
1962
        # we move along the curve
1963
        curve_offset = _compute_curve_offset(
9✔
1964
            resp, scale_mask, max_curve_offset)
1965

1966
        # Plot the scaled sections of the curve (changing linestyle)
1967
        x_scl = np.ma.masked_where(scale_mask, resp.real)
9✔
1968
        y_scl = np.ma.masked_where(scale_mask, resp.imag)
9✔
1969
        if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1970
            out[idx, 1] += ax.plot(
9✔
1971
                x_scl * (1 + curve_offset),
1972
                y_scl * (1 + curve_offset),
1973
                primary_style[1], color=c, **kwargs)
1974
        else:
1975
            out[idx, 1] += [None]
9✔
1976

1977
        # Plot the primary curve (invisible) for setting arrows
1978
        x, y = resp.real.copy(), resp.imag.copy()
9✔
1979
        x[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1980
        y[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1981
        p = ax.plot(x, y, linestyle='None', color=c)
9✔
1982

1983
        # Add arrows
1984
        _add_arrows_to_line2D(
9✔
1985
            ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1)
1986

1987
        # Plot the mirror image
1988
        if mirror_style is not False:
9✔
1989
            # Plot the regular and scaled segments
1990
            out[idx, 2] += ax.plot(
9✔
1991
                x_reg, -y_reg, mirror_style[0], color=c, **kwargs)
1992
            if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1993
                out[idx, 3] += ax.plot(
9✔
1994
                    x_scl * (1 - curve_offset),
1995
                    -y_scl * (1 - curve_offset),
1996
                    mirror_style[1], color=c, **kwargs)
1997
            else:
1998
                out[idx, 3] += [None]
9✔
1999

2000
            # Add the arrows (on top of an invisible contour)
2001
            x, y = resp.real.copy(), resp.imag.copy()
9✔
2002
            x[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
2003
            y[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
2004
            p = ax.plot(x, -y, linestyle='None', color=c, **kwargs)
9✔
2005
            _add_arrows_to_line2D(
9✔
2006
                ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1)
2007
        else:
2008
            out[idx, 2] += [None]
9✔
2009
            out[idx, 3] += [None]
9✔
2010

2011
        # Mark the start of the curve
2012
        if start_marker:
9✔
2013
            segment = 0 if 0 in rescale_idx else 1      # regular vs scaled
9✔
2014
            out[idx, segment] += ax.plot(
9✔
2015
                resp[0].real, resp[0].imag, start_marker,
2016
                color=c, markersize=start_marker_size)
2017

2018
        # Mark the -1 point
2019
        ax.plot([-1], [0], 'r+')
9✔
2020

2021
        #
2022
        # Draw circles for gain crossover and sensitivity functions
2023
        #
2024
        theta = np.linspace(0, 2*np.pi, 100)
9✔
2025
        cos = np.cos(theta)
9✔
2026
        sin = np.sin(theta)
9✔
2027
        label_pos = 15
9✔
2028

2029
        # Display the unit circle, to read gain crossover frequency
2030
        if unit_circle:
9✔
2031
            ax.plot(cos, sin, **config.defaults['nyquist.circle_style'])
9✔
2032

2033
        # Draw circles for given magnitudes of sensitivity
2034
        if ms_circles is not None:
9✔
2035
            for ms in ms_circles:
9✔
2036
                pos_x = -1 + (1/ms)*cos
9✔
2037
                pos_y = (1/ms)*sin
9✔
2038
                ax.plot(
9✔
2039
                    pos_x, pos_y, **config.defaults['nyquist.circle_style'])
2040
                ax.text(pos_x[label_pos], pos_y[label_pos], ms)
9✔
2041

2042
        # Draw circles for given magnitudes of complementary sensitivity
2043
        if mt_circles is not None:
9✔
2044
            for mt in mt_circles:
9✔
2045
                if mt != 1:
9✔
2046
                    ct = -mt**2/(mt**2-1)  # Mt center
9✔
2047
                    rt = mt/(mt**2-1)  # Mt radius
9✔
2048
                    pos_x = ct+rt*cos
9✔
2049
                    pos_y = rt*sin
9✔
2050
                    ax.plot(
9✔
2051
                        pos_x, pos_y,
2052
                        **config.defaults['nyquist.circle_style'])
2053
                    ax.text(pos_x[label_pos], pos_y[label_pos], mt)
9✔
2054
                else:
2055
                    _, _, ymin, ymax = ax.axis()
9✔
2056
                    pos_y = np.linspace(ymin, ymax, 100)
9✔
2057
                    ax.vlines(
9✔
2058
                        -0.5, ymin=ymin, ymax=ymax,
2059
                        **config.defaults['nyquist.circle_style'])
2060
                    ax.text(-0.5, pos_y[label_pos], 1)
9✔
2061

2062
        # Label the frequencies of the points on the Nyquist curve
2063
        if label_freq:
9✔
2064
            ind = slice(None, None, label_freq)
9✔
2065
            omega_sys = np.imag(splane_contour[np.real(splane_contour) == 0])
9✔
2066
            for xpt, ypt, omegapt in zip(x[ind], y[ind], omega_sys[ind]):
9✔
2067
                # Convert to Hz
2068
                f = omegapt / (2 * np.pi)
9✔
2069

2070
                # Factor out multiples of 1000 and limit the
2071
                # result to the range [-8, 8].
2072
                pow1000 = max(min(get_pow1000(f), 8), -8)
9✔
2073

2074
                # Get the SI prefix.
2075
                prefix = gen_prefix(pow1000)
9✔
2076

2077
                # Apply the text. (Use a space before the text to
2078
                # prevent overlap with the data.)
2079
                #
2080
                # np.round() is used because 0.99... appears
2081
                # instead of 1.0, and this would otherwise be
2082
                # truncated to 0.
2083
                ax.text(xpt, ypt, ' ' +
9✔
2084
                         str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' +
2085
                         prefix + 'Hz')
2086

2087
    # Label the axes
2088
    ax.set_xlabel("Real axis")
9✔
2089
    ax.set_ylabel("Imaginary axis")
9✔
2090
    ax.grid(color="lightgray")
9✔
2091

2092
    # List of systems that are included in this plot
2093
    lines, labels = _get_line_labels(ax)
9✔
2094

2095
    # Add legend if there is more than one system plotted
2096
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2097
        with plt.rc_context(rcParams):
9✔
2098
            legend = ax.legend(lines, labels, loc=legend_loc)
9✔
2099
    else:
2100
        legend = None
9✔
2101

2102
    # Add the title
2103
    sysnames = [response.sysname for response in nyquist_responses]
9✔
2104
    if ax_user is None and title is None:
9✔
2105
        title = "Nyquist plot for " + ", ".join(sysnames)
9✔
2106
        _update_plot_title(
9✔
2107
            title, fig=fig, rcParams=rcParams, frame=title_frame)
2108
    elif ax_user is None:
9✔
2109
        _update_plot_title(
9✔
2110
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2111
            use_existing=False)
2112

2113
    # Legacy return processing
2114
    if plot is True or return_contour is not None:
9✔
2115
        if len(data) == 1:
9✔
2116
            counts, contours = counts[0], contours[0]
9✔
2117

2118
        # Return counts and (optionally) the contour we used
2119
        return (counts, contours) if return_contour else counts
9✔
2120

2121
    return ControlPlot(out, ax, fig, legend=legend)
9✔
2122

2123

2124
#
2125
# Function to compute Nyquist curve offsets
2126
#
2127
# This function computes a smoothly varying offset that starts and ends at
2128
# zero at the ends of a scaled segment.
2129
#
2130
def _compute_curve_offset(resp, mask, max_offset):
9✔
2131
    # Compute the arc length along the curve
2132
    s_curve = np.cumsum(
9✔
2133
        np.sqrt(np.diff(resp.real) ** 2 + np.diff(resp.imag) ** 2))
2134

2135
    # Initialize the offset
2136
    offset = np.zeros(resp.size)
9✔
2137
    arclen = np.zeros(resp.size)
9✔
2138

2139
    # Walk through the response and keep track of each continuous component
2140
    i, nsegs = 0, 0
9✔
2141
    while i < resp.size:
9✔
2142
        # Skip the regular segment
2143
        while i < resp.size and mask[i]:
9✔
2144
            i += 1              # Increment the counter
9✔
2145
            if i == resp.size:
9✔
2146
                break
9✔
2147
            # Keep track of the arclength
2148
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2149

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

2154
        # Save the starting offset of this segment
2155
        seg_start = i
9✔
2156

2157
        # Walk through the scaled segment
2158
        while i < resp.size and not mask[i]:
9✔
2159
            i += 1
9✔
2160
            if i == resp.size:  # See if we are done with this segment
9✔
2161
                break
×
2162
            # Keep track of the arclength
2163
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2164

2165
        nsegs += 0.5
9✔
2166
        if i == resp.size:
9✔
2167
            break
×
2168

2169
        # Save the ending offset of this segment
2170
        seg_end = i
9✔
2171

2172
        # Now compute the scaling for this segment
2173
        s_segment = arclen[seg_end-1] - arclen[seg_start]
9✔
2174
        offset[seg_start:seg_end] = max_offset * s_segment/s_curve[-1] * \
9✔
2175
            np.sin(np.pi * (arclen[seg_start:seg_end]
2176
                            - arclen[seg_start])/s_segment)
2177

2178
    return offset
9✔
2179

2180

2181
#
2182
# Gang of Four plot
2183
#
2184
def gangof4_response(
9✔
2185
        P, C, omega=None, omega_limits=None, omega_num=None, Hz=False):
2186
    """Compute response of "Gang of 4" transfer functions.
2187

2188
    Generates a 2x2 frequency response for the "Gang of 4" sensitivity
2189
    functions [T, PS; CS, S].
2190

2191
    Parameters
2192
    ----------
2193
    P, C : LTI
2194
        Linear input/output systems (process and control).
2195
    omega : array
2196
        Range of frequencies (list or bounds) in rad/sec.
2197
    omega_limits : array_like of two values
2198
        Set limits for plotted frequency range. If Hz=True the limits are
2199
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2200
        elements is equivalent to providing `omega_limits`. Ignored if
2201
        data is not a list of systems.
2202
    omega_num : int
2203
        Number of samples to use for the frequency range.  Defaults to
2204
        `config.defaults['freqplot.number_of_samples']`.  Ignored if data is
2205
        not a list of systems.
2206
    Hz : bool, optional
2207
        If True, when computing frequency limits automatically set
2208
        limits to full decades in Hz instead of rad/s.
2209

2210
    Returns
2211
    -------
2212
    response : `FrequencyResponseData`
2213
        Frequency response with inputs 'r' and 'd' and outputs 'y', and 'u'
2214
        representing the 2x2 matrix of transfer functions in the Gang of 4.
2215

2216
    Examples
2217
    --------
2218
    >>> P = ct.tf([1], [1, 1])
2219
    >>> C = ct.tf([2], [1])
2220
    >>> response = ct.gangof4_response(P, C)
2221
    >>> cplt = response.plot()
2222

2223
    """
2224
    if not P.issiso() or not C.issiso():
9✔
2225
        # TODO: Add MIMO go4 plots.
2226
        raise ControlMIMONotImplemented(
×
2227
            "Gang of four is currently only implemented for SISO systems.")
2228

2229
    # Compute the sensitivity functions
2230
    L = P * C
9✔
2231
    S = feedback(1, L)
9✔
2232
    T = L * S
9✔
2233

2234
    # Select a default range if none is provided
2235
    # TODO: This needs to be made more intelligent
2236
    omega, _ = _determine_omega_vector(
9✔
2237
        [P, C, S], omega, omega_limits, omega_num, Hz=Hz)
2238

2239
    #
2240
    # bode_plot based implementation
2241
    #
2242

2243
    # Compute the response of the Gang of 4
2244
    resp_T = T(1j * omega)
9✔
2245
    resp_PS = (P * S)(1j * omega)
9✔
2246
    resp_CS = (C * S)(1j * omega)
9✔
2247
    resp_S = S(1j * omega)
9✔
2248

2249
    # Create a single frequency response data object with the underlying data
2250
    data = np.empty((2, 2, omega.size), dtype=complex)
9✔
2251
    data[0, 0, :] = resp_T
9✔
2252
    data[0, 1, :] = resp_PS
9✔
2253
    data[1, 0, :] = resp_CS
9✔
2254
    data[1, 1, :] = resp_S
9✔
2255

2256
    return FrequencyResponseData(
9✔
2257
        data, omega, outputs=['y', 'u'], inputs=['r', 'd'],
2258
        title=f"Gang of Four for P={P.name}, C={C.name}",
2259
        sysname=f"P={P.name}, C={C.name}", plot_phase=False)
2260

2261

2262
def gangof4_plot(
9✔
2263
        *args, omega=None, omega_limits=None, omega_num=None,
2264
        Hz=False, **kwargs):
2265
    """gangof4_plot(response) \
2266
    gangof4_plot(P, C, omega)
2267

2268
    Plot response of "Gang of 4" transfer functions.
2269

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

2273
        gangof4_plot(response[, ...])
2274
        gangof4_plot(P, C[, ...])
2275

2276
    Parameters
2277
    ----------
2278
    response : FrequencyPlotData
2279
        Gang of 4 frequency response from `gangof4_response`.
2280
    P, C : LTI
2281
        Linear input/output systems (process and control).
2282
    omega : array
2283
        Range of frequencies (list or bounds) in rad/sec.
2284
    omega_limits : array_like of two values
2285
        Set limits for plotted frequency range. If Hz=True the limits are
2286
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2287
        elements is equivalent to providing `omega_limits`. Ignored if
2288
        data is not a list of systems.
2289
    omega_num : int
2290
        Number of samples to use for the frequency range.  Defaults to
2291
        `config.defaults['freqplot.number_of_samples']`.  Ignored if data is
2292
        not a list of systems.
2293
    Hz : bool, optional
2294
        If True, when computing frequency limits automatically set
2295
        limits to full decades in Hz instead of rad/s.
2296

2297
    Returns
2298
    -------
2299
    cplt : `ControlPlot` object
2300
        Object containing the data that were plotted.  See `ControlPlot`
2301
        for more detailed information.
2302
    cplt.lines : 2x2 array of `matplotlib.lines.Line2D`
2303
        Array containing information on each line in the plot.  The value
2304
        of each array entry is a list of Line2D objects in that subplot.
2305
    cplt.axes : 2D array of `matplotlib.axes.Axes`
2306
        Axes for each subplot.
2307
    cplt.figure : `matplotlib.figure.Figure`
2308
        Figure containing the plot.
2309
    cplt.legend : 2D array of `matplotlib.legend.Legend`
2310
        Legend object(s) contained in the plot.
2311

2312
    """
2313
    if len(args) == 1 and isinstance(args[0], FrequencyResponseData):
9✔
2314
        if any([kw is not None
×
2315
                for kw in [omega, omega_limits, omega_num, Hz]]):
2316
            raise ValueError(
×
2317
                "omega, omega_limits, omega_num, Hz not allowed when "
2318
                "given a Gang of 4 response as first argument")
2319
        return args[0].plot(kwargs)
×
2320
    else:
2321
        if len(args) > 3:
9✔
2322
            raise TypeError(
×
2323
                f"expecting 2 or 3 positional arguments; received {len(args)}")
2324
        omega = omega if len(args) < 3 else args[2]
9✔
2325
        args = args[0:2]
9✔
2326
        return gangof4_response(
9✔
2327
            *args, omega=omega, omega_limits=omega_limits,
2328
            omega_num=omega_num, Hz=Hz).plot(**kwargs)
2329

2330

2331
#
2332
# Singular values plot
2333
#
2334
def singular_values_response(
9✔
2335
        sysdata, omega=None, omega_limits=None, omega_num=None, Hz=False):
2336
    """Singular value response for a system.
2337

2338
    Computes the singular values for a system or list of systems over
2339
    a (optional) frequency range.
2340

2341
    Parameters
2342
    ----------
2343
    sysdata : LTI or list of LTI
2344
        List of linear input/output systems (single system is OK).
2345
    omega : array_like
2346
        List of frequencies in rad/sec to be used for frequency response.
2347
    Hz : bool, optional
2348
        If True, when computing frequency limits automatically set
2349
        limits to full decades in Hz instead of rad/s.
2350

2351
    Returns
2352
    -------
2353
    response : `FrequencyResponseData`
2354
        Frequency response with the number of outputs equal to the
2355
        number of singular values in the response, and a single input.
2356

2357
    Other Parameters
2358
    ----------------
2359
    omega_limits : array_like of two values
2360
        Set limits for plotted frequency range. If Hz=True the limits are
2361
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2362
        elements is equivalent to providing `omega_limits`.
2363
    omega_num : int, optional
2364
        Number of samples to use for the frequency range.  Defaults to
2365
        `config.defaults['freqplot.number_of_samples']`.
2366

2367
    See Also
2368
    --------
2369
    singular_values_plot
2370

2371
    Examples
2372
    --------
2373
    >>> omegas = np.logspace(-4, 1, 1000)
2374
    >>> den = [75, 1]
2375
    >>> G = ct.tf([[[87.8], [-86.4]], [[108.2], [-109.6]]],
2376
    ...           [[den, den], [den, den]])
2377
    >>> response = ct.singular_values_response(G, omega=omegas)
2378

2379
    """
2380
    # Convert the first argument to a list
2381
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
2382

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

2386
    # Compute the frequency responses for the systems
2387
    responses = frequency_response(
9✔
2388
        syslist, omega=omega, omega_limits=omega_limits,
2389
        omega_num=omega_num, Hz=Hz, squeeze=False)
2390

2391
    # Calculate the singular values for each system in the list
2392
    svd_responses = []
9✔
2393
    for response in responses:
9✔
2394
        # Compute the singular values (permute indices to make things work)
2395
        fresp_permuted = response.frdata.transpose((2, 0, 1))
9✔
2396
        sigma = np.linalg.svd(fresp_permuted, compute_uv=False).transpose()
9✔
2397
        sigma_fresp = sigma.reshape(sigma.shape[0], 1, sigma.shape[1])
9✔
2398

2399
        # Save the singular values as an FRD object
2400
        svd_responses.append(
9✔
2401
            FrequencyResponseData(
2402
                sigma_fresp, response.omega, _return_singvals=True,
2403
                outputs=[f'$\\sigma_{{{k+1}}}$' for k in range(sigma.shape[0])],
2404
                inputs='inputs', dt=response.dt, plot_phase=False,
2405
                sysname=response.sysname, plot_type='svplot',
2406
                title=f"Singular values for {response.sysname}"))
2407

2408
    if isinstance(sysdata, (list, tuple)):
9✔
2409
        return FrequencyResponseList(svd_responses)
9✔
2410
    else:
2411
        return svd_responses[0]
9✔
2412

2413

2414
def singular_values_plot(
9✔
2415
        data, omega=None, *fmt, plot=None, omega_limits=None, omega_num=None,
2416
        ax=None, label=None, title=None, **kwargs):
2417
    """Plot the singular values for a system.
2418

2419
    Plot the singular values as a function of frequency for a system or
2420
    list of systems.  If multiple systems are plotted, each system in the
2421
    list is plotted in a different color.
2422

2423
    Parameters
2424
    ----------
2425
    data : list of `FrequencyResponseData`
2426
        List of `FrequencyResponseData` objects.  For backward
2427
        compatibility, a list of LTI systems can also be given.
2428
    omega : array_like
2429
        List of frequencies in rad/sec over to plot over.
2430
    *fmt : `matplotlib.pyplot.plot` format string, optional
2431
        Passed to `matplotlib` as the format string for all lines in the plot.
2432
        The `omega` parameter must be present (use omega=None if needed).
2433
    dB : bool
2434
        If True, plot result in dB.  Default is False.
2435
    Hz : bool
2436
        If True, plot frequency in Hz (omega must be provided in rad/sec).
2437
        Default value (False) set by `config.defaults['freqplot.Hz']`.
2438
    **kwargs : `matplotlib.pyplot.plot` keyword properties, optional
2439
        Additional keywords passed to `matplotlib` to specify line properties.
2440

2441
    Returns
2442
    -------
2443
    cplt : `ControlPlot` object
2444
        Object containing the data that were plotted.  See `ControlPlot`
2445
        for more detailed information.
2446
    cplt.lines : array of `matplotlib.lines.Line2D`
2447
        Array containing information on each line in the plot.  The size of
2448
        the array matches the number of systems and the value of the array
2449
        is a list of Line2D objects for that system.
2450
    cplt.axes : 2D array of `matplotlib.axes.Axes`
2451
        Axes for each subplot.
2452
    cplt.figure : `matplotlib.figure.Figure`
2453
        Figure containing the plot.
2454
    cplt.legend : 2D array of `matplotlib.legend.Legend`
2455
        Legend object(s) contained in the plot.
2456

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

2502
    See Also
2503
    --------
2504
    singular_values_response
2505

2506
    Notes
2507
    -----
2508
    If `plot` = False, the following legacy values are returned:
2509
       * `mag` : ndarray (or list of ndarray if len(data) > 1))
2510
           Magnitude of the response (deprecated).
2511
       * `phase` : ndarray (or list of ndarray if len(data) > 1))
2512
           Phase in radians of the response (deprecated).
2513
       * `omega` : ndarray (or list of ndarray if len(data) > 1))
2514
           Frequency in rad/sec (deprecated).
2515

2516
    """
2517
    # Keyword processing
2518
    color = kwargs.pop('color', None)
9✔
2519
    dB = config._get_param(
9✔
2520
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
2521
    Hz = config._get_param(
9✔
2522
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
2523
    grid = config._get_param(
9✔
2524
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
2525
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
2526
    title_frame = config._get_param(
9✔
2527
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
2528

2529
    # If argument was a singleton, turn it into a tuple
2530
    data = data if isinstance(data, (list, tuple)) else (data,)
9✔
2531

2532
    # Convert systems into frequency responses
2533
    if any([isinstance(response, (StateSpace, TransferFunction))
9✔
2534
            for response in data]):
2535
        responses = singular_values_response(
9✔
2536
                    data, omega=omega, omega_limits=omega_limits,
2537
                    omega_num=omega_num)
2538
    else:
2539
        # Generate warnings if frequency keywords were given
2540
        if omega_num is not None:
9✔
2541
            warnings.warn("`omega_num` ignored when passed response data")
9✔
2542
        elif omega is not None:
9✔
2543
            warnings.warn("`omega` ignored when passed response data")
9✔
2544

2545
        # Check to make sure omega_limits is sensible
2546
        if omega_limits is not None and \
9✔
2547
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
2548
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
2549

2550
        responses = data
9✔
2551

2552
    # Process label keyword
2553
    line_labels = _process_line_labels(label, len(data))
9✔
2554

2555
    # Process (legacy) plot keyword
2556
    if plot is not None:
9✔
2557
        warnings.warn(
×
2558
            "`singular_values_plot` return values of sigma, omega is "
2559
            "deprecated; use singular_values_response()", FutureWarning)
2560

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

2566
    # Extract the data we need for plotting
2567
    sigmas = [np.real(response.frdata[:, 0, :]) for response in responses]
9✔
2568
    omegas = [response.omega for response in responses]
9✔
2569

2570
    # Legacy processing for no plotting case
2571
    if plot is False:
9✔
2572
        if len(data) == 1:
×
2573
            return sigmas[0], omegas[0]
×
2574
        else:
2575
            return sigmas, omegas
×
2576

2577
    fig, ax_sigma = _process_ax_keyword(
9✔
2578
        ax, shape=(1, 1), squeeze=True, rcParams=rcParams)
2579
    ax_sigma.set_label('control-sigma')         # TODO: deprecate?
9✔
2580
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
2581
        kwargs, None, 'center right')
2582

2583
    # Get color offset for first (new) line to be drawn
2584
    color_offset, color_cycle = _get_color_offset(ax_sigma)
9✔
2585

2586
    # Create a list of lines for the output
2587
    out = np.empty(len(data), dtype=object)
9✔
2588

2589
    # Plot the singular values for each response
2590
    for idx_sys, response in enumerate(responses):
9✔
2591
        sigma = sigmas[idx_sys].transpose()     # frequency first for plotting
9✔
2592
        omega = omegas[idx_sys] / (2 * math.pi) if Hz else  omegas[idx_sys]
9✔
2593

2594
        if response.isdtime(strict=True):
9✔
2595
            nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
2596
        else:
2597
            nyq_freq = None
9✔
2598

2599
        # Determine the color to use for this response
2600
        current_color = _get_color(
9✔
2601
            color, fmt=fmt, offset=color_offset + idx_sys,
2602
            color_cycle=color_cycle)
2603

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

2607
        # Decide on the system name
2608
        sysname = response.sysname if response.sysname is not None \
9✔
2609
            else f"Unknown-{idx_sys}"
2610

2611
        # Get the label to use for the line
2612
        label = sysname if line_labels is None else line_labels[idx_sys]
9✔
2613

2614
        # Plot the data
2615
        if dB:
9✔
2616
            out[idx_sys] = ax_sigma.semilogx(
9✔
2617
                omega, 20 * np.log10(sigma), *fmt,
2618
                label=label, **color_arg, **kwargs)
2619
        else:
2620
            out[idx_sys] = ax_sigma.loglog(
9✔
2621
                omega, sigma, label=label, *fmt, **color_arg, **kwargs)
2622

2623
        # Plot the Nyquist frequency
2624
        if nyq_freq is not None:
9✔
2625
            ax_sigma.axvline(
9✔
2626
                nyq_freq, linestyle='--', label='_nyq_freq_' + sysname,
2627
                **color_arg)
2628

2629
    # If specific omega_limits were given, use them
2630
    if omega_limits is not None:
9✔
2631
        ax_sigma.set_xlim(omega_limits)
9✔
2632

2633
    # Add a grid to the plot + labeling
2634
    if grid:
9✔
2635
        ax_sigma.grid(grid, which='both')
9✔
2636

2637
    ax_sigma.set_ylabel(
9✔
2638
        "Singular Values [dB]" if dB else "Singular Values")
2639
    ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]")
9✔
2640

2641
    # List of systems that are included in this plot
2642
    lines, labels = _get_line_labels(ax_sigma)
9✔
2643

2644
    # Add legend if there is more than one system plotted
2645
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2646
        with plt.rc_context(rcParams):
9✔
2647
            legend = ax_sigma.legend(lines, labels, loc=legend_loc)
9✔
2648
    else:
2649
        legend = None
9✔
2650

2651
    # Add the title
2652
    if ax is None:
9✔
2653
        if title is None:
9✔
2654
            title = "Singular values for " + ", ".join(labels)
9✔
2655
        _update_plot_title(
9✔
2656
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2657
            use_existing=False)
2658

2659
    # Legacy return processing
2660
    if plot is not None:
9✔
2661
        if len(responses) == 1:
×
2662
            return sigmas[0], omegas[0]
×
2663
        else:
2664
            return sigmas, omegas
×
2665

2666
    return ControlPlot(out, ax_sigma, fig, legend=legend)
9✔
2667

2668
#
2669
# Utility functions
2670
#
2671
# This section of the code contains some utility functions for
2672
# generating frequency domain plots.
2673
#
2674

2675

2676
# Determine the frequency range to be used
2677
def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
9✔
2678
                            Hz=None, feature_periphery_decades=None):
2679
    """Determine the frequency range for a frequency-domain plot
2680
    according to a standard logic.
2681

2682
    If `omega_in` and `omega_limits` are both None, then `omega_out` is
2683
    computed on `omega_num` points according to a default logic defined by
2684
    `_default_frequency_range` and tailored for the list of systems
2685
    syslist, and `omega_range_given` is set to False.
2686

2687
    If `omega_in` is None but `omega_limits` is a tuple of 2 elements, then
2688
    `omega_out` is computed with the function `numpy.logspace` on
2689
    `omega_num` points within the interval ``[min, max] = [omega_limits[0],
2690
    omega_limits[1]]``, and `omega_range_given` is set to True.
2691

2692
    If `omega_in` is a tuple of length 2, it is interpreted as a range and
2693
    handled like `omega_limits`.  If `omega_in` is a tuple of length 3, it
2694
    is interpreted a range plus number of points and handled like
2695
    `omega_limits` and `omega_num`.
2696

2697
    If `omega_in` is an array or a list/tuple of length greater than two,
2698
    then `omega_out` is set to `omega_in` (as an array), and
2699
    `omega_range_given` is set to True
2700

2701
    Parameters
2702
    ----------
2703
    syslist : list of LTI
2704
        List of linear input/output systems (single system is OK).
2705
    omega_in : 1D array_like or None
2706
        Frequency range specified by the user.
2707
    omega_limits : 1D array_like or None
2708
        Frequency limits specified by the user.
2709
    omega_num : int
2710
        Number of points to be used for the frequency range (if the
2711
        frequency range is not user-specified).
2712
    Hz : bool, optional
2713
        If True, the limits (first and last value) of the frequencies
2714
        are set to full decades in Hz so it fits plotting with logarithmic
2715
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2716

2717
    Returns
2718
    -------
2719
    omega_out : 1D array
2720
        Frequency range to be used.
2721
    omega_range_given : bool
2722
        True if the frequency range was specified by the user, either through
2723
        omega_in or through omega_limits. False if both omega_in
2724
        and omega_limits are None.
2725

2726
    """
2727
    # Handle the special case of a range of frequencies
2728
    if omega_in is not None and omega_limits is not None:
9✔
2729
        warnings.warn(
×
2730
            "omega and omega_limits both specified; ignoring limits")
2731
    elif isinstance(omega_in, (list, tuple)) and len(omega_in) == 2:
9✔
2732
        omega_limits = omega_in
9✔
2733
        omega_in = None
9✔
2734

2735
    omega_range_given = True
9✔
2736
    if omega_in is None:
9✔
2737
        if omega_limits is None:
9✔
2738
            omega_range_given = False
9✔
2739
            # Select a default range if none is provided
2740
            omega_out = _default_frequency_range(
9✔
2741
                syslist, number_of_samples=omega_num, Hz=Hz,
2742
                feature_periphery_decades=feature_periphery_decades)
2743
        else:
2744
            omega_limits = np.asarray(omega_limits)
9✔
2745
            if len(omega_limits) != 2:
9✔
2746
                raise ValueError("len(omega_limits) must be 2")
×
2747
            omega_out = np.logspace(np.log10(omega_limits[0]),
9✔
2748
                                    np.log10(omega_limits[1]),
2749
                                    num=omega_num, endpoint=True)
2750
    else:
2751
        omega_out = np.copy(omega_in)
9✔
2752

2753
    return omega_out, omega_range_given
9✔
2754

2755

2756
# Compute reasonable defaults for axes
2757
def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
9✔
2758
                             feature_periphery_decades=None):
2759
    """Compute a default frequency range for frequency domain plots.
2760

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

2766
    Parameters
2767
    ----------
2768
    syslist : list of LTI
2769
        List of linear input/output systems (single system is OK)
2770
    Hz : bool, optional
2771
        If True, the limits (first and last value) of the frequencies
2772
        are set to full decades in Hz so it fits plotting with logarithmic
2773
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2774
    number_of_samples : int, optional
2775
        Number of samples to generate.  The default value is read from
2776
        `config.defaults['freqplot.number_of_samples']`.  If None,
2777
        then the default from `numpy.logspace` is used.
2778
    feature_periphery_decades : float, optional
2779
        Defines how many decades shall be included in the frequency range on
2780
        both sides of features (poles, zeros).  The default value is read from
2781
        `config.defaults['freqplot.feature_periphery_decades']`.
2782

2783
    Returns
2784
    -------
2785
    omega : array
2786
        Range of frequencies in rad/sec
2787

2788
    Examples
2789
    --------
2790
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
2791
    >>> omega = ct._default_frequency_range(G)
2792
    >>> omega.min(), omega.max()
2793
    (0.1, 100.0)
2794

2795
    """
2796
    # Set default values for options
2797
    number_of_samples = config._get_param(
9✔
2798
        'freqplot', 'number_of_samples', number_of_samples)
2799
    feature_periphery_decades = config._get_param(
9✔
2800
        'freqplot', 'feature_periphery_decades', feature_periphery_decades, 1)
2801

2802
    # Find the list of all poles and zeros in the systems
2803
    features = np.array(())
9✔
2804
    freq_interesting = []
9✔
2805

2806
    # detect if single sys passed by checking if it is sequence-like
2807
    if not hasattr(syslist, '__iter__'):
9✔
2808
        syslist = (syslist,)
9✔
2809

2810
    for sys in syslist:
9✔
2811
        # For FRD systems, just use the response frequencies
2812
        if isinstance(sys, FrequencyResponseData):
9✔
2813
            # Add the min and max frequency, minus periphery decades
2814
            # (keeps frequency ranges from artificially expanding)
2815
            features = np.concatenate([features, np.array([
9✔
2816
                np.min(sys.omega) * 10**feature_periphery_decades,
2817
                np.max(sys.omega) / 10**feature_periphery_decades])])
2818
            continue
9✔
2819

2820
        try:
9✔
2821
            # Add new features to the list
2822
            if sys.isctime():
9✔
2823
                features_ = np.concatenate(
9✔
2824
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2825
                # Get rid of poles and zeros at the origin
2826
                toreplace = np.isclose(features_, 0.0)
9✔
2827
                if np.any(toreplace):
9✔
2828
                    features_ = features_[~toreplace]
9✔
2829
            elif sys.isdtime(strict=True):
9✔
2830
                fn = math.pi / sys.dt
9✔
2831
                # TODO: What distance to the Nyquist frequency is appropriate?
2832
                freq_interesting.append(fn * 0.9)
9✔
2833

2834
                features_ = np.concatenate(
9✔
2835
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2836
                # Get rid of poles and zeros on the real axis (imag==0)
2837
                # * origin and real < 0
2838
                # * at 1.: would result in omega=0. (logarithmic plot!)
2839
                toreplace = np.isclose(features_.imag, 0.0) & (
9✔
2840
                                    (features_.real <= 0.) |
2841
                                    (np.abs(features_.real - 1.0) < 1.e-10))
2842
                if np.any(toreplace):
9✔
2843
                    features_ = features_[~toreplace]
9✔
2844
                # TODO: improve (mapping pack to continuous time)
2845
                features_ = np.abs(np.log(features_) / (1.j * sys.dt))
9✔
2846
            else:
2847
                # TODO
2848
                raise NotImplementedError(
2849
                    "type of system in not implemented now")
2850
            features = np.concatenate([features, features_])
9✔
2851
        except NotImplementedError:
9✔
2852
            # Don't add any features for anything we don't understand
2853
            pass
9✔
2854

2855
    # Make sure there is at least one point in the range
2856
    if features.shape[0] == 0:
9✔
2857
        features = np.array([1.])
9✔
2858

2859
    if Hz:
9✔
2860
        features /= 2. * math.pi
9✔
2861
    features = np.log10(features)
9✔
2862
    lsp_min = np.rint(np.min(features) - feature_periphery_decades)
9✔
2863
    lsp_max = np.rint(np.max(features) + feature_periphery_decades)
9✔
2864
    if Hz:
9✔
2865
        lsp_min += np.log10(2. * math.pi)
9✔
2866
        lsp_max += np.log10(2. * math.pi)
9✔
2867

2868
    if freq_interesting:
9✔
2869
        lsp_min = min(lsp_min, np.log10(min(freq_interesting)))
9✔
2870
        lsp_max = max(lsp_max, np.log10(max(freq_interesting)))
9✔
2871

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

2875
    # Set the range to be an order of magnitude beyond any features
2876
    if number_of_samples:
9✔
2877
        omega = np.logspace(
9✔
2878
            lsp_min, lsp_max, num=number_of_samples, endpoint=True)
2879
    else:
2880
        omega = np.logspace(lsp_min, lsp_max, endpoint=True)
×
2881
    return omega
9✔
2882

2883

2884
#
2885
# Utility functions to create nice looking labels (KLD 5/23/11)
2886
#
2887

2888
def get_pow1000(num):
9✔
2889
    """Determine exponent for which significance of a number is within the
2890
    range [1, 1000).
2891
    """
2892
    # Based on algorithm from http://www.mail-archive.com/
2893
    # matplotlib-users@lists.sourceforge.net/msg14433.html, accessed 2010/11/7
2894
    # by Jason Heeris 2009/11/18
2895
    from decimal import Decimal
9✔
2896
    from math import floor
9✔
2897
    dnum = Decimal(str(num))
9✔
2898
    if dnum == 0:
9✔
2899
        return 0
9✔
2900
    elif dnum < 0:
9✔
2901
        dnum = -dnum
×
2902
    return int(floor(dnum.log10() / 3))
9✔
2903

2904

2905
def gen_prefix(pow1000):
9✔
2906
    """Return the SI prefix for a power of 1000.
2907
    """
2908
    # Prefixes according to Table 5 of [BIPM 2006] (excluding hecto,
2909
    # deca, deci, and centi).
2910
    if pow1000 < -8 or pow1000 > 8:
9✔
2911
        raise ValueError(
×
2912
            "Value is out of the range covered by the SI prefixes.")
2913
    return ['Y',  # yotta (10^24)
9✔
2914
            'Z',  # zetta (10^21)
2915
            'E',  # exa (10^18)
2916
            'P',  # peta (10^15)
2917
            'T',  # tera (10^12)
2918
            'G',  # giga (10^9)
2919
            'M',  # mega (10^6)
2920
            'k',  # kilo (10^3)
2921
            '',  # (10^0)
2922
            'm',  # milli (10^-3)
2923
            r'$\mu$',  # micro (10^-6)
2924
            'n',  # nano (10^-9)
2925
            'p',  # pico (10^-12)
2926
            'f',  # femto (10^-15)
2927
            'a',  # atto (10^-18)
2928
            'z',  # zepto (10^-21)
2929
            'y'][8 - pow1000]  # yocto (10^-24)
2930

2931

2932
# Function aliases
2933
bode = bode_plot
9✔
2934
nyquist = nyquist_plot
9✔
2935
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

© 2026 Coveralls, Inc