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

python-control / python-control / 10370763703

13 Aug 2024 01:32PM UTC coverage: 94.693% (-0.001%) from 94.694%
10370763703

push

github

web-flow
Merge pull request #1038 from murrayrm/doc-comment_fixes-11May2024

Documentation updates and docstring unit tests

9136 of 9648 relevant lines covered (94.69%)

8.27 hits per line

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

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

11
import itertools
9✔
12
import math
9✔
13
import warnings
9✔
14
from os.path import commonprefix
9✔
15

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

225
    See Also
226
    --------
227
    frequency_response
228

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

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

245
    Examples
246
    --------
247
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
248
    >>> out = ct.bode_plot(G)
249

250
    """
251
    #
252
    # Process keywords and set defaults
253
    #
254

255
    # Make a copy of the kwargs dictionary since we will modify it
256
    kwargs = dict(kwargs)
9✔
257

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

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

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

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

309
    if not isinstance(data, (list, tuple)):
9✔
310
        data = [data]
9✔
311

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

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

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

340
    # If plot_phase is not specified, check the data first, otherwise true
341
    if plot_phase is None:
9✔
342
        plot_phase = True if data[0].plot_phase is None else data[0].plot_phase
9✔
343

344
    if not plot_magnitude and not plot_phase:
9✔
345
        raise ValueError(
9✔
346
            "plot_magnitude and plot_phase both False; no data to plot")
347

348
    mag_data, phase_data, omega_data = [], [], []
9✔
349
    for response in data:
9✔
350
        noutputs, ninputs = response.noutputs, response.ninputs
9✔
351

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

366
        # Reshape the phase to allow standard indexing
367
        phase = response.phase.copy().reshape((noutputs, ninputs, -1))
9✔
368

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

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

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

392
        # Put the phase back into the original shape
393
        phase = phase.reshape(response.magnitude.shape)
9✔
394

395
        # Save the data for later use (legacy return values)
396
        mag_data.append(response.magnitude)
9✔
397
        phase_data.append(phase)
9✔
398
        omega_data.append(response.omega)
9✔
399

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

429
    if plot is not None:
9✔
430
        warnings.warn(
9✔
431
            "bode_plot() return value of mag, phase, omega is deprecated; "
432
            "use frequency_response()", FutureWarning)
433

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

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

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

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

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

509
    fig, ax_array = _process_ax_keyword(
9✔
510
        ax, (nrows, ncols), squeeze=False, rcParams=rcParams, clear_text=True)
511
    legend_loc, legend_map, show_legend = _process_legend_keywords(
9✔
512
        kwargs, (nrows,ncols), 'center right')
513

514
    # Get the values for sharing axes limits
515
    share_magnitude = kwargs.pop('share_magnitude', None)
9✔
516
    share_phase = kwargs.pop('share_phase', None)
9✔
517
    share_frequency = kwargs.pop('share_frequency', None)
9✔
518

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

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

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

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

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

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

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

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

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

648
    # Process label keyword
649
    line_labels = _process_line_labels(label, len(data), ninputs, noutputs)
9✔
650

651
    # Utility function for creating line label
652
    def _make_line_label(response, output_index, input_index):
9✔
653
        label = ""              # start with an empty label
9✔
654

655
        # Add the output name if it won't appear as an axes label
656
        if noutputs > 1 and overlay_outputs:
9✔
657
            label += response.output_labels[output_index]
9✔
658

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

664
        # Add the system name (will strip off later if redundant)
665
        label += ", " if label != "" else ""
9✔
666
        label += f"{response.sysname}"
9✔
667

668
        return label
9✔
669

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

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

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

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

690
            # Generate a label
691
            if line_labels is None:
9✔
692
                label = _make_line_label(response, i, j)
9✔
693
            else:
694
                label = line_labels[index, i, j]
9✔
695

696
            # Magnitude
697
            if plot_magnitude:
9✔
698
                pltfcn = ax_mag.semilogx if dB else ax_mag.loglog
9✔
699

700
                # Plot the main data
701
                lines = pltfcn(
9✔
702
                    omega_plot, mag_plot, *fmt, label=label, **kwargs)
703
                out[mag_map[i, j]] += lines
9✔
704

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

711
                # Add a grid to the plot
712
                ax_mag.grid(grid and not display_margins, which='both')
9✔
713

714
            # Phase
715
            if plot_phase:
9✔
716
                lines = ax_phase.semilogx(
9✔
717
                    omega_plot, phase_plot, *fmt, label=label, **kwargs)
718
                out[phase_map[i, j]] += lines
9✔
719

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

726
                # Add a grid to the plot
727
                ax_phase.grid(grid and not display_margins, which='both')
9✔
728

729
        #
730
        # Display gain and phase margins (SISO only)
731
        #
732

733
        if display_margins:
9✔
734
            if ninputs > 1 or noutputs > 1:
9✔
735
                raise NotImplementedError(
736
                    "margins are not available for MIMO systems")
737

738
            # Compute stability margins for the system
739
            margins = stability_margins(response, method=margins_method)
9✔
740
            gm, pm, Wcg, Wcp = (margins[i] for i in [0, 1, 3, 4])
9✔
741

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

751
            if Hz:
9✔
752
                Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi)
9✔
753

754
            # Draw lines at gain and phase limits
755
            if plot_magnitude:
9✔
756
                ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':',
9✔
757
                               zorder=-20)
758
                mag_ylim = ax_mag.get_ylim()
9✔
759

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1066
    return ControlPlot(out, ax_array, fig, legend=legend_array)
9✔
1067

1068

1069
#
1070
# Nyquist plot
1071
#
1072

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

1091

1092
class NyquistResponseData:
9✔
1093
    """Nyquist response data object.
1094

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

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

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

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

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

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

1148
    def plot(self, *args, **kwargs):
9✔
1149
        return nyquist_plot(self, *args, **kwargs)
9✔
1150

1151

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

1156

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

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

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

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

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

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

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

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

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

1249
    See Also
1250
    --------
1251
    nyquist_plot
1252

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

1260
    """
1261
    # Get values for params
1262
    omega_num_given = omega_num is not None
9✔
1263
    omega_num = config._get_param('freqplot', 'number_of_samples', omega_num)
9✔
1264
    indent_radius = config._get_param(
9✔
1265
        'nyquist', 'indent_radius', kwargs, _nyquist_defaults, pop=True)
1266
    encirclement_threshold = config._get_param(
9✔
1267
        'nyquist', 'encirclement_threshold', kwargs,
1268
        _nyquist_defaults, pop=True)
1269
    indent_direction = config._get_param(
9✔
1270
        'nyquist', 'indent_direction', kwargs, _nyquist_defaults, pop=True)
1271
    indent_points = config._get_param(
9✔
1272
        'nyquist', 'indent_points', kwargs, _nyquist_defaults, pop=True)
1273

1274
    if _check_kwargs and kwargs:
9✔
1275
        raise TypeError("unrecognized keywords: ", str(kwargs))
9✔
1276

1277
    # Convert the first argument to a list
1278
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
1279

1280
    # Determine the range of frequencies to use, based on args/features
1281
    omega, omega_range_given = _determine_omega_vector(
9✔
1282
        syslist, omega, omega_limits, omega_num, feature_periphery_decades=2)
1283

1284
    # If omega was not specified explicitly, start at omega = 0
1285
    if not omega_range_given:
9✔
1286
        if omega_num_given:
9✔
1287
            # Just reset the starting point
1288
            omega[0] = 0.0
9✔
1289
        else:
1290
            # Insert points between the origin and the first frequency point
1291
            omega = np.concatenate((
9✔
1292
                np.linspace(0, omega[0], indent_points), omega[1:]))
1293

1294
    # Go through each system and keep track of the results
1295
    responses = []
9✔
1296
    for idx, sys in enumerate(syslist):
9✔
1297
        if not sys.issiso():
9✔
1298
            # TODO: Add MIMO nyquist plots.
1299
            raise ControlMIMONotImplemented(
9✔
1300
                "Nyquist plot currently only supports SISO systems.")
1301

1302
        # Figure out the frequency range
1303
        if isinstance(sys, FrequencyResponseData) and sys.ifunc is None \
9✔
1304
           and not omega_range_given:
1305
            omega_sys = sys.omega               # use system frequencies
9✔
1306
        else:
1307
            omega_sys = np.asarray(omega)       # use common omega vector
9✔
1308

1309
        # Determine the contour used to evaluate the Nyquist curve
1310
        if sys.isdtime(strict=True):
9✔
1311
            # Restrict frequencies for discrete-time systems
1312
            nyq_freq = math.pi / sys.dt
9✔
1313
            if not omega_range_given:
9✔
1314
                # limit up to and including Nyquist frequency
1315
                omega_sys = np.hstack((
9✔
1316
                    omega_sys[omega_sys < nyq_freq], nyq_freq))
1317

1318
            # Issue a warning if we are sampling above Nyquist
1319
            if np.any(omega_sys * sys.dt > np.pi) and warn_nyquist:
9✔
1320
                warnings.warn("evaluation above Nyquist frequency")
9✔
1321

1322
        # do indentations in s-plane where it is more convenient
1323
        splane_contour = 1j * omega_sys
9✔
1324

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

1339
                zplane_cl_poles = sys.feedback().poles()
9✔
1340
                # eliminate z-plane poles at the origin to avoid warnings
1341
                zplane_cl_poles = zplane_cl_poles[
9✔
1342
                    ~np.isclose(abs(zplane_cl_poles), 0.)]
1343
                splane_cl_poles = np.log(zplane_cl_poles) / sys.dt
9✔
1344

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

1362
                        if abs(p_ol - p_cl) <= indent_radius and \
9✔
1363
                                warn_encirclements:
1364
                            warnings.warn(
9✔
1365
                                "indented contour may miss closed loop pole; "
1366
                                "consider reducing indent_radius to below "
1367
                                f"{abs(p_ol - p_cl):5.2g}", stacklevel=2)
1368

1369
            #
1370
            # See if we should add some frequency points near imaginary poles
1371
            #
1372
            for p in splane_poles:
9✔
1373
                # See if we need to process this pole (skip if on the negative
1374
                # imaginary axis or not near imaginary axis + user override)
1375
                if p.imag < 0 or abs(p.real) > indent_radius or \
9✔
1376
                   omega_range_given:
1377
                    continue
9✔
1378

1379
                # Find the frequencies before the pole frequency
1380
                below_points = np.argwhere(
9✔
1381
                    splane_contour.imag - abs(p.imag) < -indent_radius)
1382
                if below_points.size > 0:
9✔
1383
                    first_point = below_points[-1].item()
9✔
1384
                    start_freq = p.imag - indent_radius
9✔
1385
                else:
1386
                    # Add the points starting at the beginning of the contour
1387
                    assert splane_contour[0] == 0
9✔
1388
                    first_point = 0
9✔
1389
                    start_freq = 0
9✔
1390

1391
                # Find the frequencies after the pole frequency
1392
                above_points = np.argwhere(
9✔
1393
                    splane_contour.imag - abs(p.imag) > indent_radius)
1394
                last_point = above_points[0].item()
9✔
1395

1396
                # Add points for half/quarter circle around pole frequency
1397
                # (these will get indented left or right below)
1398
                splane_contour = np.concatenate((
9✔
1399
                    splane_contour[0:first_point+1],
1400
                    (1j * np.linspace(
1401
                        start_freq, p.imag + indent_radius, indent_points)),
1402
                    splane_contour[last_point:]))
1403

1404
            # Indent points that are too close to a pole
1405
            if len(splane_poles) > 0: # accomodate no splane poles if dtime sys
9✔
1406
                for i, s in enumerate(splane_contour):
9✔
1407
                    # Find the nearest pole
1408
                    p = splane_poles[(np.abs(splane_poles - s)).argmin()]
9✔
1409

1410
                    # See if we need to indent around it
1411
                    if abs(s - p) < indent_radius:
9✔
1412
                        # Figure out how much to offset (simple trigonometry)
1413
                        offset = np.sqrt(
9✔
1414
                            indent_radius ** 2 - (s - p).imag ** 2) \
1415
                            - (s - p).real
1416

1417
                        # Figure out which way to offset the contour point
1418
                        if p.real < 0 or (p.real == 0 and
9✔
1419
                                        indent_direction == 'right'):
1420
                            # Indent to the right
1421
                            splane_contour[i] += offset
9✔
1422

1423
                        elif p.real > 0 or (p.real == 0 and
9✔
1424
                                            indent_direction == 'left'):
1425
                            # Indent to the left
1426
                            splane_contour[i] -= offset
9✔
1427

1428
                        else:
1429
                            raise ValueError(
9✔
1430
                                "unknown value for indent_direction")
1431

1432
        # change contour to z-plane if necessary
1433
        if sys.isctime():
9✔
1434
            contour = splane_contour
9✔
1435
        else:
1436
            contour = np.exp(splane_contour * sys.dt)
9✔
1437

1438
        # Compute the primary curve
1439
        resp = sys(contour)
9✔
1440

1441
        # Compute CW encirclements of -1 by integrating the (unwrapped) angle
1442
        phase = -unwrap(np.angle(resp + 1))
9✔
1443
        encirclements = np.sum(np.diff(phase)) / np.pi
9✔
1444
        count = int(np.round(encirclements, 0))
9✔
1445

1446
        # Let the user know if the count might not make sense
1447
        if abs(encirclements - count) > encirclement_threshold and \
9✔
1448
           warn_encirclements:
1449
            warnings.warn(
9✔
1450
                "number of encirclements was a non-integer value; this can"
1451
                " happen is contour is not closed, possibly based on a"
1452
                " frequency range that does not include zero.")
1453

1454
        #
1455
        # Make sure that the enciriclements match the Nyquist criterion
1456
        #
1457
        # If the user specifies the frequency points to use, it is possible
1458
        # to miss enciriclements, so we check here to make sure that the
1459
        # Nyquist criterion is actually satisfied.
1460
        #
1461
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1462
            # Count the number of open/closed loop RHP poles
1463
            if sys.isctime():
9✔
1464
                if indent_direction == 'right':
9✔
1465
                    P = (sys.poles().real > 0).sum()
9✔
1466
                else:
1467
                    P = (sys.poles().real >= 0).sum()
9✔
1468
                Z = (sys.feedback().poles().real >= 0).sum()
9✔
1469
            else:
1470
                if indent_direction == 'right':
9✔
1471
                    P = (np.abs(sys.poles()) > 1).sum()
9✔
1472
                else:
1473
                    P = (np.abs(sys.poles()) >= 1).sum()
×
1474
                Z = (np.abs(sys.feedback().poles()) >= 1).sum()
9✔
1475

1476
            # Check to make sure the results make sense; warn if not
1477
            if Z != count + P and warn_encirclements:
9✔
1478
                warnings.warn(
9✔
1479
                    "number of encirclements does not match Nyquist criterion;"
1480
                    " check frequency range and indent radius/direction",
1481
                    UserWarning, stacklevel=2)
1482
            elif indent_direction == 'none' and any(sys.poles().real == 0) and \
9✔
1483
                 warn_encirclements:
1484
                warnings.warn(
×
1485
                    "system has pure imaginary poles but indentation is"
1486
                    " turned off; results may be meaningless",
1487
                    RuntimeWarning, stacklevel=2)
1488

1489
        # Decide on system name
1490
        sysname = sys.name if sys.name is not None else f"Unknown-{idx}"
9✔
1491

1492
        responses.append(NyquistResponseData(
9✔
1493
            count, contour, resp, sys.dt, sysname=sysname,
1494
            return_contour=return_contour))
1495

1496
    if isinstance(sysdata, (list, tuple)):
9✔
1497
        return NyquistResponseList(responses)
9✔
1498
    else:
1499
        return responses[0]
9✔
1500

1501

1502
def nyquist_plot(
9✔
1503
        data, omega=None, plot=None, label_freq=0, color=None, label=None,
1504
        return_contour=None, title=None, ax=None,
1505
        unit_circle=False, mt_circles=None, ms_circles=None, **kwargs):
1506
    """Nyquist plot for a system.
1507

1508
    Generates a Nyquist plot for the system over a (optional) frequency
1509
    range.  The curve is computed by evaluating the Nyqist segment along
1510
    the positive imaginary axis, with a mirror image generated to reflect
1511
    the negative imaginary axis.  Poles on or near the imaginary axis are
1512
    avoided using a small indentation.  The portion of the Nyquist contour
1513
    at infinity is not explicitly computed (since it maps to a constant
1514
    value for any system with a proper transfer function).
1515

1516
    Parameters
1517
    ----------
1518
    data : list of LTI or NyquistResponseData
1519
        List of linear input/output systems (single system is OK) or
1520
        Nyquist ersponses (computed using :func:`~control.nyquist_response`).
1521
        Nyquist curves for each system are plotted on the same graph.
1522
    omega : array_like, optional
1523
        Set of frequencies to be evaluated, in rad/sec. Specifying
1524
        ``omega`` as a list of two elements is equivalent to providing
1525
        ``omega_limits``.
1526
    unit_circle : bool, optional
1527
        If ``True``, display the unit circle, to read gain crossover frequency.
1528
    mt_circles : array_like, optional
1529
        Draw circles corresponding to the given magnitudes of sensitivity.
1530
    ms_circles : array_like, optional
1531
        Draw circles corresponding to the given magnitudes of complementary
1532
        sensitivity.
1533
    **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
1534
        Additional keywords passed to `matplotlib` to specify line properties.
1535

1536
    Returns
1537
    -------
1538
    cplt : :class:`ControlPlot` object
1539
        Object containing the data that were plotted:
1540

1541
          * cplt.lines: 2D array of :class:`matplotlib.lines.Line2D`
1542
            objects for each line in the plot.  The shape of the array is
1543
            given by (nsys, 4) where nsys is the number of systems or
1544
            Nyquist responses passed to the function.  The second index
1545
            specifies the segment type:
1546

1547
              - lines[idx, 0]: unscaled portion of the primary curve
1548
              - lines[idx, 1]: scaled portion of the primary curve
1549
              - lines[idx, 2]: unscaled portion of the mirror curve
1550
              - lines[idx, 3]: scaled portion of the mirror curve
1551

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

1554
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
1555

1556
          * cplt.legend: legend object(s) contained in the plot
1557

1558
        See :class:`ControlPlot` for more detailed information.
1559

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

1665
    See Also
1666
    --------
1667
    nyquist_response
1668

1669
    Notes
1670
    -----
1671
    1. If a discrete time model is given, the frequency response is computed
1672
       along the upper branch of the unit circle, using the mapping ``z =
1673
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
1674
       is the discrete timebase.  If timebase not specified (``dt=True``),
1675
       `dt` is set to 1.
1676

1677
    2. If a continuous-time system contains poles on or near the imaginary
1678
       axis, a small indentation will be used to avoid the pole.  The radius
1679
       of the indentation is given by `indent_radius` and it is taken to the
1680
       right of stable poles and the left of unstable poles.  If a pole is
1681
       exactly on the imaginary axis, the `indent_direction` parameter can be
1682
       used to set the direction of indentation.  Setting `indent_direction`
1683
       to `none` will turn off indentation.  If `return_contour` is True, the
1684
       exact contour used for evaluation is returned.
1685

1686
    3. For those portions of the Nyquist plot in which the contour is
1687
       indented to avoid poles, resuling in a scaling of the Nyquist plot,
1688
       the line styles are according to the settings of the `primary_style`
1689
       and `mirror_style` keywords.  By default the scaled portions of the
1690
       primary curve use a dotted line style and the scaled portion of the
1691
       mirror image use a dashdot line style.
1692

1693
    Examples
1694
    --------
1695
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1696
    >>> out = ct.nyquist_plot(G)
1697

1698
    """
1699
    #
1700
    # Keyword processing
1701
    #
1702
    # Keywords for the nyquist_plot function can either be keywords that
1703
    # are unique to this function, keywords that are intended for use by
1704
    # nyquist_response (if data is a list of systems), or keywords that
1705
    # are intended for the plotting commands.
1706
    #
1707
    # We first pop off all keywords that are used directly by this
1708
    # function.  If data is a list of systems, when then pop off keywords
1709
    # that correspond to nyquist_response() keywords.  The remaining
1710
    # keywords are passed to matplotlib (and will generate an error if
1711
    # unrecognized).
1712
    #
1713

1714
    # Get values for params (and pop from list to allow keyword use in plot)
1715
    arrows = config._get_param(
9✔
1716
        'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True)
1717
    arrow_size = config._get_param(
9✔
1718
        'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True)
1719
    arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None)
9✔
1720
    ax_user = ax
9✔
1721
    max_curve_magnitude = config._get_param(
9✔
1722
        'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True)
1723
    max_curve_offset = config._get_param(
9✔
1724
        'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True)
1725
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
1726
    start_marker = config._get_param(
9✔
1727
        'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True)
1728
    start_marker_size = config._get_param(
9✔
1729
        'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True)
1730
    title_frame = config._get_param(
9✔
1731
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
1732

1733
    # Set line styles for the curves
1734
    def _parse_linestyle(style_name, allow_false=False):
9✔
1735
        style = config._get_param(
9✔
1736
            'nyquist', style_name, kwargs, _nyquist_defaults, pop=True)
1737
        if isinstance(style, str):
9✔
1738
            # Only one style provided, use the default for the other
1739
            style = [style, _nyquist_defaults['nyquist.' + style_name][1]]
9✔
1740
            warnings.warn(
9✔
1741
                "use of a single string for linestyle will be deprecated "
1742
                " in a future release", PendingDeprecationWarning)
1743
        if (allow_false and style is False) or \
9✔
1744
           (isinstance(style, list) and len(style) == 2):
1745
            return style
9✔
1746
        else:
1747
            raise ValueError(f"invalid '{style_name}': {style}")
9✔
1748

1749
    primary_style = _parse_linestyle('primary_style')
9✔
1750
    mirror_style = _parse_linestyle('mirror_style', allow_false=True)
9✔
1751

1752
    # Parse the arrows keyword
1753
    if not arrows:
9✔
1754
        arrow_pos = []
9✔
1755
    elif isinstance(arrows, int):
9✔
1756
        N = arrows
9✔
1757
        # Space arrows out, starting midway along each "region"
1758
        arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False)
9✔
1759
    elif isinstance(arrows, (list, np.ndarray)):
9✔
1760
        arrow_pos = np.sort(np.atleast_1d(arrows))
9✔
1761
    else:
1762
        raise ValueError("unknown or unsupported arrow location")
9✔
1763

1764
    # Set the arrow style
1765
    if arrow_style is None:
9✔
1766
        arrow_style = mpl.patches.ArrowStyle(
9✔
1767
            'simple', head_width=arrow_size, head_length=arrow_size)
1768

1769
    # If argument was a singleton, turn it into a tuple
1770
    if not isinstance(data, (list, tuple)):
9✔
1771
        data = [data]
9✔
1772

1773
    # Process label keyword
1774
    line_labels = _process_line_labels(label, len(data))
9✔
1775

1776
    # If we are passed a list of systems, compute response first
1777
    if all([isinstance(
9✔
1778
            sys, (StateSpace, TransferFunction, FrequencyResponseData))
1779
            for sys in data]):
1780
        # Get the response, popping off keywords used there
1781
        nyquist_responses = nyquist_response(
9✔
1782
            data, omega=omega, return_contour=return_contour,
1783
            omega_limits=kwargs.pop('omega_limits', None),
1784
            omega_num=kwargs.pop('omega_num', None),
1785
            warn_encirclements=kwargs.pop('warn_encirclements', True),
1786
            warn_nyquist=kwargs.pop('warn_nyquist', True),
1787
            indent_radius=kwargs.pop('indent_radius', None),
1788
            _check_kwargs=False, **kwargs)
1789
    else:
1790
        nyquist_responses = data
9✔
1791

1792
    # Legacy return value processing
1793
    if plot is not None or return_contour is not None:
9✔
1794
        warnings.warn(
9✔
1795
            "nyquist_plot() return value of count[, contour] is deprecated; "
1796
            "use nyquist_response()", FutureWarning)
1797

1798
        # Extract out the values that we will eventually return
1799
        counts = [response.count for response in nyquist_responses]
9✔
1800
        contours = [response.contour for response in nyquist_responses]
9✔
1801

1802
    if plot is False:
9✔
1803
        # Make sure we used all of the keywrods
1804
        if kwargs:
9✔
1805
            raise TypeError("unrecognized keywords: ", str(kwargs))
×
1806

1807
        if len(data) == 1:
9✔
1808
            counts, contours = counts[0], contours[0]
9✔
1809

1810
        # Return counts and (optionally) the contour we used
1811
        return (counts, contours) if return_contour else counts
9✔
1812

1813
    fig, ax = _process_ax_keyword(
9✔
1814
        ax_user, shape=(1, 1), squeeze=True, rcParams=rcParams)
1815
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
1816
        kwargs, None, 'upper right')
1817

1818
    # Create a list of lines for the output
1819
    out = np.empty(len(nyquist_responses), dtype=object)
9✔
1820
    for i in range(out.shape[0]):
9✔
1821
        out[i] = []             # unique list in each element
9✔
1822

1823
    for idx, response in enumerate(nyquist_responses):
9✔
1824
        resp = response.response
9✔
1825
        if response.dt in [0, None]:
9✔
1826
            splane_contour = response.contour
9✔
1827
        else:
1828
            splane_contour = np.log(response.contour) / response.dt
9✔
1829

1830
        # Find the different portions of the curve (with scaled pts marked)
1831
        reg_mask = np.logical_or(
9✔
1832
            np.abs(resp) > max_curve_magnitude,
1833
            splane_contour.real != 0)
1834
        # reg_mask = np.logical_or(
1835
        #     np.abs(resp.real) > max_curve_magnitude,
1836
        #     np.abs(resp.imag) > max_curve_magnitude)
1837

1838
        scale_mask = ~reg_mask \
9✔
1839
            & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \
1840
            & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1]))
1841

1842
        # Rescale the points with large magnitude
1843
        rescale = np.logical_and(
9✔
1844
            reg_mask, abs(resp) > max_curve_magnitude)
1845
        resp[rescale] *= max_curve_magnitude / abs(resp[rescale])
9✔
1846

1847
        # Get the label to use for the line
1848
        label = response.sysname if line_labels is None else line_labels[idx]
9✔
1849

1850
        # Plot the regular portions of the curve (and grab the color)
1851
        x_reg = np.ma.masked_where(reg_mask, resp.real)
9✔
1852
        y_reg = np.ma.masked_where(reg_mask, resp.imag)
9✔
1853
        p = plt.plot(
9✔
1854
            x_reg, y_reg, primary_style[0], color=color, label=label, **kwargs)
1855
        c = p[0].get_color()
9✔
1856
        out[idx] += p
9✔
1857

1858
        # Figure out how much to offset the curve: the offset goes from
1859
        # zero at the start of the scaled section to max_curve_offset as
1860
        # we move along the curve
1861
        curve_offset = _compute_curve_offset(
9✔
1862
            resp, scale_mask, max_curve_offset)
1863

1864
        # Plot the scaled sections of the curve (changing linestyle)
1865
        x_scl = np.ma.masked_where(scale_mask, resp.real)
9✔
1866
        y_scl = np.ma.masked_where(scale_mask, resp.imag)
9✔
1867
        if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1868
            out[idx] += plt.plot(
9✔
1869
                x_scl * (1 + curve_offset),
1870
                y_scl * (1 + curve_offset),
1871
                primary_style[1], color=c, **kwargs)
1872
        else:
1873
            out[idx] += [None]
9✔
1874

1875
        # Plot the primary curve (invisible) for setting arrows
1876
        x, y = resp.real.copy(), resp.imag.copy()
9✔
1877
        x[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1878
        y[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1879
        p = plt.plot(x, y, linestyle='None', color=c)
9✔
1880

1881
        # Add arrows
1882
        ax = plt.gca()
9✔
1883
        _add_arrows_to_line2D(
9✔
1884
            ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1)
1885

1886
        # Plot the mirror image
1887
        if mirror_style is not False:
9✔
1888
            # Plot the regular and scaled segments
1889
            out[idx] += plt.plot(
9✔
1890
                x_reg, -y_reg, mirror_style[0], color=c, **kwargs)
1891
            if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1892
                out[idx] += plt.plot(
9✔
1893
                    x_scl * (1 - curve_offset),
1894
                    -y_scl * (1 - curve_offset),
1895
                    mirror_style[1], color=c, **kwargs)
1896
            else:
1897
                out[idx] += [None]
9✔
1898

1899
            # Add the arrows (on top of an invisible contour)
1900
            x, y = resp.real.copy(), resp.imag.copy()
9✔
1901
            x[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1902
            y[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1903
            p = plt.plot(x, -y, linestyle='None', color=c, **kwargs)
9✔
1904
            _add_arrows_to_line2D(
9✔
1905
                ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1)
1906
        else:
1907
            out[idx] += [None, None]
9✔
1908

1909
        # Mark the start of the curve
1910
        if start_marker:
9✔
1911
            plt.plot(resp[0].real, resp[0].imag, start_marker,
9✔
1912
                     color=c, markersize=start_marker_size)
1913

1914
        # Mark the -1 point
1915
        plt.plot([-1], [0], 'r+')
9✔
1916

1917
        #
1918
        # Draw circles for gain crossover and sensitivity functions
1919
        #
1920
        theta = np.linspace(0, 2*np.pi, 100)
9✔
1921
        cos = np.cos(theta)
9✔
1922
        sin = np.sin(theta)
9✔
1923
        label_pos = 15
9✔
1924

1925
        # Display the unit circle, to read gain crossover frequency
1926
        if unit_circle:
9✔
1927
            plt.plot(cos, sin, **config.defaults['nyquist.circle_style'])
9✔
1928

1929
        # Draw circles for given magnitudes of sensitivity
1930
        if ms_circles is not None:
9✔
1931
            for ms in ms_circles:
9✔
1932
                pos_x = -1 + (1/ms)*cos
9✔
1933
                pos_y = (1/ms)*sin
9✔
1934
                plt.plot(
9✔
1935
                    pos_x, pos_y, **config.defaults['nyquist.circle_style'])
1936
                plt.text(pos_x[label_pos], pos_y[label_pos], ms)
9✔
1937

1938
        # Draw circles for given magnitudes of complementary sensitivity
1939
        if mt_circles is not None:
9✔
1940
            for mt in mt_circles:
9✔
1941
                if mt != 1:
9✔
1942
                    ct = -mt**2/(mt**2-1)  # Mt center
9✔
1943
                    rt = mt/(mt**2-1)  # Mt radius
9✔
1944
                    pos_x = ct+rt*cos
9✔
1945
                    pos_y = rt*sin
9✔
1946
                    plt.plot(
9✔
1947
                        pos_x, pos_y,
1948
                        **config.defaults['nyquist.circle_style'])
1949
                    plt.text(pos_x[label_pos], pos_y[label_pos], mt)
9✔
1950
                else:
1951
                    _, _, ymin, ymax = plt.axis()
9✔
1952
                    pos_y = np.linspace(ymin, ymax, 100)
9✔
1953
                    plt.vlines(
9✔
1954
                        -0.5, ymin=ymin, ymax=ymax,
1955
                        **config.defaults['nyquist.circle_style'])
1956
                    plt.text(-0.5, pos_y[label_pos], 1)
9✔
1957

1958
        # Label the frequencies of the points on the Nyquist curve
1959
        if label_freq:
9✔
1960
            ind = slice(None, None, label_freq)
9✔
1961
            omega_sys = np.imag(splane_contour[np.real(splane_contour) == 0])
9✔
1962
            for xpt, ypt, omegapt in zip(x[ind], y[ind], omega_sys[ind]):
9✔
1963
                # Convert to Hz
1964
                f = omegapt / (2 * np.pi)
9✔
1965

1966
                # Factor out multiples of 1000 and limit the
1967
                # result to the range [-8, 8].
1968
                pow1000 = max(min(get_pow1000(f), 8), -8)
9✔
1969

1970
                # Get the SI prefix.
1971
                prefix = gen_prefix(pow1000)
9✔
1972

1973
                # Apply the text. (Use a space before the text to
1974
                # prevent overlap with the data.)
1975
                #
1976
                # np.round() is used because 0.99... appears
1977
                # instead of 1.0, and this would otherwise be
1978
                # truncated to 0.
1979
                plt.text(xpt, ypt, ' ' +
9✔
1980
                         str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' +
1981
                         prefix + 'Hz')
1982

1983
    # Label the axes
1984
    ax.set_xlabel("Real axis")
9✔
1985
    ax.set_ylabel("Imaginary axis")
9✔
1986
    ax.grid(color="lightgray")
9✔
1987

1988
    # List of systems that are included in this plot
1989
    lines, labels = _get_line_labels(ax)
9✔
1990

1991
    # Add legend if there is more than one system plotted
1992
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
1993
        with plt.rc_context(rcParams):
9✔
1994
            legend = ax.legend(lines, labels, loc=legend_loc)
9✔
1995
    else:
1996
        legend = None
9✔
1997

1998
    # Add the title
1999
    sysnames = [response.sysname for response in nyquist_responses]
9✔
2000
    if ax_user is None and title is None:
9✔
2001
        title = "Nyquist plot for " + ", ".join(sysnames)
9✔
2002
        _update_plot_title(
9✔
2003
            title, fig=fig, rcParams=rcParams, frame=title_frame)
2004
    elif ax_user is None:
9✔
2005
        _update_plot_title(
9✔
2006
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2007
            use_existing=False)
2008

2009
    # Legacy return pocessing
2010
    if plot is True or return_contour is not None:
9✔
2011
        if len(data) == 1:
9✔
2012
            counts, contours = counts[0], contours[0]
9✔
2013

2014
        # Return counts and (optionally) the contour we used
2015
        return (counts, contours) if return_contour else counts
9✔
2016

2017
    return ControlPlot(out, ax, fig, legend=legend)
9✔
2018

2019

2020
#
2021
# Function to compute Nyquist curve offsets
2022
#
2023
# This function computes a smoothly varying offset that starts and ends at
2024
# zero at the ends of a scaled segment.
2025
#
2026
def _compute_curve_offset(resp, mask, max_offset):
9✔
2027
    # Compute the arc length along the curve
2028
    s_curve = np.cumsum(
9✔
2029
        np.sqrt(np.diff(resp.real) ** 2 + np.diff(resp.imag) ** 2))
2030

2031
    # Initialize the offset
2032
    offset = np.zeros(resp.size)
9✔
2033
    arclen = np.zeros(resp.size)
9✔
2034

2035
    # Walk through the response and keep track of each continous component
2036
    i, nsegs = 0, 0
9✔
2037
    while i < resp.size:
9✔
2038
        # Skip the regular segment
2039
        while i < resp.size and mask[i]:
9✔
2040
            i += 1              # Increment the counter
9✔
2041
            if i == resp.size:
9✔
2042
                break
9✔
2043
            # Keep track of the arclength
2044
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2045

2046
        nsegs += 0.5
9✔
2047
        if i == resp.size:
9✔
2048
            break
9✔
2049

2050
        # Save the starting offset of this segment
2051
        seg_start = i
9✔
2052

2053
        # Walk through the scaled segment
2054
        while i < resp.size and not mask[i]:
9✔
2055
            i += 1
9✔
2056
            if i == resp.size:  # See if we are done with this segment
9✔
2057
                break
9✔
2058
            # Keep track of the arclength
2059
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2060

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

2065
        # Save the ending offset of this segment
2066
        seg_end = i
9✔
2067

2068
        # Now compute the scaling for this segment
2069
        s_segment = arclen[seg_end-1] - arclen[seg_start]
9✔
2070
        offset[seg_start:seg_end] = max_offset * s_segment/s_curve[-1] * \
9✔
2071
            np.sin(np.pi * (arclen[seg_start:seg_end]
2072
                            - arclen[seg_start])/s_segment)
2073

2074
    return offset
9✔
2075

2076

2077
#
2078
# Gang of Four plot
2079
#
2080
def gangof4_response(
9✔
2081
        P, C, omega=None, omega_limits=None, omega_num=None, Hz=False):
2082
    """Compute the response of the "Gang of 4" transfer functions for a system.
2083

2084
    Generates a 2x2 frequency response for the "Gang of 4" sensitivity
2085
    functions [T, PS; CS, S].
2086

2087
    Parameters
2088
    ----------
2089
    P, C : LTI
2090
        Linear input/output systems (process and control).
2091
    omega : array
2092
        Range of frequencies (list or bounds) in rad/sec.
2093
    omega_limits : array_like of two values
2094
        Set limits for plotted frequency range. If Hz=True the limits are
2095
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2096
        elements is equivalent to providing ``omega_limits``. Ignored if
2097
        data is not a list of systems.
2098
    omega_num : int
2099
        Number of samples to use for the frequeny range.  Defaults to
2100
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
2101
        not a list of systems.
2102
    Hz : bool, optional
2103
        If True, when computing frequency limits automatically set
2104
        limits to full decades in Hz instead of rad/s.
2105

2106
    Returns
2107
    -------
2108
    response : :class:`~control.FrequencyResponseData`
2109
        Frequency response with inputs 'r' and 'd' and outputs 'y', and 'u'
2110
        representing the 2x2 matrix of transfer functions in the Gang of 4.
2111

2112
    Examples
2113
    --------
2114
    >>> P = ct.tf([1], [1, 1])
2115
    >>> C = ct.tf([2], [1])
2116
    >>> response = ct.gangof4_response(P, C)
2117
    >>> lines = response.plot()
2118

2119
    """
2120
    if not P.issiso() or not C.issiso():
9✔
2121
        # TODO: Add MIMO go4 plots.
2122
        raise ControlMIMONotImplemented(
×
2123
            "Gang of four is currently only implemented for SISO systems.")
2124

2125
    # Compute the senstivity functions
2126
    L = P * C
9✔
2127
    S = feedback(1, L)
9✔
2128
    T = L * S
9✔
2129

2130
    # Select a default range if none is provided
2131
    # TODO: This needs to be made more intelligent
2132
    omega, _ = _determine_omega_vector(
9✔
2133
        [P, C, S], omega, omega_limits, omega_num, Hz=Hz)
2134

2135
    #
2136
    # bode_plot based implementation
2137
    #
2138

2139
    # Compute the response of the Gang of 4
2140
    resp_T = T(1j * omega)
9✔
2141
    resp_PS = (P * S)(1j * omega)
9✔
2142
    resp_CS = (C * S)(1j * omega)
9✔
2143
    resp_S = S(1j * omega)
9✔
2144

2145
    # Create a single frequency response data object with the underlying data
2146
    data = np.empty((2, 2, omega.size), dtype=complex)
9✔
2147
    data[0, 0, :] = resp_T
9✔
2148
    data[0, 1, :] = resp_PS
9✔
2149
    data[1, 0, :] = resp_CS
9✔
2150
    data[1, 1, :] = resp_S
9✔
2151

2152
    return FrequencyResponseData(
9✔
2153
        data, omega, outputs=['y', 'u'], inputs=['r', 'd'],
2154
        title=f"Gang of Four for P={P.name}, C={C.name}",
2155
        sysname=f"P={P.name}, C={C.name}", plot_phase=False)
2156

2157

2158
def gangof4_plot(
9✔
2159
        *args, omega=None, omega_limits=None, omega_num=None,
2160
        Hz=False, **kwargs):
2161
    """Plot the response of the "Gang of 4" transfer functions for a system.
2162

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

2166
        gangof4_plot(response[, ...])
2167
        gangof4_plot(P, C[, ...])
2168

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

2190
    Returns
2191
    -------
2192
    cplt : :class:`ControlPlot` object
2193
        Object containing the data that were plotted:
2194

2195
          * cplt.lines: 2x2 array of :class:`matplotlib.lines.Line2D`
2196
            objects for each line in the plot.  The value of each array
2197
            entry is a list of Line2D objects in that subplot.
2198

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

2201
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
2202

2203
          * cplt.legend: legend object(s) contained in the plot
2204

2205
        See :class:`ControlPlot` for more detailed information.
2206

2207
    """
2208
    if len(args) == 1 and isinstance(arg, FrequencyResponseData):
9✔
2209
        if any([kw is not None
×
2210
                for kw in [omega, omega_limits, omega_num, Hz]]):
2211
            raise ValueError(
×
2212
                "omega, omega_limits, omega_num, Hz not allowed when "
2213
                "given a Gang of 4 response as first argument")
2214
        return args[0].plot(kwargs)
×
2215
    else:
2216
        if len(args) > 3:
9✔
2217
            raise TypeError(
×
2218
                f"expecting 2 or 3 positional arguments; received {len(args)}")
2219
        omega = omega if len(args) < 3 else args[2]
9✔
2220
        args = args[0:2]
9✔
2221
        return gangof4_response(
9✔
2222
            *args, omega=omega, omega_limits=omega_limits,
2223
            omega_num=omega_num, Hz=Hz).plot(**kwargs)
2224

2225

2226
#
2227
# Singular values plot
2228
#
2229
def singular_values_response(
9✔
2230
        sysdata, omega=None, omega_limits=None, omega_num=None, Hz=False):
2231
    """Singular value response for a system.
2232

2233
    Computes the singular values for a system or list of systems over
2234
    a (optional) frequency range.
2235

2236
    Parameters
2237
    ----------
2238
    sysdata : LTI or list of LTI
2239
        List of linear input/output systems (single system is OK).
2240
    omega : array_like
2241
        List of frequencies in rad/sec to be used for frequency response.
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
    response : FrequencyResponseData
2249
        Frequency response with the number of outputs equal to the
2250
        number of singular values in the response, and a single input.
2251

2252
    Other Parameters
2253
    ----------------
2254
    omega_limits : array_like of two values
2255
        Set limits for plotted frequency range. If Hz=True the limits are
2256
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2257
        elements is equivalent to providing ``omega_limits``.
2258
    omega_num : int, optional
2259
        Number of samples to use for the frequeny range.  Defaults to
2260
        config.defaults['freqplot.number_of_samples'].
2261

2262
    See Also
2263
    --------
2264
    singular_values_plot
2265

2266
    Examples
2267
    --------
2268
    >>> omegas = np.logspace(-4, 1, 1000)
2269
    >>> den = [75, 1]
2270
    >>> G = ct.tf([[[87.8], [-86.4]], [[108.2], [-109.6]]],
2271
    ...           [[den, den], [den, den]])
2272
    >>> response = ct.singular_values_response(G, omega=omegas)
2273

2274
    """
2275
    # Convert the first argument to a list
2276
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
2277

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

2281
    # Compute the frequency responses for the systems
2282
    responses = frequency_response(
9✔
2283
        syslist, omega=omega, omega_limits=omega_limits,
2284
        omega_num=omega_num, Hz=Hz, squeeze=False)
2285

2286
    # Calculate the singular values for each system in the list
2287
    svd_responses = []
9✔
2288
    for response in responses:
9✔
2289
        # Compute the singular values (permute indices to make things work)
2290
        fresp_permuted = response.fresp.transpose((2, 0, 1))
9✔
2291
        sigma = np.linalg.svd(fresp_permuted, compute_uv=False).transpose()
9✔
2292
        sigma_fresp = sigma.reshape(sigma.shape[0], 1, sigma.shape[1])
9✔
2293

2294
        # Save the singular values as an FRD object
2295
        svd_responses.append(
9✔
2296
            FrequencyResponseData(
2297
                sigma_fresp, response.omega, _return_singvals=True,
2298
                outputs=[f'$\\sigma_{{{k+1}}}$' for k in range(sigma.shape[0])],
2299
                inputs='inputs', dt=response.dt, plot_phase=False,
2300
                sysname=response.sysname, plot_type='svplot',
2301
                title=f"Singular values for {response.sysname}"))
2302

2303
    if isinstance(sysdata, (list, tuple)):
9✔
2304
        return FrequencyResponseList(svd_responses)
9✔
2305
    else:
2306
        return svd_responses[0]
9✔
2307

2308

2309
def singular_values_plot(
9✔
2310
        data, omega=None, *fmt, plot=None, omega_limits=None, omega_num=None,
2311
        ax=None, label=None, title=None, **kwargs):
2312
    """Plot the singular values for a system.
2313

2314
    Plot the singular values as a function of frequency for a system or
2315
    list of systems.  If multiple systems are plotted, each system in the
2316
    list is plotted in a different color.
2317

2318
    Parameters
2319
    ----------
2320
    data : list of `FrequencyResponseData`
2321
        List of :class:`FrequencyResponseData` objects.  For backward
2322
        compatibility, a list of LTI systems can also be given.
2323
    omega : array_like
2324
        List of frequencies in rad/sec over to plot over.
2325
    *fmt : :func:`matplotlib.pyplot.plot` format string, optional
2326
        Passed to `matplotlib` as the format string for all lines in the plot.
2327
        The `omega` parameter must be present (use omega=None if needed).
2328
    dB : bool
2329
        If True, plot result in dB.  Default is False.
2330
    Hz : bool
2331
        If True, plot frequency in Hz (omega must be provided in rad/sec).
2332
        Default value (False) set by config.defaults['freqplot.Hz'].
2333
    **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
2334
        Additional keywords passed to `matplotlib` to specify line properties.
2335

2336
    Returns
2337
    -------
2338
    cplt : :class:`ControlPlot` object
2339
        Object containing the data that were plotted:
2340

2341
          * cplt.lines: 1-D array of :class:`matplotlib.lines.Line2D` objects.
2342
            The size of the array matches the number of systems and the
2343
            value of the array is a list of Line2D objects for that system.
2344

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

2347
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
2348

2349
          * cplt.legend: legend object(s) contained in the plot
2350

2351
        See :class:`ControlPlot` for more detailed information.
2352

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

2398
    See Also
2399
    --------
2400
    singular_values_response
2401

2402
    Notes
2403
    -----
2404
    1. If plot==False, the following legacy values are returned:
2405
         * mag : ndarray (or list of ndarray if len(data) > 1))
2406
             Magnitude of the response (deprecated).
2407
         * phase : ndarray (or list of ndarray if len(data) > 1))
2408
             Phase in radians of the response (deprecated).
2409
         * omega : ndarray (or list of ndarray if len(data) > 1))
2410
             Frequency in rad/sec (deprecated).
2411

2412
    """
2413
    # Keyword processing
2414
    color = kwargs.pop('color', None)
9✔
2415
    dB = config._get_param(
9✔
2416
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
2417
    Hz = config._get_param(
9✔
2418
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
2419
    grid = config._get_param(
9✔
2420
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
2421
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
2422
    title_frame = config._get_param(
9✔
2423
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
2424

2425
    # If argument was a singleton, turn it into a tuple
2426
    data = data if isinstance(data, (list, tuple)) else (data,)
9✔
2427

2428
    # Convert systems into frequency responses
2429
    if any([isinstance(response, (StateSpace, TransferFunction))
9✔
2430
            for response in data]):
2431
        responses = singular_values_response(
9✔
2432
                    data, omega=omega, omega_limits=omega_limits,
2433
                    omega_num=omega_num)
2434
    else:
2435
        # Generate warnings if frequency keywords were given
2436
        if omega_num is not None:
9✔
2437
            warnings.warn("`omega_num` ignored when passed response data")
9✔
2438
        elif omega is not None:
9✔
2439
            warnings.warn("`omega` ignored when passed response data")
9✔
2440

2441
        # Check to make sure omega_limits is sensible
2442
        if omega_limits is not None and \
9✔
2443
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
2444
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
2445

2446
        responses = data
9✔
2447

2448
    # Process label keyword
2449
    line_labels = _process_line_labels(label, len(data))
9✔
2450

2451
    # Process (legacy) plot keyword
2452
    if plot is not None:
9✔
2453
        warnings.warn(
×
2454
            "`singular_values_plot` return values of sigma, omega is "
2455
            "deprecated; use singular_values_response()", FutureWarning)
2456

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

2462
    # Extract the data we need for plotting
2463
    sigmas = [np.real(response.fresp[:, 0, :]) for response in responses]
9✔
2464
    omegas = [response.omega for response in responses]
9✔
2465

2466
    # Legacy processing for no plotting case
2467
    if plot is False:
9✔
2468
        if len(data) == 1:
×
2469
            return sigmas[0], omegas[0]
×
2470
        else:
2471
            return sigmas, omegas
×
2472

2473
    fig, ax_sigma = _process_ax_keyword(
9✔
2474
        ax, shape=(1, 1), squeeze=True, rcParams=rcParams)
2475
    ax_sigma.set_label('control-sigma')         # TODO: deprecate?
9✔
2476
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
2477
        kwargs, None, 'center right')
2478

2479
    # Get color offset for first (new) line to be drawn
2480
    color_offset, color_cycle = _get_color_offset(ax_sigma)
9✔
2481

2482
    # Create a list of lines for the output
2483
    out = np.empty(len(data), dtype=object)
9✔
2484

2485
    # Plot the singular values for each response
2486
    for idx_sys, response in enumerate(responses):
9✔
2487
        sigma = sigmas[idx_sys].transpose()     # frequency first for plotting
9✔
2488
        omega = omegas[idx_sys] / (2 * math.pi) if Hz else  omegas[idx_sys]
9✔
2489

2490
        if response.isdtime(strict=True):
9✔
2491
            nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
2492
        else:
2493
            nyq_freq = None
9✔
2494

2495
        # Determine the color to use for this response
2496
        color = _get_color(
9✔
2497
            color, fmt=fmt, offset=color_offset + idx_sys,
2498
            color_cycle=color_cycle)
2499

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

2503
        # Decide on the system name
2504
        sysname = response.sysname if response.sysname is not None \
9✔
2505
            else f"Unknown-{idx_sys}"
2506

2507
        # Get the label to use for the line
2508
        label = sysname if line_labels is None else line_labels[idx_sys]
9✔
2509

2510
        # Plot the data
2511
        if dB:
9✔
2512
            out[idx_sys] = ax_sigma.semilogx(
9✔
2513
                omega, 20 * np.log10(sigma), *fmt,
2514
                label=label, **color_arg, **kwargs)
2515
        else:
2516
            out[idx_sys] = ax_sigma.loglog(
9✔
2517
                omega, sigma, label=label, *fmt, **color_arg, **kwargs)
2518

2519
        # Plot the Nyquist frequency
2520
        if nyq_freq is not None:
9✔
2521
            ax_sigma.axvline(
9✔
2522
                nyq_freq, linestyle='--', label='_nyq_freq_' + sysname,
2523
                **color_arg)
2524

2525
    # If specific omega_limits were given, use them
2526
    if omega_limits is not None:
9✔
2527
        ax_sigma.set_xlim(omega_limits)
9✔
2528

2529
    # Add a grid to the plot + labeling
2530
    if grid:
9✔
2531
        ax_sigma.grid(grid, which='both')
9✔
2532

2533
    ax_sigma.set_ylabel(
9✔
2534
        "Singular Values [dB]" if dB else "Singular Values")
2535
    ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]")
9✔
2536

2537
    # List of systems that are included in this plot
2538
    lines, labels = _get_line_labels(ax_sigma)
9✔
2539

2540
    # Add legend if there is more than one system plotted
2541
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2542
        with plt.rc_context(rcParams):
9✔
2543
            legend = ax_sigma.legend(lines, labels, loc=legend_loc)
9✔
2544
    else:
2545
        legend = None
9✔
2546

2547
    # Add the title
2548
    if ax is None:
9✔
2549
        if title is None:
9✔
2550
            title = "Singular values for " + ", ".join(labels)
9✔
2551
        _update_plot_title(
9✔
2552
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2553
            use_existing=False)
2554

2555
    # Legacy return processing
2556
    if plot is not None:
9✔
2557
        if len(responses) == 1:
×
2558
            return sigmas[0], omegas[0]
×
2559
        else:
2560
            return sigmas, omegas
×
2561

2562
    return ControlPlot(out, ax_sigma, fig, legend=legend)
9✔
2563

2564
#
2565
# Utility functions
2566
#
2567
# This section of the code contains some utility functions for
2568
# generating frequency domain plots.
2569
#
2570

2571

2572
# Determine the frequency range to be used
2573
def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
9✔
2574
                            Hz=None, feature_periphery_decades=None):
2575
    """Determine the frequency range for a frequency-domain plot
2576
    according to a standard logic.
2577

2578
    If omega_in and omega_limits are both None, then omega_out is computed
2579
    on omega_num points according to a default logic defined by
2580
    _default_frequency_range and tailored for the list of systems syslist, and
2581
    omega_range_given is set to False.
2582

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

2588
    If omega_in is a list or tuple of length 2, it is interpreted as a
2589
    range and handled like omega_limits.  If omega_in is a list or tuple of
2590
    length 3, it is interpreted a range plus number of points and handled
2591
    like omega_limits and omega_num.
2592

2593
    If omega_in is an array or a list/tuple of length greater than
2594
    two, then omega_out is set to omega_in (as an array), and
2595
    omega_range_given is set to True
2596

2597
    Parameters
2598
    ----------
2599
    syslist : list of LTI
2600
        List of linear input/output systems (single system is OK).
2601
    omega_in : 1D array_like or None
2602
        Frequency range specified by the user.
2603
    omega_limits : 1D array_like or None
2604
        Frequency limits specified by the user.
2605
    omega_num : int
2606
        Number of points to be used for the frequency range (if the
2607
        frequency range is not user-specified).
2608
    Hz : bool, optional
2609
        If True, the limits (first and last value) of the frequencies
2610
        are set to full decades in Hz so it fits plotting with logarithmic
2611
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2612

2613
    Returns
2614
    -------
2615
    omega_out : 1D array
2616
        Frequency range to be used.
2617
    omega_range_given : bool
2618
        True if the frequency range was specified by the user, either through
2619
        omega_in or through omega_limits. False if both omega_in
2620
        and omega_limits are None.
2621

2622
    """
2623
    # Handle the special case of a range of frequencies
2624
    if omega_in is not None and omega_limits is not None:
9✔
2625
        warnings.warn(
×
2626
            "omega and omega_limits both specified; ignoring limits")
2627
    elif isinstance(omega_in, (list, tuple)) and len(omega_in) == 2:
9✔
2628
        omega_limits = omega_in
9✔
2629
        omega_in = None
9✔
2630

2631
    omega_range_given = True
9✔
2632
    if omega_in is None:
9✔
2633
        if omega_limits is None:
9✔
2634
            omega_range_given = False
9✔
2635
            # Select a default range if none is provided
2636
            omega_out = _default_frequency_range(
9✔
2637
                syslist, number_of_samples=omega_num, Hz=Hz,
2638
                feature_periphery_decades=feature_periphery_decades)
2639
        else:
2640
            omega_limits = np.asarray(omega_limits)
9✔
2641
            if len(omega_limits) != 2:
9✔
2642
                raise ValueError("len(omega_limits) must be 2")
×
2643
            omega_out = np.logspace(np.log10(omega_limits[0]),
9✔
2644
                                    np.log10(omega_limits[1]),
2645
                                    num=omega_num, endpoint=True)
2646
    else:
2647
        omega_out = np.copy(omega_in)
9✔
2648

2649
    return omega_out, omega_range_given
9✔
2650

2651

2652
# Compute reasonable defaults for axes
2653
def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
9✔
2654
                             feature_periphery_decades=None):
2655
    """Compute a default frequency range for frequency domain plots.
2656

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

2662
    Parameters
2663
    ----------
2664
    syslist : list of LTI
2665
        List of linear input/output systems (single system is OK)
2666
    Hz : bool, optional
2667
        If True, the limits (first and last value) of the frequencies
2668
        are set to full decades in Hz so it fits plotting with logarithmic
2669
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2670
    number_of_samples : int, optional
2671
        Number of samples to generate.  The default value is read from
2672
        ``config.defaults['freqplot.number_of_samples'].  If None, then the
2673
        default from `numpy.logspace` is used.
2674
    feature_periphery_decades : float, optional
2675
        Defines how many decades shall be included in the frequency range on
2676
        both sides of features (poles, zeros).  The default value is read from
2677
        ``config.defaults['freqplot.feature_periphery_decades']``.
2678

2679
    Returns
2680
    -------
2681
    omega : array
2682
        Range of frequencies in rad/sec
2683

2684
    Examples
2685
    --------
2686
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
2687
    >>> omega = ct._default_frequency_range(G)
2688
    >>> omega.min(), omega.max()
2689
    (0.1, 100.0)
2690

2691
    """
2692
    # Set default values for options
2693
    number_of_samples = config._get_param(
9✔
2694
        'freqplot', 'number_of_samples', number_of_samples)
2695
    feature_periphery_decades = config._get_param(
9✔
2696
        'freqplot', 'feature_periphery_decades', feature_periphery_decades, 1)
2697

2698
    # Find the list of all poles and zeros in the systems
2699
    features = np.array(())
9✔
2700
    freq_interesting = []
9✔
2701

2702
    # detect if single sys passed by checking if it is sequence-like
2703
    if not hasattr(syslist, '__iter__'):
9✔
2704
        syslist = (syslist,)
9✔
2705

2706
    for sys in syslist:
9✔
2707
        # For FRD systems, just use the response frequencies
2708
        if isinstance(sys, FrequencyResponseData):
9✔
2709
            # Add the min and max frequency, minus periphery decades
2710
            # (keeps frequency ranges from artificially expanding)
2711
            features = np.concatenate([features, np.array([
9✔
2712
                np.min(sys.omega) * 10**feature_periphery_decades,
2713
                np.max(sys.omega) / 10**feature_periphery_decades])])
2714
            continue
9✔
2715

2716
        try:
9✔
2717
            # Add new features to the list
2718
            if sys.isctime():
9✔
2719
                features_ = np.concatenate(
9✔
2720
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2721
                # Get rid of poles and zeros at the origin
2722
                toreplace = np.isclose(features_, 0.0)
9✔
2723
                if np.any(toreplace):
9✔
2724
                    features_ = features_[~toreplace]
9✔
2725
            elif sys.isdtime(strict=True):
9✔
2726
                fn = math.pi / sys.dt
9✔
2727
                # TODO: What distance to the Nyquist frequency is appropriate?
2728
                freq_interesting.append(fn * 0.9)
9✔
2729

2730
                features_ = np.concatenate(
9✔
2731
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2732
                # Get rid of poles and zeros on the real axis (imag==0)
2733
                # * origin and real < 0
2734
                # * at 1.: would result in omega=0. (logaritmic plot!)
2735
                toreplace = np.isclose(features_.imag, 0.0) & (
9✔
2736
                                    (features_.real <= 0.) |
2737
                                    (np.abs(features_.real - 1.0) < 1.e-10))
2738
                if np.any(toreplace):
9✔
2739
                    features_ = features_[~toreplace]
9✔
2740
                # TODO: improve (mapping pack to continuous time)
2741
                features_ = np.abs(np.log(features_) / (1.j * sys.dt))
9✔
2742
            else:
2743
                # TODO
2744
                raise NotImplementedError(
2745
                    "type of system in not implemented now")
2746
            features = np.concatenate([features, features_])
9✔
2747
        except NotImplementedError:
9✔
2748
            # Don't add any features for anything we don't understand
2749
            pass
9✔
2750

2751
    # Make sure there is at least one point in the range
2752
    if features.shape[0] == 0:
9✔
2753
        features = np.array([1.])
9✔
2754

2755
    if Hz:
9✔
2756
        features /= 2. * math.pi
9✔
2757
    features = np.log10(features)
9✔
2758
    lsp_min = np.rint(np.min(features) - feature_periphery_decades)
9✔
2759
    lsp_max = np.rint(np.max(features) + feature_periphery_decades)
9✔
2760
    if Hz:
9✔
2761
        lsp_min += np.log10(2. * math.pi)
9✔
2762
        lsp_max += np.log10(2. * math.pi)
9✔
2763

2764
    if freq_interesting:
9✔
2765
        lsp_min = min(lsp_min, np.log10(min(freq_interesting)))
9✔
2766
        lsp_max = max(lsp_max, np.log10(max(freq_interesting)))
9✔
2767

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

2771
    # Set the range to be an order of magnitude beyond any features
2772
    if number_of_samples:
9✔
2773
        omega = np.logspace(
9✔
2774
            lsp_min, lsp_max, num=number_of_samples, endpoint=True)
2775
    else:
2776
        omega = np.logspace(lsp_min, lsp_max, endpoint=True)
×
2777
    return omega
9✔
2778

2779

2780
#
2781
# Utility functions to create nice looking labels (KLD 5/23/11)
2782
#
2783

2784
def get_pow1000(num):
9✔
2785
    """Determine exponent for which significand of a number is within the
2786
    range [1, 1000).
2787
    """
2788
    # Based on algorithm from http://www.mail-archive.com/
2789
    # matplotlib-users@lists.sourceforge.net/msg14433.html, accessed 2010/11/7
2790
    # by Jason Heeris 2009/11/18
2791
    from decimal import Decimal
9✔
2792
    from math import floor
9✔
2793
    dnum = Decimal(str(num))
9✔
2794
    if dnum == 0:
9✔
2795
        return 0
9✔
2796
    elif dnum < 0:
9✔
2797
        dnum = -dnum
×
2798
    return int(floor(dnum.log10() / 3))
9✔
2799

2800

2801
def gen_prefix(pow1000):
9✔
2802
    """Return the SI prefix for a power of 1000.
2803
    """
2804
    # Prefixes according to Table 5 of [BIPM 2006] (excluding hecto,
2805
    # deca, deci, and centi).
2806
    if pow1000 < -8 or pow1000 > 8:
9✔
2807
        raise ValueError(
×
2808
            "Value is out of the range covered by the SI prefixes.")
2809
    return ['Y',  # yotta (10^24)
9✔
2810
            'Z',  # zetta (10^21)
2811
            'E',  # exa (10^18)
2812
            'P',  # peta (10^15)
2813
            'T',  # tera (10^12)
2814
            'G',  # giga (10^9)
2815
            'M',  # mega (10^6)
2816
            'k',  # kilo (10^3)
2817
            '',  # (10^0)
2818
            'm',  # milli (10^-3)
2819
            r'$\mu$',  # micro (10^-6)
2820
            'n',  # nano (10^-9)
2821
            'p',  # pico (10^-12)
2822
            'f',  # femto (10^-15)
2823
            'a',  # atto (10^-18)
2824
            'z',  # zepto (10^-21)
2825
            'y'][8 - pow1000]  # yocto (10^-24)
2826

2827

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

© 2026 Coveralls, Inc