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

python-control / python-control / 13107996232

03 Feb 2025 06:53AM UTC coverage: 94.731% (+0.02%) from 94.709%
13107996232

push

github

web-flow
Merge pull request #1094 from murrayrm/userguide-22Dec2024

Updated user documentation (User Guide, Reference Manual)

9673 of 10211 relevant lines covered (94.73%)

8.28 hits per line

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

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

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

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

13
"""
14

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

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

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

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

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

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

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

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

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

79
        See `FrequencyResponseData.plot` for details.
80

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

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

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

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

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

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

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

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

243
    See Also
244
    --------
245
    frequency_response
246

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

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

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

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

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

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

279
    # Get values for params (and pop from list to allow keyword use in plot)
280
    dB = config._get_param(
9✔
281
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
282
    deg = config._get_param(
9✔
283
        'freqplot', 'deg', kwargs, _freqplot_defaults, pop=True)
284
    Hz = config._get_param(
9✔
285
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
286
    grid = config._get_param(
9✔
287
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
288
    wrap_phase = config._get_param(
9✔
289
        'freqplot', 'wrap_phase', kwargs, _freqplot_defaults, pop=True)
290
    initial_phase = config._get_param(
9✔
291
        'freqplot', 'initial_phase', kwargs, None, pop=True)
292
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
293
    title_frame = config._get_param(
9✔
294
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
295

296
    # Set the default labels
297
    freq_label = config._get_param(
9✔
298
        'freqplot', 'freq_label', kwargs, _freqplot_defaults, pop=True)
299
    if magnitude_label is None:
9✔
300
        magnitude_label = config._get_param(
9✔
301
            'freqplot', 'magnitude_label', kwargs,
302
            _freqplot_defaults, pop=True) + (" [dB]" if dB else "")
303
    if phase_label is None:
9✔
304
        phase_label = "Phase [deg]" if deg else "Phase [rad]"
9✔
305

306
    # Use sharex and sharey as proxies for share_{magnitude, phase, frequency}
307
    if sharey is not None:
9✔
308
        if 'share_magnitude' in kwargs or 'share_phase' in kwargs:
9✔
309
            ValueError(
×
310
                "sharey cannot be present with share_magnitude/share_phase")
311
        kwargs['share_magnitude'] = sharey
9✔
312
        kwargs['share_phase'] = sharey
9✔
313
    if sharex is not None:
9✔
314
        if 'share_frequency' in kwargs:
9✔
315
            ValueError(
×
316
                "sharex cannot be present with share_frequency")
317
        kwargs['share_frequency'] = sharex
9✔
318

319
    # Legacy keywords for margins
320
    display_margins = config._process_legacy_keyword(
9✔
321
        kwargs, 'margins', 'display_margins', display_margins)
322
    if kwargs.pop('margin_info', False):
9✔
323
        warnings.warn(
×
324
            "keyword 'margin_info' is deprecated; "
325
            "use 'display_margins='overlay'")
326
        if display_margins is False:
×
327
            raise ValueError(
×
328
                "conflicting_keywords: `display_margins` and `margin_info`")
329
    margins_method = config._process_legacy_keyword(
9✔
330
        kwargs, 'method', 'margins_method', margins_method)
331

332
    if not isinstance(data, (list, tuple)):
9✔
333
        data = [data]
9✔
334

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

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

358
        # Check to make sure omega_limits is sensible
359
        if omega_limits is not None and \
9✔
360
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
361
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
362

363
    # If plot_phase is not specified, check the data first, otherwise true
364
    if plot_phase is None:
9✔
365
        plot_phase = True if data[0].plot_phase is None else data[0].plot_phase
9✔
366

367
    if not plot_magnitude and not plot_phase:
9✔
368
        raise ValueError(
9✔
369
            "plot_magnitude and plot_phase both False; no data to plot")
370

371
    mag_data, phase_data, omega_data = [], [], []
9✔
372
    for response in data:
9✔
373
        noutputs, ninputs = response.noutputs, response.ninputs
9✔
374

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

389
        # Shift and wrap the phase
390
        phase = np.angle(response.frdata)               # 3D array
9✔
391
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
392
            # Shift the phase if needed
393
            if abs(phase[i, j, 0] - initial_phase_value) > math.pi:
9✔
394
                phase[i, j] -= 2*math.pi * round(
9✔
395
                    (phase[i, j, 0] - initial_phase_value) / (2*math.pi))
396

397
            # Phase wrapping
398
            if wrap_phase is False:
9✔
399
                phase[i, j] = unwrap(phase[i, j]) # unwrap the phase
9✔
400
            elif wrap_phase is True:
9✔
401
                pass                                    # default calc OK
9✔
402
            elif isinstance(wrap_phase, (int, float)):
9✔
403
                phase[i, j] = unwrap(phase[i, j]) # unwrap phase first
9✔
404
                if deg:
9✔
405
                    wrap_phase *= math.pi/180.
9✔
406

407
                # Shift the phase if it is below the wrap_phase
408
                phase[i, j] += 2*math.pi * np.maximum(
9✔
409
                    0, np.ceil((wrap_phase - phase[i, j])/(2*math.pi)))
410
            else:
411
                raise ValueError("wrap_phase must be bool or float.")
×
412

413
        # Save the data for later use
414
        mag_data.append(np.abs(response.frdata))
9✔
415
        phase_data.append(phase)
9✔
416
        omega_data.append(response.omega)
9✔
417

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

447
    if plot is not None:
9✔
448
        warnings.warn(
9✔
449
            "bode_plot() return value of mag, phase, omega is deprecated; "
450
            "use frequency_response()", FutureWarning)
451

452
    if plot is False:
9✔
453
        # Process the data to match what we were sent
454
        for i in range(len(mag_data)):
9✔
455
            mag_data[i] = _process_frequency_response(
9✔
456
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
457
            phase_data[i] = _process_frequency_response(
9✔
458
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
459

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

499
    # Decide on the maximum number of inputs and outputs
500
    ninputs, noutputs = 0, 0
9✔
501
    for response in data:       # TODO: make more pythonic/numpic
9✔
502
        ninputs = max(ninputs, response.ninputs)
9✔
503
        noutputs = max(noutputs, response.noutputs)
9✔
504

505
    # Figure how how many rows and columns to use + offsets for inputs/outputs
506
    if overlay_outputs and overlay_inputs:
9✔
507
        nrows = plot_magnitude + plot_phase
9✔
508
        ncols = 1
9✔
509
    elif overlay_outputs:
9✔
510
        nrows = plot_magnitude + plot_phase
9✔
511
        ncols = ninputs
9✔
512
    elif overlay_inputs:
9✔
513
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
514
            (noutputs if plot_phase else 0)
515
        ncols = 1
9✔
516
    else:
517
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
518
            (noutputs if plot_phase else 0)
519
        ncols = ninputs
9✔
520

521
    if ax is None:
9✔
522
        # Set up default sharing of axis limits if not specified
523
        for kw in ['share_magnitude', 'share_phase', 'share_frequency']:
9✔
524
            if kw not in kwargs or kwargs[kw] is None:
9✔
525
                kwargs[kw] = config.defaults['freqplot.' + kw]
9✔
526

527
    fig, ax_array = _process_ax_keyword(
9✔
528
        ax, (nrows, ncols), squeeze=False, rcParams=rcParams, clear_text=True)
529
    legend_loc, legend_map, show_legend = _process_legend_keywords(
9✔
530
        kwargs, (nrows,ncols), 'center right')
531

532
    # Get the values for sharing axes limits
533
    share_magnitude = kwargs.pop('share_magnitude', None)
9✔
534
    share_phase = kwargs.pop('share_phase', None)
9✔
535
    share_frequency = kwargs.pop('share_frequency', None)
9✔
536

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

553
    elif plot_phase and not plot_magnitude:
9✔
554
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
555
        for i in range(noutputs):
9✔
556
            for j in range(ninputs):
9✔
557
                if overlay_outputs and overlay_inputs:
9✔
558
                    phase_map[i, j] = (0, 0)
×
559
                elif overlay_outputs:
9✔
560
                    phase_map[i, j] = (0, j)
×
561
                elif overlay_inputs:
9✔
562
                    phase_map[i, j] = (i, 0)
9✔
563
                else:
564
                    phase_map[i, j] = (i, j)
9✔
565
        mag_map = np.full((noutputs, ninputs), None)
9✔
566
        share_magnitude = False
9✔
567

568
    else:
569
        mag_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
570
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
571
        for i in range(noutputs):
9✔
572
            for j in range(ninputs):
9✔
573
                if overlay_outputs and overlay_inputs:
9✔
574
                    mag_map[i, j] = (0, 0)
×
575
                    phase_map[i, j] = (1, 0)
×
576
                elif overlay_outputs:
9✔
577
                    mag_map[i, j] = (0, j)
×
578
                    phase_map[i, j] = (1, j)
×
579
                elif overlay_inputs:
9✔
580
                    mag_map[i, j] = (i*2, 0)
×
581
                    phase_map[i, j] = (i*2 + 1, 0)
×
582
                else:
583
                    mag_map[i, j] = (i*2, j)
9✔
584
                    phase_map[i, j] = (i*2 + 1, j)
9✔
585

586
    # Identity map needed for setting up shared axes
587
    ax_map = np.empty((nrows, ncols), dtype=tuple)
9✔
588
    for i, j in itertools.product(range(nrows), range(ncols)):
9✔
589
        ax_map[i, j] = (i, j)
9✔
590

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

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

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

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

660
    # Create a list of lines for the output
661
    out = np.empty((nrows, ncols), dtype=object)
9✔
662
    for i in range(nrows):
9✔
663
        for j in range(ncols):
9✔
664
            out[i, j] = []      # unique list in each element
9✔
665

666
    # Process label keyword
667
    line_labels = _process_line_labels(label, len(data), ninputs, noutputs)
9✔
668

669
    # Utility function for creating line label
670
    def _make_line_label(response, output_index, input_index):
9✔
671
        label = ""              # start with an empty label
9✔
672

673
        # Add the output name if it won't appear as an axes label
674
        if noutputs > 1 and overlay_outputs:
9✔
675
            label += response.output_labels[output_index]
9✔
676

677
        # Add the input name if it won't appear as a column label
678
        if ninputs > 1 and overlay_inputs:
9✔
679
            label += ", " if label != "" else ""
9✔
680
            label += response.input_labels[input_index]
9✔
681

682
        # Add the system name (will strip off later if redundant)
683
        label += ", " if label != "" else ""
9✔
684
        label += f"{response.sysname}"
9✔
685

686
        return label
9✔
687

688
    for index, response in enumerate(data):
9✔
689
        # Get the (pre-processed) data in fully indexed form
690
        mag = mag_data[index]
9✔
691
        phase = phase_data[index]
9✔
692
        omega_sys, sysname = omega_data[index], response.sysname
9✔
693

694
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
695
            # Get the axes to use for magnitude and phase
696
            ax_mag = ax_array[mag_map[i, j]]
9✔
697
            ax_phase = ax_array[phase_map[i, j]]
9✔
698

699
            # Get the frequencies and convert to Hz, if needed
700
            omega_plot = omega_sys / (2 * math.pi) if Hz else omega_sys
9✔
701
            if response.isdtime(strict=True):
9✔
702
                nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
703

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

708
            # Generate a label
709
            if line_labels is None:
9✔
710
                label = _make_line_label(response, i, j)
9✔
711
            else:
712
                label = line_labels[index, i, j]
9✔
713

714
            # Magnitude
715
            if plot_magnitude:
9✔
716
                pltfcn = ax_mag.semilogx if dB else ax_mag.loglog
9✔
717

718
                # Plot the main data
719
                lines = pltfcn(
9✔
720
                    omega_plot, mag_plot, *fmt, label=label, **kwargs)
721
                out[mag_map[i, j]] += lines
9✔
722

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

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

732
            # Phase
733
            if plot_phase:
9✔
734
                lines = ax_phase.semilogx(
9✔
735
                    omega_plot, phase_plot, *fmt, label=label, **kwargs)
736
                out[phase_map[i, j]] += lines
9✔
737

738
                # Save the information needed for the Nyquist line
739
                if response.isdtime(strict=True):
9✔
740
                    ax_phase.axvline(
9✔
741
                        nyq_freq, color=lines[0].get_color(), linestyle='--',
742
                        label='_nyq_phase_' + sysname)
743

744
                # Add a grid to the plot
745
                ax_phase.grid(grid and not display_margins, which='both')
9✔
746

747
        #
748
        # Display gain and phase margins (SISO only)
749
        #
750

751
        if display_margins:
9✔
752
            if ninputs > 1 or noutputs > 1:
9✔
753
                raise NotImplementedError(
754
                    "margins are not available for MIMO systems")
755

756
            # Compute stability margins for the system
757
            margins = stability_margins(response, method=margins_method)
9✔
758
            gm, pm, Wcg, Wcp = (margins[i] for i in [0, 1, 3, 4])
9✔
759

760
            # Figure out sign of the phase at the first gain crossing
761
            # (needed if phase_wrap is True)
762
            phase_at_cp = phase[
9✔
763
                0, 0, (np.abs(omega_data[0] - Wcp)).argmin()]
764
            if phase_at_cp >= 0.:
9✔
765
                phase_limit = 180.
×
766
            else:
767
                phase_limit = -180.
9✔
768

769
            if Hz:
9✔
770
                Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi)
9✔
771

772
            # Draw lines at gain and phase limits
773
            if plot_magnitude:
9✔
774
                ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':',
9✔
775
                               zorder=-20)
776

777
            if plot_phase:
9✔
778
                ax_phase.axhline(y=phase_limit if deg else
9✔
779
                                 math.radians(phase_limit),
780
                                 color='k', linestyle=':', zorder=-20)
781

782
            # Annotate the phase margin (if it exists)
783
            if plot_phase and pm != float('inf') and Wcp != float('nan'):
9✔
784
                # Draw dotted lines marking the gain crossover frequencies
785
                if plot_magnitude:
9✔
786
                    ax_mag.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
787
                ax_phase.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
788

789
                # Draw solid segments indicating the margins
790
                if deg:
9✔
791
                    ax_phase.semilogx(
9✔
792
                        [Wcp, Wcp], [phase_limit + pm, phase_limit],
793
                        color='k', zorder=-20)
794
                else:
795
                    ax_phase.semilogx(
9✔
796
                        [Wcp, Wcp], [math.radians(phase_limit) +
797
                                     math.radians(pm),
798
                                     math.radians(phase_limit)],
799
                        color='k', zorder=-20)
800

801
            # Annotate the gain margin (if it exists)
802
            if plot_magnitude and gm != float('inf') and \
9✔
803
               Wcg != float('nan'):
804
                # Draw dotted lines marking the phase crossover frequencies
805
                ax_mag.axvline(Wcg, color='k', linestyle=':', zorder=-30)
9✔
806
                if plot_phase:
9✔
807
                    ax_phase.axvline(Wcg, color='k', linestyle=':', zorder=-30)
9✔
808

809
                # Draw solid segments indicating the margins
810
                if dB:
9✔
811
                    ax_mag.semilogx(
9✔
812
                        [Wcg, Wcg], [0, -20*np.log10(gm)],
813
                        color='k', zorder=-20)
814
                else:
815
                    ax_mag.loglog(
9✔
816
                        [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20)
817

818
            if display_margins == 'overlay':
9✔
819
                # TODO: figure out how to handle case of multiple lines
820
                # Put the margin information in the lower left corner
821
                if plot_magnitude:
9✔
822
                    ax_mag.text(
9✔
823
                        0.04, 0.06,
824
                        'G.M.: %.2f %s\nFreq: %.2f %s' %
825
                        (20*np.log10(gm) if dB else gm,
826
                         'dB ' if dB else '',
827
                         Wcg, 'Hz' if Hz else 'rad/s'),
828
                        horizontalalignment='left',
829
                        verticalalignment='bottom',
830
                        transform=ax_mag.transAxes,
831
                        fontsize=8 if int(mpl.__version__[0]) == 1 else 6)
832

833
                if plot_phase:
9✔
834
                    ax_phase.text(
9✔
835
                        0.04, 0.06,
836
                        'P.M.: %.2f %s\nFreq: %.2f %s' %
837
                        (pm if deg else math.radians(pm),
838
                         'deg' if deg else 'rad',
839
                         Wcp, 'Hz' if Hz else 'rad/s'),
840
                        horizontalalignment='left',
841
                        verticalalignment='bottom',
842
                        transform=ax_phase.transAxes,
843
                        fontsize=8 if int(mpl.__version__[0]) == 1 else 6)
844

845
            else:
846
                # Put the title underneath the suptitle (one line per system)
847
                ax = ax_mag if ax_mag else ax_phase
9✔
848
                axes_title = ax.get_title()
9✔
849
                if axes_title is not None and axes_title != "":
9✔
850
                    axes_title += "\n"
×
851
                with plt.rc_context(rcParams):
9✔
852
                    ax.set_title(
9✔
853
                        axes_title + f"{sysname}: "
854
                        "Gm = %.2f %s(at %.2f %s), "
855
                        "Pm = %.2f %s (at %.2f %s)" %
856
                        (20*np.log10(gm) if dB else gm,
857
                         'dB ' if dB else '',
858
                         Wcg, 'Hz' if Hz else 'rad/s',
859
                         pm if deg else math.radians(pm),
860
                         'deg' if deg else 'rad',
861
                         Wcp, 'Hz' if Hz else 'rad/s'))
862

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

881
    for i in range(noutputs):
9✔
882
        for j in range(ninputs):
9✔
883
            # Utility function to generate phase labels
884
            def gen_zero_centered_series(val_min, val_max, period):
9✔
885
                v1 = np.ceil(val_min / period - 0.2)
9✔
886
                v2 = np.floor(val_max / period + 0.2)
9✔
887
                return np.arange(v1, v2 + 1) * period
9✔
888

889
            # Label the phase axes using multiples of 45 degrees
890
            if plot_phase:
9✔
891
                ax_phase = ax_array[phase_map[i, j]]
9✔
892

893
                # Set the labels
894
                if deg:
9✔
895
                    ylim = ax_phase.get_ylim()
9✔
896
                    num = np.floor((ylim[1] - ylim[0]) / 45)
9✔
897
                    factor = max(1, np.round(num / (32 / nrows)) * 2)
9✔
898
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
899
                        ylim[0], ylim[1], 45 * factor))
900
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
901
                        ylim[0], ylim[1], 15 * factor), minor=True)
902
                else:
903
                    ylim = ax_phase.get_ylim()
9✔
904
                    num = np.ceil((ylim[1] - ylim[0]) / (math.pi/4))
9✔
905
                    factor = max(1, np.round(num / (36 / nrows)) * 2)
9✔
906
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
907
                        ylim[0], ylim[1], math.pi / 4. * factor))
908
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
909
                        ylim[0], ylim[1], math.pi / 12. * factor), minor=True)
910

911
    # Turn off y tick labels for shared axes
912
    for i in range(0, noutputs):
9✔
913
        for j in range(1, ncols):
9✔
914
            if share_magnitude in [True, 'all', 'row']:
9✔
915
                ax_array[mag_map[i, j]].tick_params(labelleft=False)
9✔
916
            if share_phase in [True, 'all', 'row']:
9✔
917
                ax_array[phase_map[i, j]].tick_params(labelleft=False)
9✔
918

919
    # Turn off x tick labels for shared axes
920
    for i in range(0, nrows-1):
9✔
921
        for j in range(0, ncols):
9✔
922
            if share_frequency in [True, 'all', 'col']:
9✔
923
                ax_array[i, j].tick_params(labelbottom=False)
9✔
924

925
    # If specific omega_limits were given, use them
926
    if omega_limits is not None:
9✔
927
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
928
            ax_array[i, j].set_xlim(omega_limits)
9✔
929

930
    #
931
    # Label the axes (including header labels)
932
    #
933
    # Once the data are plotted, we label the axes.  The horizontal axes is
934
    # always frequency and this is labeled only on the bottom most row.  The
935
    # vertical axes can consist either of a single signal or a combination
936
    # of signals (when overlay_inputs or overlay_outputs is True)
937
    #
938
    # Input/output signals are give at the top of columns and left of rows
939
    # when these are individually plotted.
940
    #
941

942
    # Label the columns (do this first to get row labels in the right spot)
943
    for j in range(ncols):
9✔
944
        # If we have more than one column, label the individual responses
945
        if (noutputs > 1 and not overlay_outputs or ninputs > 1) \
9✔
946
           and not overlay_inputs:
947
            with plt.rc_context(rcParams):
9✔
948
                ax_array[0, j].set_title(f"From {data[0].input_labels[j]}")
9✔
949

950
        # Label the frequency axis
951
        ax_array[-1, j].set_xlabel(
9✔
952
            freq_label.format(units="Hz" if Hz else "rad/s"))
953

954
    # Label the rows
955
    for i in range(noutputs if not overlay_outputs else 1):
9✔
956
        if plot_magnitude:
9✔
957
            ax_mag = ax_array[mag_map[i, 0]]
9✔
958
            ax_mag.set_ylabel(magnitude_label)
9✔
959
        if plot_phase:
9✔
960
            ax_phase = ax_array[phase_map[i, 0]]
9✔
961
            ax_phase.set_ylabel(phase_label)
9✔
962

963
        if (noutputs > 1 or ninputs > 1) and not overlay_outputs:
9✔
964
            if plot_magnitude and plot_phase:
9✔
965
                # Get existing ylabel for left column and add a blank line
966
                ax_mag.set_ylabel("\n" + ax_mag.get_ylabel())
9✔
967
                ax_phase.set_ylabel("\n" + ax_phase.get_ylabel())
9✔
968

969
                # Find the midpoint between the row axes (+ tight_layout)
970
                _, ypos = _find_axes_center(fig, [ax_mag, ax_phase])
9✔
971

972
                # Get the bounding box including the labels
973
                inv_transform = fig.transFigure.inverted()
9✔
974
                mag_bbox = inv_transform.transform(
9✔
975
                    ax_mag.get_tightbbox(fig.canvas.get_renderer()))
976

977
                # Figure out location for text (center left in figure frame)
978
                xpos = mag_bbox[0, 0]               # left edge
9✔
979

980
                # Put a centered label as text outside the box
981
                fig.text(
9✔
982
                    0.8 * xpos, ypos, f"To {data[0].output_labels[i]}\n",
983
                    rotation=90, ha='left', va='center',
984
                    fontsize=rcParams['axes.titlesize'])
985
            else:
986
                # Only a single axes => add label to the left
987
                ax_array[i, 0].set_ylabel(
9✔
988
                    f"To {data[0].output_labels[i]}\n" +
989
                    ax_array[i, 0].get_ylabel())
990

991
    #
992
    # Update the plot title (= figure suptitle)
993
    #
994
    # If plots are built up by multiple calls to plot() and the title is
995
    # not given, then the title is updated to provide a list of unique text
996
    # items in each successive title.  For data generated by the frequency
997
    # response function this will generate a common prefix followed by a
998
    # list of systems (e.g., "Step response for sys[1], sys[2]").
999
    #
1000

1001
    # Set initial title for the data (unique system names, preserving order)
1002
    seen = set()
9✔
1003
    sysnames = [response.sysname for response in data if not
9✔
1004
                (response.sysname in seen or seen.add(response.sysname))]
1005

1006
    if ax is None and title is None:
9✔
1007
        if data[0].title is None:
9✔
1008
            title = "Bode plot for " + ", ".join(sysnames)
9✔
1009
        else:
1010
            # Allow data to set the title (used by gangof4)
1011
            title = data[0].title
9✔
1012
        _update_plot_title(title, fig, rcParams=rcParams, frame=title_frame)
9✔
1013
    elif ax is None:
9✔
1014
        _update_plot_title(
9✔
1015
            title, fig=fig, rcParams=rcParams, frame=title_frame,
1016
            use_existing=False)
1017

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

1038
    # Create axis legends
1039
    if show_legend != False:
9✔
1040
        # Figure out where to put legends
1041
        if legend_map is None:
9✔
1042
            legend_map = np.full(ax_array.shape, None, dtype=object)
9✔
1043
            legend_map[0, -1] = legend_loc
9✔
1044

1045
        legend_array = np.full(ax_array.shape, None, dtype=object)
9✔
1046
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
1047
            if legend_map[i, j] is None:
9✔
1048
                continue
9✔
1049
            ax = ax_array[i, j]
9✔
1050

1051
            # Get the labels to use, removing common strings
1052
            lines = [line for line in ax.get_lines()
9✔
1053
                     if line.get_label()[0] != '_']
1054
            labels = _make_legend_labels(
9✔
1055
                [line.get_label() for line in lines],
1056
                ignore_common=line_labels is not None)
1057

1058
            # Generate the label, if needed
1059
            if show_legend == True or len(labels) > 1:
9✔
1060
                with plt.rc_context(rcParams):
9✔
1061
                    legend_array[i, j] = ax.legend(
9✔
1062
                        lines, labels, loc=legend_map[i, j])
1063
    else:
1064
        legend_array = None
9✔
1065

1066
    #
1067
    # Legacy return processing
1068
    #
1069
    if plot is True:            # legacy usage; remove in future release
9✔
1070
        # Process the data to match what we were sent
1071
        for i in range(len(mag_data)):
9✔
1072
            mag_data[i] = _process_frequency_response(
9✔
1073
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
1074
            phase_data[i] = _process_frequency_response(
9✔
1075
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
1076

1077
        if len(data) == 1:
9✔
1078
            return mag_data[0], phase_data[0], omega_data[0]
9✔
1079
        else:
1080
            return mag_data, phase_data, omega_data
9✔
1081

1082
    return ControlPlot(out, ax_array, fig, legend=legend_array)
9✔
1083

1084

1085
#
1086
# Nyquist plot
1087
#
1088

1089
# Default values for module parameter variables
1090
_nyquist_defaults = {
9✔
1091
    'nyquist.primary_style': ['-', '-.'],       # style for primary curve
1092
    'nyquist.mirror_style': ['--', ':'],        # style for mirror curve
1093
    'nyquist.arrows': 2,                        # number of arrows around curve
1094
    'nyquist.arrow_size': 8,                    # pixel size for arrows
1095
    'nyquist.encirclement_threshold': 0.05,     # warning threshold
1096
    'nyquist.indent_radius': 1e-4,              # indentation radius
1097
    'nyquist.indent_direction': 'right',        # indentation direction
1098
    'nyquist.indent_points': 50,                # number of points to insert
1099
    'nyquist.max_curve_magnitude': 20,          # clip large values
1100
    'nyquist.max_curve_offset': 0.02,           # offset of primary/mirror
1101
    'nyquist.start_marker': 'o',                # marker at start of curve
1102
    'nyquist.start_marker_size': 4,             # size of the marker
1103
    'nyquist.circle_style':                     # style for unit circles
1104
      {'color': 'black', 'linestyle': 'dashed', 'linewidth': 1}
1105
}
1106

1107

1108
class NyquistResponseData:
9✔
1109
    """Nyquist response data object.
1110

1111
    Nyquist contour analysis allows the stability and robustness of a
1112
    closed loop linear system to be evaluated using the open loop response
1113
    of the loop transfer function.  The NyquistResponseData class is used
1114
    by the `nyquist_response` function to return the
1115
    response of a linear system along the Nyquist 'D' contour.  The
1116
    response object can be used to obtain information about the Nyquist
1117
    response or to generate a Nyquist plot.
1118

1119
    Parameters
1120
    ----------
1121
    count : integer
1122
        Number of encirclements of the -1 point by the Nyquist curve for
1123
        a system evaluated along the Nyquist contour.
1124
    contour : complex array
1125
        The Nyquist 'D' contour, with appropriate indentations to avoid
1126
        open loop poles and zeros near/on the imaginary axis.
1127
    response : complex array
1128
        The value of the linear system under study along the Nyquist contour.
1129
    dt : None or float
1130
        The system timebase.
1131
    sysname : str
1132
        The name of the system being analyzed.
1133
    return_contour : bool
1134
        If True, when the object is accessed as an iterable return two
1135
        elements: `count` (number of encirclements) and `contour`.  If
1136
        False (default), then return only `count`.
1137

1138
    """
1139
    def __init__(
9✔
1140
            self, count, contour, response, dt, sysname=None,
1141
            return_contour=False):
1142
        self.count = count
9✔
1143
        self.contour = contour
9✔
1144
        self.response = response
9✔
1145
        self.dt = dt
9✔
1146
        self.sysname = sysname
9✔
1147
        self.return_contour = return_contour
9✔
1148

1149
    # Implement iter to allow assigning to a tuple
1150
    def __iter__(self):
9✔
1151
        if self.return_contour:
9✔
1152
            return iter((self.count, self.contour))
9✔
1153
        else:
1154
            return iter((self.count, ))
9✔
1155

1156
    # Implement (thin) getitem to allow access via legacy indexing
1157
    def __getitem__(self, index):
9✔
1158
        return list(self.__iter__())[index]
×
1159

1160
    # Implement (thin) len to emulate legacy testing interface
1161
    def __len__(self):
9✔
1162
        return 2 if self.return_contour else 1
9✔
1163

1164
    def plot(self, *args, **kwargs):
9✔
1165
        """Plot a list of Nyquist responses.
1166

1167
        See `nyquist_plot` for details.
1168

1169
        """
1170
        return nyquist_plot(self, *args, **kwargs)
9✔
1171

1172

1173
class NyquistResponseList(list):
9✔
1174
    """List of NyquistResponseData objects with plotting capability.
1175

1176
    This class consists of a list of `NyquistResponseData` objects.
1177
    It is a subclass of the Python `list` class, with a `plot` method that
1178
    plots the individual `NyquistResponseData` objects.
1179

1180
    """
1181
    def plot(self, *args, **kwargs):
9✔
1182
        """Plot a list of Nyquist responses.
1183

1184
        See `nyquist_plot` for details.
1185

1186
        """
1187
        return nyquist_plot(self, *args, **kwargs)
9✔
1188

1189

1190
def nyquist_response(
9✔
1191
        sysdata, omega=None, omega_limits=None, omega_num=None,
1192
        return_contour=False, warn_encirclements=True, warn_nyquist=True,
1193
        _kwargs=None, _check_kwargs=True, **kwargs):
1194
    """Nyquist response for a system.
1195

1196
    Computes a Nyquist contour for the system over a (optional) frequency
1197
    range and evaluates the number of net encirclements.  The curve is
1198
    computed by evaluating the Nyquist segment along the positive imaginary
1199
    axis, with a mirror image generated to reflect the negative imaginary
1200
    axis.  Poles on or near the imaginary axis are avoided using a small
1201
    indentation.  The portion of the Nyquist contour at infinity is not
1202
    explicitly computed (since it maps to a constant value for any system
1203
    with a proper transfer function).
1204

1205
    Parameters
1206
    ----------
1207
    sysdata : LTI or list of LTI
1208
        List of linear input/output systems (single system is OK). Nyquist
1209
        curves for each system are plotted on the same graph.
1210
    omega : array_like, optional
1211
        Set of frequencies to be evaluated, in rad/sec.
1212

1213
    Returns
1214
    -------
1215
    responses : list of `NyquistResponseData`
1216
        For each system, a Nyquist response data object is returned.  If
1217
        `sysdata` is a single system, a single element is returned (not a
1218
        list).
1219
    response.count : int
1220
        Number of encirclements of the point -1 by the Nyquist curve.  If
1221
        multiple systems are given, an array of counts is returned.
1222
    response.contour : ndarray
1223
        The contour used to create the primary Nyquist curve segment.  To
1224
        obtain the Nyquist curve values, evaluate system(s) along contour.
1225

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

1258
    Notes
1259
    -----
1260
    If a discrete-time model is given, the frequency response is computed
1261
    along the upper branch of the unit circle, using the mapping ``z =
1262
    exp(1j * omega * dt)`` where `omega` ranges from 0 to pi/`dt` and
1263
    `dt` is the discrete timebase.  If timebase not specified
1264
    (`dt` = True), `dt` is set to 1.
1265

1266
    If a continuous-time system contains poles on or near the imaginary
1267
    axis, a small indentation will be used to avoid the pole.  The radius
1268
    of the indentation is given by `indent_radius` and it is taken to the
1269
    right of stable poles and the left of unstable poles.  If a pole is
1270
    exactly on the imaginary axis, the `indent_direction` parameter can be
1271
    used to set the direction of indentation.  Setting `indent_direction`
1272
    to 'none' will turn off indentation.
1273

1274
    For those portions of the Nyquist plot in which the contour is indented
1275
    to avoid poles, resulting in a scaling of the Nyquist plot, the line
1276
    styles are according to the settings of the `primary_style` and
1277
    `mirror_style` keywords.  By default the scaled portions of the primary
1278
    curve use a dotted line style and the scaled portion of the mirror
1279
    image use a dashdot line style.
1280

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

1285
    See Also
1286
    --------
1287
    nyquist_plot
1288

1289
    Examples
1290
    --------
1291
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1292
    >>> response = ct.nyquist_response(G)
1293
    >>> count = response.count
1294
    >>> cplt = response.plot()
1295

1296
    """
1297
    # Create unified list of keyword arguments
1298
    if _kwargs is None:
9✔
1299
        _kwargs = kwargs
9✔
1300
    else:
1301
        # Use existing dictionary, to keep track of processed keywords
1302
        _kwargs |= kwargs
9✔
1303

1304
    # Get values for params
1305
    omega_num_given = omega_num is not None
9✔
1306
    omega_num = config._get_param('freqplot', 'number_of_samples', omega_num)
9✔
1307
    indent_radius = config._get_param(
9✔
1308
        'nyquist', 'indent_radius', _kwargs, _nyquist_defaults, pop=True)
1309
    encirclement_threshold = config._get_param(
9✔
1310
        'nyquist', 'encirclement_threshold', _kwargs,
1311
        _nyquist_defaults, pop=True)
1312
    indent_direction = config._get_param(
9✔
1313
        'nyquist', 'indent_direction', _kwargs, _nyquist_defaults, pop=True)
1314
    indent_points = config._get_param(
9✔
1315
        'nyquist', 'indent_points', _kwargs, _nyquist_defaults, pop=True)
1316

1317
    if _check_kwargs and _kwargs:
9✔
1318
        raise TypeError("unrecognized keywords: ", str(_kwargs))
9✔
1319

1320
    # Convert the first argument to a list
1321
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
1322

1323
    # Determine the range of frequencies to use, based on args/features
1324
    omega, omega_range_given = _determine_omega_vector(
9✔
1325
        syslist, omega, omega_limits, omega_num, feature_periphery_decades=2)
1326

1327
    # If omega was not specified explicitly, start at omega = 0
1328
    if not omega_range_given:
9✔
1329
        if omega_num_given:
9✔
1330
            # Just reset the starting point
1331
            omega[0] = 0.0
9✔
1332
        else:
1333
            # Insert points between the origin and the first frequency point
1334
            omega = np.concatenate((
9✔
1335
                np.linspace(0, omega[0], indent_points), omega[1:]))
1336

1337
    # Go through each system and keep track of the results
1338
    responses = []
9✔
1339
    for idx, sys in enumerate(syslist):
9✔
1340
        if not sys.issiso():
9✔
1341
            # TODO: Add MIMO nyquist plots.
1342
            raise ControlMIMONotImplemented(
9✔
1343
                "Nyquist plot currently only supports SISO systems.")
1344

1345
        # Figure out the frequency range
1346
        if isinstance(sys, FrequencyResponseData) and sys._ifunc is None \
9✔
1347
           and not omega_range_given:
1348
            omega_sys = sys.omega               # use system frequencies
9✔
1349
        else:
1350
            omega_sys = np.asarray(omega)       # use common omega vector
9✔
1351

1352
        # Determine the contour used to evaluate the Nyquist curve
1353
        if sys.isdtime(strict=True):
9✔
1354
            # Restrict frequencies for discrete-time systems
1355
            nyq_freq = math.pi / sys.dt
9✔
1356
            if not omega_range_given:
9✔
1357
                # limit up to and including Nyquist frequency
1358
                omega_sys = np.hstack((
9✔
1359
                    omega_sys[omega_sys < nyq_freq], nyq_freq))
1360

1361
            # Issue a warning if we are sampling above Nyquist
1362
            if np.any(omega_sys * sys.dt > np.pi) and warn_nyquist:
9✔
1363
                warnings.warn("evaluation above Nyquist frequency")
9✔
1364

1365
        # do indentations in s-plane where it is more convenient
1366
        splane_contour = 1j * omega_sys
9✔
1367

1368
        # Bend the contour around any poles on/near the imaginary axis
1369
        if isinstance(sys, (StateSpace, TransferFunction)) \
9✔
1370
                and indent_direction != 'none':
1371
            if sys.isctime():
9✔
1372
                splane_poles = sys.poles()
9✔
1373
                splane_cl_poles = sys.feedback().poles()
9✔
1374
            else:
1375
                # map z-plane poles to s-plane. We ignore any at the origin
1376
                # to avoid numerical warnings because we know we
1377
                # don't need to indent for them
1378
                zplane_poles = sys.poles()
9✔
1379
                zplane_poles = zplane_poles[~np.isclose(abs(zplane_poles), 0.)]
9✔
1380
                splane_poles = np.log(zplane_poles) / sys.dt
9✔
1381

1382
                zplane_cl_poles = sys.feedback().poles()
9✔
1383
                # eliminate z-plane poles at the origin to avoid warnings
1384
                zplane_cl_poles = zplane_cl_poles[
9✔
1385
                    ~np.isclose(abs(zplane_cl_poles), 0.)]
1386
                splane_cl_poles = np.log(zplane_cl_poles) / sys.dt
9✔
1387

1388
            #
1389
            # Check to make sure indent radius is small enough
1390
            #
1391
            # If there is a closed loop pole that is near the imaginary axis
1392
            # at a point that is near an open loop pole, it is possible that
1393
            # indentation might skip or create an extraneous encirclement.
1394
            # We check for that situation here and generate a warning if that
1395
            # could happen.
1396
            #
1397
            for p_cl in splane_cl_poles:
9✔
1398
                # See if any closed loop poles are near the imaginary axis
1399
                if abs(p_cl.real) <= indent_radius:
9✔
1400
                    # See if any open loop poles are close to closed loop poles
1401
                    if len(splane_poles) > 0:
9✔
1402
                        p_ol = splane_poles[
9✔
1403
                            (np.abs(splane_poles - p_cl)).argmin()]
1404

1405
                        if abs(p_ol - p_cl) <= indent_radius and \
9✔
1406
                                warn_encirclements:
1407
                            warnings.warn(
9✔
1408
                                "indented contour may miss closed loop pole; "
1409
                                "consider reducing indent_radius to below "
1410
                                f"{abs(p_ol - p_cl):5.2g}", stacklevel=2)
1411

1412
            #
1413
            # See if we should add some frequency points near imaginary poles
1414
            #
1415
            for p in splane_poles:
9✔
1416
                # See if we need to process this pole (skip if on the negative
1417
                # imaginary axis or not near imaginary axis + user override)
1418
                if p.imag < 0 or abs(p.real) > indent_radius or \
9✔
1419
                   omega_range_given:
1420
                    continue
9✔
1421

1422
                # Find the frequencies before the pole frequency
1423
                below_points = np.argwhere(
9✔
1424
                    splane_contour.imag - abs(p.imag) < -indent_radius)
1425
                if below_points.size > 0:
9✔
1426
                    first_point = below_points[-1].item()
9✔
1427
                    start_freq = p.imag - indent_radius
9✔
1428
                else:
1429
                    # Add the points starting at the beginning of the contour
1430
                    assert splane_contour[0] == 0
9✔
1431
                    first_point = 0
9✔
1432
                    start_freq = 0
9✔
1433

1434
                # Find the frequencies after the pole frequency
1435
                above_points = np.argwhere(
9✔
1436
                    splane_contour.imag - abs(p.imag) > indent_radius)
1437
                last_point = above_points[0].item()
9✔
1438

1439
                # Add points for half/quarter circle around pole frequency
1440
                # (these will get indented left or right below)
1441
                splane_contour = np.concatenate((
9✔
1442
                    splane_contour[0:first_point+1],
1443
                    (1j * np.linspace(
1444
                        start_freq, p.imag + indent_radius, indent_points)),
1445
                    splane_contour[last_point:]))
1446

1447
            # Indent points that are too close to a pole
1448
            if len(splane_poles) > 0: # accommodate no splane poles if dtime sys
9✔
1449
                for i, s in enumerate(splane_contour):
9✔
1450
                    # Find the nearest pole
1451
                    p = splane_poles[(np.abs(splane_poles - s)).argmin()]
9✔
1452

1453
                    # See if we need to indent around it
1454
                    if abs(s - p) < indent_radius:
9✔
1455
                        # Figure out how much to offset (simple trigonometry)
1456
                        offset = np.sqrt(
9✔
1457
                            indent_radius ** 2 - (s - p).imag ** 2) \
1458
                            - (s - p).real
1459

1460
                        # Figure out which way to offset the contour point
1461
                        if p.real < 0 or (p.real == 0 and
9✔
1462
                                        indent_direction == 'right'):
1463
                            # Indent to the right
1464
                            splane_contour[i] += offset
9✔
1465

1466
                        elif p.real > 0 or (p.real == 0 and
9✔
1467
                                            indent_direction == 'left'):
1468
                            # Indent to the left
1469
                            splane_contour[i] -= offset
9✔
1470

1471
                        else:
1472
                            raise ValueError(
9✔
1473
                                "unknown value for indent_direction")
1474

1475
        # change contour to z-plane if necessary
1476
        if sys.isctime():
9✔
1477
            contour = splane_contour
9✔
1478
        else:
1479
            contour = np.exp(splane_contour * sys.dt)
9✔
1480

1481
        # Make sure we don't try to evaluate at a pole
1482
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1483
            if any([pole in contour for pole in sys.poles()]):
9✔
1484
                raise RuntimeError(
9✔
1485
                    "attempt to evaluate at a pole; indent required")
1486

1487
        # Compute the primary curve
1488
        resp = sys(contour)
9✔
1489

1490
        # Compute CW encirclements of -1 by integrating the (unwrapped) angle
1491
        phase = -unwrap(np.angle(resp + 1))
9✔
1492
        encirclements = np.sum(np.diff(phase)) / np.pi
9✔
1493
        count = int(np.round(encirclements, 0))
9✔
1494

1495
        # Let the user know if the count might not make sense
1496
        if abs(encirclements - count) > encirclement_threshold and \
9✔
1497
           warn_encirclements:
1498
            warnings.warn(
9✔
1499
                "number of encirclements was a non-integer value; this can"
1500
                " happen is contour is not closed, possibly based on a"
1501
                " frequency range that does not include zero.")
1502

1503
        #
1504
        # Make sure that the encirclements match the Nyquist criterion
1505
        #
1506
        # If the user specifies the frequency points to use, it is possible
1507
        # to miss encirclements, so we check here to make sure that the
1508
        # Nyquist criterion is actually satisfied.
1509
        #
1510
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1511
            # Count the number of open/closed loop RHP poles
1512
            if sys.isctime():
9✔
1513
                if indent_direction == 'right':
9✔
1514
                    P = (sys.poles().real > 0).sum()
9✔
1515
                else:
1516
                    P = (sys.poles().real >= 0).sum()
9✔
1517
                Z = (sys.feedback().poles().real >= 0).sum()
9✔
1518
            else:
1519
                if indent_direction == 'right':
9✔
1520
                    P = (np.abs(sys.poles()) > 1).sum()
9✔
1521
                else:
1522
                    P = (np.abs(sys.poles()) >= 1).sum()
×
1523
                Z = (np.abs(sys.feedback().poles()) >= 1).sum()
9✔
1524

1525
            # Check to make sure the results make sense; warn if not
1526
            if Z != count + P and warn_encirclements:
9✔
1527
                warnings.warn(
9✔
1528
                    "number of encirclements does not match Nyquist criterion;"
1529
                    " check frequency range and indent radius/direction",
1530
                    UserWarning, stacklevel=2)
1531
            elif indent_direction == 'none' and any(sys.poles().real == 0) \
9✔
1532
                 and warn_encirclements:
1533
                warnings.warn(
×
1534
                    "system has pure imaginary poles but indentation is"
1535
                    " turned off; results may be meaningless",
1536
                    RuntimeWarning, stacklevel=2)
1537

1538
        # Decide on system name
1539
        sysname = sys.name if sys.name is not None else f"Unknown-{idx}"
9✔
1540

1541
        responses.append(NyquistResponseData(
9✔
1542
            count, contour, resp, sys.dt, sysname=sysname,
1543
            return_contour=return_contour))
1544

1545
    if isinstance(sysdata, (list, tuple)):
9✔
1546
        return NyquistResponseList(responses)
9✔
1547
    else:
1548
        return responses[0]
9✔
1549

1550

1551
def nyquist_plot(
9✔
1552
        data, omega=None, plot=None, label_freq=0, color=None, label=None,
1553
        return_contour=None, title=None, ax=None,
1554
        unit_circle=False, mt_circles=None, ms_circles=None, **kwargs):
1555
    """Nyquist plot for a system.
1556

1557
    Generates a Nyquist plot for the system over a (optional) frequency
1558
    range.  The curve is computed by evaluating the Nyquist segment along
1559
    the positive imaginary axis, with a mirror image generated to reflect
1560
    the negative imaginary axis.  Poles on or near the imaginary axis are
1561
    avoided using a small indentation.  The portion of the Nyquist contour
1562
    at infinity is not explicitly computed (since it maps to a constant
1563
    value for any system with a proper transfer function).
1564

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

1587
    Returns
1588
    -------
1589
    cplt : `ControlPlot` object
1590
        Object containing the data that were plotted.  See `ControlPlot`
1591
        for more detailed information.
1592
    cplt.lines : 2D array of `matplotlib.lines.Line2D`
1593
        Array containing information on each line in the plot.  The shape
1594
        of the array is given by (nsys, 4) where nsys is the number of
1595
        systems or Nyquist responses passed to the function.  The second
1596
        index specifies the segment type:
1597

1598
            - lines[idx, 0]: unscaled portion of the primary curve
1599
            - lines[idx, 1]: scaled portion of the primary curve
1600
            - lines[idx, 2]: unscaled portion of the mirror curve
1601
            - lines[idx, 3]: scaled portion of the mirror curve
1602

1603
    cplt.axes : 2D array of `matplotlib.axes.Axes`
1604
        Axes for each subplot.
1605
    cplt.figure : `matplotlib.figure.Figure`
1606
        Figure containing the plot.
1607
    cplt.legend : 2D array of `matplotlib.legend.Legend`
1608
        Legend object(s) contained in the plot.
1609

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

1719
    See Also
1720
    --------
1721
    nyquist_response
1722

1723
    Notes
1724
    -----
1725
    If a discrete-time model is given, the frequency response is computed
1726
    along the upper branch of the unit circle, using the mapping ``z =
1727
    exp(1j * omega * dt)`` where `omega` ranges from 0 to pi/`dt` and
1728
    `dt` is the discrete timebase.  If timebase not specified
1729
    (`dt` = True), `dt` is set to 1.
1730

1731
    If a continuous-time system contains poles on or near the imaginary
1732
    axis, a small indentation will be used to avoid the pole.  The radius
1733
    of the indentation is given by `indent_radius` and it is taken to the
1734
    right of stable poles and the left of unstable poles.  If a pole is
1735
    exactly on the imaginary axis, the `indent_direction` parameter can be
1736
    used to set the direction of indentation.  Setting `indent_direction`
1737
    to 'none' will turn off indentation.  If `return_contour` is True,
1738
    the exact contour used for evaluation is returned.
1739

1740
    For those portions of the Nyquist plot in which the contour is indented
1741
    to avoid poles, resulting in a scaling of the Nyquist plot, the line
1742
    styles are according to the settings of the `primary_style` and
1743
    `mirror_style` keywords.  By default the scaled portions of the primary
1744
    curve use a dotted line style and the scaled portion of the mirror
1745
    image use a dashdot line style.
1746

1747
    Examples
1748
    --------
1749
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1750
    >>> out = ct.nyquist_plot(G)
1751

1752
    """
1753
    #
1754
    # Keyword processing
1755
    #
1756
    # Keywords for the nyquist_plot function can either be keywords that
1757
    # are unique to this function, keywords that are intended for use by
1758
    # nyquist_response (if data is a list of systems), or keywords that
1759
    # are intended for the plotting commands.
1760
    #
1761
    # We first pop off all keywords that are used directly by this
1762
    # function.  If data is a list of systems, when then pop off keywords
1763
    # that correspond to nyquist_response() keywords.  The remaining
1764
    # keywords are passed to matplotlib (and will generate an error if
1765
    # unrecognized).
1766
    #
1767

1768
    # Get values for params (and pop from list to allow keyword use in plot)
1769
    arrows = config._get_param(
9✔
1770
        'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True)
1771
    arrow_size = config._get_param(
9✔
1772
        'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True)
1773
    arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None)
9✔
1774
    ax_user = ax
9✔
1775
    max_curve_magnitude = config._get_param(
9✔
1776
        'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True)
1777
    max_curve_offset = config._get_param(
9✔
1778
        'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True)
1779
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
1780
    start_marker = config._get_param(
9✔
1781
        'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True)
1782
    start_marker_size = config._get_param(
9✔
1783
        'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True)
1784
    title_frame = config._get_param(
9✔
1785
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
1786

1787
    # Set line styles for the curves
1788
    def _parse_linestyle(style_name, allow_false=False):
9✔
1789
        style = config._get_param(
9✔
1790
            'nyquist', style_name, kwargs, _nyquist_defaults, pop=True)
1791
        if isinstance(style, str):
9✔
1792
            # Only one style provided, use the default for the other
1793
            style = [style, _nyquist_defaults['nyquist.' + style_name][1]]
9✔
1794
            warnings.warn(
9✔
1795
                "use of a single string for linestyle will be deprecated "
1796
                " in a future release", PendingDeprecationWarning)
1797
        if (allow_false and style is False) or \
9✔
1798
           (isinstance(style, list) and len(style) == 2):
1799
            return style
9✔
1800
        else:
1801
            raise ValueError(f"invalid '{style_name}': {style}")
9✔
1802

1803
    primary_style = _parse_linestyle('primary_style')
9✔
1804
    mirror_style = _parse_linestyle('mirror_style', allow_false=True)
9✔
1805

1806
    # Parse the arrows keyword
1807
    if not arrows:
9✔
1808
        arrow_pos = []
9✔
1809
    elif isinstance(arrows, int):
9✔
1810
        N = arrows
9✔
1811
        # Space arrows out, starting midway along each "region"
1812
        arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False)
9✔
1813
    elif isinstance(arrows, (list, np.ndarray)):
9✔
1814
        arrow_pos = np.sort(np.atleast_1d(arrows))
9✔
1815
    else:
1816
        raise ValueError("unknown or unsupported arrow location")
9✔
1817

1818
    # Set the arrow style
1819
    if arrow_style is None:
9✔
1820
        arrow_style = mpl.patches.ArrowStyle(
9✔
1821
            'simple', head_width=arrow_size, head_length=arrow_size)
1822

1823
    # If argument was a singleton, turn it into a tuple
1824
    if not isinstance(data, (list, tuple)):
9✔
1825
        data = [data]
9✔
1826

1827
    # Process label keyword
1828
    line_labels = _process_line_labels(label, len(data))
9✔
1829

1830
    # If we are passed a list of systems, compute response first
1831
    if all([isinstance(
9✔
1832
            sys, (StateSpace, TransferFunction, FrequencyResponseData))
1833
            for sys in data]):
1834
        # Get the response; pop explicit keywords here, kwargs in _response()
1835
        nyquist_responses = nyquist_response(
9✔
1836
            data, omega=omega, return_contour=return_contour,
1837
            omega_limits=kwargs.pop('omega_limits', None),
1838
            omega_num=kwargs.pop('omega_num', None),
1839
            warn_encirclements=kwargs.pop('warn_encirclements', True),
1840
            warn_nyquist=kwargs.pop('warn_nyquist', True),
1841
            _kwargs=kwargs, _check_kwargs=False)
1842
    else:
1843
        nyquist_responses = data
9✔
1844

1845
    # Legacy return value processing
1846
    if plot is not None or return_contour is not None:
9✔
1847
        warnings.warn(
9✔
1848
            "nyquist_plot() return value of count[, contour] is deprecated; "
1849
            "use nyquist_response()", FutureWarning)
1850

1851
        # Extract out the values that we will eventually return
1852
        counts = [response.count for response in nyquist_responses]
9✔
1853
        contours = [response.contour for response in nyquist_responses]
9✔
1854

1855
    if plot is False:
9✔
1856
        # Make sure we used all of the keywords
1857
        if kwargs:
9✔
1858
            raise TypeError("unrecognized keywords: ", str(kwargs))
×
1859

1860
        if len(data) == 1:
9✔
1861
            counts, contours = counts[0], contours[0]
9✔
1862

1863
        # Return counts and (optionally) the contour we used
1864
        return (counts, contours) if return_contour else counts
9✔
1865

1866
    fig, ax = _process_ax_keyword(
9✔
1867
        ax_user, shape=(1, 1), squeeze=True, rcParams=rcParams)
1868
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
1869
        kwargs, None, 'upper right')
1870

1871
    # Create a list of lines for the output
1872
    out = np.empty(len(nyquist_responses), dtype=object)
9✔
1873
    for i in range(out.shape[0]):
9✔
1874
        out[i] = []             # unique list in each element
9✔
1875

1876
    for idx, response in enumerate(nyquist_responses):
9✔
1877
        resp = response.response
9✔
1878
        if response.dt in [0, None]:
9✔
1879
            splane_contour = response.contour
9✔
1880
        else:
1881
            splane_contour = np.log(response.contour) / response.dt
9✔
1882

1883
        # Find the different portions of the curve (with scaled pts marked)
1884
        reg_mask = np.logical_or(
9✔
1885
            np.abs(resp) > max_curve_magnitude,
1886
            splane_contour.real != 0)
1887
        # reg_mask = np.logical_or(
1888
        #     np.abs(resp.real) > max_curve_magnitude,
1889
        #     np.abs(resp.imag) > max_curve_magnitude)
1890

1891
        scale_mask = ~reg_mask \
9✔
1892
            & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \
1893
            & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1]))
1894

1895
        # Rescale the points with large magnitude
1896
        rescale = np.logical_and(
9✔
1897
            reg_mask, abs(resp) > max_curve_magnitude)
1898
        resp[rescale] *= max_curve_magnitude / abs(resp[rescale])
9✔
1899

1900
        # Get the label to use for the line
1901
        label = response.sysname if line_labels is None else line_labels[idx]
9✔
1902

1903
        # Plot the regular portions of the curve (and grab the color)
1904
        x_reg = np.ma.masked_where(reg_mask, resp.real)
9✔
1905
        y_reg = np.ma.masked_where(reg_mask, resp.imag)
9✔
1906
        p = plt.plot(
9✔
1907
            x_reg, y_reg, primary_style[0], color=color, label=label, **kwargs)
1908
        c = p[0].get_color()
9✔
1909
        out[idx] += p
9✔
1910

1911
        # Figure out how much to offset the curve: the offset goes from
1912
        # zero at the start of the scaled section to max_curve_offset as
1913
        # we move along the curve
1914
        curve_offset = _compute_curve_offset(
9✔
1915
            resp, scale_mask, max_curve_offset)
1916

1917
        # Plot the scaled sections of the curve (changing linestyle)
1918
        x_scl = np.ma.masked_where(scale_mask, resp.real)
9✔
1919
        y_scl = np.ma.masked_where(scale_mask, resp.imag)
9✔
1920
        if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1921
            out[idx] += plt.plot(
9✔
1922
                x_scl * (1 + curve_offset),
1923
                y_scl * (1 + curve_offset),
1924
                primary_style[1], color=c, **kwargs)
1925
        else:
1926
            out[idx] += [None]
9✔
1927

1928
        # Plot the primary curve (invisible) for setting arrows
1929
        x, y = resp.real.copy(), resp.imag.copy()
9✔
1930
        x[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1931
        y[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1932
        p = plt.plot(x, y, linestyle='None', color=c)
9✔
1933

1934
        # Add arrows
1935
        ax = plt.gca()
9✔
1936
        _add_arrows_to_line2D(
9✔
1937
            ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1)
1938

1939
        # Plot the mirror image
1940
        if mirror_style is not False:
9✔
1941
            # Plot the regular and scaled segments
1942
            out[idx] += plt.plot(
9✔
1943
                x_reg, -y_reg, mirror_style[0], color=c, **kwargs)
1944
            if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1945
                out[idx] += plt.plot(
9✔
1946
                    x_scl * (1 - curve_offset),
1947
                    -y_scl * (1 - curve_offset),
1948
                    mirror_style[1], color=c, **kwargs)
1949
            else:
1950
                out[idx] += [None]
9✔
1951

1952
            # Add the arrows (on top of an invisible contour)
1953
            x, y = resp.real.copy(), resp.imag.copy()
9✔
1954
            x[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1955
            y[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1956
            p = plt.plot(x, -y, linestyle='None', color=c, **kwargs)
9✔
1957
            _add_arrows_to_line2D(
9✔
1958
                ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1)
1959
        else:
1960
            out[idx] += [None, None]
9✔
1961

1962
        # Mark the start of the curve
1963
        if start_marker:
9✔
1964
            plt.plot(resp[0].real, resp[0].imag, start_marker,
9✔
1965
                     color=c, markersize=start_marker_size)
1966

1967
        # Mark the -1 point
1968
        plt.plot([-1], [0], 'r+')
9✔
1969

1970
        #
1971
        # Draw circles for gain crossover and sensitivity functions
1972
        #
1973
        theta = np.linspace(0, 2*np.pi, 100)
9✔
1974
        cos = np.cos(theta)
9✔
1975
        sin = np.sin(theta)
9✔
1976
        label_pos = 15
9✔
1977

1978
        # Display the unit circle, to read gain crossover frequency
1979
        if unit_circle:
9✔
1980
            plt.plot(cos, sin, **config.defaults['nyquist.circle_style'])
9✔
1981

1982
        # Draw circles for given magnitudes of sensitivity
1983
        if ms_circles is not None:
9✔
1984
            for ms in ms_circles:
9✔
1985
                pos_x = -1 + (1/ms)*cos
9✔
1986
                pos_y = (1/ms)*sin
9✔
1987
                plt.plot(
9✔
1988
                    pos_x, pos_y, **config.defaults['nyquist.circle_style'])
1989
                plt.text(pos_x[label_pos], pos_y[label_pos], ms)
9✔
1990

1991
        # Draw circles for given magnitudes of complementary sensitivity
1992
        if mt_circles is not None:
9✔
1993
            for mt in mt_circles:
9✔
1994
                if mt != 1:
9✔
1995
                    ct = -mt**2/(mt**2-1)  # Mt center
9✔
1996
                    rt = mt/(mt**2-1)  # Mt radius
9✔
1997
                    pos_x = ct+rt*cos
9✔
1998
                    pos_y = rt*sin
9✔
1999
                    plt.plot(
9✔
2000
                        pos_x, pos_y,
2001
                        **config.defaults['nyquist.circle_style'])
2002
                    plt.text(pos_x[label_pos], pos_y[label_pos], mt)
9✔
2003
                else:
2004
                    _, _, ymin, ymax = plt.axis()
9✔
2005
                    pos_y = np.linspace(ymin, ymax, 100)
9✔
2006
                    plt.vlines(
9✔
2007
                        -0.5, ymin=ymin, ymax=ymax,
2008
                        **config.defaults['nyquist.circle_style'])
2009
                    plt.text(-0.5, pos_y[label_pos], 1)
9✔
2010

2011
        # Label the frequencies of the points on the Nyquist curve
2012
        if label_freq:
9✔
2013
            ind = slice(None, None, label_freq)
9✔
2014
            omega_sys = np.imag(splane_contour[np.real(splane_contour) == 0])
9✔
2015
            for xpt, ypt, omegapt in zip(x[ind], y[ind], omega_sys[ind]):
9✔
2016
                # Convert to Hz
2017
                f = omegapt / (2 * np.pi)
9✔
2018

2019
                # Factor out multiples of 1000 and limit the
2020
                # result to the range [-8, 8].
2021
                pow1000 = max(min(get_pow1000(f), 8), -8)
9✔
2022

2023
                # Get the SI prefix.
2024
                prefix = gen_prefix(pow1000)
9✔
2025

2026
                # Apply the text. (Use a space before the text to
2027
                # prevent overlap with the data.)
2028
                #
2029
                # np.round() is used because 0.99... appears
2030
                # instead of 1.0, and this would otherwise be
2031
                # truncated to 0.
2032
                plt.text(xpt, ypt, ' ' +
9✔
2033
                         str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' +
2034
                         prefix + 'Hz')
2035

2036
    # Label the axes
2037
    ax.set_xlabel("Real axis")
9✔
2038
    ax.set_ylabel("Imaginary axis")
9✔
2039
    ax.grid(color="lightgray")
9✔
2040

2041
    # List of systems that are included in this plot
2042
    lines, labels = _get_line_labels(ax)
9✔
2043

2044
    # Add legend if there is more than one system plotted
2045
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2046
        with plt.rc_context(rcParams):
9✔
2047
            legend = ax.legend(lines, labels, loc=legend_loc)
9✔
2048
    else:
2049
        legend = None
9✔
2050

2051
    # Add the title
2052
    sysnames = [response.sysname for response in nyquist_responses]
9✔
2053
    if ax_user is None and title is None:
9✔
2054
        title = "Nyquist plot for " + ", ".join(sysnames)
9✔
2055
        _update_plot_title(
9✔
2056
            title, fig=fig, rcParams=rcParams, frame=title_frame)
2057
    elif ax_user is None:
9✔
2058
        _update_plot_title(
9✔
2059
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2060
            use_existing=False)
2061

2062
    # Legacy return processing
2063
    if plot is True or return_contour is not None:
9✔
2064
        if len(data) == 1:
9✔
2065
            counts, contours = counts[0], contours[0]
9✔
2066

2067
        # Return counts and (optionally) the contour we used
2068
        return (counts, contours) if return_contour else counts
9✔
2069

2070
    return ControlPlot(out, ax, fig, legend=legend)
9✔
2071

2072

2073
#
2074
# Function to compute Nyquist curve offsets
2075
#
2076
# This function computes a smoothly varying offset that starts and ends at
2077
# zero at the ends of a scaled segment.
2078
#
2079
def _compute_curve_offset(resp, mask, max_offset):
9✔
2080
    # Compute the arc length along the curve
2081
    s_curve = np.cumsum(
9✔
2082
        np.sqrt(np.diff(resp.real) ** 2 + np.diff(resp.imag) ** 2))
2083

2084
    # Initialize the offset
2085
    offset = np.zeros(resp.size)
9✔
2086
    arclen = np.zeros(resp.size)
9✔
2087

2088
    # Walk through the response and keep track of each continuous component
2089
    i, nsegs = 0, 0
9✔
2090
    while i < resp.size:
9✔
2091
        # Skip the regular segment
2092
        while i < resp.size and mask[i]:
9✔
2093
            i += 1              # Increment the counter
9✔
2094
            if i == resp.size:
9✔
2095
                break
9✔
2096
            # Keep track of the arclength
2097
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2098

2099
        nsegs += 0.5
9✔
2100
        if i == resp.size:
9✔
2101
            break
9✔
2102

2103
        # Save the starting offset of this segment
2104
        seg_start = i
9✔
2105

2106
        # Walk through the scaled segment
2107
        while i < resp.size and not mask[i]:
9✔
2108
            i += 1
9✔
2109
            if i == resp.size:  # See if we are done with this segment
9✔
2110
                break
9✔
2111
            # Keep track of the arclength
2112
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2113

2114
        nsegs += 0.5
9✔
2115
        if i == resp.size:
9✔
2116
            break
9✔
2117

2118
        # Save the ending offset of this segment
2119
        seg_end = i
9✔
2120

2121
        # Now compute the scaling for this segment
2122
        s_segment = arclen[seg_end-1] - arclen[seg_start]
9✔
2123
        offset[seg_start:seg_end] = max_offset * s_segment/s_curve[-1] * \
9✔
2124
            np.sin(np.pi * (arclen[seg_start:seg_end]
2125
                            - arclen[seg_start])/s_segment)
2126

2127
    return offset
9✔
2128

2129

2130
#
2131
# Gang of Four plot
2132
#
2133
def gangof4_response(
9✔
2134
        P, C, omega=None, omega_limits=None, omega_num=None, Hz=False):
2135
    """Compute response of "Gang of 4" transfer functions.
2136

2137
    Generates a 2x2 frequency response for the "Gang of 4" sensitivity
2138
    functions [T, PS; CS, S].
2139

2140
    Parameters
2141
    ----------
2142
    P, C : LTI
2143
        Linear input/output systems (process and control).
2144
    omega : array
2145
        Range of frequencies (list or bounds) in rad/sec.
2146
    omega_limits : array_like of two values
2147
        Set limits for plotted frequency range. If Hz=True the limits are
2148
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2149
        elements is equivalent to providing `omega_limits`. Ignored if
2150
        data is not a list of systems.
2151
    omega_num : int
2152
        Number of samples to use for the frequency range.  Defaults to
2153
        `config.defaults['freqplot.number_of_samples']`.  Ignored if data is
2154
        not a list of systems.
2155
    Hz : bool, optional
2156
        If True, when computing frequency limits automatically set
2157
        limits to full decades in Hz instead of rad/s.
2158

2159
    Returns
2160
    -------
2161
    response : `FrequencyResponseData`
2162
        Frequency response with inputs 'r' and 'd' and outputs 'y', and 'u'
2163
        representing the 2x2 matrix of transfer functions in the Gang of 4.
2164

2165
    Examples
2166
    --------
2167
    >>> P = ct.tf([1], [1, 1])
2168
    >>> C = ct.tf([2], [1])
2169
    >>> response = ct.gangof4_response(P, C)
2170
    >>> cplt = response.plot()
2171

2172
    """
2173
    if not P.issiso() or not C.issiso():
9✔
2174
        # TODO: Add MIMO go4 plots.
2175
        raise ControlMIMONotImplemented(
×
2176
            "Gang of four is currently only implemented for SISO systems.")
2177

2178
    # Compute the sensitivity functions
2179
    L = P * C
9✔
2180
    S = feedback(1, L)
9✔
2181
    T = L * S
9✔
2182

2183
    # Select a default range if none is provided
2184
    # TODO: This needs to be made more intelligent
2185
    omega, _ = _determine_omega_vector(
9✔
2186
        [P, C, S], omega, omega_limits, omega_num, Hz=Hz)
2187

2188
    #
2189
    # bode_plot based implementation
2190
    #
2191

2192
    # Compute the response of the Gang of 4
2193
    resp_T = T(1j * omega)
9✔
2194
    resp_PS = (P * S)(1j * omega)
9✔
2195
    resp_CS = (C * S)(1j * omega)
9✔
2196
    resp_S = S(1j * omega)
9✔
2197

2198
    # Create a single frequency response data object with the underlying data
2199
    data = np.empty((2, 2, omega.size), dtype=complex)
9✔
2200
    data[0, 0, :] = resp_T
9✔
2201
    data[0, 1, :] = resp_PS
9✔
2202
    data[1, 0, :] = resp_CS
9✔
2203
    data[1, 1, :] = resp_S
9✔
2204

2205
    return FrequencyResponseData(
9✔
2206
        data, omega, outputs=['y', 'u'], inputs=['r', 'd'],
2207
        title=f"Gang of Four for P={P.name}, C={C.name}",
2208
        sysname=f"P={P.name}, C={C.name}", plot_phase=False)
2209

2210

2211
def gangof4_plot(
9✔
2212
        *args, omega=None, omega_limits=None, omega_num=None,
2213
        Hz=False, **kwargs):
2214
    """gangof4_plot(response) \
2215
    gangof4_plot(P, C, omega)
2216

2217
    Plot response of "Gang of 4" transfer functions.
2218

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

2222
        gangof4_plot(response[, ...])
2223
        gangof4_plot(P, C[, ...])
2224

2225
    Parameters
2226
    ----------
2227
    response : FrequencyPlotData
2228
        Gang of 4 frequency response from `gangof4_response`.
2229
    P, C : LTI
2230
        Linear input/output systems (process and control).
2231
    omega : array
2232
        Range of frequencies (list or bounds) in rad/sec.
2233
    omega_limits : array_like of two values
2234
        Set limits for plotted frequency range. If Hz=True the limits are
2235
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2236
        elements is equivalent to providing `omega_limits`. Ignored if
2237
        data is not a list of systems.
2238
    omega_num : int
2239
        Number of samples to use for the frequency range.  Defaults to
2240
        `config.defaults['freqplot.number_of_samples']`.  Ignored if data is
2241
        not a list of systems.
2242
    Hz : bool, optional
2243
        If True, when computing frequency limits automatically set
2244
        limits to full decades in Hz instead of rad/s.
2245

2246
    Returns
2247
    -------
2248
    cplt : `ControlPlot` object
2249
        Object containing the data that were plotted.  See `ControlPlot`
2250
        for more detailed information.
2251
    cplt.lines : 2x2 array of `matplotlib.lines.Line2D`
2252
        Array containing information on each line in the plot.  The value
2253
        of each array entry is a list of Line2D objects in that subplot.
2254
    cplt.axes : 2D array of `matplotlib.axes.Axes`
2255
        Axes for each subplot.
2256
    cplt.figure : `matplotlib.figure.Figure`
2257
        Figure containing the plot.
2258
    cplt.legend : 2D array of `matplotlib.legend.Legend`
2259
        Legend object(s) contained in the plot.
2260

2261
    """
2262
    if len(args) == 1 and isinstance(args[0], FrequencyResponseData):
9✔
2263
        if any([kw is not None
×
2264
                for kw in [omega, omega_limits, omega_num, Hz]]):
2265
            raise ValueError(
×
2266
                "omega, omega_limits, omega_num, Hz not allowed when "
2267
                "given a Gang of 4 response as first argument")
2268
        return args[0].plot(kwargs)
×
2269
    else:
2270
        if len(args) > 3:
9✔
2271
            raise TypeError(
×
2272
                f"expecting 2 or 3 positional arguments; received {len(args)}")
2273
        omega = omega if len(args) < 3 else args[2]
9✔
2274
        args = args[0:2]
9✔
2275
        return gangof4_response(
9✔
2276
            *args, omega=omega, omega_limits=omega_limits,
2277
            omega_num=omega_num, Hz=Hz).plot(**kwargs)
2278

2279

2280
#
2281
# Singular values plot
2282
#
2283
def singular_values_response(
9✔
2284
        sysdata, omega=None, omega_limits=None, omega_num=None, Hz=False):
2285
    """Singular value response for a system.
2286

2287
    Computes the singular values for a system or list of systems over
2288
    a (optional) frequency range.
2289

2290
    Parameters
2291
    ----------
2292
    sysdata : LTI or list of LTI
2293
        List of linear input/output systems (single system is OK).
2294
    omega : array_like
2295
        List of frequencies in rad/sec to be used for frequency response.
2296
    Hz : bool, optional
2297
        If True, when computing frequency limits automatically set
2298
        limits to full decades in Hz instead of rad/s.
2299

2300
    Returns
2301
    -------
2302
    response : `FrequencyResponseData`
2303
        Frequency response with the number of outputs equal to the
2304
        number of singular values in the response, and a single input.
2305

2306
    Other Parameters
2307
    ----------------
2308
    omega_limits : array_like of two values
2309
        Set limits for plotted frequency range. If Hz=True the limits are
2310
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2311
        elements is equivalent to providing `omega_limits`.
2312
    omega_num : int, optional
2313
        Number of samples to use for the frequency range.  Defaults to
2314
        `config.defaults['freqplot.number_of_samples']`.
2315

2316
    See Also
2317
    --------
2318
    singular_values_plot
2319

2320
    Examples
2321
    --------
2322
    >>> omegas = np.logspace(-4, 1, 1000)
2323
    >>> den = [75, 1]
2324
    >>> G = ct.tf([[[87.8], [-86.4]], [[108.2], [-109.6]]],
2325
    ...           [[den, den], [den, den]])
2326
    >>> response = ct.singular_values_response(G, omega=omegas)
2327

2328
    """
2329
    # Convert the first argument to a list
2330
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
2331

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

2335
    # Compute the frequency responses for the systems
2336
    responses = frequency_response(
9✔
2337
        syslist, omega=omega, omega_limits=omega_limits,
2338
        omega_num=omega_num, Hz=Hz, squeeze=False)
2339

2340
    # Calculate the singular values for each system in the list
2341
    svd_responses = []
9✔
2342
    for response in responses:
9✔
2343
        # Compute the singular values (permute indices to make things work)
2344
        fresp_permuted = response.frdata.transpose((2, 0, 1))
9✔
2345
        sigma = np.linalg.svd(fresp_permuted, compute_uv=False).transpose()
9✔
2346
        sigma_fresp = sigma.reshape(sigma.shape[0], 1, sigma.shape[1])
9✔
2347

2348
        # Save the singular values as an FRD object
2349
        svd_responses.append(
9✔
2350
            FrequencyResponseData(
2351
                sigma_fresp, response.omega, _return_singvals=True,
2352
                outputs=[f'$\\sigma_{{{k+1}}}$' for k in range(sigma.shape[0])],
2353
                inputs='inputs', dt=response.dt, plot_phase=False,
2354
                sysname=response.sysname, plot_type='svplot',
2355
                title=f"Singular values for {response.sysname}"))
2356

2357
    if isinstance(sysdata, (list, tuple)):
9✔
2358
        return FrequencyResponseList(svd_responses)
9✔
2359
    else:
2360
        return svd_responses[0]
9✔
2361

2362

2363
def singular_values_plot(
9✔
2364
        data, omega=None, *fmt, plot=None, omega_limits=None, omega_num=None,
2365
        ax=None, label=None, title=None, **kwargs):
2366
    """Plot the singular values for a system.
2367

2368
    Plot the singular values as a function of frequency for a system or
2369
    list of systems.  If multiple systems are plotted, each system in the
2370
    list is plotted in a different color.
2371

2372
    Parameters
2373
    ----------
2374
    data : list of `FrequencyResponseData`
2375
        List of `FrequencyResponseData` objects.  For backward
2376
        compatibility, a list of LTI systems can also be given.
2377
    omega : array_like
2378
        List of frequencies in rad/sec over to plot over.
2379
    *fmt : `matplotlib.pyplot.plot` format string, optional
2380
        Passed to `matplotlib` as the format string for all lines in the plot.
2381
        The `omega` parameter must be present (use omega=None if needed).
2382
    dB : bool
2383
        If True, plot result in dB.  Default is False.
2384
    Hz : bool
2385
        If True, plot frequency in Hz (omega must be provided in rad/sec).
2386
        Default value (False) set by `config.defaults['freqplot.Hz']`.
2387
    **kwargs : `matplotlib.pyplot.plot` keyword properties, optional
2388
        Additional keywords passed to `matplotlib` to specify line properties.
2389

2390
    Returns
2391
    -------
2392
    cplt : `ControlPlot` object
2393
        Object containing the data that were plotted.  See `ControlPlot`
2394
        for more detailed information.
2395
    cplt.lines : array of `matplotlib.lines.Line2D`
2396
        Array containing information on each line in the plot.  The size of
2397
        the array matches the number of systems and the value of the array
2398
        is a list of Line2D objects for that system.
2399
    cplt.axes : 2D array of `matplotlib.axes.Axes`
2400
        Axes for each subplot.
2401
    cplt.figure : `matplotlib.figure.Figure`
2402
        Figure containing the plot.
2403
    cplt.legend : 2D array of `matplotlib.legend.Legend`
2404
        Legend object(s) contained in the plot.
2405

2406
    Other Parameters
2407
    ----------------
2408
    ax : `matplotlib.axes.Axes`, optional
2409
        The matplotlib axes to draw the figure on.  If not specified and
2410
        the current figure has a single axes, that axes is used.
2411
        Otherwise, a new figure is created.
2412
    color : matplotlib color spec
2413
        Color to use for singular values (or None for matplotlib default).
2414
    grid : bool
2415
        If True, plot grid lines on gain and phase plots.  Default is
2416
        set by `config.defaults['freqplot.grid']`.
2417
    label : str or array_like of str, optional
2418
        If present, replace automatically generated label(s) with the given
2419
        label(s).  If sysdata is a list, strings should be specified for each
2420
        system.
2421
    legend_loc : int or str, optional
2422
        Include a legend in the given location. Default is 'center right',
2423
        with no legend for a single response.  Use False to suppress legend.
2424
    omega_limits : array_like of two values
2425
        Set limits for plotted frequency range. If Hz=True the limits are
2426
        in Hz otherwise in rad/s.  Specifying `omega` as a list of two
2427
        elements is equivalent to providing `omega_limits`.
2428
    omega_num : int, optional
2429
        Number of samples to use for the frequency range.  Defaults to
2430
        `config.defaults['freqplot.number_of_samples']`.  Ignored if data is
2431
        not a list of systems.
2432
    plot : bool, optional
2433
        (legacy) If given, `singular_values_plot` returns the legacy return
2434
        values of magnitude, phase, and frequency.  If False, just return
2435
        the values with no plot.
2436
    rcParams : dict
2437
        Override the default parameters used for generating plots.
2438
        Default is set up `config.defaults['ctrlplot.rcParams']`.
2439
    show_legend : bool, optional
2440
        Force legend to be shown if True or hidden if False.  If
2441
        None, then show legend when there is more than one line on an
2442
        axis or `legend_loc` or `legend_map` has been specified.
2443
    title : str, optional
2444
        Set the title of the plot.  Defaults to plot type and system name(s).
2445
    title_frame : str, optional
2446
        Set the frame of reference used to center the plot title. If set to
2447
        'axes' (default), the horizontal position of the title will
2448
        centered relative to the axes.  If set to 'figure', it will be
2449
        centered with respect to the figure (faster execution).
2450

2451
    See Also
2452
    --------
2453
    singular_values_response
2454

2455
    Notes
2456
    -----
2457
    If `plot` = False, the following legacy values are returned:
2458
       * `mag` : ndarray (or list of ndarray if len(data) > 1))
2459
           Magnitude of the response (deprecated).
2460
       * `phase` : ndarray (or list of ndarray if len(data) > 1))
2461
           Phase in radians of the response (deprecated).
2462
       * `omega` : ndarray (or list of ndarray if len(data) > 1))
2463
           Frequency in rad/sec (deprecated).
2464

2465
    """
2466
    # Keyword processing
2467
    color = kwargs.pop('color', None)
9✔
2468
    dB = config._get_param(
9✔
2469
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
2470
    Hz = config._get_param(
9✔
2471
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
2472
    grid = config._get_param(
9✔
2473
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
2474
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
2475
    title_frame = config._get_param(
9✔
2476
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
2477

2478
    # If argument was a singleton, turn it into a tuple
2479
    data = data if isinstance(data, (list, tuple)) else (data,)
9✔
2480

2481
    # Convert systems into frequency responses
2482
    if any([isinstance(response, (StateSpace, TransferFunction))
9✔
2483
            for response in data]):
2484
        responses = singular_values_response(
9✔
2485
                    data, omega=omega, omega_limits=omega_limits,
2486
                    omega_num=omega_num)
2487
    else:
2488
        # Generate warnings if frequency keywords were given
2489
        if omega_num is not None:
9✔
2490
            warnings.warn("`omega_num` ignored when passed response data")
9✔
2491
        elif omega is not None:
9✔
2492
            warnings.warn("`omega` ignored when passed response data")
9✔
2493

2494
        # Check to make sure omega_limits is sensible
2495
        if omega_limits is not None and \
9✔
2496
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
2497
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
2498

2499
        responses = data
9✔
2500

2501
    # Process label keyword
2502
    line_labels = _process_line_labels(label, len(data))
9✔
2503

2504
    # Process (legacy) plot keyword
2505
    if plot is not None:
9✔
2506
        warnings.warn(
×
2507
            "`singular_values_plot` return values of sigma, omega is "
2508
            "deprecated; use singular_values_response()", FutureWarning)
2509

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

2515
    # Extract the data we need for plotting
2516
    sigmas = [np.real(response.frdata[:, 0, :]) for response in responses]
9✔
2517
    omegas = [response.omega for response in responses]
9✔
2518

2519
    # Legacy processing for no plotting case
2520
    if plot is False:
9✔
2521
        if len(data) == 1:
×
2522
            return sigmas[0], omegas[0]
×
2523
        else:
2524
            return sigmas, omegas
×
2525

2526
    fig, ax_sigma = _process_ax_keyword(
9✔
2527
        ax, shape=(1, 1), squeeze=True, rcParams=rcParams)
2528
    ax_sigma.set_label('control-sigma')         # TODO: deprecate?
9✔
2529
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
2530
        kwargs, None, 'center right')
2531

2532
    # Get color offset for first (new) line to be drawn
2533
    color_offset, color_cycle = _get_color_offset(ax_sigma)
9✔
2534

2535
    # Create a list of lines for the output
2536
    out = np.empty(len(data), dtype=object)
9✔
2537

2538
    # Plot the singular values for each response
2539
    for idx_sys, response in enumerate(responses):
9✔
2540
        sigma = sigmas[idx_sys].transpose()     # frequency first for plotting
9✔
2541
        omega = omegas[idx_sys] / (2 * math.pi) if Hz else  omegas[idx_sys]
9✔
2542

2543
        if response.isdtime(strict=True):
9✔
2544
            nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
2545
        else:
2546
            nyq_freq = None
9✔
2547

2548
        # Determine the color to use for this response
2549
        color = _get_color(
9✔
2550
            color, fmt=fmt, offset=color_offset + idx_sys,
2551
            color_cycle=color_cycle)
2552

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

2556
        # Decide on the system name
2557
        sysname = response.sysname if response.sysname is not None \
9✔
2558
            else f"Unknown-{idx_sys}"
2559

2560
        # Get the label to use for the line
2561
        label = sysname if line_labels is None else line_labels[idx_sys]
9✔
2562

2563
        # Plot the data
2564
        if dB:
9✔
2565
            out[idx_sys] = ax_sigma.semilogx(
9✔
2566
                omega, 20 * np.log10(sigma), *fmt,
2567
                label=label, **color_arg, **kwargs)
2568
        else:
2569
            out[idx_sys] = ax_sigma.loglog(
9✔
2570
                omega, sigma, label=label, *fmt, **color_arg, **kwargs)
2571

2572
        # Plot the Nyquist frequency
2573
        if nyq_freq is not None:
9✔
2574
            ax_sigma.axvline(
9✔
2575
                nyq_freq, linestyle='--', label='_nyq_freq_' + sysname,
2576
                **color_arg)
2577

2578
    # If specific omega_limits were given, use them
2579
    if omega_limits is not None:
9✔
2580
        ax_sigma.set_xlim(omega_limits)
9✔
2581

2582
    # Add a grid to the plot + labeling
2583
    if grid:
9✔
2584
        ax_sigma.grid(grid, which='both')
9✔
2585

2586
    ax_sigma.set_ylabel(
9✔
2587
        "Singular Values [dB]" if dB else "Singular Values")
2588
    ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]")
9✔
2589

2590
    # List of systems that are included in this plot
2591
    lines, labels = _get_line_labels(ax_sigma)
9✔
2592

2593
    # Add legend if there is more than one system plotted
2594
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2595
        with plt.rc_context(rcParams):
9✔
2596
            legend = ax_sigma.legend(lines, labels, loc=legend_loc)
9✔
2597
    else:
2598
        legend = None
9✔
2599

2600
    # Add the title
2601
    if ax is None:
9✔
2602
        if title is None:
9✔
2603
            title = "Singular values for " + ", ".join(labels)
9✔
2604
        _update_plot_title(
9✔
2605
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2606
            use_existing=False)
2607

2608
    # Legacy return processing
2609
    if plot is not None:
9✔
2610
        if len(responses) == 1:
×
2611
            return sigmas[0], omegas[0]
×
2612
        else:
2613
            return sigmas, omegas
×
2614

2615
    return ControlPlot(out, ax_sigma, fig, legend=legend)
9✔
2616

2617
#
2618
# Utility functions
2619
#
2620
# This section of the code contains some utility functions for
2621
# generating frequency domain plots.
2622
#
2623

2624

2625
# Determine the frequency range to be used
2626
def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
9✔
2627
                            Hz=None, feature_periphery_decades=None):
2628
    """Determine the frequency range for a frequency-domain plot
2629
    according to a standard logic.
2630

2631
    If `omega_in` and `omega_limits` are both None, then `omega_out` is
2632
    computed on `omega_num` points according to a default logic defined by
2633
    `_default_frequency_range` and tailored for the list of systems
2634
    syslist, and `omega_range_given` is set to False.
2635

2636
    If `omega_in` is None but `omega_limits` is a tuple of 2 elements, then
2637
    `omega_out` is computed with the function `numpy.logspace` on
2638
    `omega_num` points within the interval ``[min, max] = [omega_limits[0],
2639
    omega_limits[1]]``, and `omega_range_given` is set to True.
2640

2641
    If `omega_in` is a tuple of length 2, it is interpreted as a range and
2642
    handled like `omega_limits`.  If `omega_in` is a tuple of length 3, it
2643
    is interpreted a range plus number of points and handled like
2644
    `omega_limits` and `omega_num`.
2645

2646
    If `omega_in` is an array or a list/tuple of length greater than two,
2647
    then `omega_out` is set to `omega_in` (as an array), and
2648
    `omega_range_given` is set to True
2649

2650
    Parameters
2651
    ----------
2652
    syslist : list of LTI
2653
        List of linear input/output systems (single system is OK).
2654
    omega_in : 1D array_like or None
2655
        Frequency range specified by the user.
2656
    omega_limits : 1D array_like or None
2657
        Frequency limits specified by the user.
2658
    omega_num : int
2659
        Number of points to be used for the frequency range (if the
2660
        frequency range is not user-specified).
2661
    Hz : bool, optional
2662
        If True, the limits (first and last value) of the frequencies
2663
        are set to full decades in Hz so it fits plotting with logarithmic
2664
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2665

2666
    Returns
2667
    -------
2668
    omega_out : 1D array
2669
        Frequency range to be used.
2670
    omega_range_given : bool
2671
        True if the frequency range was specified by the user, either through
2672
        omega_in or through omega_limits. False if both omega_in
2673
        and omega_limits are None.
2674

2675
    """
2676
    # Handle the special case of a range of frequencies
2677
    if omega_in is not None and omega_limits is not None:
9✔
2678
        warnings.warn(
×
2679
            "omega and omega_limits both specified; ignoring limits")
2680
    elif isinstance(omega_in, (list, tuple)) and len(omega_in) == 2:
9✔
2681
        omega_limits = omega_in
9✔
2682
        omega_in = None
9✔
2683

2684
    omega_range_given = True
9✔
2685
    if omega_in is None:
9✔
2686
        if omega_limits is None:
9✔
2687
            omega_range_given = False
9✔
2688
            # Select a default range if none is provided
2689
            omega_out = _default_frequency_range(
9✔
2690
                syslist, number_of_samples=omega_num, Hz=Hz,
2691
                feature_periphery_decades=feature_periphery_decades)
2692
        else:
2693
            omega_limits = np.asarray(omega_limits)
9✔
2694
            if len(omega_limits) != 2:
9✔
2695
                raise ValueError("len(omega_limits) must be 2")
×
2696
            omega_out = np.logspace(np.log10(omega_limits[0]),
9✔
2697
                                    np.log10(omega_limits[1]),
2698
                                    num=omega_num, endpoint=True)
2699
    else:
2700
        omega_out = np.copy(omega_in)
9✔
2701

2702
    return omega_out, omega_range_given
9✔
2703

2704

2705
# Compute reasonable defaults for axes
2706
def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
9✔
2707
                             feature_periphery_decades=None):
2708
    """Compute a default frequency range for frequency domain plots.
2709

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

2715
    Parameters
2716
    ----------
2717
    syslist : list of LTI
2718
        List of linear input/output systems (single system is OK)
2719
    Hz : bool, optional
2720
        If True, the limits (first and last value) of the frequencies
2721
        are set to full decades in Hz so it fits plotting with logarithmic
2722
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2723
    number_of_samples : int, optional
2724
        Number of samples to generate.  The default value is read from
2725
        `config.defaults['freqplot.number_of_samples']`.  If None,
2726
        then the default from `numpy.logspace` is used.
2727
    feature_periphery_decades : float, optional
2728
        Defines how many decades shall be included in the frequency range on
2729
        both sides of features (poles, zeros).  The default value is read from
2730
        `config.defaults['freqplot.feature_periphery_decades']`.
2731

2732
    Returns
2733
    -------
2734
    omega : array
2735
        Range of frequencies in rad/sec
2736

2737
    Examples
2738
    --------
2739
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
2740
    >>> omega = ct._default_frequency_range(G)
2741
    >>> omega.min(), omega.max()
2742
    (0.1, 100.0)
2743

2744
    """
2745
    # Set default values for options
2746
    number_of_samples = config._get_param(
9✔
2747
        'freqplot', 'number_of_samples', number_of_samples)
2748
    feature_periphery_decades = config._get_param(
9✔
2749
        'freqplot', 'feature_periphery_decades', feature_periphery_decades, 1)
2750

2751
    # Find the list of all poles and zeros in the systems
2752
    features = np.array(())
9✔
2753
    freq_interesting = []
9✔
2754

2755
    # detect if single sys passed by checking if it is sequence-like
2756
    if not hasattr(syslist, '__iter__'):
9✔
2757
        syslist = (syslist,)
9✔
2758

2759
    for sys in syslist:
9✔
2760
        # For FRD systems, just use the response frequencies
2761
        if isinstance(sys, FrequencyResponseData):
9✔
2762
            # Add the min and max frequency, minus periphery decades
2763
            # (keeps frequency ranges from artificially expanding)
2764
            features = np.concatenate([features, np.array([
9✔
2765
                np.min(sys.omega) * 10**feature_periphery_decades,
2766
                np.max(sys.omega) / 10**feature_periphery_decades])])
2767
            continue
9✔
2768

2769
        try:
9✔
2770
            # Add new features to the list
2771
            if sys.isctime():
9✔
2772
                features_ = np.concatenate(
9✔
2773
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2774
                # Get rid of poles and zeros at the origin
2775
                toreplace = np.isclose(features_, 0.0)
9✔
2776
                if np.any(toreplace):
9✔
2777
                    features_ = features_[~toreplace]
9✔
2778
            elif sys.isdtime(strict=True):
9✔
2779
                fn = math.pi / sys.dt
9✔
2780
                # TODO: What distance to the Nyquist frequency is appropriate?
2781
                freq_interesting.append(fn * 0.9)
9✔
2782

2783
                features_ = np.concatenate(
9✔
2784
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2785
                # Get rid of poles and zeros on the real axis (imag==0)
2786
                # * origin and real < 0
2787
                # * at 1.: would result in omega=0. (logarithmic plot!)
2788
                toreplace = np.isclose(features_.imag, 0.0) & (
9✔
2789
                                    (features_.real <= 0.) |
2790
                                    (np.abs(features_.real - 1.0) < 1.e-10))
2791
                if np.any(toreplace):
9✔
2792
                    features_ = features_[~toreplace]
9✔
2793
                # TODO: improve (mapping pack to continuous time)
2794
                features_ = np.abs(np.log(features_) / (1.j * sys.dt))
9✔
2795
            else:
2796
                # TODO
2797
                raise NotImplementedError(
2798
                    "type of system in not implemented now")
2799
            features = np.concatenate([features, features_])
9✔
2800
        except NotImplementedError:
9✔
2801
            # Don't add any features for anything we don't understand
2802
            pass
9✔
2803

2804
    # Make sure there is at least one point in the range
2805
    if features.shape[0] == 0:
9✔
2806
        features = np.array([1.])
9✔
2807

2808
    if Hz:
9✔
2809
        features /= 2. * math.pi
9✔
2810
    features = np.log10(features)
9✔
2811
    lsp_min = np.rint(np.min(features) - feature_periphery_decades)
9✔
2812
    lsp_max = np.rint(np.max(features) + feature_periphery_decades)
9✔
2813
    if Hz:
9✔
2814
        lsp_min += np.log10(2. * math.pi)
9✔
2815
        lsp_max += np.log10(2. * math.pi)
9✔
2816

2817
    if freq_interesting:
9✔
2818
        lsp_min = min(lsp_min, np.log10(min(freq_interesting)))
9✔
2819
        lsp_max = max(lsp_max, np.log10(max(freq_interesting)))
9✔
2820

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

2824
    # Set the range to be an order of magnitude beyond any features
2825
    if number_of_samples:
9✔
2826
        omega = np.logspace(
9✔
2827
            lsp_min, lsp_max, num=number_of_samples, endpoint=True)
2828
    else:
2829
        omega = np.logspace(lsp_min, lsp_max, endpoint=True)
×
2830
    return omega
9✔
2831

2832

2833
#
2834
# Utility functions to create nice looking labels (KLD 5/23/11)
2835
#
2836

2837
def get_pow1000(num):
9✔
2838
    """Determine exponent for which significance of a number is within the
2839
    range [1, 1000).
2840
    """
2841
    # Based on algorithm from http://www.mail-archive.com/
2842
    # matplotlib-users@lists.sourceforge.net/msg14433.html, accessed 2010/11/7
2843
    # by Jason Heeris 2009/11/18
2844
    from decimal import Decimal
9✔
2845
    from math import floor
9✔
2846
    dnum = Decimal(str(num))
9✔
2847
    if dnum == 0:
9✔
2848
        return 0
9✔
2849
    elif dnum < 0:
9✔
2850
        dnum = -dnum
×
2851
    return int(floor(dnum.log10() / 3))
9✔
2852

2853

2854
def gen_prefix(pow1000):
9✔
2855
    """Return the SI prefix for a power of 1000.
2856
    """
2857
    # Prefixes according to Table 5 of [BIPM 2006] (excluding hecto,
2858
    # deca, deci, and centi).
2859
    if pow1000 < -8 or pow1000 > 8:
9✔
2860
        raise ValueError(
×
2861
            "Value is out of the range covered by the SI prefixes.")
2862
    return ['Y',  # yotta (10^24)
9✔
2863
            'Z',  # zetta (10^21)
2864
            'E',  # exa (10^18)
2865
            'P',  # peta (10^15)
2866
            'T',  # tera (10^12)
2867
            'G',  # giga (10^9)
2868
            'M',  # mega (10^6)
2869
            'k',  # kilo (10^3)
2870
            '',  # (10^0)
2871
            'm',  # milli (10^-3)
2872
            r'$\mu$',  # micro (10^-6)
2873
            'n',  # nano (10^-9)
2874
            'p',  # pico (10^-12)
2875
            'f',  # femto (10^-15)
2876
            'a',  # atto (10^-18)
2877
            'z',  # zepto (10^-21)
2878
            'y'][8 - pow1000]  # yocto (10^-24)
2879

2880

2881
# Function aliases
2882
bode = bode_plot
9✔
2883
nyquist = nyquist_plot
9✔
2884
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