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

python-control / python-control / 13106139066

03 Feb 2025 04:04AM UTC coverage: 94.709% (+0.2%) from 94.536%
13106139066

push

github

web-flow
Merge pull request #1118 from roryyorke/lint-lib-only

Lint library code only with `ruff check`

9630 of 10168 relevant lines covered (94.71%)

8.28 hits per line

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

94.82
control/freqplot.py
1
# freqplot.py - frequency domain plots for control systems
2
#
3
# Initial author: Richard M. Murray
4
# Date: 24 May 09
5
#
6
# This file contains some standard control system plots: Bode plots,
7
# Nyquist plots and other frequency response plots.  The code for Nichols
8
# charts is in nichols.py.  The code for pole-zero diagrams is in pzmap.py
9
# and rlocus.py.
10

11
import itertools
9✔
12
import math
9✔
13
import warnings
9✔
14

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

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

33
__all__ = ['bode_plot', 'NyquistResponseData', 'nyquist_response',
9✔
34
           'nyquist_plot', 'singular_values_response',
35
           'singular_values_plot', 'gangof4_plot', 'gangof4_response',
36
           'bode', 'nyquist', 'gangof4', 'FrequencyResponseList']
37

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

54
#
55
# Frequency response data list class
56
#
57
# This class is a subclass of list that adds a plot() method, enabling
58
# direct plotting from routines returning a list of FrequencyResponseData
59
# objects.
60
#
61

62
class FrequencyResponseList(list):
9✔
63
    def plot(self, *args, plot_type=None, **kwargs):
9✔
64
        if plot_type == None:
9✔
65
            for response in self:
9✔
66
                if plot_type is not None and response.plot_type != plot_type:
9✔
67
                    raise TypeError(
×
68
                        "inconsistent plot_types in data; set plot_type "
69
                        "to 'bode', 'nichols', or 'svplot'")
70
                plot_type = response.plot_type
9✔
71

72
        # Use FRD plot method, which can handle lists via plot functions
73
        return FrequencyResponseData.plot(
9✔
74
            self, plot_type=plot_type, *args, **kwargs)
75

76
#
77
# Bode plot
78
#
79
# This is the default method for plotting frequency responses.  There are
80
# lots of options available for tuning the format of the plot, (hopefully)
81
# covering most of the common use cases.
82
#
83

84
def bode_plot(
9✔
85
        data, omega=None, *fmt, ax=None, omega_limits=None, omega_num=None,
86
        plot=None, plot_magnitude=True, plot_phase=None,
87
        overlay_outputs=None, overlay_inputs=None, phase_label=None,
88
        magnitude_label=None, label=None, display_margins=None,
89
        margins_method='best', title=None, sharex=None, sharey=None, **kwargs):
90
    """Bode plot for a system.
91

92
    Plot the magnitude and phase of the frequency response over a
93
    (optional) frequency range.
94

95
    Parameters
96
    ----------
97
    data : list of `FrequencyResponseData` or `LTI`
98
        List of LTI systems or :class:`FrequencyResponseData` objects.  A
99
        single system or frequency response can also be passed.
100
    omega : array_like, optoinal
101
        Set of frequencies in rad/sec to plot over.  If not specified, this
102
        will be determined from the proporties of the systems.  Ignored if
103
        `data` is not a list of systems.
104
    *fmt : :func:`matplotlib.pyplot.plot` format string, optional
105
        Passed to `matplotlib` as the format string for all lines in the plot.
106
        The `omega` parameter must be present (use omega=None if needed).
107
    dB : bool
108
        If True, plot result in dB.  Default is False.
109
    Hz : bool
110
        If True, plot frequency in Hz (omega must be provided in rad/sec).
111
        Default value (False) set by config.defaults['freqplot.Hz'].
112
    deg : bool
113
        If True, plot phase in degrees (else radians).  Default value (True)
114
        set by config.defaults['freqplot.deg'].
115
    display_margins : bool or str
116
        If True, draw gain and phase margin lines on the magnitude and phase
117
        graphs and display the margins at the top of the graph.  If set to
118
        'overlay', the values for the gain and phase margin are placed on
119
        the graph.  Setting display_margins turns off the axes grid.
120
    **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
121
        Additional keywords passed to `matplotlib` to specify line properties.
122

123
    Returns
124
    -------
125
    cplt : :class:`ControlPlot` object
126
        Object containing the data that were plotted:
127

128
          * cplt.lines: Array of :class:`matplotlib.lines.Line2D` objects
129
            for each line in the plot.  The shape of the array matches the
130
            subplots shape and the value of the array is a list of Line2D
131
            objects in that subplot.
132

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

135
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
136

137
          * cplt.legend: legend object(s) contained in the plot
138

139
        See :class:`ControlPlot` for more detailed information.
140

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

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

229
    See Also
230
    --------
231
    frequency_response
232

233
    Notes
234
    -----
235
    1. Starting with python-control version 0.10, `bode_plot` returns a
236
       :class:`ControlPlot` object instead of magnitude, phase, and
237
       frequency. To recover the old behavior, call `bode_plot` with
238
       `plot=True`, which will force the legacy values (mag, phase, omega)
239
       to be returned (with a warning).  To obtain just the frequency
240
       response of a system (or list of systems) without plotting, use the
241
       :func:`~control.frequency_response` command.
242

243
    2. If a discrete time model is given, the frequency response is plotted
244
       along the upper branch of the unit circle, using the mapping ``z =
245
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
246
       is the discrete timebase.  If timebase not specified (``dt=True``),
247
       `dt` is set to 1.
248

249
    Examples
250
    --------
251
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
252
    >>> out = ct.bode_plot(G)
253

254
    """
255
    #
256
    # Process keywords and set defaults
257
    #
258

259
    # Make a copy of the kwargs dictionary since we will modify it
260
    kwargs = dict(kwargs)
9✔
261

262
    # Get values for params (and pop from list to allow keyword use in plot)
263
    dB = config._get_param(
9✔
264
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
265
    deg = config._get_param(
9✔
266
        'freqplot', 'deg', kwargs, _freqplot_defaults, pop=True)
267
    Hz = config._get_param(
9✔
268
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
269
    grid = config._get_param(
9✔
270
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
271
    wrap_phase = config._get_param(
9✔
272
        'freqplot', 'wrap_phase', kwargs, _freqplot_defaults, pop=True)
273
    initial_phase = config._get_param(
9✔
274
        'freqplot', 'initial_phase', kwargs, None, pop=True)
275
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
276
    title_frame = config._get_param(
9✔
277
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
278

279
    # Set the default labels
280
    freq_label = config._get_param(
9✔
281
        'freqplot', 'freq_label', kwargs, _freqplot_defaults, pop=True)
282
    if magnitude_label is None:
9✔
283
        magnitude_label = "Magnitude [dB]" if dB else "Magnitude"
9✔
284
    if phase_label is None:
9✔
285
        phase_label = "Phase [deg]" if deg else "Phase [rad]"
9✔
286

287
    # Use sharex and sharey as proxies for share_{magnitude, phase, frequency}
288
    if sharey is not None:
9✔
289
        if 'share_magnitude' in kwargs or 'share_phase' in kwargs:
9✔
290
            ValueError(
×
291
                "sharey cannot be present with share_magnitude/share_phase")
292
        kwargs['share_magnitude'] = sharey
9✔
293
        kwargs['share_phase'] = sharey
9✔
294
    if sharex is not None:
9✔
295
        if 'share_frequency' in kwargs:
9✔
296
            ValueError(
×
297
                "sharex cannot be present with share_frequency")
298
        kwargs['share_frequency'] = sharex
9✔
299

300
    # Legacy keywords for margins
301
    display_margins = config._process_legacy_keyword(
9✔
302
        kwargs, 'margins', 'display_margins', display_margins)
303
    if kwargs.pop('margin_info', False):
9✔
304
        warnings.warn(
×
305
            "keyword 'margin_info' is deprecated; "
306
            "use 'display_margins='overlay'")
307
        if display_margins is False:
×
308
            raise ValueError(
×
309
                "conflicting_keywords: `display_margins` and `margin_info`")
310
    margins_method = config._process_legacy_keyword(
9✔
311
        kwargs, 'method', 'margins_method', margins_method)
312

313
    if not isinstance(data, (list, tuple)):
9✔
314
        data = [data]
9✔
315

316
    #
317
    # Pre-process the data to be plotted (unwrap phase, limit frequencies)
318
    #
319
    # To maintain compatibility with legacy uses of bode_plot(), we do some
320
    # initial processing on the data, specifically phase unwrapping and
321
    # setting the initial value of the phase.  If bode_plot is called with
322
    # plot == False, then these values are returned to the user (instead of
323
    # the list of lines created, which is the new output for _plot functions.
324
    #
325

326
    # If we were passed a list of systems, convert to data
327
    if any([isinstance(
9✔
328
            sys, (StateSpace, TransferFunction)) for sys in data]):
329
        data = frequency_response(
9✔
330
            data, omega=omega, omega_limits=omega_limits,
331
            omega_num=omega_num, Hz=Hz)
332
    else:
333
        # Generate warnings if frequency keywords were given
334
        if omega_num is not None:
9✔
335
            warnings.warn("`omega_num` ignored when passed response data")
9✔
336
        elif omega is not None:
9✔
337
            warnings.warn("`omega` ignored when passed response data")
9✔
338

339
        # Check to make sure omega_limits is sensible
340
        if omega_limits is not None and \
9✔
341
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
342
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
343

344
    # If plot_phase is not specified, check the data first, otherwise true
345
    if plot_phase is None:
9✔
346
        plot_phase = True if data[0].plot_phase is None else data[0].plot_phase
9✔
347

348
    if not plot_magnitude and not plot_phase:
9✔
349
        raise ValueError(
9✔
350
            "plot_magnitude and plot_phase both False; no data to plot")
351

352
    mag_data, phase_data, omega_data = [], [], []
9✔
353
    for response in data:
9✔
354
        noutputs, ninputs = response.noutputs, response.ninputs
9✔
355

356
        if initial_phase is None:
9✔
357
            # Start phase in the range 0 to -360 w/ initial phase = 0
358
            # TODO: change this to 0 to 270 (?)
359
            # If wrap_phase is true, use 0 instead (phase \in (-pi, pi])
360
            initial_phase_value = -math.pi if wrap_phase is not True else 0
9✔
361
        elif isinstance(initial_phase, (int, float)):
9✔
362
            # Allow the user to override the default calculation
363
            if deg:
9✔
364
                initial_phase_value = initial_phase/180. * math.pi
9✔
365
            else:
366
                initial_phase_value = initial_phase
9✔
367
        else:
368
            raise ValueError("initial_phase must be a number.")
×
369

370
        # Reshape the phase to allow standard indexing
371
        phase = response.phase.copy().reshape((noutputs, ninputs, -1))
9✔
372

373
        # Shift and wrap the phase
374
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
375
            # Shift the phase if needed
376
            if abs(phase[i, j, 0] - initial_phase_value) > math.pi:
9✔
377
                phase[i, j] -= 2*math.pi * round(
9✔
378
                    (phase[i, j, 0] - initial_phase_value) / (2*math.pi))
379

380
            # Phase wrapping
381
            if wrap_phase is False:
9✔
382
                phase[i, j] = unwrap(phase[i, j]) # unwrap the phase
9✔
383
            elif wrap_phase is True:
9✔
384
                pass                                    # default calc OK
9✔
385
            elif isinstance(wrap_phase, (int, float)):
9✔
386
                phase[i, j] = unwrap(phase[i, j]) # unwrap phase first
9✔
387
                if deg:
9✔
388
                    wrap_phase *= math.pi/180.
9✔
389

390
                # Shift the phase if it is below the wrap_phase
391
                phase[i, j] += 2*math.pi * np.maximum(
9✔
392
                    0, np.ceil((wrap_phase - phase[i, j])/(2*math.pi)))
393
            else:
394
                raise ValueError("wrap_phase must be bool or float.")
×
395

396
        # Put the phase back into the original shape
397
        phase = phase.reshape(response.magnitude.shape)
9✔
398

399
        # Save the data for later use (legacy return values)
400
        mag_data.append(response.magnitude)
9✔
401
        phase_data.append(phase)
9✔
402
        omega_data.append(response.omega)
9✔
403

404
    #
405
    # Process `plot` keyword
406
    #
407
    # We use the `plot` keyword to track legacy usage of `bode_plot`.
408
    # Prior to v0.10, the `bode_plot` command returned mag, phase, and
409
    # omega.  Post v0.10, we return an array with the same shape as the
410
    # axes we use for plotting, with each array element containing a list
411
    # of lines drawn on that axes.
412
    #
413
    # There are three possibilities at this stage in the code:
414
    #
415
    # * plot == True: set explicitly by the user. Return mag, phase, omega,
416
    #   with a warning.
417
    #
418
    # * plot == False: set explicitly by the user. Return mag, phase,
419
    #   omega, with a warning.
420
    #
421
    # * plot == None: this is the new default setting.  Return an array of
422
    #   lines that were drawn.
423
    #
424
    # If `bode_plot` was called with no `plot` argument and the return
425
    # values were used, the new code will cause problems (you get an array
426
    # of lines instead of magnitude, phase, and frequency).  To recover the
427
    # old behavior, call `bode_plot` with `plot=True`.
428
    #
429
    # All of this should be removed in v0.11+ when we get rid of deprecated
430
    # code.
431
    #
432

433
    if plot is not None:
9✔
434
        warnings.warn(
9✔
435
            "bode_plot() return value of mag, phase, omega is deprecated; "
436
            "use frequency_response()", FutureWarning)
437

438
    if plot is False:
9✔
439
        # Process the data to match what we were sent
440
        for i in range(len(mag_data)):
9✔
441
            mag_data[i] = _process_frequency_response(
9✔
442
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
443
            phase_data[i] = _process_frequency_response(
9✔
444
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
445

446
        if len(data) == 1:
9✔
447
            return mag_data[0], phase_data[0], omega_data[0]
9✔
448
        else:
449
            return mag_data, phase_data, omega_data
9✔
450
    #
451
    # Find/create axes
452
    #
453
    # Data are plotted in a standard subplots array, whose size depends on
454
    # which signals are being plotted and how they are combined.  The
455
    # baseline layout for data is to plot everything separately, with
456
    # the magnitude and phase for each output making up the rows and the
457
    # columns corresponding to the different inputs.
458
    #
459
    #      Input 0                 Input m
460
    # +---------------+       +---------------+
461
    # |  mag H_y0,u0  |  ...  |  mag H_y0,um  |
462
    # +---------------+       +---------------+
463
    # +---------------+       +---------------+
464
    # | phase H_y0,u0 |  ...  | phase H_y0,um |
465
    # +---------------+       +---------------+
466
    #         :                       :
467
    # +---------------+       +---------------+
468
    # |  mag H_yp,u0  |  ...  |  mag H_yp,um  |
469
    # +---------------+       +---------------+
470
    # +---------------+       +---------------+
471
    # | phase H_yp,u0 |  ...  | phase H_yp,um |
472
    # +---------------+       +---------------+
473
    #
474
    # Several operations are available that change this layout.
475
    #
476
    # * Omitting: either the magnitude or the phase plots can be omitted
477
    #   using the plot_magnitude and plot_phase keywords.
478
    #
479
    # * Overlay: inputs and/or outputs can be combined onto a single set of
480
    #   axes using the overlay_inputs and overlay_outputs keywords.  This
481
    #   basically collapses data along either the rows or columns, and a
482
    #   legend is generated.
483
    #
484

485
    # Decide on the maximum number of inputs and outputs
486
    ninputs, noutputs = 0, 0
9✔
487
    for response in data:       # TODO: make more pythonic/numpic
9✔
488
        ninputs = max(ninputs, response.ninputs)
9✔
489
        noutputs = max(noutputs, response.noutputs)
9✔
490

491
    # Figure how how many rows and columns to use + offsets for inputs/outputs
492
    if overlay_outputs and overlay_inputs:
9✔
493
        nrows = plot_magnitude + plot_phase
9✔
494
        ncols = 1
9✔
495
    elif overlay_outputs:
9✔
496
        nrows = plot_magnitude + plot_phase
9✔
497
        ncols = ninputs
9✔
498
    elif overlay_inputs:
9✔
499
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
500
            (noutputs if plot_phase else 0)
501
        ncols = 1
9✔
502
    else:
503
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
504
            (noutputs if plot_phase else 0)
505
        ncols = ninputs
9✔
506

507
    if ax is None:
9✔
508
        # Set up default sharing of axis limits if not specified
509
        for kw in ['share_magnitude', 'share_phase', 'share_frequency']:
9✔
510
            if kw not in kwargs or kwargs[kw] is None:
9✔
511
                kwargs[kw] = config.defaults['freqplot.' + kw]
9✔
512

513
    fig, ax_array = _process_ax_keyword(
9✔
514
        ax, (nrows, ncols), squeeze=False, rcParams=rcParams, clear_text=True)
515
    legend_loc, legend_map, show_legend = _process_legend_keywords(
9✔
516
        kwargs, (nrows,ncols), 'center right')
517

518
    # Get the values for sharing axes limits
519
    share_magnitude = kwargs.pop('share_magnitude', None)
9✔
520
    share_phase = kwargs.pop('share_phase', None)
9✔
521
    share_frequency = kwargs.pop('share_frequency', None)
9✔
522

523
    # Set up axes variables for easier access below
524
    if plot_magnitude and not plot_phase:
9✔
525
        mag_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
526
        for i in range(noutputs):
9✔
527
            for j in range(ninputs):
9✔
528
                if overlay_outputs and overlay_inputs:
9✔
529
                    mag_map[i, j] = (0, 0)
9✔
530
                elif overlay_outputs:
9✔
531
                    mag_map[i, j] = (0, j)
9✔
532
                elif overlay_inputs:
9✔
533
                    mag_map[i, j] = (i, 0)
×
534
                else:
535
                    mag_map[i, j] = (i, j)
9✔
536
        phase_map = np.full((noutputs, ninputs), None)
9✔
537
        share_phase = False
9✔
538

539
    elif plot_phase and not plot_magnitude:
9✔
540
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
541
        for i in range(noutputs):
9✔
542
            for j in range(ninputs):
9✔
543
                if overlay_outputs and overlay_inputs:
9✔
544
                    phase_map[i, j] = (0, 0)
×
545
                elif overlay_outputs:
9✔
546
                    phase_map[i, j] = (0, j)
×
547
                elif overlay_inputs:
9✔
548
                    phase_map[i, j] = (i, 0)
9✔
549
                else:
550
                    phase_map[i, j] = (i, j)
9✔
551
        mag_map = np.full((noutputs, ninputs), None)
9✔
552
        share_magnitude = False
9✔
553

554
    else:
555
        mag_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
556
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
557
        for i in range(noutputs):
9✔
558
            for j in range(ninputs):
9✔
559
                if overlay_outputs and overlay_inputs:
9✔
560
                    mag_map[i, j] = (0, 0)
×
561
                    phase_map[i, j] = (1, 0)
×
562
                elif overlay_outputs:
9✔
563
                    mag_map[i, j] = (0, j)
×
564
                    phase_map[i, j] = (1, j)
×
565
                elif overlay_inputs:
9✔
566
                    mag_map[i, j] = (i*2, 0)
×
567
                    phase_map[i, j] = (i*2 + 1, 0)
×
568
                else:
569
                    mag_map[i, j] = (i*2, j)
9✔
570
                    phase_map[i, j] = (i*2 + 1, j)
9✔
571

572
    # Identity map needed for setting up shared axes
573
    ax_map = np.empty((nrows, ncols), dtype=tuple)
9✔
574
    for i, j in itertools.product(range(nrows), range(ncols)):
9✔
575
        ax_map[i, j] = (i, j)
9✔
576

577
    #
578
    # Set up axes limit sharing
579
    #
580
    # This code uses the share_magnitude, share_phase, and share_frequency
581
    # keywords to decide which axes have shared limits and what ticklabels
582
    # to include.  The sharing code needs to come before the plots are
583
    # generated, but additional code for removing tick labels needs to come
584
    # *during* and *after* the plots are generated (see below).
585
    #
586
    # Note: if the various share_* keywords are None then a previous set of
587
    # axes are available and no updates should be made.
588
    #
589

590
    # Utility function to turn on sharing
591
    def _share_axes(ref, share_map, axis):
9✔
592
        ref_ax = ax_array[ref]
9✔
593
        for index in np.nditer(share_map, flags=["refs_ok"]):
9✔
594
            if index.item() == ref:
9✔
595
                continue
9✔
596
            if axis == 'x':
9✔
597
                ax_array[index.item()].sharex(ref_ax)
9✔
598
            elif axis == 'y':
9✔
599
                ax_array[index.item()].sharey(ref_ax)
9✔
600
            else:
601
                raise ValueError("axis must be 'x' or 'y'")
×
602

603
    # Process magnitude, phase, and frequency axes
604
    for name, value, map, axis in zip(
9✔
605
            ['share_magnitude', 'share_phase', 'share_frequency'],
606
            [ share_magnitude,   share_phase,   share_frequency],
607
            [ mag_map,           phase_map,     ax_map],
608
            [ 'y',               'y',           'x']):
609
        if value in [True, 'all']:
9✔
610
            _share_axes(map[0 if axis == 'y' else -1, 0], map, axis)
9✔
611
        elif axis == 'y' and value in ['row']:
9✔
612
            for i in range(noutputs if not overlay_outputs else 1):
9✔
613
                _share_axes(map[i, 0], map[i], 'y')
9✔
614
        elif axis == 'x' and value in ['col']:
9✔
615
            for j in range(ncols):
9✔
616
                _share_axes(map[-1, j], map[:, j], 'x')
9✔
617
        elif value in [False, 'none']:
9✔
618
            # TODO: turn off any sharing that is on
619
            pass
9✔
620
        elif value is not None:
9✔
621
            raise ValueError(
×
622
                f"unknown value for `{name}`: '{value}'")
623

624
    #
625
    # Plot the data
626
    #
627
    # The mag_map and phase_map arrays have the indices axes needed for
628
    # making the plots.  Labels are used on each axes for later creation of
629
    # legends.  The generic labels if of the form:
630
    #
631
    #     To output label, From input label, system name
632
    #
633
    # The input and output labels are omitted if overlay_inputs or
634
    # overlay_outputs is False, respectively.  The system name is always
635
    # included, since multiple calls to plot() will require a legend that
636
    # distinguishes which system signals are plotted.  The system name is
637
    # stripped off later (in the legend-handling code) if it is not needed.
638
    #
639
    # Note: if we are building on top of an existing plot, tick labels
640
    # should be preserved from the existing axes.  For log scale axes the
641
    # tick labels seem to appear no matter what => we have to detect if
642
    # they are present at the start and, it not, remove them after calling
643
    # loglog or semilogx.
644
    #
645

646
    # Create a list of lines for the output
647
    out = np.empty((nrows, ncols), dtype=object)
9✔
648
    for i in range(nrows):
9✔
649
        for j in range(ncols):
9✔
650
            out[i, j] = []      # unique list in each element
9✔
651

652
    # Process label keyword
653
    line_labels = _process_line_labels(label, len(data), ninputs, noutputs)
9✔
654

655
    # Utility function for creating line label
656
    def _make_line_label(response, output_index, input_index):
9✔
657
        label = ""              # start with an empty label
9✔
658

659
        # Add the output name if it won't appear as an axes label
660
        if noutputs > 1 and overlay_outputs:
9✔
661
            label += response.output_labels[output_index]
9✔
662

663
        # Add the input name if it won't appear as a column label
664
        if ninputs > 1 and overlay_inputs:
9✔
665
            label += ", " if label != "" else ""
9✔
666
            label += response.input_labels[input_index]
9✔
667

668
        # Add the system name (will strip off later if redundant)
669
        label += ", " if label != "" else ""
9✔
670
        label += f"{response.sysname}"
9✔
671

672
        return label
9✔
673

674
    for index, response in enumerate(data):
9✔
675
        # Get the (pre-processed) data in fully indexed form
676
        mag = mag_data[index].reshape((noutputs, ninputs, -1))
9✔
677
        phase = phase_data[index].reshape((noutputs, ninputs, -1))
9✔
678
        omega_sys, sysname = omega_data[index], response.sysname
9✔
679

680
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
681
            # Get the axes to use for magnitude and phase
682
            ax_mag = ax_array[mag_map[i, j]]
9✔
683
            ax_phase = ax_array[phase_map[i, j]]
9✔
684

685
            # Get the frequencies and convert to Hz, if needed
686
            omega_plot = omega_sys / (2 * math.pi) if Hz else omega_sys
9✔
687
            if response.isdtime(strict=True):
9✔
688
                nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
689

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

694
            # Generate a label
695
            if line_labels is None:
9✔
696
                label = _make_line_label(response, i, j)
9✔
697
            else:
698
                label = line_labels[index, i, j]
9✔
699

700
            # Magnitude
701
            if plot_magnitude:
9✔
702
                pltfcn = ax_mag.semilogx if dB else ax_mag.loglog
9✔
703

704
                # Plot the main data
705
                lines = pltfcn(
9✔
706
                    omega_plot, mag_plot, *fmt, label=label, **kwargs)
707
                out[mag_map[i, j]] += lines
9✔
708

709
                # Save the information needed for the Nyquist line
710
                if response.isdtime(strict=True):
9✔
711
                    ax_mag.axvline(
9✔
712
                        nyq_freq, color=lines[0].get_color(), linestyle='--',
713
                        label='_nyq_mag_' + sysname)
714

715
                # Add a grid to the plot
716
                ax_mag.grid(grid and not display_margins, which='both')
9✔
717

718
            # Phase
719
            if plot_phase:
9✔
720
                lines = ax_phase.semilogx(
9✔
721
                    omega_plot, phase_plot, *fmt, label=label, **kwargs)
722
                out[phase_map[i, j]] += lines
9✔
723

724
                # Save the information needed for the Nyquist line
725
                if response.isdtime(strict=True):
9✔
726
                    ax_phase.axvline(
9✔
727
                        nyq_freq, color=lines[0].get_color(), linestyle='--',
728
                        label='_nyq_phase_' + sysname)
729

730
                # Add a grid to the plot
731
                ax_phase.grid(grid and not display_margins, which='both')
9✔
732

733
        #
734
        # Display gain and phase margins (SISO only)
735
        #
736

737
        if display_margins:
9✔
738
            if ninputs > 1 or noutputs > 1:
9✔
739
                raise NotImplementedError(
740
                    "margins are not available for MIMO systems")
741

742
            # Compute stability margins for the system
743
            margins = stability_margins(response, method=margins_method)
9✔
744
            gm, pm, Wcg, Wcp = (margins[i] for i in [0, 1, 3, 4])
9✔
745

746
            # Figure out sign of the phase at the first gain crossing
747
            # (needed if phase_wrap is True)
748
            phase_at_cp = phase[
9✔
749
                0, 0, (np.abs(omega_data[0] - Wcp)).argmin()]
750
            if phase_at_cp >= 0.:
9✔
751
                phase_limit = 180.
×
752
            else:
753
                phase_limit = -180.
9✔
754

755
            if Hz:
9✔
756
                Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi)
9✔
757

758
            # Draw lines at gain and phase limits
759
            if plot_magnitude:
9✔
760
                ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':',
9✔
761
                               zorder=-20)
762

763
            if plot_phase:
9✔
764
                ax_phase.axhline(y=phase_limit if deg else
9✔
765
                                 math.radians(phase_limit),
766
                                 color='k', linestyle=':', zorder=-20)
767

768
            # Annotate the phase margin (if it exists)
769
            if plot_phase and pm != float('inf') and Wcp != float('nan'):
9✔
770
                # Draw dotted lines marking the gain crossover frequencies
771
                if plot_magnitude:
9✔
772
                    ax_mag.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
773
                ax_phase.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
774

775
                # Draw solid segments indicating the margins
776
                if deg:
9✔
777
                    ax_phase.semilogx(
9✔
778
                        [Wcp, Wcp], [phase_limit + pm, phase_limit],
779
                        color='k', zorder=-20)
780
                else:
781
                    ax_phase.semilogx(
9✔
782
                        [Wcp, Wcp], [math.radians(phase_limit) +
783
                                     math.radians(pm),
784
                                     math.radians(phase_limit)],
785
                        color='k', zorder=-20)
786

787
            # Annotate the gain margin (if it exists)
788
            if plot_magnitude and gm != float('inf') and \
9✔
789
               Wcg != float('nan'):
790
                # Draw dotted lines marking the phase crossover frequencies
791
                ax_mag.axvline(Wcg, color='k', linestyle=':', zorder=-30)
9✔
792
                if plot_phase:
9✔
793
                    ax_phase.axvline(Wcg, color='k', linestyle=':', zorder=-30)
9✔
794

795
                # Draw solid segments indicating the margins
796
                if dB:
9✔
797
                    ax_mag.semilogx(
9✔
798
                        [Wcg, Wcg], [0, -20*np.log10(gm)],
799
                        color='k', zorder=-20)
800
                else:
801
                    ax_mag.loglog(
9✔
802
                        [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20)
803

804
            if display_margins == 'overlay':
9✔
805
                # TODO: figure out how to handle case of multiple lines
806
                # Put the margin information in the lower left corner
807
                if plot_magnitude:
9✔
808
                    ax_mag.text(
9✔
809
                        0.04, 0.06,
810
                        'G.M.: %.2f %s\nFreq: %.2f %s' %
811
                        (20*np.log10(gm) if dB else gm,
812
                         'dB ' if dB else '',
813
                         Wcg, 'Hz' if Hz else 'rad/s'),
814
                        horizontalalignment='left',
815
                        verticalalignment='bottom',
816
                        transform=ax_mag.transAxes,
817
                        fontsize=8 if int(mpl.__version__[0]) == 1 else 6)
818

819
                if plot_phase:
9✔
820
                    ax_phase.text(
9✔
821
                        0.04, 0.06,
822
                        'P.M.: %.2f %s\nFreq: %.2f %s' %
823
                        (pm if deg else math.radians(pm),
824
                         'deg' if deg else 'rad',
825
                         Wcp, 'Hz' if Hz else 'rad/s'),
826
                        horizontalalignment='left',
827
                        verticalalignment='bottom',
828
                        transform=ax_phase.transAxes,
829
                        fontsize=8 if int(mpl.__version__[0]) == 1 else 6)
830

831
            else:
832
                # Put the title underneath the suptitle (one line per system)
833
                ax = ax_mag if ax_mag else ax_phase
9✔
834
                axes_title = ax.get_title()
9✔
835
                if axes_title is not None and axes_title != "":
9✔
836
                    axes_title += "\n"
×
837
                with plt.rc_context(rcParams):
9✔
838
                    ax.set_title(
9✔
839
                        axes_title + f"{sysname}: "
840
                        "Gm = %.2f %s(at %.2f %s), "
841
                        "Pm = %.2f %s (at %.2f %s)" %
842
                        (20*np.log10(gm) if dB else gm,
843
                         'dB ' if dB else '',
844
                         Wcg, 'Hz' if Hz else 'rad/s',
845
                         pm if deg else math.radians(pm),
846
                         'deg' if deg else 'rad',
847
                         Wcp, 'Hz' if Hz else 'rad/s'))
848

849
    #
850
    # Finishing handling axes limit sharing
851
    #
852
    # This code handles labels on Bode plots and also removes tick labels
853
    # on shared axes.  It needs to come *after* the plots are generated,
854
    # in order to handle two things:
855
    #
856
    # * manually generated labels and grids need to reflect the limits for
857
    #   shared axes, which we don't know until we have plotted everything;
858
    #
859
    # * the loglog and semilog functions regenerate the labels (not quite
860
    #   sure why, since using sharex and sharey in subplots does not have
861
    #   this behavior).
862
    #
863
    # Note: as before, if the various share_* keywords are None then a
864
    # previous set of axes are available and no updates are made. (TODO: true?)
865
    #
866

867
    for i in range(noutputs):
9✔
868
        for j in range(ninputs):
9✔
869
            # Utility function to generate phase labels
870
            def gen_zero_centered_series(val_min, val_max, period):
9✔
871
                v1 = np.ceil(val_min / period - 0.2)
9✔
872
                v2 = np.floor(val_max / period + 0.2)
9✔
873
                return np.arange(v1, v2 + 1) * period
9✔
874

875
            # Label the phase axes using multiples of 45 degrees
876
            if plot_phase:
9✔
877
                ax_phase = ax_array[phase_map[i, j]]
9✔
878

879
                # Set the labels
880
                if deg:
9✔
881
                    ylim = ax_phase.get_ylim()
9✔
882
                    num = np.floor((ylim[1] - ylim[0]) / 45)
9✔
883
                    factor = max(1, np.round(num / (32 / nrows)) * 2)
9✔
884
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
885
                        ylim[0], ylim[1], 45 * factor))
886
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
887
                        ylim[0], ylim[1], 15 * factor), minor=True)
888
                else:
889
                    ylim = ax_phase.get_ylim()
9✔
890
                    num = np.ceil((ylim[1] - ylim[0]) / (math.pi/4))
9✔
891
                    factor = max(1, np.round(num / (36 / nrows)) * 2)
9✔
892
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
893
                        ylim[0], ylim[1], math.pi / 4. * factor))
894
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
895
                        ylim[0], ylim[1], math.pi / 12. * factor), minor=True)
896

897
    # Turn off y tick labels for shared axes
898
    for i in range(0, noutputs):
9✔
899
        for j in range(1, ncols):
9✔
900
            if share_magnitude in [True, 'all', 'row']:
9✔
901
                ax_array[mag_map[i, j]].tick_params(labelleft=False)
9✔
902
            if share_phase in [True, 'all', 'row']:
9✔
903
                ax_array[phase_map[i, j]].tick_params(labelleft=False)
9✔
904

905
    # Turn off x tick labels for shared axes
906
    for i in range(0, nrows-1):
9✔
907
        for j in range(0, ncols):
9✔
908
            if share_frequency in [True, 'all', 'col']:
9✔
909
                ax_array[i, j].tick_params(labelbottom=False)
9✔
910

911
    # If specific omega_limits were given, use them
912
    if omega_limits is not None:
9✔
913
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
914
            ax_array[i, j].set_xlim(omega_limits)
9✔
915

916
    #
917
    # Label the axes (including header labels)
918
    #
919
    # Once the data are plotted, we label the axes.  The horizontal axes is
920
    # always frequency and this is labeled only on the bottom most row.  The
921
    # vertical axes can consist either of a single signal or a combination
922
    # of signals (when overlay_inputs or overlay_outputs is True)
923
    #
924
    # Input/output signals are give at the top of columns and left of rows
925
    # when these are individually plotted.
926
    #
927

928
    # Label the columns (do this first to get row labels in the right spot)
929
    for j in range(ncols):
9✔
930
        # If we have more than one column, label the individual responses
931
        if (noutputs > 1 and not overlay_outputs or ninputs > 1) \
9✔
932
           and not overlay_inputs:
933
            with plt.rc_context(rcParams):
9✔
934
                ax_array[0, j].set_title(f"From {data[0].input_labels[j]}")
9✔
935

936
        # Label the frequency axis
937
        ax_array[-1, j].set_xlabel(
9✔
938
            freq_label.format(units="Hz" if Hz else "rad/s"))
939

940
    # Label the rows
941
    for i in range(noutputs if not overlay_outputs else 1):
9✔
942
        if plot_magnitude:
9✔
943
            ax_mag = ax_array[mag_map[i, 0]]
9✔
944
            ax_mag.set_ylabel(magnitude_label)
9✔
945
        if plot_phase:
9✔
946
            ax_phase = ax_array[phase_map[i, 0]]
9✔
947
            ax_phase.set_ylabel(phase_label)
9✔
948

949
        if (noutputs > 1 or ninputs > 1) and not overlay_outputs:
9✔
950
            if plot_magnitude and plot_phase:
9✔
951
                # Get existing ylabel for left column and add a blank line
952
                ax_mag.set_ylabel("\n" + ax_mag.get_ylabel())
9✔
953
                ax_phase.set_ylabel("\n" + ax_phase.get_ylabel())
9✔
954

955
                # Find the midpoint between the row axes (+ tight_layout)
956
                _, ypos = _find_axes_center(fig, [ax_mag, ax_phase])
9✔
957

958
                # Get the bounding box including the labels
959
                inv_transform = fig.transFigure.inverted()
9✔
960
                mag_bbox = inv_transform.transform(
9✔
961
                    ax_mag.get_tightbbox(fig.canvas.get_renderer()))
962

963
                # Figure out location for the text (center left in figure frame)
964
                xpos = mag_bbox[0, 0]               # left edge
9✔
965

966
                # Put a centered label as text outside the box
967
                fig.text(
9✔
968
                    0.8 * xpos, ypos, f"To {data[0].output_labels[i]}\n",
969
                    rotation=90, ha='left', va='center',
970
                    fontsize=rcParams['axes.titlesize'])
971
            else:
972
                # Only a single axes => add label to the left
973
                ax_array[i, 0].set_ylabel(
9✔
974
                    f"To {data[0].output_labels[i]}\n" +
975
                    ax_array[i, 0].get_ylabel())
976

977
    #
978
    # Update the plot title (= figure suptitle)
979
    #
980
    # If plots are built up by multiple calls to plot() and the title is
981
    # not given, then the title is updated to provide a list of unique text
982
    # items in each successive title.  For data generated by the frequency
983
    # response function this will generate a common prefix followed by a
984
    # list of systems (e.g., "Step response for sys[1], sys[2]").
985
    #
986

987
    # Set the initial title for the data (unique system names, preserving order)
988
    seen = set()
9✔
989
    sysnames = [response.sysname for response in data \
9✔
990
                if not (response.sysname in seen or seen.add(response.sysname))]
991

992
    if ax is None and title is None:
9✔
993
        if data[0].title is None:
9✔
994
            title = "Bode plot for " + ", ".join(sysnames)
9✔
995
        else:
996
            # Allow data to set the title (used by gangof4)
997
            title = data[0].title
9✔
998
        _update_plot_title(title, fig, rcParams=rcParams, frame=title_frame)
9✔
999
    elif ax is None:
9✔
1000
        _update_plot_title(
9✔
1001
            title, fig=fig, rcParams=rcParams, frame=title_frame,
1002
            use_existing=False)
1003

1004
    #
1005
    # Create legends
1006
    #
1007
    # Legends can be placed manually by passing a legend_map array that
1008
    # matches the shape of the suplots, with each item being a string
1009
    # indicating the location of the legend for that axes (or None for no
1010
    # legend).
1011
    #
1012
    # If no legend spec is passed, a minimal number of legends are used so
1013
    # that each line in each axis can be uniquely identified.  The details
1014
    # depends on the various plotting parameters, but the general rule is
1015
    # to place legends in the top row and right column.
1016
    #
1017
    # Because plots can be built up by multiple calls to plot(), the legend
1018
    # strings are created from the line labels manually.  Thus an initial
1019
    # call to plot() may not generate any legends (eg, if no signals are
1020
    # overlaid), but subsequent calls to plot() will need a legend for each
1021
    # different response (system).
1022
    #
1023

1024
    # Create axis legends
1025
    if show_legend != False:
9✔
1026
        # Figure out where to put legends
1027
        if legend_map is None:
9✔
1028
            legend_map = np.full(ax_array.shape, None, dtype=object)
9✔
1029
            legend_map[0, -1] = legend_loc
9✔
1030

1031
        legend_array = np.full(ax_array.shape, None, dtype=object)
9✔
1032
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
1033
            if legend_map[i, j] is None:
9✔
1034
                continue
9✔
1035
            ax = ax_array[i, j]
9✔
1036

1037
            # Get the labels to use, removing common strings
1038
            lines = [line for line in ax.get_lines()
9✔
1039
                     if line.get_label()[0] != '_']
1040
            labels = _make_legend_labels(
9✔
1041
                [line.get_label() for line in lines],
1042
                ignore_common=line_labels is not None)
1043

1044
            # Generate the label, if needed
1045
            if show_legend == True or len(labels) > 1:
9✔
1046
                with plt.rc_context(rcParams):
9✔
1047
                    legend_array[i, j] = ax.legend(
9✔
1048
                        lines, labels, loc=legend_map[i, j])
1049
    else:
1050
        legend_array = None
9✔
1051

1052
    #
1053
    # Legacy return pocessing
1054
    #
1055
    if plot is True:            # legacy usage; remove in future release
9✔
1056
        # Process the data to match what we were sent
1057
        for i in range(len(mag_data)):
9✔
1058
            mag_data[i] = _process_frequency_response(
9✔
1059
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
1060
            phase_data[i] = _process_frequency_response(
9✔
1061
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
1062

1063
        if len(data) == 1:
9✔
1064
            return mag_data[0], phase_data[0], omega_data[0]
9✔
1065
        else:
1066
            return mag_data, phase_data, omega_data
9✔
1067

1068
    return ControlPlot(out, ax_array, fig, legend=legend_array)
9✔
1069

1070

1071
#
1072
# Nyquist plot
1073
#
1074

1075
# Default values for module parameter variables
1076
_nyquist_defaults = {
9✔
1077
    'nyquist.primary_style': ['-', '-.'],       # style for primary curve
1078
    'nyquist.mirror_style': ['--', ':'],        # style for mirror curve
1079
    'nyquist.arrows': 2,                        # number of arrows around curve
1080
    'nyquist.arrow_size': 8,                    # pixel size for arrows
1081
    'nyquist.encirclement_threshold': 0.05,     # warning threshold
1082
    'nyquist.indent_radius': 1e-4,              # indentation radius
1083
    'nyquist.indent_direction': 'right',        # indentation direction
1084
    'nyquist.indent_points': 50,                # number of points to insert
1085
    'nyquist.max_curve_magnitude': 20,          # clip large values
1086
    'nyquist.max_curve_offset': 0.02,           # offset of primary/mirror
1087
    'nyquist.start_marker': 'o',                # marker at start of curve
1088
    'nyquist.start_marker_size': 4,             # size of the marker
1089
    'nyquist.circle_style':                     # style for unit circles
1090
      {'color': 'black', 'linestyle': 'dashed', 'linewidth': 1}
1091
}
1092

1093

1094
class NyquistResponseData:
9✔
1095
    """Nyquist response data object.
1096

1097
    Nyquist contour analysis allows the stability and robustness of a
1098
    closed loop linear system to be evaluated using the open loop response
1099
    of the loop transfer function.  The NyquistResponseData class is used
1100
    by the :func:`~control.nyquist_response` function to return the
1101
    response of a linear system along the Nyquist 'D' contour.  The
1102
    response object can be used to obtain information about the Nyquist
1103
    response or to generate a Nyquist plot.
1104

1105
    Attributes
1106
    ----------
1107
    count : integer
1108
        Number of encirclements of the -1 point by the Nyquist curve for
1109
        a system evaluated along the Nyquist contour.
1110
    contour : complex array
1111
        The Nyquist 'D' contour, with appropriate indendtations to avoid
1112
        open loop poles and zeros near/on the imaginary axis.
1113
    response : complex array
1114
        The value of the linear system under study along the Nyquist contour.
1115
    dt : None or float
1116
        The system timebase.
1117
    sysname : str
1118
        The name of the system being analyzed.
1119
    return_contour: bool
1120
        If true, when the object is accessed as an iterable return two
1121
        elements": `count` (number of encirlements) and `contour`.  If
1122
        false (default), then return only `count`.
1123

1124
    """
1125
    def __init__(
9✔
1126
            self, count, contour, response, dt, sysname=None,
1127
            return_contour=False):
1128
        self.count = count
9✔
1129
        self.contour = contour
9✔
1130
        self.response = response
9✔
1131
        self.dt = dt
9✔
1132
        self.sysname = sysname
9✔
1133
        self.return_contour = return_contour
9✔
1134

1135
    # Implement iter to allow assigning to a tuple
1136
    def __iter__(self):
9✔
1137
        if self.return_contour:
9✔
1138
            return iter((self.count, self.contour))
9✔
1139
        else:
1140
            return iter((self.count, ))
9✔
1141

1142
    # Implement (thin) getitem to allow access via legacy indexing
1143
    def __getitem__(self, index):
9✔
1144
        return list(self.__iter__())[index]
×
1145

1146
    # Implement (thin) len to emulate legacy testing interface
1147
    def __len__(self):
9✔
1148
        return 2 if self.return_contour else 1
9✔
1149

1150
    def plot(self, *args, **kwargs):
9✔
1151
        return nyquist_plot(self, *args, **kwargs)
9✔
1152

1153

1154
class NyquistResponseList(list):
9✔
1155
    def plot(self, *args, **kwargs):
9✔
1156
        return nyquist_plot(self, *args, **kwargs)
9✔
1157

1158

1159
def nyquist_response(
9✔
1160
        sysdata, omega=None, omega_limits=None, omega_num=None,
1161
        return_contour=False, warn_encirclements=True, warn_nyquist=True,
1162
        _kwargs=None, _check_kwargs=True, **kwargs):
1163
    """Nyquist response for a system.
1164

1165
    Computes a Nyquist contour for the system over a (optional) frequency
1166
    range and evaluates the number of net encirclements.  The curve is
1167
    computed by evaluating the Nyqist segment along the positive imaginary
1168
    axis, with a mirror image generated to reflect the negative imaginary
1169
    axis.  Poles on or near the imaginary axis are avoided using a small
1170
    indentation.  The portion of the Nyquist contour at infinity is not
1171
    explicitly computed (since it maps to a constant value for any system
1172
    with a proper transfer function).
1173

1174
    Parameters
1175
    ----------
1176
    sysdata : LTI or list of LTI
1177
        List of linear input/output systems (single system is OK). Nyquist
1178
        curves for each system are plotted on the same graph.
1179
    omega : array_like, optional
1180
        Set of frequencies to be evaluated, in rad/sec.
1181

1182
    Returns
1183
    -------
1184
    responses : list of :class:`~control.NyquistResponseData`
1185
        For each system, a Nyquist response data object is returned.  If
1186
        `sysdata` is a single system, a single elemeent is returned (not a
1187
        list).  For each response, the following information is available:
1188
    response.count : int
1189
        Number of encirclements of the point -1 by the Nyquist curve.  If
1190
        multiple systems are given, an array of counts is returned.
1191
    response.contour : ndarray
1192
        The contour used to create the primary Nyquist curve segment.  To
1193
        obtain the Nyquist curve values, evaluate system(s) along contour.
1194

1195
    Other Parameters
1196
    ----------------
1197
    encirclement_threshold : float, optional
1198
        Define the threshold for generating a warning if the number of net
1199
        encirclements is a non-integer value.  Default value is 0.05 and can
1200
        be set using config.defaults['nyquist.encirclement_threshold'].
1201
    indent_direction : str, optional
1202
        For poles on the imaginary axis, set the direction of indentation to
1203
        be 'right' (default), 'left', or 'none'.
1204
    indent_points : int, optional
1205
        Number of points to insert in the Nyquist contour around poles that
1206
        are at or near the imaginary axis.
1207
    indent_radius : float, optional
1208
        Amount to indent the Nyquist contour around poles on or near the
1209
        imaginary axis. Portions of the Nyquist plot corresponding to indented
1210
        portions of the contour are plotted using a different line style.
1211
    omega_limits : array_like of two values
1212
        Set limits for plotted frequency range. If Hz=True the limits are
1213
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
1214
        elements is equivalent to providing ``omega_limits``.
1215
    omega_num : int, optional
1216
        Number of samples to use for the frequeny range.  Defaults to
1217
        config.defaults['freqplot.number_of_samples'].
1218
    warn_nyquist : bool, optional
1219
        If set to 'False', turn off warnings about frequencies above Nyquist.
1220
    warn_encirclements : bool, optional
1221
        If set to 'False', turn off warnings about number of encirclements not
1222
        meeting the Nyquist criterion.
1223

1224
    Notes
1225
    -----
1226
    1. If a discrete time model is given, the frequency response is computed
1227
       along the upper branch of the unit circle, using the mapping ``z =
1228
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
1229
       is the discrete timebase.  If timebase not specified (``dt=True``),
1230
       `dt` is set to 1.
1231

1232
    2. If a continuous-time system contains poles on or near the imaginary
1233
       axis, a small indentation will be used to avoid the pole.  The radius
1234
       of the indentation is given by `indent_radius` and it is taken to the
1235
       right of stable poles and the left of unstable poles.  If a pole is
1236
       exactly on the imaginary axis, the `indent_direction` parameter can be
1237
       used to set the direction of indentation.  Setting `indent_direction`
1238
       to `none` will turn off indentation.
1239

1240
    3. For those portions of the Nyquist plot in which the contour is
1241
       indented to avoid poles, resuling in a scaling of the Nyquist plot,
1242
       the line styles are according to the settings of the `primary_style`
1243
       and `mirror_style` keywords.  By default the scaled portions of the
1244
       primary curve use a dotted line style and the scaled portion of the
1245
       mirror image use a dashdot line style.
1246

1247
    4. If the legacy keyword `return_contour` is specified as True, the
1248
       response object can be iterated over to return `count, contour`.
1249
       This behavior is deprecated and will be removed in a future release.
1250

1251
    See Also
1252
    --------
1253
    nyquist_plot
1254

1255
    Examples
1256
    --------
1257
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1258
    >>> response = ct.nyquist_response(G)
1259
    >>> count = response.count
1260
    >>> lines = response.plot()
1261

1262
    """
1263
    # Create unified list of keyword arguments
1264
    if _kwargs is None:
9✔
1265
        _kwargs = kwargs
9✔
1266
    else:
1267
        # Use existing dictionary, to keep track of processed keywords
1268
        _kwargs |= kwargs
9✔
1269

1270
    # Get values for params
1271
    omega_num_given = omega_num is not None
9✔
1272
    omega_num = config._get_param('freqplot', 'number_of_samples', omega_num)
9✔
1273
    indent_radius = config._get_param(
9✔
1274
        'nyquist', 'indent_radius', _kwargs, _nyquist_defaults, pop=True)
1275
    encirclement_threshold = config._get_param(
9✔
1276
        'nyquist', 'encirclement_threshold', _kwargs,
1277
        _nyquist_defaults, pop=True)
1278
    indent_direction = config._get_param(
9✔
1279
        'nyquist', 'indent_direction', _kwargs, _nyquist_defaults, pop=True)
1280
    indent_points = config._get_param(
9✔
1281
        'nyquist', 'indent_points', _kwargs, _nyquist_defaults, pop=True)
1282

1283
    if _check_kwargs and _kwargs:
9✔
1284
        raise TypeError("unrecognized keywords: ", str(_kwargs))
9✔
1285

1286
    # Convert the first argument to a list
1287
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
1288

1289
    # Determine the range of frequencies to use, based on args/features
1290
    omega, omega_range_given = _determine_omega_vector(
9✔
1291
        syslist, omega, omega_limits, omega_num, feature_periphery_decades=2)
1292

1293
    # If omega was not specified explicitly, start at omega = 0
1294
    if not omega_range_given:
9✔
1295
        if omega_num_given:
9✔
1296
            # Just reset the starting point
1297
            omega[0] = 0.0
9✔
1298
        else:
1299
            # Insert points between the origin and the first frequency point
1300
            omega = np.concatenate((
9✔
1301
                np.linspace(0, omega[0], indent_points), omega[1:]))
1302

1303
    # Go through each system and keep track of the results
1304
    responses = []
9✔
1305
    for idx, sys in enumerate(syslist):
9✔
1306
        if not sys.issiso():
9✔
1307
            # TODO: Add MIMO nyquist plots.
1308
            raise ControlMIMONotImplemented(
9✔
1309
                "Nyquist plot currently only supports SISO systems.")
1310

1311
        # Figure out the frequency range
1312
        if isinstance(sys, FrequencyResponseData) and sys._ifunc is None \
9✔
1313
           and not omega_range_given:
1314
            omega_sys = sys.omega               # use system frequencies
9✔
1315
        else:
1316
            omega_sys = np.asarray(omega)       # use common omega vector
9✔
1317

1318
        # Determine the contour used to evaluate the Nyquist curve
1319
        if sys.isdtime(strict=True):
9✔
1320
            # Restrict frequencies for discrete-time systems
1321
            nyq_freq = math.pi / sys.dt
9✔
1322
            if not omega_range_given:
9✔
1323
                # limit up to and including Nyquist frequency
1324
                omega_sys = np.hstack((
9✔
1325
                    omega_sys[omega_sys < nyq_freq], nyq_freq))
1326

1327
            # Issue a warning if we are sampling above Nyquist
1328
            if np.any(omega_sys * sys.dt > np.pi) and warn_nyquist:
9✔
1329
                warnings.warn("evaluation above Nyquist frequency")
9✔
1330

1331
        # do indentations in s-plane where it is more convenient
1332
        splane_contour = 1j * omega_sys
9✔
1333

1334
        # Bend the contour around any poles on/near the imaginary axis
1335
        if isinstance(sys, (StateSpace, TransferFunction)) \
9✔
1336
                and indent_direction != 'none':
1337
            if sys.isctime():
9✔
1338
                splane_poles = sys.poles()
9✔
1339
                splane_cl_poles = sys.feedback().poles()
9✔
1340
            else:
1341
                # map z-plane poles to s-plane. We ignore any at the origin
1342
                # to avoid numerical warnings because we know we
1343
                # don't need to indent for them
1344
                zplane_poles = sys.poles()
9✔
1345
                zplane_poles = zplane_poles[~np.isclose(abs(zplane_poles), 0.)]
9✔
1346
                splane_poles = np.log(zplane_poles) / sys.dt
9✔
1347

1348
                zplane_cl_poles = sys.feedback().poles()
9✔
1349
                # eliminate z-plane poles at the origin to avoid warnings
1350
                zplane_cl_poles = zplane_cl_poles[
9✔
1351
                    ~np.isclose(abs(zplane_cl_poles), 0.)]
1352
                splane_cl_poles = np.log(zplane_cl_poles) / sys.dt
9✔
1353

1354
            #
1355
            # Check to make sure indent radius is small enough
1356
            #
1357
            # If there is a closed loop pole that is near the imaginary axis
1358
            # at a point that is near an open loop pole, it is possible that
1359
            # indentation might skip or create an extraneous encirclement.
1360
            # We check for that situation here and generate a warning if that
1361
            # could happen.
1362
            #
1363
            for p_cl in splane_cl_poles:
9✔
1364
                # See if any closed loop poles are near the imaginary axis
1365
                if abs(p_cl.real) <= indent_radius:
9✔
1366
                    # See if any open loop poles are close to closed loop poles
1367
                    if len(splane_poles) > 0:
9✔
1368
                        p_ol = splane_poles[
9✔
1369
                            (np.abs(splane_poles - p_cl)).argmin()]
1370

1371
                        if abs(p_ol - p_cl) <= indent_radius and \
9✔
1372
                                warn_encirclements:
1373
                            warnings.warn(
9✔
1374
                                "indented contour may miss closed loop pole; "
1375
                                "consider reducing indent_radius to below "
1376
                                f"{abs(p_ol - p_cl):5.2g}", stacklevel=2)
1377

1378
            #
1379
            # See if we should add some frequency points near imaginary poles
1380
            #
1381
            for p in splane_poles:
9✔
1382
                # See if we need to process this pole (skip if on the negative
1383
                # imaginary axis or not near imaginary axis + user override)
1384
                if p.imag < 0 or abs(p.real) > indent_radius or \
9✔
1385
                   omega_range_given:
1386
                    continue
9✔
1387

1388
                # Find the frequencies before the pole frequency
1389
                below_points = np.argwhere(
9✔
1390
                    splane_contour.imag - abs(p.imag) < -indent_radius)
1391
                if below_points.size > 0:
9✔
1392
                    first_point = below_points[-1].item()
9✔
1393
                    start_freq = p.imag - indent_radius
9✔
1394
                else:
1395
                    # Add the points starting at the beginning of the contour
1396
                    assert splane_contour[0] == 0
9✔
1397
                    first_point = 0
9✔
1398
                    start_freq = 0
9✔
1399

1400
                # Find the frequencies after the pole frequency
1401
                above_points = np.argwhere(
9✔
1402
                    splane_contour.imag - abs(p.imag) > indent_radius)
1403
                last_point = above_points[0].item()
9✔
1404

1405
                # Add points for half/quarter circle around pole frequency
1406
                # (these will get indented left or right below)
1407
                splane_contour = np.concatenate((
9✔
1408
                    splane_contour[0:first_point+1],
1409
                    (1j * np.linspace(
1410
                        start_freq, p.imag + indent_radius, indent_points)),
1411
                    splane_contour[last_point:]))
1412

1413
            # Indent points that are too close to a pole
1414
            if len(splane_poles) > 0: # accomodate no splane poles if dtime sys
9✔
1415
                for i, s in enumerate(splane_contour):
9✔
1416
                    # Find the nearest pole
1417
                    p = splane_poles[(np.abs(splane_poles - s)).argmin()]
9✔
1418

1419
                    # See if we need to indent around it
1420
                    if abs(s - p) < indent_radius:
9✔
1421
                        # Figure out how much to offset (simple trigonometry)
1422
                        offset = np.sqrt(
9✔
1423
                            indent_radius ** 2 - (s - p).imag ** 2) \
1424
                            - (s - p).real
1425

1426
                        # Figure out which way to offset the contour point
1427
                        if p.real < 0 or (p.real == 0 and
9✔
1428
                                        indent_direction == 'right'):
1429
                            # Indent to the right
1430
                            splane_contour[i] += offset
9✔
1431

1432
                        elif p.real > 0 or (p.real == 0 and
9✔
1433
                                            indent_direction == 'left'):
1434
                            # Indent to the left
1435
                            splane_contour[i] -= offset
9✔
1436

1437
                        else:
1438
                            raise ValueError(
9✔
1439
                                "unknown value for indent_direction")
1440

1441
        # change contour to z-plane if necessary
1442
        if sys.isctime():
9✔
1443
            contour = splane_contour
9✔
1444
        else:
1445
            contour = np.exp(splane_contour * sys.dt)
9✔
1446

1447
        # Make sure we don't try to evaluate at a pole
1448
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1449
            if any([pole in contour for pole in sys.poles()]):
9✔
1450
                raise RuntimeError(
9✔
1451
                    "attempt to evaluate at a pole; indent required")
1452

1453
        # Compute the primary curve
1454
        resp = sys(contour)
9✔
1455

1456
        # Compute CW encirclements of -1 by integrating the (unwrapped) angle
1457
        phase = -unwrap(np.angle(resp + 1))
9✔
1458
        encirclements = np.sum(np.diff(phase)) / np.pi
9✔
1459
        count = int(np.round(encirclements, 0))
9✔
1460

1461
        # Let the user know if the count might not make sense
1462
        if abs(encirclements - count) > encirclement_threshold and \
9✔
1463
           warn_encirclements:
1464
            warnings.warn(
9✔
1465
                "number of encirclements was a non-integer value; this can"
1466
                " happen is contour is not closed, possibly based on a"
1467
                " frequency range that does not include zero.")
1468

1469
        #
1470
        # Make sure that the enciriclements match the Nyquist criterion
1471
        #
1472
        # If the user specifies the frequency points to use, it is possible
1473
        # to miss enciriclements, so we check here to make sure that the
1474
        # Nyquist criterion is actually satisfied.
1475
        #
1476
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1477
            # Count the number of open/closed loop RHP poles
1478
            if sys.isctime():
9✔
1479
                if indent_direction == 'right':
9✔
1480
                    P = (sys.poles().real > 0).sum()
9✔
1481
                else:
1482
                    P = (sys.poles().real >= 0).sum()
9✔
1483
                Z = (sys.feedback().poles().real >= 0).sum()
9✔
1484
            else:
1485
                if indent_direction == 'right':
9✔
1486
                    P = (np.abs(sys.poles()) > 1).sum()
9✔
1487
                else:
1488
                    P = (np.abs(sys.poles()) >= 1).sum()
×
1489
                Z = (np.abs(sys.feedback().poles()) >= 1).sum()
9✔
1490

1491
            # Check to make sure the results make sense; warn if not
1492
            if Z != count + P and warn_encirclements:
9✔
1493
                warnings.warn(
9✔
1494
                    "number of encirclements does not match Nyquist criterion;"
1495
                    " check frequency range and indent radius/direction",
1496
                    UserWarning, stacklevel=2)
1497
            elif indent_direction == 'none' and any(sys.poles().real == 0) and \
9✔
1498
                 warn_encirclements:
1499
                warnings.warn(
×
1500
                    "system has pure imaginary poles but indentation is"
1501
                    " turned off; results may be meaningless",
1502
                    RuntimeWarning, stacklevel=2)
1503

1504
        # Decide on system name
1505
        sysname = sys.name if sys.name is not None else f"Unknown-{idx}"
9✔
1506

1507
        responses.append(NyquistResponseData(
9✔
1508
            count, contour, resp, sys.dt, sysname=sysname,
1509
            return_contour=return_contour))
1510

1511
    if isinstance(sysdata, (list, tuple)):
9✔
1512
        return NyquistResponseList(responses)
9✔
1513
    else:
1514
        return responses[0]
9✔
1515

1516

1517
def nyquist_plot(
9✔
1518
        data, omega=None, plot=None, label_freq=0, color=None, label=None,
1519
        return_contour=None, title=None, ax=None,
1520
        unit_circle=False, mt_circles=None, ms_circles=None, **kwargs):
1521
    """Nyquist plot for a system.
1522

1523
    Generates a Nyquist plot for the system over a (optional) frequency
1524
    range.  The curve is computed by evaluating the Nyqist segment along
1525
    the positive imaginary axis, with a mirror image generated to reflect
1526
    the negative imaginary axis.  Poles on or near the imaginary axis are
1527
    avoided using a small indentation.  The portion of the Nyquist contour
1528
    at infinity is not explicitly computed (since it maps to a constant
1529
    value for any system with a proper transfer function).
1530

1531
    Parameters
1532
    ----------
1533
    data : list of LTI or NyquistResponseData
1534
        List of linear input/output systems (single system is OK) or
1535
        Nyquist ersponses (computed using :func:`~control.nyquist_response`).
1536
        Nyquist curves for each system are plotted on the same graph.
1537
    omega : array_like, optional
1538
        Set of frequencies to be evaluated, in rad/sec. Specifying
1539
        ``omega`` as a list of two elements is equivalent to providing
1540
        ``omega_limits``.
1541
    unit_circle : bool, optional
1542
        If ``True``, display the unit circle, to read gain crossover frequency.
1543
    mt_circles : array_like, optional
1544
        Draw circles corresponding to the given magnitudes of sensitivity.
1545
    ms_circles : array_like, optional
1546
        Draw circles corresponding to the given magnitudes of complementary
1547
        sensitivity.
1548
    **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
1549
        Additional keywords passed to `matplotlib` to specify line properties.
1550

1551
    Returns
1552
    -------
1553
    cplt : :class:`ControlPlot` object
1554
        Object containing the data that were plotted:
1555

1556
          * cplt.lines: 2D array of :class:`matplotlib.lines.Line2D`
1557
            objects for each line in the plot.  The shape of the array is
1558
            given by (nsys, 4) where nsys is the number of systems or
1559
            Nyquist responses passed to the function.  The second index
1560
            specifies the segment type:
1561

1562
              - lines[idx, 0]: unscaled portion of the primary curve
1563
              - lines[idx, 1]: scaled portion of the primary curve
1564
              - lines[idx, 2]: unscaled portion of the mirror curve
1565
              - lines[idx, 3]: scaled portion of the mirror curve
1566

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

1569
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
1570

1571
          * cplt.legend: legend object(s) contained in the plot
1572

1573
        See :class:`ControlPlot` for more detailed information.
1574

1575
    Other Parameters
1576
    ----------------
1577
    arrows : int or 1D/2D array of floats, optional
1578
        Specify the number of arrows to plot on the Nyquist curve.  If an
1579
        integer is passed. that number of equally spaced arrows will be
1580
        plotted on each of the primary segment and the mirror image.  If a 1D
1581
        array is passed, it should consist of a sorted list of floats between
1582
        0 and 1, indicating the location along the curve to plot an arrow.  If
1583
        a 2D array is passed, the first row will be used to specify arrow
1584
        locations for the primary curve and the second row will be used for
1585
        the mirror image.
1586
    arrow_size : float, optional
1587
        Arrowhead width and length (in display coordinates).  Default value is
1588
        8 and can be set using config.defaults['nyquist.arrow_size'].
1589
    arrow_style : matplotlib.patches.ArrowStyle, optional
1590
        Define style used for Nyquist curve arrows (overrides `arrow_size`).
1591
    ax : matplotlib.axes.Axes, optional
1592
        The matplotlib axes to draw the figure on.  If not specified and
1593
        the current figure has a single axes, that axes is used.
1594
        Otherwise, a new figure is created.
1595
    encirclement_threshold : float, optional
1596
        Define the threshold for generating a warning if the number of net
1597
        encirclements is a non-integer value.  Default value is 0.05 and can
1598
        be set using config.defaults['nyquist.encirclement_threshold'].
1599
    indent_direction : str, optional
1600
        For poles on the imaginary axis, set the direction of indentation to
1601
        be 'right' (default), 'left', or 'none'.
1602
    indent_points : int, optional
1603
        Number of points to insert in the Nyquist contour around poles that
1604
        are at or near the imaginary axis.
1605
    indent_radius : float, optional
1606
        Amount to indent the Nyquist contour around poles on or near the
1607
        imaginary axis. Portions of the Nyquist plot corresponding to indented
1608
        portions of the contour are plotted using a different line style.
1609
    label : str or array_like of str, optional
1610
        If present, replace automatically generated label(s) with the given
1611
        label(s).  If sysdata is a list, strings should be specified for each
1612
        system.
1613
    label_freq : int, optiona
1614
        Label every nth frequency on the plot.  If not specified, no labels
1615
        are generated.
1616
    legend_loc : int or str, optional
1617
        Include a legend in the given location. Default is 'upper right',
1618
        with no legend for a single response.  Use False to suppress legend.
1619
    max_curve_magnitude : float, optional
1620
        Restrict the maximum magnitude of the Nyquist plot to this value.
1621
        Portions of the Nyquist plot whose magnitude is restricted are
1622
        plotted using a different line style.
1623
    max_curve_offset : float, optional
1624
        When plotting scaled portion of the Nyquist plot, increase/decrease
1625
        the magnitude by this fraction of the max_curve_magnitude to allow
1626
        any overlaps between the primary and mirror curves to be avoided.
1627
    mirror_style : [str, str] or False
1628
        Linestyles for mirror image of the Nyquist curve.  The first element
1629
        is used for unscaled portions of the Nyquist curve, the second element
1630
        is used for portions that are scaled (using max_curve_magnitude).  If
1631
        `False` then omit completely.  Default linestyle (['--', ':']) is
1632
        determined by config.defaults['nyquist.mirror_style'].
1633
    omega_limits : array_like of two values
1634
        Set limits for plotted frequency range. If Hz=True the limits are
1635
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
1636
        elements is equivalent to providing ``omega_limits``.
1637
    omega_num : int, optional
1638
        Number of samples to use for the frequeny range.  Defaults to
1639
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
1640
        not a list of systems.
1641
    plot : bool, optional
1642
        (legacy) If given, `nyquist_plot` returns the legacy return values
1643
        of (counts, contours).  If False, return the values with no plot.
1644
    primary_style : [str, str], optional
1645
        Linestyles for primary image of the Nyquist curve.  The first
1646
        element is used for unscaled portions of the Nyquist curve,
1647
        the second element is used for portions that are scaled (using
1648
        max_curve_magnitude).  Default linestyle (['-', '-.']) is
1649
        determined by config.defaults['nyquist.mirror_style'].
1650
    rcParams : dict
1651
        Override the default parameters used for generating plots.
1652
        Default is set by config.default['ctrlplot.rcParams'].
1653
    return_contour : bool, optional
1654
        (legacy) If 'True', return the encirclement count and Nyquist
1655
        contour used to generate the Nyquist plot.
1656
    show_legend : bool, optional
1657
        Force legend to be shown if ``True`` or hidden if ``False``.  If
1658
        ``None``, then show legend when there is more than one line on the
1659
        plot or ``legend_loc`` has been specified.
1660
    start_marker : str, optional
1661
        Matplotlib marker to use to mark the starting point of the Nyquist
1662
        plot.  Defaults value is 'o' and can be set using
1663
        config.defaults['nyquist.start_marker'].
1664
    start_marker_size : float, optional
1665
        Start marker size (in display coordinates).  Default value is
1666
        4 and can be set using config.defaults['nyquist.start_marker_size'].
1667
    title : str, optional
1668
        Set the title of the plot.  Defaults to plot type and system name(s).
1669
    title_frame : str, optional
1670
        Set the frame of reference used to center the plot title. If set to
1671
        'axes' (default), the horizontal position of the title will
1672
        centered relative to the axes.  If set to 'figure', it will be
1673
        centered with respect to the figure (faster execution).
1674
    warn_nyquist : bool, optional
1675
        If set to 'False', turn off warnings about frequencies above Nyquist.
1676
    warn_encirclements : bool, optional
1677
        If set to 'False', turn off warnings about number of encirclements not
1678
        meeting the Nyquist criterion.
1679

1680
    See Also
1681
    --------
1682
    nyquist_response
1683

1684
    Notes
1685
    -----
1686
    1. If a discrete time model is given, the frequency response is computed
1687
       along the upper branch of the unit circle, using the mapping ``z =
1688
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
1689
       is the discrete timebase.  If timebase not specified (``dt=True``),
1690
       `dt` is set to 1.
1691

1692
    2. If a continuous-time system contains poles on or near the imaginary
1693
       axis, a small indentation will be used to avoid the pole.  The radius
1694
       of the indentation is given by `indent_radius` and it is taken to the
1695
       right of stable poles and the left of unstable poles.  If a pole is
1696
       exactly on the imaginary axis, the `indent_direction` parameter can be
1697
       used to set the direction of indentation.  Setting `indent_direction`
1698
       to `none` will turn off indentation.  If `return_contour` is True, the
1699
       exact contour used for evaluation is returned.
1700

1701
    3. For those portions of the Nyquist plot in which the contour is
1702
       indented to avoid poles, resuling in a scaling of the Nyquist plot,
1703
       the line styles are according to the settings of the `primary_style`
1704
       and `mirror_style` keywords.  By default the scaled portions of the
1705
       primary curve use a dotted line style and the scaled portion of the
1706
       mirror image use a dashdot line style.
1707

1708
    Examples
1709
    --------
1710
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1711
    >>> out = ct.nyquist_plot(G)
1712

1713
    """
1714
    #
1715
    # Keyword processing
1716
    #
1717
    # Keywords for the nyquist_plot function can either be keywords that
1718
    # are unique to this function, keywords that are intended for use by
1719
    # nyquist_response (if data is a list of systems), or keywords that
1720
    # are intended for the plotting commands.
1721
    #
1722
    # We first pop off all keywords that are used directly by this
1723
    # function.  If data is a list of systems, when then pop off keywords
1724
    # that correspond to nyquist_response() keywords.  The remaining
1725
    # keywords are passed to matplotlib (and will generate an error if
1726
    # unrecognized).
1727
    #
1728

1729
    # Get values for params (and pop from list to allow keyword use in plot)
1730
    arrows = config._get_param(
9✔
1731
        'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True)
1732
    arrow_size = config._get_param(
9✔
1733
        'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True)
1734
    arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None)
9✔
1735
    ax_user = ax
9✔
1736
    max_curve_magnitude = config._get_param(
9✔
1737
        'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True)
1738
    max_curve_offset = config._get_param(
9✔
1739
        'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True)
1740
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
1741
    start_marker = config._get_param(
9✔
1742
        'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True)
1743
    start_marker_size = config._get_param(
9✔
1744
        'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True)
1745
    title_frame = config._get_param(
9✔
1746
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
1747

1748
    # Set line styles for the curves
1749
    def _parse_linestyle(style_name, allow_false=False):
9✔
1750
        style = config._get_param(
9✔
1751
            'nyquist', style_name, kwargs, _nyquist_defaults, pop=True)
1752
        if isinstance(style, str):
9✔
1753
            # Only one style provided, use the default for the other
1754
            style = [style, _nyquist_defaults['nyquist.' + style_name][1]]
9✔
1755
            warnings.warn(
9✔
1756
                "use of a single string for linestyle will be deprecated "
1757
                " in a future release", PendingDeprecationWarning)
1758
        if (allow_false and style is False) or \
9✔
1759
           (isinstance(style, list) and len(style) == 2):
1760
            return style
9✔
1761
        else:
1762
            raise ValueError(f"invalid '{style_name}': {style}")
9✔
1763

1764
    primary_style = _parse_linestyle('primary_style')
9✔
1765
    mirror_style = _parse_linestyle('mirror_style', allow_false=True)
9✔
1766

1767
    # Parse the arrows keyword
1768
    if not arrows:
9✔
1769
        arrow_pos = []
9✔
1770
    elif isinstance(arrows, int):
9✔
1771
        N = arrows
9✔
1772
        # Space arrows out, starting midway along each "region"
1773
        arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False)
9✔
1774
    elif isinstance(arrows, (list, np.ndarray)):
9✔
1775
        arrow_pos = np.sort(np.atleast_1d(arrows))
9✔
1776
    else:
1777
        raise ValueError("unknown or unsupported arrow location")
9✔
1778

1779
    # Set the arrow style
1780
    if arrow_style is None:
9✔
1781
        arrow_style = mpl.patches.ArrowStyle(
9✔
1782
            'simple', head_width=arrow_size, head_length=arrow_size)
1783

1784
    # If argument was a singleton, turn it into a tuple
1785
    if not isinstance(data, (list, tuple)):
9✔
1786
        data = [data]
9✔
1787

1788
    # Process label keyword
1789
    line_labels = _process_line_labels(label, len(data))
9✔
1790

1791
    # If we are passed a list of systems, compute response first
1792
    if all([isinstance(
9✔
1793
            sys, (StateSpace, TransferFunction, FrequencyResponseData))
1794
            for sys in data]):
1795
        # Get the response; pop explicit keywords here, kwargs in _response()
1796
        nyquist_responses = nyquist_response(
9✔
1797
            data, omega=omega, return_contour=return_contour,
1798
            omega_limits=kwargs.pop('omega_limits', None),
1799
            omega_num=kwargs.pop('omega_num', None),
1800
            warn_encirclements=kwargs.pop('warn_encirclements', True),
1801
            warn_nyquist=kwargs.pop('warn_nyquist', True),
1802
            _kwargs=kwargs, _check_kwargs=False)
1803
    else:
1804
        nyquist_responses = data
9✔
1805

1806
    # Legacy return value processing
1807
    if plot is not None or return_contour is not None:
9✔
1808
        warnings.warn(
9✔
1809
            "nyquist_plot() return value of count[, contour] is deprecated; "
1810
            "use nyquist_response()", FutureWarning)
1811

1812
        # Extract out the values that we will eventually return
1813
        counts = [response.count for response in nyquist_responses]
9✔
1814
        contours = [response.contour for response in nyquist_responses]
9✔
1815

1816
    if plot is False:
9✔
1817
        # Make sure we used all of the keywrods
1818
        if kwargs:
9✔
1819
            raise TypeError("unrecognized keywords: ", str(kwargs))
×
1820

1821
        if len(data) == 1:
9✔
1822
            counts, contours = counts[0], contours[0]
9✔
1823

1824
        # Return counts and (optionally) the contour we used
1825
        return (counts, contours) if return_contour else counts
9✔
1826

1827
    fig, ax = _process_ax_keyword(
9✔
1828
        ax_user, shape=(1, 1), squeeze=True, rcParams=rcParams)
1829
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
1830
        kwargs, None, 'upper right')
1831

1832
    # Create a list of lines for the output
1833
    out = np.empty(len(nyquist_responses), dtype=object)
9✔
1834
    for i in range(out.shape[0]):
9✔
1835
        out[i] = []             # unique list in each element
9✔
1836

1837
    for idx, response in enumerate(nyquist_responses):
9✔
1838
        resp = response.response
9✔
1839
        if response.dt in [0, None]:
9✔
1840
            splane_contour = response.contour
9✔
1841
        else:
1842
            splane_contour = np.log(response.contour) / response.dt
9✔
1843

1844
        # Find the different portions of the curve (with scaled pts marked)
1845
        reg_mask = np.logical_or(
9✔
1846
            np.abs(resp) > max_curve_magnitude,
1847
            splane_contour.real != 0)
1848
        # reg_mask = np.logical_or(
1849
        #     np.abs(resp.real) > max_curve_magnitude,
1850
        #     np.abs(resp.imag) > max_curve_magnitude)
1851

1852
        scale_mask = ~reg_mask \
9✔
1853
            & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \
1854
            & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1]))
1855

1856
        # Rescale the points with large magnitude
1857
        rescale = np.logical_and(
9✔
1858
            reg_mask, abs(resp) > max_curve_magnitude)
1859
        resp[rescale] *= max_curve_magnitude / abs(resp[rescale])
9✔
1860

1861
        # Get the label to use for the line
1862
        label = response.sysname if line_labels is None else line_labels[idx]
9✔
1863

1864
        # Plot the regular portions of the curve (and grab the color)
1865
        x_reg = np.ma.masked_where(reg_mask, resp.real)
9✔
1866
        y_reg = np.ma.masked_where(reg_mask, resp.imag)
9✔
1867
        p = plt.plot(
9✔
1868
            x_reg, y_reg, primary_style[0], color=color, label=label, **kwargs)
1869
        c = p[0].get_color()
9✔
1870
        out[idx] += p
9✔
1871

1872
        # Figure out how much to offset the curve: the offset goes from
1873
        # zero at the start of the scaled section to max_curve_offset as
1874
        # we move along the curve
1875
        curve_offset = _compute_curve_offset(
9✔
1876
            resp, scale_mask, max_curve_offset)
1877

1878
        # Plot the scaled sections of the curve (changing linestyle)
1879
        x_scl = np.ma.masked_where(scale_mask, resp.real)
9✔
1880
        y_scl = np.ma.masked_where(scale_mask, resp.imag)
9✔
1881
        if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1882
            out[idx] += plt.plot(
9✔
1883
                x_scl * (1 + curve_offset),
1884
                y_scl * (1 + curve_offset),
1885
                primary_style[1], color=c, **kwargs)
1886
        else:
1887
            out[idx] += [None]
9✔
1888

1889
        # Plot the primary curve (invisible) for setting arrows
1890
        x, y = resp.real.copy(), resp.imag.copy()
9✔
1891
        x[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1892
        y[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1893
        p = plt.plot(x, y, linestyle='None', color=c)
9✔
1894

1895
        # Add arrows
1896
        ax = plt.gca()
9✔
1897
        _add_arrows_to_line2D(
9✔
1898
            ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1)
1899

1900
        # Plot the mirror image
1901
        if mirror_style is not False:
9✔
1902
            # Plot the regular and scaled segments
1903
            out[idx] += plt.plot(
9✔
1904
                x_reg, -y_reg, mirror_style[0], color=c, **kwargs)
1905
            if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1906
                out[idx] += plt.plot(
9✔
1907
                    x_scl * (1 - curve_offset),
1908
                    -y_scl * (1 - curve_offset),
1909
                    mirror_style[1], color=c, **kwargs)
1910
            else:
1911
                out[idx] += [None]
9✔
1912

1913
            # Add the arrows (on top of an invisible contour)
1914
            x, y = resp.real.copy(), resp.imag.copy()
9✔
1915
            x[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1916
            y[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1917
            p = plt.plot(x, -y, linestyle='None', color=c, **kwargs)
9✔
1918
            _add_arrows_to_line2D(
9✔
1919
                ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1)
1920
        else:
1921
            out[idx] += [None, None]
9✔
1922

1923
        # Mark the start of the curve
1924
        if start_marker:
9✔
1925
            plt.plot(resp[0].real, resp[0].imag, start_marker,
9✔
1926
                     color=c, markersize=start_marker_size)
1927

1928
        # Mark the -1 point
1929
        plt.plot([-1], [0], 'r+')
9✔
1930

1931
        #
1932
        # Draw circles for gain crossover and sensitivity functions
1933
        #
1934
        theta = np.linspace(0, 2*np.pi, 100)
9✔
1935
        cos = np.cos(theta)
9✔
1936
        sin = np.sin(theta)
9✔
1937
        label_pos = 15
9✔
1938

1939
        # Display the unit circle, to read gain crossover frequency
1940
        if unit_circle:
9✔
1941
            plt.plot(cos, sin, **config.defaults['nyquist.circle_style'])
9✔
1942

1943
        # Draw circles for given magnitudes of sensitivity
1944
        if ms_circles is not None:
9✔
1945
            for ms in ms_circles:
9✔
1946
                pos_x = -1 + (1/ms)*cos
9✔
1947
                pos_y = (1/ms)*sin
9✔
1948
                plt.plot(
9✔
1949
                    pos_x, pos_y, **config.defaults['nyquist.circle_style'])
1950
                plt.text(pos_x[label_pos], pos_y[label_pos], ms)
9✔
1951

1952
        # Draw circles for given magnitudes of complementary sensitivity
1953
        if mt_circles is not None:
9✔
1954
            for mt in mt_circles:
9✔
1955
                if mt != 1:
9✔
1956
                    ct = -mt**2/(mt**2-1)  # Mt center
9✔
1957
                    rt = mt/(mt**2-1)  # Mt radius
9✔
1958
                    pos_x = ct+rt*cos
9✔
1959
                    pos_y = rt*sin
9✔
1960
                    plt.plot(
9✔
1961
                        pos_x, pos_y,
1962
                        **config.defaults['nyquist.circle_style'])
1963
                    plt.text(pos_x[label_pos], pos_y[label_pos], mt)
9✔
1964
                else:
1965
                    _, _, ymin, ymax = plt.axis()
9✔
1966
                    pos_y = np.linspace(ymin, ymax, 100)
9✔
1967
                    plt.vlines(
9✔
1968
                        -0.5, ymin=ymin, ymax=ymax,
1969
                        **config.defaults['nyquist.circle_style'])
1970
                    plt.text(-0.5, pos_y[label_pos], 1)
9✔
1971

1972
        # Label the frequencies of the points on the Nyquist curve
1973
        if label_freq:
9✔
1974
            ind = slice(None, None, label_freq)
9✔
1975
            omega_sys = np.imag(splane_contour[np.real(splane_contour) == 0])
9✔
1976
            for xpt, ypt, omegapt in zip(x[ind], y[ind], omega_sys[ind]):
9✔
1977
                # Convert to Hz
1978
                f = omegapt / (2 * np.pi)
9✔
1979

1980
                # Factor out multiples of 1000 and limit the
1981
                # result to the range [-8, 8].
1982
                pow1000 = max(min(get_pow1000(f), 8), -8)
9✔
1983

1984
                # Get the SI prefix.
1985
                prefix = gen_prefix(pow1000)
9✔
1986

1987
                # Apply the text. (Use a space before the text to
1988
                # prevent overlap with the data.)
1989
                #
1990
                # np.round() is used because 0.99... appears
1991
                # instead of 1.0, and this would otherwise be
1992
                # truncated to 0.
1993
                plt.text(xpt, ypt, ' ' +
9✔
1994
                         str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' +
1995
                         prefix + 'Hz')
1996

1997
    # Label the axes
1998
    ax.set_xlabel("Real axis")
9✔
1999
    ax.set_ylabel("Imaginary axis")
9✔
2000
    ax.grid(color="lightgray")
9✔
2001

2002
    # List of systems that are included in this plot
2003
    lines, labels = _get_line_labels(ax)
9✔
2004

2005
    # Add legend if there is more than one system plotted
2006
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2007
        with plt.rc_context(rcParams):
9✔
2008
            legend = ax.legend(lines, labels, loc=legend_loc)
9✔
2009
    else:
2010
        legend = None
9✔
2011

2012
    # Add the title
2013
    sysnames = [response.sysname for response in nyquist_responses]
9✔
2014
    if ax_user is None and title is None:
9✔
2015
        title = "Nyquist plot for " + ", ".join(sysnames)
9✔
2016
        _update_plot_title(
9✔
2017
            title, fig=fig, rcParams=rcParams, frame=title_frame)
2018
    elif ax_user is None:
9✔
2019
        _update_plot_title(
9✔
2020
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2021
            use_existing=False)
2022

2023
    # Legacy return pocessing
2024
    if plot is True or return_contour is not None:
9✔
2025
        if len(data) == 1:
9✔
2026
            counts, contours = counts[0], contours[0]
9✔
2027

2028
        # Return counts and (optionally) the contour we used
2029
        return (counts, contours) if return_contour else counts
9✔
2030

2031
    return ControlPlot(out, ax, fig, legend=legend)
9✔
2032

2033

2034
#
2035
# Function to compute Nyquist curve offsets
2036
#
2037
# This function computes a smoothly varying offset that starts and ends at
2038
# zero at the ends of a scaled segment.
2039
#
2040
def _compute_curve_offset(resp, mask, max_offset):
9✔
2041
    # Compute the arc length along the curve
2042
    s_curve = np.cumsum(
9✔
2043
        np.sqrt(np.diff(resp.real) ** 2 + np.diff(resp.imag) ** 2))
2044

2045
    # Initialize the offset
2046
    offset = np.zeros(resp.size)
9✔
2047
    arclen = np.zeros(resp.size)
9✔
2048

2049
    # Walk through the response and keep track of each continous component
2050
    i, nsegs = 0, 0
9✔
2051
    while i < resp.size:
9✔
2052
        # Skip the regular segment
2053
        while i < resp.size and mask[i]:
9✔
2054
            i += 1              # Increment the counter
9✔
2055
            if i == resp.size:
9✔
2056
                break
9✔
2057
            # Keep track of the arclength
2058
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2059

2060
        nsegs += 0.5
9✔
2061
        if i == resp.size:
9✔
2062
            break
9✔
2063

2064
        # Save the starting offset of this segment
2065
        seg_start = i
9✔
2066

2067
        # Walk through the scaled segment
2068
        while i < resp.size and not mask[i]:
9✔
2069
            i += 1
9✔
2070
            if i == resp.size:  # See if we are done with this segment
9✔
2071
                break
9✔
2072
            # Keep track of the arclength
2073
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2074

2075
        nsegs += 0.5
9✔
2076
        if i == resp.size:
9✔
2077
            break
9✔
2078

2079
        # Save the ending offset of this segment
2080
        seg_end = i
9✔
2081

2082
        # Now compute the scaling for this segment
2083
        s_segment = arclen[seg_end-1] - arclen[seg_start]
9✔
2084
        offset[seg_start:seg_end] = max_offset * s_segment/s_curve[-1] * \
9✔
2085
            np.sin(np.pi * (arclen[seg_start:seg_end]
2086
                            - arclen[seg_start])/s_segment)
2087

2088
    return offset
9✔
2089

2090

2091
#
2092
# Gang of Four plot
2093
#
2094
def gangof4_response(
9✔
2095
        P, C, omega=None, omega_limits=None, omega_num=None, Hz=False):
2096
    """Compute the response of the "Gang of 4" transfer functions for a system.
2097

2098
    Generates a 2x2 frequency response for the "Gang of 4" sensitivity
2099
    functions [T, PS; CS, S].
2100

2101
    Parameters
2102
    ----------
2103
    P, C : LTI
2104
        Linear input/output systems (process and control).
2105
    omega : array
2106
        Range of frequencies (list or bounds) in rad/sec.
2107
    omega_limits : array_like of two values
2108
        Set limits for plotted frequency range. If Hz=True the limits are
2109
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2110
        elements is equivalent to providing ``omega_limits``. Ignored if
2111
        data is not a list of systems.
2112
    omega_num : int
2113
        Number of samples to use for the frequeny range.  Defaults to
2114
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
2115
        not a list of systems.
2116
    Hz : bool, optional
2117
        If True, when computing frequency limits automatically set
2118
        limits to full decades in Hz instead of rad/s.
2119

2120
    Returns
2121
    -------
2122
    response : :class:`~control.FrequencyResponseData`
2123
        Frequency response with inputs 'r' and 'd' and outputs 'y', and 'u'
2124
        representing the 2x2 matrix of transfer functions in the Gang of 4.
2125

2126
    Examples
2127
    --------
2128
    >>> P = ct.tf([1], [1, 1])
2129
    >>> C = ct.tf([2], [1])
2130
    >>> response = ct.gangof4_response(P, C)
2131
    >>> lines = response.plot()
2132

2133
    """
2134
    if not P.issiso() or not C.issiso():
9✔
2135
        # TODO: Add MIMO go4 plots.
2136
        raise ControlMIMONotImplemented(
×
2137
            "Gang of four is currently only implemented for SISO systems.")
2138

2139
    # Compute the senstivity functions
2140
    L = P * C
9✔
2141
    S = feedback(1, L)
9✔
2142
    T = L * S
9✔
2143

2144
    # Select a default range if none is provided
2145
    # TODO: This needs to be made more intelligent
2146
    omega, _ = _determine_omega_vector(
9✔
2147
        [P, C, S], omega, omega_limits, omega_num, Hz=Hz)
2148

2149
    #
2150
    # bode_plot based implementation
2151
    #
2152

2153
    # Compute the response of the Gang of 4
2154
    resp_T = T(1j * omega)
9✔
2155
    resp_PS = (P * S)(1j * omega)
9✔
2156
    resp_CS = (C * S)(1j * omega)
9✔
2157
    resp_S = S(1j * omega)
9✔
2158

2159
    # Create a single frequency response data object with the underlying data
2160
    data = np.empty((2, 2, omega.size), dtype=complex)
9✔
2161
    data[0, 0, :] = resp_T
9✔
2162
    data[0, 1, :] = resp_PS
9✔
2163
    data[1, 0, :] = resp_CS
9✔
2164
    data[1, 1, :] = resp_S
9✔
2165

2166
    return FrequencyResponseData(
9✔
2167
        data, omega, outputs=['y', 'u'], inputs=['r', 'd'],
2168
        title=f"Gang of Four for P={P.name}, C={C.name}",
2169
        sysname=f"P={P.name}, C={C.name}", plot_phase=False)
2170

2171

2172
def gangof4_plot(
9✔
2173
        *args, omega=None, omega_limits=None, omega_num=None,
2174
        Hz=False, **kwargs):
2175
    """Plot the response of the "Gang of 4" transfer functions for a system.
2176

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

2180
        gangof4_plot(response[, ...])
2181
        gangof4_plot(P, C[, ...])
2182

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

2204
    Returns
2205
    -------
2206
    cplt : :class:`ControlPlot` object
2207
        Object containing the data that were plotted:
2208

2209
          * cplt.lines: 2x2 array of :class:`matplotlib.lines.Line2D`
2210
            objects for each line in the plot.  The value of each array
2211
            entry is a list of Line2D objects in that subplot.
2212

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

2215
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
2216

2217
          * cplt.legend: legend object(s) contained in the plot
2218

2219
        See :class:`ControlPlot` for more detailed information.
2220

2221
    """
2222
    if len(args) == 1 and isinstance(args[0], FrequencyResponseData):
9✔
2223
        if any([kw is not None
×
2224
                for kw in [omega, omega_limits, omega_num, Hz]]):
2225
            raise ValueError(
×
2226
                "omega, omega_limits, omega_num, Hz not allowed when "
2227
                "given a Gang of 4 response as first argument")
2228
        return args[0].plot(kwargs)
×
2229
    else:
2230
        if len(args) > 3:
9✔
2231
            raise TypeError(
×
2232
                f"expecting 2 or 3 positional arguments; received {len(args)}")
2233
        omega = omega if len(args) < 3 else args[2]
9✔
2234
        args = args[0:2]
9✔
2235
        return gangof4_response(
9✔
2236
            *args, omega=omega, omega_limits=omega_limits,
2237
            omega_num=omega_num, Hz=Hz).plot(**kwargs)
2238

2239

2240
#
2241
# Singular values plot
2242
#
2243
def singular_values_response(
9✔
2244
        sysdata, omega=None, omega_limits=None, omega_num=None, Hz=False):
2245
    """Singular value response for a system.
2246

2247
    Computes the singular values for a system or list of systems over
2248
    a (optional) frequency range.
2249

2250
    Parameters
2251
    ----------
2252
    sysdata : LTI or list of LTI
2253
        List of linear input/output systems (single system is OK).
2254
    omega : array_like
2255
        List of frequencies in rad/sec to be used for frequency response.
2256
    Hz : bool, optional
2257
        If True, when computing frequency limits automatically set
2258
        limits to full decades in Hz instead of rad/s.
2259

2260
    Returns
2261
    -------
2262
    response : FrequencyResponseData
2263
        Frequency response with the number of outputs equal to the
2264
        number of singular values in the response, and a single input.
2265

2266
    Other Parameters
2267
    ----------------
2268
    omega_limits : array_like of two values
2269
        Set limits for plotted frequency range. If Hz=True the limits are
2270
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2271
        elements is equivalent to providing ``omega_limits``.
2272
    omega_num : int, optional
2273
        Number of samples to use for the frequeny range.  Defaults to
2274
        config.defaults['freqplot.number_of_samples'].
2275

2276
    See Also
2277
    --------
2278
    singular_values_plot
2279

2280
    Examples
2281
    --------
2282
    >>> omegas = np.logspace(-4, 1, 1000)
2283
    >>> den = [75, 1]
2284
    >>> G = ct.tf([[[87.8], [-86.4]], [[108.2], [-109.6]]],
2285
    ...           [[den, den], [den, den]])
2286
    >>> response = ct.singular_values_response(G, omega=omegas)
2287

2288
    """
2289
    # Convert the first argument to a list
2290
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
2291

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

2295
    # Compute the frequency responses for the systems
2296
    responses = frequency_response(
9✔
2297
        syslist, omega=omega, omega_limits=omega_limits,
2298
        omega_num=omega_num, Hz=Hz, squeeze=False)
2299

2300
    # Calculate the singular values for each system in the list
2301
    svd_responses = []
9✔
2302
    for response in responses:
9✔
2303
        # Compute the singular values (permute indices to make things work)
2304
        fresp_permuted = response.fresp.transpose((2, 0, 1))
9✔
2305
        sigma = np.linalg.svd(fresp_permuted, compute_uv=False).transpose()
9✔
2306
        sigma_fresp = sigma.reshape(sigma.shape[0], 1, sigma.shape[1])
9✔
2307

2308
        # Save the singular values as an FRD object
2309
        svd_responses.append(
9✔
2310
            FrequencyResponseData(
2311
                sigma_fresp, response.omega, _return_singvals=True,
2312
                outputs=[f'$\\sigma_{{{k+1}}}$' for k in range(sigma.shape[0])],
2313
                inputs='inputs', dt=response.dt, plot_phase=False,
2314
                sysname=response.sysname, plot_type='svplot',
2315
                title=f"Singular values for {response.sysname}"))
2316

2317
    if isinstance(sysdata, (list, tuple)):
9✔
2318
        return FrequencyResponseList(svd_responses)
9✔
2319
    else:
2320
        return svd_responses[0]
9✔
2321

2322

2323
def singular_values_plot(
9✔
2324
        data, omega=None, *fmt, plot=None, omega_limits=None, omega_num=None,
2325
        ax=None, label=None, title=None, **kwargs):
2326
    """Plot the singular values for a system.
2327

2328
    Plot the singular values as a function of frequency for a system or
2329
    list of systems.  If multiple systems are plotted, each system in the
2330
    list is plotted in a different color.
2331

2332
    Parameters
2333
    ----------
2334
    data : list of `FrequencyResponseData`
2335
        List of :class:`FrequencyResponseData` objects.  For backward
2336
        compatibility, a list of LTI systems can also be given.
2337
    omega : array_like
2338
        List of frequencies in rad/sec over to plot over.
2339
    *fmt : :func:`matplotlib.pyplot.plot` format string, optional
2340
        Passed to `matplotlib` as the format string for all lines in the plot.
2341
        The `omega` parameter must be present (use omega=None if needed).
2342
    dB : bool
2343
        If True, plot result in dB.  Default is False.
2344
    Hz : bool
2345
        If True, plot frequency in Hz (omega must be provided in rad/sec).
2346
        Default value (False) set by config.defaults['freqplot.Hz'].
2347
    **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
2348
        Additional keywords passed to `matplotlib` to specify line properties.
2349

2350
    Returns
2351
    -------
2352
    cplt : :class:`ControlPlot` object
2353
        Object containing the data that were plotted:
2354

2355
          * cplt.lines: 1-D array of :class:`matplotlib.lines.Line2D` objects.
2356
            The size of the array matches the number of systems and the
2357
            value of the array is a list of Line2D objects for that system.
2358

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

2361
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
2362

2363
          * cplt.legend: legend object(s) contained in the plot
2364

2365
        See :class:`ControlPlot` for more detailed information.
2366

2367
    Other Parameters
2368
    ----------------
2369
    ax : matplotlib.axes.Axes, optional
2370
        The matplotlib axes to draw the figure on.  If not specified and
2371
        the current figure has a single axes, that axes is used.
2372
        Otherwise, a new figure is created.
2373
    color : matplotlib color spec
2374
        Color to use for singular values (or None for matplotlib default).
2375
    grid : bool
2376
        If True, plot grid lines on gain and phase plots.  Default is set by
2377
        `config.defaults['freqplot.grid']`.
2378
    label : str or array_like of str, optional
2379
        If present, replace automatically generated label(s) with the given
2380
        label(s).  If sysdata is a list, strings should be specified for each
2381
        system.
2382
    legend_loc : int or str, optional
2383
        Include a legend in the given location. Default is 'center right',
2384
        with no legend for a single response.  Use False to suppress legend.
2385
    omega_limits : array_like of two values
2386
        Set limits for plotted frequency range. If Hz=True the limits are
2387
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2388
        elements is equivalent to providing ``omega_limits``.
2389
    omega_num : int, optional
2390
        Number of samples to use for the frequeny range.  Defaults to
2391
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
2392
        not a list of systems.
2393
    plot : bool, optional
2394
        (legacy) If given, `singular_values_plot` returns the legacy return
2395
        values of magnitude, phase, and frequency.  If False, just return
2396
        the values with no plot.
2397
    rcParams : dict
2398
        Override the default parameters used for generating plots.
2399
        Default is set up config.default['ctrlplot.rcParams'].
2400
    show_legend : bool, optional
2401
        Force legend to be shown if ``True`` or hidden if ``False``.  If
2402
        ``None``, then show legend when there is more than one line on an
2403
        axis or ``legend_loc`` or ``legend_map`` has been specified.
2404
    title : str, optional
2405
        Set the title of the plot.  Defaults to plot type and system name(s).
2406
    title_frame : str, optional
2407
        Set the frame of reference used to center the plot title. If set to
2408
        'axes' (default), the horizontal position of the title will
2409
        centered relative to the axes.  If set to 'figure', it will be
2410
        centered with respect to the figure (faster execution).
2411

2412
    See Also
2413
    --------
2414
    singular_values_response
2415

2416
    Notes
2417
    -----
2418
    1. If plot==False, the following legacy values are returned:
2419
         * mag : ndarray (or list of ndarray if len(data) > 1))
2420
             Magnitude of the response (deprecated).
2421
         * phase : ndarray (or list of ndarray if len(data) > 1))
2422
             Phase in radians of the response (deprecated).
2423
         * omega : ndarray (or list of ndarray if len(data) > 1))
2424
             Frequency in rad/sec (deprecated).
2425

2426
    """
2427
    # Keyword processing
2428
    color = kwargs.pop('color', None)
9✔
2429
    dB = config._get_param(
9✔
2430
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
2431
    Hz = config._get_param(
9✔
2432
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
2433
    grid = config._get_param(
9✔
2434
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
2435
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
2436
    title_frame = config._get_param(
9✔
2437
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
2438

2439
    # If argument was a singleton, turn it into a tuple
2440
    data = data if isinstance(data, (list, tuple)) else (data,)
9✔
2441

2442
    # Convert systems into frequency responses
2443
    if any([isinstance(response, (StateSpace, TransferFunction))
9✔
2444
            for response in data]):
2445
        responses = singular_values_response(
9✔
2446
                    data, omega=omega, omega_limits=omega_limits,
2447
                    omega_num=omega_num)
2448
    else:
2449
        # Generate warnings if frequency keywords were given
2450
        if omega_num is not None:
9✔
2451
            warnings.warn("`omega_num` ignored when passed response data")
9✔
2452
        elif omega is not None:
9✔
2453
            warnings.warn("`omega` ignored when passed response data")
9✔
2454

2455
        # Check to make sure omega_limits is sensible
2456
        if omega_limits is not None and \
9✔
2457
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
2458
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
2459

2460
        responses = data
9✔
2461

2462
    # Process label keyword
2463
    line_labels = _process_line_labels(label, len(data))
9✔
2464

2465
    # Process (legacy) plot keyword
2466
    if plot is not None:
9✔
2467
        warnings.warn(
×
2468
            "`singular_values_plot` return values of sigma, omega is "
2469
            "deprecated; use singular_values_response()", FutureWarning)
2470

2471
    # Warn the user if we got past something that is not real-valued
2472
    if any([not np.allclose(np.imag(response.fresp[:, 0, :]), 0)
9✔
2473
            for response in responses]):
2474
        warnings.warn("data has non-zero imaginary component")
×
2475

2476
    # Extract the data we need for plotting
2477
    sigmas = [np.real(response.fresp[:, 0, :]) for response in responses]
9✔
2478
    omegas = [response.omega for response in responses]
9✔
2479

2480
    # Legacy processing for no plotting case
2481
    if plot is False:
9✔
2482
        if len(data) == 1:
×
2483
            return sigmas[0], omegas[0]
×
2484
        else:
2485
            return sigmas, omegas
×
2486

2487
    fig, ax_sigma = _process_ax_keyword(
9✔
2488
        ax, shape=(1, 1), squeeze=True, rcParams=rcParams)
2489
    ax_sigma.set_label('control-sigma')         # TODO: deprecate?
9✔
2490
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
2491
        kwargs, None, 'center right')
2492

2493
    # Get color offset for first (new) line to be drawn
2494
    color_offset, color_cycle = _get_color_offset(ax_sigma)
9✔
2495

2496
    # Create a list of lines for the output
2497
    out = np.empty(len(data), dtype=object)
9✔
2498

2499
    # Plot the singular values for each response
2500
    for idx_sys, response in enumerate(responses):
9✔
2501
        sigma = sigmas[idx_sys].transpose()     # frequency first for plotting
9✔
2502
        omega = omegas[idx_sys] / (2 * math.pi) if Hz else  omegas[idx_sys]
9✔
2503

2504
        if response.isdtime(strict=True):
9✔
2505
            nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
2506
        else:
2507
            nyq_freq = None
9✔
2508

2509
        # Determine the color to use for this response
2510
        color = _get_color(
9✔
2511
            color, fmt=fmt, offset=color_offset + idx_sys,
2512
            color_cycle=color_cycle)
2513

2514
        # To avoid conflict with *fmt, only pass color kw if non-None
2515
        color_arg = {} if color is None else {'color': color}
9✔
2516

2517
        # Decide on the system name
2518
        sysname = response.sysname if response.sysname is not None \
9✔
2519
            else f"Unknown-{idx_sys}"
2520

2521
        # Get the label to use for the line
2522
        label = sysname if line_labels is None else line_labels[idx_sys]
9✔
2523

2524
        # Plot the data
2525
        if dB:
9✔
2526
            out[idx_sys] = ax_sigma.semilogx(
9✔
2527
                omega, 20 * np.log10(sigma), *fmt,
2528
                label=label, **color_arg, **kwargs)
2529
        else:
2530
            out[idx_sys] = ax_sigma.loglog(
9✔
2531
                omega, sigma, label=label, *fmt, **color_arg, **kwargs)
2532

2533
        # Plot the Nyquist frequency
2534
        if nyq_freq is not None:
9✔
2535
            ax_sigma.axvline(
9✔
2536
                nyq_freq, linestyle='--', label='_nyq_freq_' + sysname,
2537
                **color_arg)
2538

2539
    # If specific omega_limits were given, use them
2540
    if omega_limits is not None:
9✔
2541
        ax_sigma.set_xlim(omega_limits)
9✔
2542

2543
    # Add a grid to the plot + labeling
2544
    if grid:
9✔
2545
        ax_sigma.grid(grid, which='both')
9✔
2546

2547
    ax_sigma.set_ylabel(
9✔
2548
        "Singular Values [dB]" if dB else "Singular Values")
2549
    ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]")
9✔
2550

2551
    # List of systems that are included in this plot
2552
    lines, labels = _get_line_labels(ax_sigma)
9✔
2553

2554
    # Add legend if there is more than one system plotted
2555
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2556
        with plt.rc_context(rcParams):
9✔
2557
            legend = ax_sigma.legend(lines, labels, loc=legend_loc)
9✔
2558
    else:
2559
        legend = None
9✔
2560

2561
    # Add the title
2562
    if ax is None:
9✔
2563
        if title is None:
9✔
2564
            title = "Singular values for " + ", ".join(labels)
9✔
2565
        _update_plot_title(
9✔
2566
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2567
            use_existing=False)
2568

2569
    # Legacy return processing
2570
    if plot is not None:
9✔
2571
        if len(responses) == 1:
×
2572
            return sigmas[0], omegas[0]
×
2573
        else:
2574
            return sigmas, omegas
×
2575

2576
    return ControlPlot(out, ax_sigma, fig, legend=legend)
9✔
2577

2578
#
2579
# Utility functions
2580
#
2581
# This section of the code contains some utility functions for
2582
# generating frequency domain plots.
2583
#
2584

2585

2586
# Determine the frequency range to be used
2587
def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
9✔
2588
                            Hz=None, feature_periphery_decades=None):
2589
    """Determine the frequency range for a frequency-domain plot
2590
    according to a standard logic.
2591

2592
    If omega_in and omega_limits are both None, then omega_out is computed
2593
    on omega_num points according to a default logic defined by
2594
    _default_frequency_range and tailored for the list of systems syslist, and
2595
    omega_range_given is set to False.
2596

2597
    If omega_in is None but omega_limits is an array-like of 2 elements, then
2598
    omega_out is computed with the function np.logspace on omega_num points
2599
    within the interval [min, max] =  [omega_limits[0], omega_limits[1]], and
2600
    omega_range_given is set to True.
2601

2602
    If omega_in is a list or tuple of length 2, it is interpreted as a
2603
    range and handled like omega_limits.  If omega_in is a list or tuple of
2604
    length 3, it is interpreted a range plus number of points and handled
2605
    like omega_limits and omega_num.
2606

2607
    If omega_in is an array or a list/tuple of length greater than
2608
    two, then omega_out is set to omega_in (as an array), and
2609
    omega_range_given is set to True
2610

2611
    Parameters
2612
    ----------
2613
    syslist : list of LTI
2614
        List of linear input/output systems (single system is OK).
2615
    omega_in : 1D array_like or None
2616
        Frequency range specified by the user.
2617
    omega_limits : 1D array_like or None
2618
        Frequency limits specified by the user.
2619
    omega_num : int
2620
        Number of points to be used for the frequency range (if the
2621
        frequency range is not user-specified).
2622
    Hz : bool, optional
2623
        If True, the limits (first and last value) of the frequencies
2624
        are set to full decades in Hz so it fits plotting with logarithmic
2625
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2626

2627
    Returns
2628
    -------
2629
    omega_out : 1D array
2630
        Frequency range to be used.
2631
    omega_range_given : bool
2632
        True if the frequency range was specified by the user, either through
2633
        omega_in or through omega_limits. False if both omega_in
2634
        and omega_limits are None.
2635

2636
    """
2637
    # Handle the special case of a range of frequencies
2638
    if omega_in is not None and omega_limits is not None:
9✔
2639
        warnings.warn(
×
2640
            "omega and omega_limits both specified; ignoring limits")
2641
    elif isinstance(omega_in, (list, tuple)) and len(omega_in) == 2:
9✔
2642
        omega_limits = omega_in
9✔
2643
        omega_in = None
9✔
2644

2645
    omega_range_given = True
9✔
2646
    if omega_in is None:
9✔
2647
        if omega_limits is None:
9✔
2648
            omega_range_given = False
9✔
2649
            # Select a default range if none is provided
2650
            omega_out = _default_frequency_range(
9✔
2651
                syslist, number_of_samples=omega_num, Hz=Hz,
2652
                feature_periphery_decades=feature_periphery_decades)
2653
        else:
2654
            omega_limits = np.asarray(omega_limits)
9✔
2655
            if len(omega_limits) != 2:
9✔
2656
                raise ValueError("len(omega_limits) must be 2")
×
2657
            omega_out = np.logspace(np.log10(omega_limits[0]),
9✔
2658
                                    np.log10(omega_limits[1]),
2659
                                    num=omega_num, endpoint=True)
2660
    else:
2661
        omega_out = np.copy(omega_in)
9✔
2662

2663
    return omega_out, omega_range_given
9✔
2664

2665

2666
# Compute reasonable defaults for axes
2667
def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
9✔
2668
                             feature_periphery_decades=None):
2669
    """Compute a default frequency range for frequency domain plots.
2670

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

2676
    Parameters
2677
    ----------
2678
    syslist : list of LTI
2679
        List of linear input/output systems (single system is OK)
2680
    Hz : bool, optional
2681
        If True, the limits (first and last value) of the frequencies
2682
        are set to full decades in Hz so it fits plotting with logarithmic
2683
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2684
    number_of_samples : int, optional
2685
        Number of samples to generate.  The default value is read from
2686
        ``config.defaults['freqplot.number_of_samples'].  If None, then the
2687
        default from `numpy.logspace` is used.
2688
    feature_periphery_decades : float, optional
2689
        Defines how many decades shall be included in the frequency range on
2690
        both sides of features (poles, zeros).  The default value is read from
2691
        ``config.defaults['freqplot.feature_periphery_decades']``.
2692

2693
    Returns
2694
    -------
2695
    omega : array
2696
        Range of frequencies in rad/sec
2697

2698
    Examples
2699
    --------
2700
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
2701
    >>> omega = ct._default_frequency_range(G)
2702
    >>> omega.min(), omega.max()
2703
    (0.1, 100.0)
2704

2705
    """
2706
    # Set default values for options
2707
    number_of_samples = config._get_param(
9✔
2708
        'freqplot', 'number_of_samples', number_of_samples)
2709
    feature_periphery_decades = config._get_param(
9✔
2710
        'freqplot', 'feature_periphery_decades', feature_periphery_decades, 1)
2711

2712
    # Find the list of all poles and zeros in the systems
2713
    features = np.array(())
9✔
2714
    freq_interesting = []
9✔
2715

2716
    # detect if single sys passed by checking if it is sequence-like
2717
    if not hasattr(syslist, '__iter__'):
9✔
2718
        syslist = (syslist,)
9✔
2719

2720
    for sys in syslist:
9✔
2721
        # For FRD systems, just use the response frequencies
2722
        if isinstance(sys, FrequencyResponseData):
9✔
2723
            # Add the min and max frequency, minus periphery decades
2724
            # (keeps frequency ranges from artificially expanding)
2725
            features = np.concatenate([features, np.array([
9✔
2726
                np.min(sys.omega) * 10**feature_periphery_decades,
2727
                np.max(sys.omega) / 10**feature_periphery_decades])])
2728
            continue
9✔
2729

2730
        try:
9✔
2731
            # Add new features to the list
2732
            if sys.isctime():
9✔
2733
                features_ = np.concatenate(
9✔
2734
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2735
                # Get rid of poles and zeros at the origin
2736
                toreplace = np.isclose(features_, 0.0)
9✔
2737
                if np.any(toreplace):
9✔
2738
                    features_ = features_[~toreplace]
9✔
2739
            elif sys.isdtime(strict=True):
9✔
2740
                fn = math.pi / sys.dt
9✔
2741
                # TODO: What distance to the Nyquist frequency is appropriate?
2742
                freq_interesting.append(fn * 0.9)
9✔
2743

2744
                features_ = np.concatenate(
9✔
2745
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2746
                # Get rid of poles and zeros on the real axis (imag==0)
2747
                # * origin and real < 0
2748
                # * at 1.: would result in omega=0. (logaritmic plot!)
2749
                toreplace = np.isclose(features_.imag, 0.0) & (
9✔
2750
                                    (features_.real <= 0.) |
2751
                                    (np.abs(features_.real - 1.0) < 1.e-10))
2752
                if np.any(toreplace):
9✔
2753
                    features_ = features_[~toreplace]
9✔
2754
                # TODO: improve (mapping pack to continuous time)
2755
                features_ = np.abs(np.log(features_) / (1.j * sys.dt))
9✔
2756
            else:
2757
                # TODO
2758
                raise NotImplementedError(
2759
                    "type of system in not implemented now")
2760
            features = np.concatenate([features, features_])
9✔
2761
        except NotImplementedError:
9✔
2762
            # Don't add any features for anything we don't understand
2763
            pass
9✔
2764

2765
    # Make sure there is at least one point in the range
2766
    if features.shape[0] == 0:
9✔
2767
        features = np.array([1.])
9✔
2768

2769
    if Hz:
9✔
2770
        features /= 2. * math.pi
9✔
2771
    features = np.log10(features)
9✔
2772
    lsp_min = np.rint(np.min(features) - feature_periphery_decades)
9✔
2773
    lsp_max = np.rint(np.max(features) + feature_periphery_decades)
9✔
2774
    if Hz:
9✔
2775
        lsp_min += np.log10(2. * math.pi)
9✔
2776
        lsp_max += np.log10(2. * math.pi)
9✔
2777

2778
    if freq_interesting:
9✔
2779
        lsp_min = min(lsp_min, np.log10(min(freq_interesting)))
9✔
2780
        lsp_max = max(lsp_max, np.log10(max(freq_interesting)))
9✔
2781

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

2785
    # Set the range to be an order of magnitude beyond any features
2786
    if number_of_samples:
9✔
2787
        omega = np.logspace(
9✔
2788
            lsp_min, lsp_max, num=number_of_samples, endpoint=True)
2789
    else:
2790
        omega = np.logspace(lsp_min, lsp_max, endpoint=True)
×
2791
    return omega
9✔
2792

2793

2794
#
2795
# Utility functions to create nice looking labels (KLD 5/23/11)
2796
#
2797

2798
def get_pow1000(num):
9✔
2799
    """Determine exponent for which significand of a number is within the
2800
    range [1, 1000).
2801
    """
2802
    # Based on algorithm from http://www.mail-archive.com/
2803
    # matplotlib-users@lists.sourceforge.net/msg14433.html, accessed 2010/11/7
2804
    # by Jason Heeris 2009/11/18
2805
    from decimal import Decimal
9✔
2806
    from math import floor
9✔
2807
    dnum = Decimal(str(num))
9✔
2808
    if dnum == 0:
9✔
2809
        return 0
9✔
2810
    elif dnum < 0:
9✔
2811
        dnum = -dnum
×
2812
    return int(floor(dnum.log10() / 3))
9✔
2813

2814

2815
def gen_prefix(pow1000):
9✔
2816
    """Return the SI prefix for a power of 1000.
2817
    """
2818
    # Prefixes according to Table 5 of [BIPM 2006] (excluding hecto,
2819
    # deca, deci, and centi).
2820
    if pow1000 < -8 or pow1000 > 8:
9✔
2821
        raise ValueError(
×
2822
            "Value is out of the range covered by the SI prefixes.")
2823
    return ['Y',  # yotta (10^24)
9✔
2824
            'Z',  # zetta (10^21)
2825
            'E',  # exa (10^18)
2826
            'P',  # peta (10^15)
2827
            'T',  # tera (10^12)
2828
            'G',  # giga (10^9)
2829
            'M',  # mega (10^6)
2830
            'k',  # kilo (10^3)
2831
            '',  # (10^0)
2832
            'm',  # milli (10^-3)
2833
            r'$\mu$',  # micro (10^-6)
2834
            'n',  # nano (10^-9)
2835
            'p',  # pico (10^-12)
2836
            'f',  # femto (10^-15)
2837
            'a',  # atto (10^-18)
2838
            'z',  # zepto (10^-21)
2839
            'y'][8 - pow1000]  # yocto (10^-24)
2840

2841

2842
# Function aliases
2843
bode = bode_plot
9✔
2844
nyquist = nyquist_plot
9✔
2845
gangof4 = gangof4_plot
9✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc