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

python-control / python-control / 10312443515

09 Aug 2024 02:07AM UTC coverage: 94.694% (+0.04%) from 94.65%
10312443515

push

github

web-flow
Merge pull request #1034 from murrayrm/ctrlplot_updates-27Jun2024

Control plot refactoring for consistent functionality

9137 of 9649 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
    grid : bool, optional
151
        If True, plot grid lines on gain and phase plots.  Default is set by
152
        `config.defaults['freqplot.grid']`.
153
    initial_phase : float, optional
154
        Set the reference phase to use for the lowest frequency.  If set, the
155
        initial phase of the Bode plot will be set to the value closest to the
156
        value specified.  Units are in either degrees or radians, depending on
157
        the `deg` parameter. Default is -180 if wrap_phase is False, 0 if
158
        wrap_phase is True.
159
    label : str or array_like of str, optional
160
        If present, replace automatically generated label(s) with the given
161
        label(s).  If sysdata is a list, strings should be specified for each
162
        system.  If MIMO, strings required for each system, output, and input.
163
    legend_map : array of str, optional
164
        Location of the legend for multi-axes plots.  Specifies an array
165
        of legend location strings matching the shape of the subplots, with
166
        each entry being either None (for no legend) or a legend location
167
        string (see :func:`~matplotlib.pyplot.legend`).
168
    legend_loc : int or str, optional
169
        Include a legend in the given location. Default is 'center right',
170
        with no legend for a single response.  Use False to suppress legend.
171
    margins_method : str, optional
172
        Method to use in computing margins (see :func:`stability_margins`).
173
    omega_limits : array_like of two values
174
        Set limits for plotted frequency range. If Hz=True the limits are
175
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
176
        elements is equivalent to providing ``omega_limits``. Ignored if
177
        data is not a list of systems.
178
    omega_num : int
179
        Number of samples to use for the frequeny range.  Defaults to
180
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
181
        not a list of systems.
182
    plot : bool, optional
183
        (legacy) If given, `bode_plot` returns the legacy return values
184
        of magnitude, phase, and frequency.  If False, just return the
185
        values with no plot.
186
    rcParams : dict
187
        Override the default parameters used for generating plots.
188
        Default is set by config.default['ctrlplot.rcParams'].
189
    show_legend : bool, optional
190
        Force legend to be shown if ``True`` or hidden if ``False``.  If
191
        ``None``, then show legend when there is more than one line on an
192
        axis or ``legend_loc`` or ``legend_map`` has been specified.
193
    title : str, optional
194
        Set the title of the plot.  Defaults to plot type and system name(s).
195
    wrap_phase : bool or float
196
        If wrap_phase is `False` (default), then the phase will be unwrapped
197
        so that it is continuously increasing or decreasing.  If wrap_phase is
198
        `True` the phase will be restricted to the range [-180, 180) (or
199
        [:math:`-\\pi`, :math:`\\pi`) radians). If `wrap_phase` is specified
200
        as a float, the phase will be offset by 360 degrees if it falls below
201
        the specified value. Default value is `False` and can be set using
202
        config.defaults['freqplot.wrap_phase'].
203

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

207
    See Also
208
    --------
209
    frequency_response
210

211
    Notes
212
    -----
213
    1. Starting with python-control version 0.10, `bode_plot`returns an
214
       array of lines instead of magnitude, phase, and frequency. To
215
       recover the old behavior, call `bode_plot` with `plot=True`, which
216
       will force the legacy values (mag, phase, omega) to be returned
217
       (with a warning).  To obtain just the frequency response of a system
218
       (or list of systems) without plotting, use the
219
       :func:`~control.frequency_response` command.
220

221
    2. If a discrete time model is given, the frequency response is plotted
222
       along the upper branch of the unit circle, using the mapping ``z =
223
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
224
       is the discrete timebase.  If timebase not specified (``dt=True``),
225
       `dt` is set to 1.
226

227
    Examples
228
    --------
229
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
230
    >>> out = ct.bode_plot(G)
231

232
    """
233
    #
234
    # Process keywords and set defaults
235
    #
236

237
    # Make a copy of the kwargs dictionary since we will modify it
238
    kwargs = dict(kwargs)
9✔
239

240
    # Get values for params (and pop from list to allow keyword use in plot)
241
    dB = config._get_param(
9✔
242
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
243
    deg = config._get_param(
9✔
244
        'freqplot', 'deg', kwargs, _freqplot_defaults, pop=True)
245
    Hz = config._get_param(
9✔
246
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
247
    grid = config._get_param(
9✔
248
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
249
    wrap_phase = config._get_param(
9✔
250
        'freqplot', 'wrap_phase', kwargs, _freqplot_defaults, pop=True)
251
    initial_phase = config._get_param(
9✔
252
        'freqplot', 'initial_phase', kwargs, None, pop=True)
253
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
254
    title_frame = config._get_param(
9✔
255
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
256

257
    # Set the default labels
258
    freq_label = config._get_param(
9✔
259
        'freqplot', 'freq_label', kwargs, _freqplot_defaults, pop=True)
260
    if magnitude_label is None:
9✔
261
        magnitude_label = "Magnitude [dB]" if dB else "Magnitude"
9✔
262
    if phase_label is None:
9✔
263
        phase_label = "Phase [deg]" if deg else "Phase [rad]"
9✔
264

265
    # Use sharex and sharey as proxies for share_{magnitude, phase, frequency}
266
    if sharey is not None:
9✔
267
        if 'share_magnitude' in kwargs or 'share_phase' in kwargs:
9✔
268
            ValueError(
×
269
                "sharey cannot be present with share_magnitude/share_phase")
270
        kwargs['share_magnitude'] = sharey
9✔
271
        kwargs['share_phase'] = sharey
9✔
272
    if sharex is not None:
9✔
273
        if 'share_frequency' in kwargs:
9✔
274
            ValueError(
×
275
                "sharex cannot be present with share_frequency")
276
        kwargs['share_frequency'] = sharex
9✔
277

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

291
    if not isinstance(data, (list, tuple)):
9✔
292
        data = [data]
9✔
293

294
    #
295
    # Pre-process the data to be plotted (unwrap phase, limit frequencies)
296
    #
297
    # To maintain compatibility with legacy uses of bode_plot(), we do some
298
    # initial processing on the data, specifically phase unwrapping and
299
    # setting the initial value of the phase.  If bode_plot is called with
300
    # plot == False, then these values are returned to the user (instead of
301
    # the list of lines created, which is the new output for _plot functions.
302
    #
303

304
    # If we were passed a list of systems, convert to data
305
    if any([isinstance(
9✔
306
            sys, (StateSpace, TransferFunction)) for sys in data]):
307
        data = frequency_response(
9✔
308
            data, omega=omega, omega_limits=omega_limits,
309
            omega_num=omega_num, Hz=Hz)
310
    else:
311
        # Generate warnings if frequency keywords were given
312
        if omega_num is not None:
9✔
313
            warnings.warn("`omega_num` ignored when passed response data")
9✔
314
        elif omega is not None:
9✔
315
            warnings.warn("`omega` ignored when passed response data")
9✔
316

317
        # Check to make sure omega_limits is sensible
318
        if omega_limits is not None and \
9✔
319
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
320
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
321

322
    # If plot_phase is not specified, check the data first, otherwise true
323
    if plot_phase is None:
9✔
324
        plot_phase = True if data[0].plot_phase is None else data[0].plot_phase
9✔
325

326
    if not plot_magnitude and not plot_phase:
9✔
327
        raise ValueError(
9✔
328
            "plot_magnitude and plot_phase both False; no data to plot")
329

330
    mag_data, phase_data, omega_data = [], [], []
9✔
331
    for response in data:
9✔
332
        noutputs, ninputs = response.noutputs, response.ninputs
9✔
333

334
        if initial_phase is None:
9✔
335
            # Start phase in the range 0 to -360 w/ initial phase = 0
336
            # TODO: change this to 0 to 270 (?)
337
            # If wrap_phase is true, use 0 instead (phase \in (-pi, pi])
338
            initial_phase_value = -math.pi if wrap_phase is not True else 0
9✔
339
        elif isinstance(initial_phase, (int, float)):
9✔
340
            # Allow the user to override the default calculation
341
            if deg:
9✔
342
                initial_phase_value = initial_phase/180. * math.pi
9✔
343
            else:
344
                initial_phase_value = initial_phase
9✔
345
        else:
346
            raise ValueError("initial_phase must be a number.")
×
347

348
        # Reshape the phase to allow standard indexing
349
        phase = response.phase.copy().reshape((noutputs, ninputs, -1))
9✔
350

351
        # Shift and wrap the phase
352
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
353
            # Shift the phase if needed
354
            if abs(phase[i, j, 0] - initial_phase_value) > math.pi:
9✔
355
                phase[i, j] -= 2*math.pi * round(
9✔
356
                    (phase[i, j, 0] - initial_phase_value) / (2*math.pi))
357

358
            # Phase wrapping
359
            if wrap_phase is False:
9✔
360
                phase[i, j] = unwrap(phase[i, j]) # unwrap the phase
9✔
361
            elif wrap_phase is True:
9✔
362
                pass                                    # default calc OK
9✔
363
            elif isinstance(wrap_phase, (int, float)):
9✔
364
                phase[i, j] = unwrap(phase[i, j]) # unwrap phase first
9✔
365
                if deg:
9✔
366
                    wrap_phase *= math.pi/180.
9✔
367

368
                # Shift the phase if it is below the wrap_phase
369
                phase[i, j] += 2*math.pi * np.maximum(
9✔
370
                    0, np.ceil((wrap_phase - phase[i, j])/(2*math.pi)))
371
            else:
372
                raise ValueError("wrap_phase must be bool or float.")
×
373

374
        # Put the phase back into the original shape
375
        phase = phase.reshape(response.magnitude.shape)
9✔
376

377
        # Save the data for later use (legacy return values)
378
        mag_data.append(response.magnitude)
9✔
379
        phase_data.append(phase)
9✔
380
        omega_data.append(response.omega)
9✔
381

382
    #
383
    # Process `plot` keyword
384
    #
385
    # We use the `plot` keyword to track legacy usage of `bode_plot`.
386
    # Prior to v0.10, the `bode_plot` command returned mag, phase, and
387
    # omega.  Post v0.10, we return an array with the same shape as the
388
    # axes we use for plotting, with each array element containing a list
389
    # of lines drawn on that axes.
390
    #
391
    # There are three possibilities at this stage in the code:
392
    #
393
    # * plot == True: set explicitly by the user. Return mag, phase, omega,
394
    #   with a warning.
395
    #
396
    # * plot == False: set explicitly by the user. Return mag, phase,
397
    #   omega, with a warning.
398
    #
399
    # * plot == None: this is the new default setting.  Return an array of
400
    #   lines that were drawn.
401
    #
402
    # If `bode_plot` was called with no `plot` argument and the return
403
    # values were used, the new code will cause problems (you get an array
404
    # of lines instead of magnitude, phase, and frequency).  To recover the
405
    # old behavior, call `bode_plot` with `plot=True`.
406
    #
407
    # All of this should be removed in v0.11+ when we get rid of deprecated
408
    # code.
409
    #
410

411
    if plot is not None:
9✔
412
        warnings.warn(
9✔
413
            "`bode_plot` return values of mag, phase, omega is deprecated; "
414
            "use frequency_response()", DeprecationWarning)
415

416
    if plot is False:
9✔
417
        # Process the data to match what we were sent
418
        for i in range(len(mag_data)):
9✔
419
            mag_data[i] = _process_frequency_response(
9✔
420
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
421
            phase_data[i] = _process_frequency_response(
9✔
422
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
423

424
        if len(data) == 1:
9✔
425
            return mag_data[0], phase_data[0], omega_data[0]
9✔
426
        else:
427
            return mag_data, phase_data, omega_data
9✔
428
    #
429
    # Find/create axes
430
    #
431
    # Data are plotted in a standard subplots array, whose size depends on
432
    # which signals are being plotted and how they are combined.  The
433
    # baseline layout for data is to plot everything separately, with
434
    # the magnitude and phase for each output making up the rows and the
435
    # columns corresponding to the different inputs.
436
    #
437
    #      Input 0                 Input m
438
    # +---------------+       +---------------+
439
    # |  mag H_y0,u0  |  ...  |  mag H_y0,um  |
440
    # +---------------+       +---------------+
441
    # +---------------+       +---------------+
442
    # | phase H_y0,u0 |  ...  | phase H_y0,um |
443
    # +---------------+       +---------------+
444
    #         :                       :
445
    # +---------------+       +---------------+
446
    # |  mag H_yp,u0  |  ...  |  mag H_yp,um  |
447
    # +---------------+       +---------------+
448
    # +---------------+       +---------------+
449
    # | phase H_yp,u0 |  ...  | phase H_yp,um |
450
    # +---------------+       +---------------+
451
    #
452
    # Several operations are available that change this layout.
453
    #
454
    # * Omitting: either the magnitude or the phase plots can be omitted
455
    #   using the plot_magnitude and plot_phase keywords.
456
    #
457
    # * Overlay: inputs and/or outputs can be combined onto a single set of
458
    #   axes using the overlay_inputs and overlay_outputs keywords.  This
459
    #   basically collapses data along either the rows or columns, and a
460
    #   legend is generated.
461
    #
462

463
    # Decide on the maximum number of inputs and outputs
464
    ninputs, noutputs = 0, 0
9✔
465
    for response in data:       # TODO: make more pythonic/numpic
9✔
466
        ninputs = max(ninputs, response.ninputs)
9✔
467
        noutputs = max(noutputs, response.noutputs)
9✔
468

469
    # Figure how how many rows and columns to use + offsets for inputs/outputs
470
    if overlay_outputs and overlay_inputs:
9✔
471
        nrows = plot_magnitude + plot_phase
9✔
472
        ncols = 1
9✔
473
    elif overlay_outputs:
9✔
474
        nrows = plot_magnitude + plot_phase
9✔
475
        ncols = ninputs
9✔
476
    elif overlay_inputs:
9✔
477
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
478
            (noutputs if plot_phase else 0)
479
        ncols = 1
9✔
480
    else:
481
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
482
            (noutputs if plot_phase else 0)
483
        ncols = ninputs
9✔
484

485
    if ax is None:
9✔
486
        # Set up default sharing of axis limits if not specified
487
        for kw in ['share_magnitude', 'share_phase', 'share_frequency']:
9✔
488
            if kw not in kwargs or kwargs[kw] is None:
9✔
489
                kwargs[kw] = config.defaults['freqplot.' + kw]
9✔
490

491
    fig, ax_array = _process_ax_keyword(
9✔
492
        ax, (nrows, ncols), squeeze=False, rcParams=rcParams, clear_text=True)
493
    legend_loc, legend_map, show_legend = _process_legend_keywords(
9✔
494
        kwargs, (nrows,ncols), 'center right')
495

496
    # Get the values for sharing axes limits
497
    share_magnitude = kwargs.pop('share_magnitude', None)
9✔
498
    share_phase = kwargs.pop('share_phase', None)
9✔
499
    share_frequency = kwargs.pop('share_frequency', None)
9✔
500

501
    # Set up axes variables for easier access below
502
    if plot_magnitude and not plot_phase:
9✔
503
        mag_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
504
        for i in range(noutputs):
9✔
505
            for j in range(ninputs):
9✔
506
                if overlay_outputs and overlay_inputs:
9✔
507
                    mag_map[i, j] = (0, 0)
9✔
508
                elif overlay_outputs:
9✔
509
                    mag_map[i, j] = (0, j)
9✔
510
                elif overlay_inputs:
9✔
511
                    mag_map[i, j] = (i, 0)
×
512
                else:
513
                    mag_map[i, j] = (i, j)
9✔
514
        phase_map = np.full((noutputs, ninputs), None)
9✔
515
        share_phase = False
9✔
516

517
    elif plot_phase and not plot_magnitude:
9✔
518
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
519
        for i in range(noutputs):
9✔
520
            for j in range(ninputs):
9✔
521
                if overlay_outputs and overlay_inputs:
9✔
522
                    phase_map[i, j] = (0, 0)
×
523
                elif overlay_outputs:
9✔
524
                    phase_map[i, j] = (0, j)
×
525
                elif overlay_inputs:
9✔
526
                    phase_map[i, j] = (i, 0)
9✔
527
                else:
528
                    phase_map[i, j] = (i, j)
9✔
529
        mag_map = np.full((noutputs, ninputs), None)
9✔
530
        share_magnitude = False
9✔
531

532
    else:
533
        mag_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
534
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
535
        for i in range(noutputs):
9✔
536
            for j in range(ninputs):
9✔
537
                if overlay_outputs and overlay_inputs:
9✔
538
                    mag_map[i, j] = (0, 0)
×
539
                    phase_map[i, j] = (1, 0)
×
540
                elif overlay_outputs:
9✔
541
                    mag_map[i, j] = (0, j)
×
542
                    phase_map[i, j] = (1, j)
×
543
                elif overlay_inputs:
9✔
544
                    mag_map[i, j] = (i*2, 0)
×
545
                    phase_map[i, j] = (i*2 + 1, 0)
×
546
                else:
547
                    mag_map[i, j] = (i*2, j)
9✔
548
                    phase_map[i, j] = (i*2 + 1, j)
9✔
549

550
    # Identity map needed for setting up shared axes
551
    ax_map = np.empty((nrows, ncols), dtype=tuple)
9✔
552
    for i, j in itertools.product(range(nrows), range(ncols)):
9✔
553
        ax_map[i, j] = (i, j)
9✔
554

555
    #
556
    # Set up axes limit sharing
557
    #
558
    # This code uses the share_magnitude, share_phase, and share_frequency
559
    # keywords to decide which axes have shared limits and what ticklabels
560
    # to include.  The sharing code needs to come before the plots are
561
    # generated, but additional code for removing tick labels needs to come
562
    # *during* and *after* the plots are generated (see below).
563
    #
564
    # Note: if the various share_* keywords are None then a previous set of
565
    # axes are available and no updates should be made.
566
    #
567

568
    # Utility function to turn off sharing
569
    def _share_axes(ref, share_map, axis):
9✔
570
        ref_ax = ax_array[ref]
9✔
571
        for index in np.nditer(share_map, flags=["refs_ok"]):
9✔
572
            if index.item() == ref:
9✔
573
                continue
9✔
574
            if axis == 'x':
9✔
575
                ax_array[index.item()].sharex(ref_ax)
9✔
576
            elif axis == 'y':
9✔
577
                ax_array[index.item()].sharey(ref_ax)
9✔
578
            else:
579
                raise ValueError("axis must be 'x' or 'y'")
×
580

581
    # Process magnitude, phase, and frequency axes
582
    for name, value, map, axis in zip(
9✔
583
            ['share_magnitude', 'share_phase', 'share_frequency'],
584
            [ share_magnitude,   share_phase,   share_frequency],
585
            [ mag_map,           phase_map,     ax_map],
586
            [ 'y',               'y',           'x']):
587
        if value in [True, 'all']:
9✔
588
            _share_axes(map[0 if axis == 'y' else -1, 0], map, axis)
9✔
589
        elif axis == 'y' and value in ['row']:
9✔
590
            for i in range(noutputs if not overlay_outputs else 1):
9✔
591
                _share_axes(map[i, 0], map[i], 'y')
9✔
592
        elif axis == 'x' and value in ['col']:
9✔
593
            for j in range(ncols):
9✔
594
                _share_axes(map[-1, j], map[:, j], 'x')
9✔
595
        elif value in [False, 'none']:
9✔
596
            # TODO: turn off any sharing that is on
597
            pass
9✔
598
        elif value is not None:
9✔
599
            raise ValueError(
×
600
                f"unknown value for `{name}`: '{value}'")
601

602
    #
603
    # Plot the data
604
    #
605
    # The mag_map and phase_map arrays have the indices axes needed for
606
    # making the plots.  Labels are used on each axes for later creation of
607
    # legends.  The generic labels if of the form:
608
    #
609
    #     To output label, From input label, system name
610
    #
611
    # The input and output labels are omitted if overlay_inputs or
612
    # overlay_outputs is False, respectively.  The system name is always
613
    # included, since multiple calls to plot() will require a legend that
614
    # distinguishes which system signals are plotted.  The system name is
615
    # stripped off later (in the legend-handling code) if it is not needed.
616
    #
617
    # Note: if we are building on top of an existing plot, tick labels
618
    # should be preserved from the existing axes.  For log scale axes the
619
    # tick labels seem to appear no matter what => we have to detect if
620
    # they are present at the start and, it not, remove them after calling
621
    # loglog or semilogx.
622
    #
623

624
    # Create a list of lines for the output
625
    out = np.empty((nrows, ncols), dtype=object)
9✔
626
    for i in range(nrows):
9✔
627
        for j in range(ncols):
9✔
628
            out[i, j] = []      # unique list in each element
9✔
629

630
    # Process label keyword
631
    line_labels = _process_line_labels(label, len(data), ninputs, noutputs)
9✔
632

633
    # Utility function for creating line label
634
    def _make_line_label(response, output_index, input_index):
9✔
635
        label = ""              # start with an empty label
9✔
636

637
        # Add the output name if it won't appear as an axes label
638
        if noutputs > 1 and overlay_outputs:
9✔
639
            label += response.output_labels[output_index]
9✔
640

641
        # Add the input name if it won't appear as a column label
642
        if ninputs > 1 and overlay_inputs:
9✔
643
            label += ", " if label != "" else ""
9✔
644
            label += response.input_labels[input_index]
9✔
645

646
        # Add the system name (will strip off later if redundant)
647
        label += ", " if label != "" else ""
9✔
648
        label += f"{response.sysname}"
9✔
649

650
        return label
9✔
651

652
    for index, response in enumerate(data):
9✔
653
        # Get the (pre-processed) data in fully indexed form
654
        mag = mag_data[index].reshape((noutputs, ninputs, -1))
9✔
655
        phase = phase_data[index].reshape((noutputs, ninputs, -1))
9✔
656
        omega_sys, sysname = omega_data[index], response.sysname
9✔
657

658
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
659
            # Get the axes to use for magnitude and phase
660
            ax_mag = ax_array[mag_map[i, j]]
9✔
661
            ax_phase = ax_array[phase_map[i, j]]
9✔
662

663
            # Get the frequencies and convert to Hz, if needed
664
            omega_plot = omega_sys / (2 * math.pi) if Hz else omega_sys
9✔
665
            if response.isdtime(strict=True):
9✔
666
                nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
667

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

672
            # Generate a label
673
            if line_labels is None:
9✔
674
                label = _make_line_label(response, i, j)
9✔
675
            else:
676
                label = line_labels[index, i, j]
9✔
677

678
            # Magnitude
679
            if plot_magnitude:
9✔
680
                pltfcn = ax_mag.semilogx if dB else ax_mag.loglog
9✔
681

682
                # Plot the main data
683
                lines = pltfcn(
9✔
684
                    omega_plot, mag_plot, *fmt, label=label, **kwargs)
685
                out[mag_map[i, j]] += lines
9✔
686

687
                # Save the information needed for the Nyquist line
688
                if response.isdtime(strict=True):
9✔
689
                    ax_mag.axvline(
9✔
690
                        nyq_freq, color=lines[0].get_color(), linestyle='--',
691
                        label='_nyq_mag_' + sysname)
692

693
                # Add a grid to the plot
694
                ax_mag.grid(grid and not display_margins, which='both')
9✔
695

696
            # Phase
697
            if plot_phase:
9✔
698
                lines = ax_phase.semilogx(
9✔
699
                    omega_plot, phase_plot, *fmt, label=label, **kwargs)
700
                out[phase_map[i, j]] += lines
9✔
701

702
                # Save the information needed for the Nyquist line
703
                if response.isdtime(strict=True):
9✔
704
                    ax_phase.axvline(
9✔
705
                        nyq_freq, color=lines[0].get_color(), linestyle='--',
706
                        label='_nyq_phase_' + sysname)
707

708
                # Add a grid to the plot
709
                ax_phase.grid(grid and not display_margins, which='both')
9✔
710

711
        #
712
        # Display gain and phase margins (SISO only)
713
        #
714

715
        if display_margins:
9✔
716
            if ninputs > 1 or noutputs > 1:
9✔
717
                raise NotImplementedError(
718
                    "margins are not available for MIMO systems")
719

720
            # Compute stability margins for the system
721
            margins = stability_margins(response, method=margins_method)
9✔
722
            gm, pm, Wcg, Wcp = (margins[i] for i in [0, 1, 3, 4])
9✔
723

724
            # Figure out sign of the phase at the first gain crossing
725
            # (needed if phase_wrap is True)
726
            phase_at_cp = phase[
9✔
727
                0, 0, (np.abs(omega_data[0] - Wcp)).argmin()]
728
            if phase_at_cp >= 0.:
9✔
729
                phase_limit = 180.
×
730
            else:
731
                phase_limit = -180.
9✔
732

733
            if Hz:
9✔
734
                Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi)
9✔
735

736
            # Draw lines at gain and phase limits
737
            if plot_magnitude:
9✔
738
                ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':',
9✔
739
                               zorder=-20)
740
                mag_ylim = ax_mag.get_ylim()
9✔
741

742
            if plot_phase:
9✔
743
                ax_phase.axhline(y=phase_limit if deg else
9✔
744
                                 math.radians(phase_limit),
745
                                 color='k', linestyle=':', zorder=-20)
746
                phase_ylim = ax_phase.get_ylim()
9✔
747

748
            # Annotate the phase margin (if it exists)
749
            if plot_phase and pm != float('inf') and Wcp != float('nan'):
9✔
750
                # Draw dotted lines marking the gain crossover frequencies
751
                if plot_magnitude:
9✔
752
                    ax_mag.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
753
                ax_phase.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
754

755
                # Draw solid segments indicating the margins
756
                if deg:
9✔
757
                    ax_phase.semilogx(
9✔
758
                        [Wcp, Wcp], [phase_limit + pm, phase_limit],
759
                        color='k', zorder=-20)
760
                else:
761
                    ax_phase.semilogx(
9✔
762
                        [Wcp, Wcp], [math.radians(phase_limit) +
763
                                     math.radians(pm),
764
                                     math.radians(phase_limit)],
765
                        color='k', zorder=-20)
766

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

775
                # Draw solid segments indicating the margins
776
                if dB:
9✔
777
                    ax_mag.semilogx(
9✔
778
                        [Wcg, Wcg], [0, -20*np.log10(gm)],
779
                        color='k', zorder=-20)
780
                else:
781
                    ax_mag.loglog(
9✔
782
                        [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20)
783

784
            if display_margins == 'overlay':
9✔
785
                # TODO: figure out how to handle case of multiple lines
786
                # Put the margin information in the lower left corner
787
                if plot_magnitude:
9✔
788
                    ax_mag.text(
9✔
789
                        0.04, 0.06,
790
                        'G.M.: %.2f %s\nFreq: %.2f %s' %
791
                        (20*np.log10(gm) if dB else gm,
792
                         'dB ' if dB else '',
793
                         Wcg, 'Hz' if Hz else 'rad/s'),
794
                        horizontalalignment='left',
795
                        verticalalignment='bottom',
796
                        transform=ax_mag.transAxes,
797
                        fontsize=8 if int(mpl.__version__[0]) == 1 else 6)
798

799
                if plot_phase:
9✔
800
                    ax_phase.text(
9✔
801
                        0.04, 0.06,
802
                        'P.M.: %.2f %s\nFreq: %.2f %s' %
803
                        (pm if deg else math.radians(pm),
804
                         'deg' if deg else 'rad',
805
                         Wcp, 'Hz' if Hz else 'rad/s'),
806
                        horizontalalignment='left',
807
                        verticalalignment='bottom',
808
                        transform=ax_phase.transAxes,
809
                        fontsize=8 if int(mpl.__version__[0]) == 1 else 6)
810

811
            else:
812
                # Put the title underneath the suptitle (one line per system)
813
                ax = ax_mag if ax_mag else ax_phase
9✔
814
                axes_title = ax.get_title()
9✔
815
                if axes_title is not None and axes_title != "":
9✔
816
                    axes_title += "\n"
×
817
                with plt.rc_context(rcParams):
9✔
818
                    ax.set_title(
9✔
819
                        axes_title + f"{sysname}: "
820
                        "Gm = %.2f %s(at %.2f %s), "
821
                        "Pm = %.2f %s (at %.2f %s)" %
822
                        (20*np.log10(gm) if dB else gm,
823
                         'dB ' if dB else '',
824
                         Wcg, 'Hz' if Hz else 'rad/s',
825
                         pm if deg else math.radians(pm),
826
                         'deg' if deg else 'rad',
827
                         Wcp, 'Hz' if Hz else 'rad/s'))
828

829
    #
830
    # Finishing handling axes limit sharing
831
    #
832
    # This code handles labels on Bode plots and also removes tick labels
833
    # on shared axes.  It needs to come *after* the plots are generated,
834
    # in order to handle two things:
835
    #
836
    # * manually generated labels and grids need to reflect the limits for
837
    #   shared axes, which we don't know until we have plotted everything;
838
    #
839
    # * the loglog and semilog functions regenerate the labels (not quite
840
    #   sure why, since using sharex and sharey in subplots does not have
841
    #   this behavior).
842
    #
843
    # Note: as before, if the various share_* keywords are None then a
844
    # previous set of axes are available and no updates are made. (TODO: true?)
845
    #
846

847
    for i in range(noutputs):
9✔
848
        for j in range(ninputs):
9✔
849
            # Utility function to generate phase labels
850
            def gen_zero_centered_series(val_min, val_max, period):
9✔
851
                v1 = np.ceil(val_min / period - 0.2)
9✔
852
                v2 = np.floor(val_max / period + 0.2)
9✔
853
                return np.arange(v1, v2 + 1) * period
9✔
854

855
            # Label the phase axes using multiples of 45 degrees
856
            if plot_phase:
9✔
857
                ax_phase = ax_array[phase_map[i, j]]
9✔
858

859
                # Set the labels
860
                if deg:
9✔
861
                    ylim = ax_phase.get_ylim()
9✔
862
                    num = np.floor((ylim[1] - ylim[0]) / 45)
9✔
863
                    factor = max(1, np.round(num / (32 / nrows)) * 2)
9✔
864
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
865
                        ylim[0], ylim[1], 45 * factor))
866
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
867
                        ylim[0], ylim[1], 15 * factor), minor=True)
868
                else:
869
                    ylim = ax_phase.get_ylim()
9✔
870
                    num = np.ceil((ylim[1] - ylim[0]) / (math.pi/4))
9✔
871
                    factor = max(1, np.round(num / (36 / nrows)) * 2)
9✔
872
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
873
                        ylim[0], ylim[1], math.pi / 4. * factor))
874
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
875
                        ylim[0], ylim[1], math.pi / 12. * factor), minor=True)
876

877
    # Turn off y tick labels for shared axes
878
    for i in range(0, noutputs):
9✔
879
        for j in range(1, ncols):
9✔
880
            if share_magnitude in [True, 'all', 'row']:
9✔
881
                ax_array[mag_map[i, j]].tick_params(labelleft=False)
9✔
882
            if share_phase in [True, 'all', 'row']:
9✔
883
                ax_array[phase_map[i, j]].tick_params(labelleft=False)
9✔
884

885
    # Turn off x tick labels for shared axes
886
    for i in range(0, nrows-1):
9✔
887
        for j in range(0, ncols):
9✔
888
            if share_frequency in [True, 'all', 'col']:
9✔
889
                ax_array[i, j].tick_params(labelbottom=False)
9✔
890

891
    # If specific omega_limits were given, use them
892
    if omega_limits is not None:
9✔
893
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
894
            ax_array[i, j].set_xlim(omega_limits)
9✔
895

896
    #
897
    # Label the axes (including header labels)
898
    #
899
    # Once the data are plotted, we label the axes.  The horizontal axes is
900
    # always frequency and this is labeled only on the bottom most row.  The
901
    # vertical axes can consist either of a single signal or a combination
902
    # of signals (when overlay_inputs or overlay_outputs is True)
903
    #
904
    # Input/output signals are give at the top of columns and left of rows
905
    # when these are individually plotted.
906
    #
907

908
    # Label the columns (do this first to get row labels in the right spot)
909
    for j in range(ncols):
9✔
910
        # If we have more than one column, label the individual responses
911
        if (noutputs > 1 and not overlay_outputs or ninputs > 1) \
9✔
912
           and not overlay_inputs:
913
            with plt.rc_context(rcParams):
9✔
914
                ax_array[0, j].set_title(f"From {data[0].input_labels[j]}")
9✔
915

916
        # Label the frequency axis
917
        ax_array[-1, j].set_xlabel(
9✔
918
            freq_label.format(units="Hz" if Hz else "rad/s"))
919

920
    # Label the rows
921
    for i in range(noutputs if not overlay_outputs else 1):
9✔
922
        if plot_magnitude:
9✔
923
            ax_mag = ax_array[mag_map[i, 0]]
9✔
924
            ax_mag.set_ylabel(magnitude_label)
9✔
925
        if plot_phase:
9✔
926
            ax_phase = ax_array[phase_map[i, 0]]
9✔
927
            ax_phase.set_ylabel(phase_label)
9✔
928

929
        if (noutputs > 1 or ninputs > 1) and not overlay_outputs:
9✔
930
            if plot_magnitude and plot_phase:
9✔
931
                # Get existing ylabel for left column and add a blank line
932
                ax_mag.set_ylabel("\n" + ax_mag.get_ylabel())
9✔
933
                ax_phase.set_ylabel("\n" + ax_phase.get_ylabel())
9✔
934

935
                # Find the midpoint between the row axes (+ tight_layout)
936
                _, ypos = _find_axes_center(fig, [ax_mag, ax_phase])
9✔
937

938
                # Get the bounding box including the labels
939
                inv_transform = fig.transFigure.inverted()
9✔
940
                mag_bbox = inv_transform.transform(
9✔
941
                    ax_mag.get_tightbbox(fig.canvas.get_renderer()))
942

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

946
                # Put a centered label as text outside the box
947
                fig.text(
9✔
948
                    0.8 * xpos, ypos, f"To {data[0].output_labels[i]}\n",
949
                    rotation=90, ha='left', va='center',
950
                    fontsize=rcParams['axes.titlesize'])
951
            else:
952
                # Only a single axes => add label to the left
953
                ax_array[i, 0].set_ylabel(
9✔
954
                    f"To {data[0].output_labels[i]}\n" +
955
                    ax_array[i, 0].get_ylabel())
956

957
    #
958
    # Update the plot title (= figure suptitle)
959
    #
960
    # If plots are built up by multiple calls to plot() and the title is
961
    # not given, then the title is updated to provide a list of unique text
962
    # items in each successive title.  For data generated by the frequency
963
    # response function this will generate a common prefix followed by a
964
    # list of systems (e.g., "Step response for sys[1], sys[2]").
965
    #
966

967
    # Set the initial title for the data (unique system names, preserving order)
968
    seen = set()
9✔
969
    sysnames = [response.sysname for response in data \
9✔
970
                if not (response.sysname in seen or seen.add(response.sysname))]
971

972
    if ax is None and title is None:
9✔
973
        if data[0].title is None:
9✔
974
            title = "Bode plot for " + ", ".join(sysnames)
9✔
975
        else:
976
            # Allow data to set the title (used by gangof4)
977
            title = data[0].title
9✔
978
        _update_plot_title(title, fig, rcParams=rcParams, frame=title_frame)
9✔
979
    elif ax is None:
9✔
980
        _update_plot_title(
9✔
981
            title, fig=fig, rcParams=rcParams, frame=title_frame,
982
            use_existing=False)
983

984
    #
985
    # Create legends
986
    #
987
    # Legends can be placed manually by passing a legend_map array that
988
    # matches the shape of the suplots, with each item being a string
989
    # indicating the location of the legend for that axes (or None for no
990
    # legend).
991
    #
992
    # If no legend spec is passed, a minimal number of legends are used so
993
    # that each line in each axis can be uniquely identified.  The details
994
    # depends on the various plotting parameters, but the general rule is
995
    # to place legends in the top row and right column.
996
    #
997
    # Because plots can be built up by multiple calls to plot(), the legend
998
    # strings are created from the line labels manually.  Thus an initial
999
    # call to plot() may not generate any legends (eg, if no signals are
1000
    # overlaid), but subsequent calls to plot() will need a legend for each
1001
    # different response (system).
1002
    #
1003

1004
    # Create axis legends
1005
    if show_legend != False:
9✔
1006
        # Figure out where to put legends
1007
        if legend_map is None:
9✔
1008
            legend_map = np.full(ax_array.shape, None, dtype=object)
9✔
1009
            legend_map[0, -1] = legend_loc
9✔
1010

1011
        legend_array = np.full(ax_array.shape, None, dtype=object)
9✔
1012
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
1013
            if legend_map[i, j] is None:
9✔
1014
                continue
9✔
1015
            ax = ax_array[i, j]
9✔
1016

1017
            # Get the labels to use, removing common strings
1018
            lines = [line for line in ax.get_lines()
9✔
1019
                     if line.get_label()[0] != '_']
1020
            labels = _make_legend_labels(
9✔
1021
                [line.get_label() for line in lines],
1022
                ignore_common=line_labels is not None)
1023

1024
            # Generate the label, if needed
1025
            if show_legend == True or len(labels) > 1:
9✔
1026
                with plt.rc_context(rcParams):
9✔
1027
                    legend_array[i, j] = ax.legend(
9✔
1028
                        lines, labels, loc=legend_map[i, j])
1029
    else:
1030
        legend_array = None
9✔
1031

1032
    #
1033
    # Legacy return pocessing
1034
    #
1035
    if plot is True:            # legacy usage; remove in future release
9✔
1036
        # Process the data to match what we were sent
1037
        for i in range(len(mag_data)):
9✔
1038
            mag_data[i] = _process_frequency_response(
9✔
1039
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
1040
            phase_data[i] = _process_frequency_response(
9✔
1041
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
1042

1043
        if len(data) == 1:
9✔
1044
            return mag_data[0], phase_data[0], omega_data[0]
9✔
1045
        else:
1046
            return mag_data, phase_data, omega_data
9✔
1047

1048
    return ControlPlot(out, ax_array, fig, legend=legend_array)
9✔
1049

1050

1051
#
1052
# Nyquist plot
1053
#
1054

1055
# Default values for module parameter variables
1056
_nyquist_defaults = {
9✔
1057
    'nyquist.primary_style': ['-', '-.'],       # style for primary curve
1058
    'nyquist.mirror_style': ['--', ':'],        # style for mirror curve
1059
    'nyquist.arrows': 2,                        # number of arrows around curve
1060
    'nyquist.arrow_size': 8,                    # pixel size for arrows
1061
    'nyquist.encirclement_threshold': 0.05,     # warning threshold
1062
    'nyquist.indent_radius': 1e-4,              # indentation radius
1063
    'nyquist.indent_direction': 'right',        # indentation direction
1064
    'nyquist.indent_points': 50,                # number of points to insert
1065
    'nyquist.max_curve_magnitude': 20,          # clip large values
1066
    'nyquist.max_curve_offset': 0.02,           # offset of primary/mirror
1067
    'nyquist.start_marker': 'o',                # marker at start of curve
1068
    'nyquist.start_marker_size': 4,             # size of the marker
1069
    'nyquist.circle_style':                     # style for unit circles
1070
      {'color': 'black', 'linestyle': 'dashed', 'linewidth': 1}
1071
}
1072

1073

1074
class NyquistResponseData:
9✔
1075
    """Nyquist response data object.
1076

1077
    Nyquist contour analysis allows the stability and robustness of a
1078
    closed loop linear system to be evaluated using the open loop response
1079
    of the loop transfer function.  The NyquistResponseData class is used
1080
    by the :func:`~control.nyquist_response` function to return the
1081
    response of a linear system along the Nyquist 'D' contour.  The
1082
    response object can be used to obtain information about the Nyquist
1083
    response or to generate a Nyquist plot.
1084

1085
    Attributes
1086
    ----------
1087
    count : integer
1088
        Number of encirclements of the -1 point by the Nyquist curve for
1089
        a system evaluated along the Nyquist contour.
1090
    contour : complex array
1091
        The Nyquist 'D' contour, with appropriate indendtations to avoid
1092
        open loop poles and zeros near/on the imaginary axis.
1093
    response : complex array
1094
        The value of the linear system under study along the Nyquist contour.
1095
    dt : None or float
1096
        The system timebase.
1097
    sysname : str
1098
        The name of the system being analyzed.
1099
    return_contour: bool
1100
        If true, when the object is accessed as an iterable return two
1101
        elements": `count` (number of encirlements) and `contour`.  If
1102
        false (default), then return only `count`.
1103

1104
    """
1105
    def __init__(
9✔
1106
            self, count, contour, response, dt, sysname=None,
1107
            return_contour=False):
1108
        self.count = count
9✔
1109
        self.contour = contour
9✔
1110
        self.response = response
9✔
1111
        self.dt = dt
9✔
1112
        self.sysname = sysname
9✔
1113
        self.return_contour = return_contour
9✔
1114

1115
    # Implement iter to allow assigning to a tuple
1116
    def __iter__(self):
9✔
1117
        if self.return_contour:
9✔
1118
            return iter((self.count, self.contour))
9✔
1119
        else:
1120
            return iter((self.count, ))
9✔
1121

1122
    # Implement (thin) getitem to allow access via legacy indexing
1123
    def __getitem__(self, index):
9✔
1124
        return list(self.__iter__())[index]
×
1125

1126
    # Implement (thin) len to emulate legacy testing interface
1127
    def __len__(self):
9✔
1128
        return 2 if self.return_contour else 1
9✔
1129

1130
    def plot(self, *args, **kwargs):
9✔
1131
        return nyquist_plot(self, *args, **kwargs)
9✔
1132

1133

1134
class NyquistResponseList(list):
9✔
1135
    def plot(self, *args, **kwargs):
9✔
1136
        return nyquist_plot(self, *args, **kwargs)
9✔
1137

1138

1139
def nyquist_response(
9✔
1140
        sysdata, omega=None, plot=None, omega_limits=None, omega_num=None,
1141
        return_contour=False, warn_encirclements=True, warn_nyquist=True,
1142
        check_kwargs=True, **kwargs):
1143
    """Nyquist response for a system.
1144

1145
    Computes a Nyquist contour for the system over a (optional) frequency
1146
    range and evaluates the number of net encirclements.  The curve is
1147
    computed by evaluating the Nyqist segment along the positive imaginary
1148
    axis, with a mirror image generated to reflect the negative imaginary
1149
    axis.  Poles on or near the imaginary axis are avoided using a small
1150
    indentation.  The portion of the Nyquist contour at infinity is not
1151
    explicitly computed (since it maps to a constant value for any system
1152
    with a proper transfer function).
1153

1154
    Parameters
1155
    ----------
1156
    sysdata : LTI or list of LTI
1157
        List of linear input/output systems (single system is OK). Nyquist
1158
        curves for each system are plotted on the same graph.
1159
    omega : array_like, optional
1160
        Set of frequencies to be evaluated, in rad/sec.
1161

1162
    Returns
1163
    -------
1164
    responses : list of :class:`~control.NyquistResponseData`
1165
        For each system, a Nyquist response data object is returned.  If
1166
        `sysdata` is a single system, a single elemeent is returned (not a
1167
        list).  For each response, the following information is available:
1168
    response.count : int
1169
        Number of encirclements of the point -1 by the Nyquist curve.  If
1170
        multiple systems are given, an array of counts is returned.
1171
    response.contour : ndarray
1172
        The contour used to create the primary Nyquist curve segment.  To
1173
        obtain the Nyquist curve values, evaluate system(s) along contour.
1174

1175
    Other Parameters
1176
    ----------------
1177
    encirclement_threshold : float, optional
1178
        Define the threshold for generating a warning if the number of net
1179
        encirclements is a non-integer value.  Default value is 0.05 and can
1180
        be set using config.defaults['nyquist.encirclement_threshold'].
1181
    indent_direction : str, optional
1182
        For poles on the imaginary axis, set the direction of indentation to
1183
        be 'right' (default), 'left', or 'none'.
1184
    indent_points : int, optional
1185
        Number of points to insert in the Nyquist contour around poles that
1186
        are at or near the imaginary axis.
1187
    indent_radius : float, optional
1188
        Amount to indent the Nyquist contour around poles on or near the
1189
        imaginary axis. Portions of the Nyquist plot corresponding to indented
1190
        portions of the contour are plotted using a different line style.
1191
    omega_limits : array_like of two values
1192
        Set limits for plotted frequency range. If Hz=True the limits are
1193
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
1194
        elements is equivalent to providing ``omega_limits``.
1195
    omega_num : int, optional
1196
        Number of samples to use for the frequeny range.  Defaults to
1197
        config.defaults['freqplot.number_of_samples'].
1198
    warn_nyquist : bool, optional
1199
        If set to 'False', turn off warnings about frequencies above Nyquist.
1200
    warn_encirclements : bool, optional
1201
        If set to 'False', turn off warnings about number of encirclements not
1202
        meeting the Nyquist criterion.
1203

1204
    Notes
1205
    -----
1206
    1. If a discrete time model is given, the frequency response is computed
1207
       along the upper branch of the unit circle, using the mapping ``z =
1208
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
1209
       is the discrete timebase.  If timebase not specified (``dt=True``),
1210
       `dt` is set to 1.
1211

1212
    2. If a continuous-time system contains poles on or near the imaginary
1213
       axis, a small indentation will be used to avoid the pole.  The radius
1214
       of the indentation is given by `indent_radius` and it is taken to the
1215
       right of stable poles and the left of unstable poles.  If a pole is
1216
       exactly on the imaginary axis, the `indent_direction` parameter can be
1217
       used to set the direction of indentation.  Setting `indent_direction`
1218
       to `none` will turn off indentation.  If `return_contour` is True, the
1219
       exact contour used for evaluation is returned.
1220

1221
    3. For those portions of the Nyquist plot in which the contour is
1222
       indented to avoid poles, resuling in a scaling of the Nyquist plot,
1223
       the line styles are according to the settings of the `primary_style`
1224
       and `mirror_style` keywords.  By default the scaled portions of the
1225
       primary curve use a dotted line style and the scaled portion of the
1226
       mirror image use a dashdot line style.
1227

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

1232
    See Also
1233
    --------
1234
    nyquist_plot
1235

1236
    Examples
1237
    --------
1238
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1239
    >>> response = ct.nyquist_response(G)
1240
    >>> count = response.count
1241
    >>> lines = response.plot()
1242

1243
    """
1244
    # Get values for params
1245
    omega_num_given = omega_num is not None
9✔
1246
    omega_num = config._get_param('freqplot', 'number_of_samples', omega_num)
9✔
1247
    indent_radius = config._get_param(
9✔
1248
        'nyquist', 'indent_radius', kwargs, _nyquist_defaults, pop=True)
1249
    encirclement_threshold = config._get_param(
9✔
1250
        'nyquist', 'encirclement_threshold', kwargs,
1251
        _nyquist_defaults, pop=True)
1252
    indent_direction = config._get_param(
9✔
1253
        'nyquist', 'indent_direction', kwargs, _nyquist_defaults, pop=True)
1254
    indent_points = config._get_param(
9✔
1255
        'nyquist', 'indent_points', kwargs, _nyquist_defaults, pop=True)
1256

1257
    if check_kwargs and kwargs:
9✔
1258
        raise TypeError("unrecognized keywords: ", str(kwargs))
9✔
1259

1260
    # Convert the first argument to a list
1261
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
1262

1263
    # Determine the range of frequencies to use, based on args/features
1264
    omega, omega_range_given = _determine_omega_vector(
9✔
1265
        syslist, omega, omega_limits, omega_num, feature_periphery_decades=2)
1266

1267
    # If omega was not specified explicitly, start at omega = 0
1268
    if not omega_range_given:
9✔
1269
        if omega_num_given:
9✔
1270
            # Just reset the starting point
1271
            omega[0] = 0.0
9✔
1272
        else:
1273
            # Insert points between the origin and the first frequency point
1274
            omega = np.concatenate((
9✔
1275
                np.linspace(0, omega[0], indent_points), omega[1:]))
1276

1277
    # Go through each system and keep track of the results
1278
    responses = []
9✔
1279
    for idx, sys in enumerate(syslist):
9✔
1280
        if not sys.issiso():
9✔
1281
            # TODO: Add MIMO nyquist plots.
1282
            raise ControlMIMONotImplemented(
9✔
1283
                "Nyquist plot currently only supports SISO systems.")
1284

1285
        # Figure out the frequency range
1286
        if isinstance(sys, FrequencyResponseData) and sys.ifunc is None \
9✔
1287
           and not omega_range_given:
1288
            omega_sys = sys.omega               # use system frequencies
9✔
1289
        else:
1290
            omega_sys = np.asarray(omega)       # use common omega vector
9✔
1291

1292
        # Determine the contour used to evaluate the Nyquist curve
1293
        if sys.isdtime(strict=True):
9✔
1294
            # Restrict frequencies for discrete-time systems
1295
            nyq_freq = math.pi / sys.dt
9✔
1296
            if not omega_range_given:
9✔
1297
                # limit up to and including Nyquist frequency
1298
                omega_sys = np.hstack((
9✔
1299
                    omega_sys[omega_sys < nyq_freq], nyq_freq))
1300

1301
            # Issue a warning if we are sampling above Nyquist
1302
            if np.any(omega_sys * sys.dt > np.pi) and warn_nyquist:
9✔
1303
                warnings.warn("evaluation above Nyquist frequency")
9✔
1304

1305
        # do indentations in s-plane where it is more convenient
1306
        splane_contour = 1j * omega_sys
9✔
1307

1308
        # Bend the contour around any poles on/near the imaginary axis
1309
        if isinstance(sys, (StateSpace, TransferFunction)) \
9✔
1310
                and indent_direction != 'none':
1311
            if sys.isctime():
9✔
1312
                splane_poles = sys.poles()
9✔
1313
                splane_cl_poles = sys.feedback().poles()
9✔
1314
            else:
1315
                # map z-plane poles to s-plane. We ignore any at the origin
1316
                # to avoid numerical warnings because we know we
1317
                # don't need to indent for them
1318
                zplane_poles = sys.poles()
9✔
1319
                zplane_poles = zplane_poles[~np.isclose(abs(zplane_poles), 0.)]
9✔
1320
                splane_poles = np.log(zplane_poles) / sys.dt
9✔
1321

1322
                zplane_cl_poles = sys.feedback().poles()
9✔
1323
                # eliminate z-plane poles at the origin to avoid warnings
1324
                zplane_cl_poles = zplane_cl_poles[
9✔
1325
                    ~np.isclose(abs(zplane_cl_poles), 0.)]
1326
                splane_cl_poles = np.log(zplane_cl_poles) / sys.dt
9✔
1327

1328
            #
1329
            # Check to make sure indent radius is small enough
1330
            #
1331
            # If there is a closed loop pole that is near the imaginary axis
1332
            # at a point that is near an open loop pole, it is possible that
1333
            # indentation might skip or create an extraneous encirclement.
1334
            # We check for that situation here and generate a warning if that
1335
            # could happen.
1336
            #
1337
            for p_cl in splane_cl_poles:
9✔
1338
                # See if any closed loop poles are near the imaginary axis
1339
                if abs(p_cl.real) <= indent_radius:
9✔
1340
                    # See if any open loop poles are close to closed loop poles
1341
                    if len(splane_poles) > 0:
9✔
1342
                        p_ol = splane_poles[
9✔
1343
                            (np.abs(splane_poles - p_cl)).argmin()]
1344

1345
                        if abs(p_ol - p_cl) <= indent_radius and \
9✔
1346
                                warn_encirclements:
1347
                            warnings.warn(
9✔
1348
                                "indented contour may miss closed loop pole; "
1349
                                "consider reducing indent_radius to below "
1350
                                f"{abs(p_ol - p_cl):5.2g}", stacklevel=2)
1351

1352
            #
1353
            # See if we should add some frequency points near imaginary poles
1354
            #
1355
            for p in splane_poles:
9✔
1356
                # See if we need to process this pole (skip if on the negative
1357
                # imaginary axis or not near imaginary axis + user override)
1358
                if p.imag < 0 or abs(p.real) > indent_radius or \
9✔
1359
                   omega_range_given:
1360
                    continue
9✔
1361

1362
                # Find the frequencies before the pole frequency
1363
                below_points = np.argwhere(
9✔
1364
                    splane_contour.imag - abs(p.imag) < -indent_radius)
1365
                if below_points.size > 0:
9✔
1366
                    first_point = below_points[-1].item()
9✔
1367
                    start_freq = p.imag - indent_radius
9✔
1368
                else:
1369
                    # Add the points starting at the beginning of the contour
1370
                    assert splane_contour[0] == 0
9✔
1371
                    first_point = 0
9✔
1372
                    start_freq = 0
9✔
1373

1374
                # Find the frequencies after the pole frequency
1375
                above_points = np.argwhere(
9✔
1376
                    splane_contour.imag - abs(p.imag) > indent_radius)
1377
                last_point = above_points[0].item()
9✔
1378

1379
                # Add points for half/quarter circle around pole frequency
1380
                # (these will get indented left or right below)
1381
                splane_contour = np.concatenate((
9✔
1382
                    splane_contour[0:first_point+1],
1383
                    (1j * np.linspace(
1384
                        start_freq, p.imag + indent_radius, indent_points)),
1385
                    splane_contour[last_point:]))
1386

1387
            # Indent points that are too close to a pole
1388
            if len(splane_poles) > 0: # accomodate no splane poles if dtime sys
9✔
1389
                for i, s in enumerate(splane_contour):
9✔
1390
                    # Find the nearest pole
1391
                    p = splane_poles[(np.abs(splane_poles - s)).argmin()]
9✔
1392

1393
                    # See if we need to indent around it
1394
                    if abs(s - p) < indent_radius:
9✔
1395
                        # Figure out how much to offset (simple trigonometry)
1396
                        offset = np.sqrt(
9✔
1397
                            indent_radius ** 2 - (s - p).imag ** 2) \
1398
                            - (s - p).real
1399

1400
                        # Figure out which way to offset the contour point
1401
                        if p.real < 0 or (p.real == 0 and
9✔
1402
                                        indent_direction == 'right'):
1403
                            # Indent to the right
1404
                            splane_contour[i] += offset
9✔
1405

1406
                        elif p.real > 0 or (p.real == 0 and
9✔
1407
                                            indent_direction == 'left'):
1408
                            # Indent to the left
1409
                            splane_contour[i] -= offset
9✔
1410

1411
                        else:
1412
                            raise ValueError(
9✔
1413
                                "unknown value for indent_direction")
1414

1415
        # change contour to z-plane if necessary
1416
        if sys.isctime():
9✔
1417
            contour = splane_contour
9✔
1418
        else:
1419
            contour = np.exp(splane_contour * sys.dt)
9✔
1420

1421
        # Compute the primary curve
1422
        resp = sys(contour)
9✔
1423

1424
        # Compute CW encirclements of -1 by integrating the (unwrapped) angle
1425
        phase = -unwrap(np.angle(resp + 1))
9✔
1426
        encirclements = np.sum(np.diff(phase)) / np.pi
9✔
1427
        count = int(np.round(encirclements, 0))
9✔
1428

1429
        # Let the user know if the count might not make sense
1430
        if abs(encirclements - count) > encirclement_threshold and \
9✔
1431
           warn_encirclements:
1432
            warnings.warn(
9✔
1433
                "number of encirclements was a non-integer value; this can"
1434
                " happen is contour is not closed, possibly based on a"
1435
                " frequency range that does not include zero.")
1436

1437
        #
1438
        # Make sure that the enciriclements match the Nyquist criterion
1439
        #
1440
        # If the user specifies the frequency points to use, it is possible
1441
        # to miss enciriclements, so we check here to make sure that the
1442
        # Nyquist criterion is actually satisfied.
1443
        #
1444
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1445
            # Count the number of open/closed loop RHP poles
1446
            if sys.isctime():
9✔
1447
                if indent_direction == 'right':
9✔
1448
                    P = (sys.poles().real > 0).sum()
9✔
1449
                else:
1450
                    P = (sys.poles().real >= 0).sum()
9✔
1451
                Z = (sys.feedback().poles().real >= 0).sum()
9✔
1452
            else:
1453
                if indent_direction == 'right':
9✔
1454
                    P = (np.abs(sys.poles()) > 1).sum()
9✔
1455
                else:
1456
                    P = (np.abs(sys.poles()) >= 1).sum()
×
1457
                Z = (np.abs(sys.feedback().poles()) >= 1).sum()
9✔
1458

1459
            # Check to make sure the results make sense; warn if not
1460
            if Z != count + P and warn_encirclements:
9✔
1461
                warnings.warn(
9✔
1462
                    "number of encirclements does not match Nyquist criterion;"
1463
                    " check frequency range and indent radius/direction",
1464
                    UserWarning, stacklevel=2)
1465
            elif indent_direction == 'none' and any(sys.poles().real == 0) and \
9✔
1466
                 warn_encirclements:
1467
                warnings.warn(
×
1468
                    "system has pure imaginary poles but indentation is"
1469
                    " turned off; results may be meaningless",
1470
                    RuntimeWarning, stacklevel=2)
1471

1472
        # Decide on system name
1473
        sysname = sys.name if sys.name is not None else f"Unknown-{idx}"
9✔
1474

1475
        responses.append(NyquistResponseData(
9✔
1476
            count, contour, resp, sys.dt, sysname=sysname,
1477
            return_contour=return_contour))
1478

1479
    if isinstance(sysdata, (list, tuple)):
9✔
1480
        return NyquistResponseList(responses)
9✔
1481
    else:
1482
        return responses[0]
9✔
1483

1484

1485
def nyquist_plot(
9✔
1486
        data, omega=None, plot=None, label_freq=0, color=None, label=None,
1487
        return_contour=None, title=None, ax=None,
1488
        unit_circle=False, mt_circles=None, ms_circles=None, **kwargs):
1489
    """Nyquist plot for a system.
1490

1491
    Generates a Nyquist plot for the system over a (optional) frequency
1492
    range.  The curve is computed by evaluating the Nyqist segment along
1493
    the positive imaginary axis, with a mirror image generated to reflect
1494
    the negative imaginary axis.  Poles on or near the imaginary axis are
1495
    avoided using a small indentation.  The portion of the Nyquist contour
1496
    at infinity is not explicitly computed (since it maps to a constant
1497
    value for any system with a proper transfer function).
1498

1499
    Parameters
1500
    ----------
1501
    data : list of LTI or NyquistResponseData
1502
        List of linear input/output systems (single system is OK) or
1503
        Nyquist ersponses (computed using :func:`~control.nyquist_response`).
1504
        Nyquist curves for each system are plotted on the same graph.
1505
    omega : array_like, optional
1506
        Set of frequencies to be evaluated, in rad/sec. Specifying
1507
        ``omega`` as a list of two elements is equivalent to providing
1508
        ``omega_limits``.
1509
    unit_circle : bool, optional
1510
        If ``True``, display the unit circle, to read gain crossover frequency.
1511
    mt_circles : array_like, optional
1512
        Draw circles corresponding to the given magnitudes of sensitivity.
1513
    ms_circles : array_like, optional
1514
        Draw circles corresponding to the given magnitudes of complementary
1515
        sensitivity.
1516
    **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
1517
        Additional keywords passed to `matplotlib` to specify line properties.
1518

1519
    Returns
1520
    -------
1521
    cplt : :class:`ControlPlot` object
1522
        Object containing the data that were plotted:
1523

1524
          * cplt.lines: 2D array of :class:`matplotlib.lines.Line2D`
1525
            objects for each line in the plot.  The shape of the array is
1526
            given by (nsys, 4) where nsys is the number of systems or
1527
            Nyquist responses passed to the function.  The second index
1528
            specifies the segment type:
1529

1530
              - lines[idx, 0]: unscaled portion of the primary curve
1531
              - lines[idx, 1]: scaled portion of the primary curve
1532
              - lines[idx, 2]: unscaled portion of the mirror curve
1533
              - lines[idx, 3]: scaled portion of the mirror curve
1534

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

1537
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
1538

1539
          * cplt.legend: legend object(s) contained in the plot
1540

1541
        See :class:`ControlPlot` for more detailed information.
1542

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

1644
    See Also
1645
    --------
1646
    nyquist_response
1647

1648
    Notes
1649
    -----
1650
    1. If a discrete time model is given, the frequency response is computed
1651
       along the upper branch of the unit circle, using the mapping ``z =
1652
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
1653
       is the discrete timebase.  If timebase not specified (``dt=True``),
1654
       `dt` is set to 1.
1655

1656
    2. If a continuous-time system contains poles on or near the imaginary
1657
       axis, a small indentation will be used to avoid the pole.  The radius
1658
       of the indentation is given by `indent_radius` and it is taken to the
1659
       right of stable poles and the left of unstable poles.  If a pole is
1660
       exactly on the imaginary axis, the `indent_direction` parameter can be
1661
       used to set the direction of indentation.  Setting `indent_direction`
1662
       to `none` will turn off indentation.  If `return_contour` is True, the
1663
       exact contour used for evaluation is returned.
1664

1665
    3. For those portions of the Nyquist plot in which the contour is
1666
       indented to avoid poles, resuling in a scaling of the Nyquist plot,
1667
       the line styles are according to the settings of the `primary_style`
1668
       and `mirror_style` keywords.  By default the scaled portions of the
1669
       primary curve use a dotted line style and the scaled portion of the
1670
       mirror image use a dashdot line style.
1671

1672
    Examples
1673
    --------
1674
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1675
    >>> out = ct.nyquist_plot(G)
1676

1677
    """
1678
    #
1679
    # Keyword processing
1680
    #
1681
    # Keywords for the nyquist_plot function can either be keywords that
1682
    # are unique to this function, keywords that are intended for use by
1683
    # nyquist_response (if data is a list of systems), or keywords that
1684
    # are intended for the plotting commands.
1685
    #
1686
    # We first pop off all keywords that are used directly by this
1687
    # function.  If data is a list of systems, when then pop off keywords
1688
    # that correspond to nyquist_response() keywords.  The remaining
1689
    # keywords are passed to matplotlib (and will generate an error if
1690
    # unrecognized).
1691
    #
1692

1693
    # Get values for params (and pop from list to allow keyword use in plot)
1694
    arrows = config._get_param(
9✔
1695
        'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True)
1696
    arrow_size = config._get_param(
9✔
1697
        'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True)
1698
    arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None)
9✔
1699
    ax_user = ax
9✔
1700
    max_curve_magnitude = config._get_param(
9✔
1701
        'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True)
1702
    max_curve_offset = config._get_param(
9✔
1703
        'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True)
1704
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
1705
    start_marker = config._get_param(
9✔
1706
        'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True)
1707
    start_marker_size = config._get_param(
9✔
1708
        'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True)
1709
    title_frame = config._get_param(
9✔
1710
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
1711

1712
    # Set line styles for the curves
1713
    def _parse_linestyle(style_name, allow_false=False):
9✔
1714
        style = config._get_param(
9✔
1715
            'nyquist', style_name, kwargs, _nyquist_defaults, pop=True)
1716
        if isinstance(style, str):
9✔
1717
            # Only one style provided, use the default for the other
1718
            style = [style, _nyquist_defaults['nyquist.' + style_name][1]]
9✔
1719
            warnings.warn(
9✔
1720
                "use of a single string for linestyle will be deprecated "
1721
                " in a future release", PendingDeprecationWarning)
1722
        if (allow_false and style is False) or \
9✔
1723
           (isinstance(style, list) and len(style) == 2):
1724
            return style
9✔
1725
        else:
1726
            raise ValueError(f"invalid '{style_name}': {style}")
9✔
1727

1728
    primary_style = _parse_linestyle('primary_style')
9✔
1729
    mirror_style = _parse_linestyle('mirror_style', allow_false=True)
9✔
1730

1731
    # Parse the arrows keyword
1732
    if not arrows:
9✔
1733
        arrow_pos = []
9✔
1734
    elif isinstance(arrows, int):
9✔
1735
        N = arrows
9✔
1736
        # Space arrows out, starting midway along each "region"
1737
        arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False)
9✔
1738
    elif isinstance(arrows, (list, np.ndarray)):
9✔
1739
        arrow_pos = np.sort(np.atleast_1d(arrows))
9✔
1740
    else:
1741
        raise ValueError("unknown or unsupported arrow location")
9✔
1742

1743
    # Set the arrow style
1744
    if arrow_style is None:
9✔
1745
        arrow_style = mpl.patches.ArrowStyle(
9✔
1746
            'simple', head_width=arrow_size, head_length=arrow_size)
1747

1748
    # If argument was a singleton, turn it into a tuple
1749
    if not isinstance(data, (list, tuple)):
9✔
1750
        data = [data]
9✔
1751

1752
    # Process label keyword
1753
    line_labels = _process_line_labels(label, len(data))
9✔
1754

1755
    # If we are passed a list of systems, compute response first
1756
    if all([isinstance(
9✔
1757
            sys, (StateSpace, TransferFunction, FrequencyResponseData))
1758
            for sys in data]):
1759
        # Get the response, popping off keywords used there
1760
        nyquist_responses = nyquist_response(
9✔
1761
            data, omega=omega, return_contour=return_contour,
1762
            omega_limits=kwargs.pop('omega_limits', None),
1763
            omega_num=kwargs.pop('omega_num', None),
1764
            warn_encirclements=kwargs.pop('warn_encirclements', True),
1765
            warn_nyquist=kwargs.pop('warn_nyquist', True),
1766
            indent_radius=kwargs.pop('indent_radius', None),
1767
            check_kwargs=False, **kwargs)
1768
    else:
1769
        nyquist_responses = data
9✔
1770

1771
    # Legacy return value processing
1772
    if plot is not None or return_contour is not None:
9✔
1773
        warnings.warn(
9✔
1774
            "`nyquist_plot` return values of count[, contour] is deprecated; "
1775
            "use nyquist_response()", DeprecationWarning)
1776

1777
        # Extract out the values that we will eventually return
1778
        counts = [response.count for response in nyquist_responses]
9✔
1779
        contours = [response.contour for response in nyquist_responses]
9✔
1780

1781
    if plot is False:
9✔
1782
        # Make sure we used all of the keywrods
1783
        if kwargs:
9✔
1784
            raise TypeError("unrecognized keywords: ", str(kwargs))
×
1785

1786
        if len(data) == 1:
9✔
1787
            counts, contours = counts[0], contours[0]
9✔
1788

1789
        # Return counts and (optionally) the contour we used
1790
        return (counts, contours) if return_contour else counts
9✔
1791

1792
    fig, ax = _process_ax_keyword(
9✔
1793
        ax_user, shape=(1, 1), squeeze=True, rcParams=rcParams)
1794
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
1795
        kwargs, None, 'upper right')
1796

1797
    # Create a list of lines for the output
1798
    out = np.empty(len(nyquist_responses), dtype=object)
9✔
1799
    for i in range(out.shape[0]):
9✔
1800
        out[i] = []             # unique list in each element
9✔
1801

1802
    for idx, response in enumerate(nyquist_responses):
9✔
1803
        resp = response.response
9✔
1804
        if response.dt in [0, None]:
9✔
1805
            splane_contour = response.contour
9✔
1806
        else:
1807
            splane_contour = np.log(response.contour) / response.dt
9✔
1808

1809
        # Find the different portions of the curve (with scaled pts marked)
1810
        reg_mask = np.logical_or(
9✔
1811
            np.abs(resp) > max_curve_magnitude,
1812
            splane_contour.real != 0)
1813
        # reg_mask = np.logical_or(
1814
        #     np.abs(resp.real) > max_curve_magnitude,
1815
        #     np.abs(resp.imag) > max_curve_magnitude)
1816

1817
        scale_mask = ~reg_mask \
9✔
1818
            & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \
1819
            & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1]))
1820

1821
        # Rescale the points with large magnitude
1822
        rescale = np.logical_and(
9✔
1823
            reg_mask, abs(resp) > max_curve_magnitude)
1824
        resp[rescale] *= max_curve_magnitude / abs(resp[rescale])
9✔
1825

1826
        # Get the label to use for the line
1827
        label = response.sysname if line_labels is None else line_labels[idx]
9✔
1828

1829
        # Plot the regular portions of the curve (and grab the color)
1830
        x_reg = np.ma.masked_where(reg_mask, resp.real)
9✔
1831
        y_reg = np.ma.masked_where(reg_mask, resp.imag)
9✔
1832
        p = plt.plot(
9✔
1833
            x_reg, y_reg, primary_style[0], color=color, label=label, **kwargs)
1834
        c = p[0].get_color()
9✔
1835
        out[idx] += p
9✔
1836

1837
        # Figure out how much to offset the curve: the offset goes from
1838
        # zero at the start of the scaled section to max_curve_offset as
1839
        # we move along the curve
1840
        curve_offset = _compute_curve_offset(
9✔
1841
            resp, scale_mask, max_curve_offset)
1842

1843
        # Plot the scaled sections of the curve (changing linestyle)
1844
        x_scl = np.ma.masked_where(scale_mask, resp.real)
9✔
1845
        y_scl = np.ma.masked_where(scale_mask, resp.imag)
9✔
1846
        if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1847
            out[idx] += plt.plot(
9✔
1848
                x_scl * (1 + curve_offset),
1849
                y_scl * (1 + curve_offset),
1850
                primary_style[1], color=c, **kwargs)
1851
        else:
1852
            out[idx] += [None]
9✔
1853

1854
        # Plot the primary curve (invisible) for setting arrows
1855
        x, y = resp.real.copy(), resp.imag.copy()
9✔
1856
        x[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1857
        y[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1858
        p = plt.plot(x, y, linestyle='None', color=c)
9✔
1859

1860
        # Add arrows
1861
        ax = plt.gca()
9✔
1862
        _add_arrows_to_line2D(
9✔
1863
            ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1)
1864

1865
        # Plot the mirror image
1866
        if mirror_style is not False:
9✔
1867
            # Plot the regular and scaled segments
1868
            out[idx] += plt.plot(
9✔
1869
                x_reg, -y_reg, mirror_style[0], color=c, **kwargs)
1870
            if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1871
                out[idx] += plt.plot(
9✔
1872
                    x_scl * (1 - curve_offset),
1873
                    -y_scl * (1 - curve_offset),
1874
                    mirror_style[1], color=c, **kwargs)
1875
            else:
1876
                out[idx] += [None]
9✔
1877

1878
            # Add the arrows (on top of an invisible contour)
1879
            x, y = resp.real.copy(), resp.imag.copy()
9✔
1880
            x[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1881
            y[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1882
            p = plt.plot(x, -y, linestyle='None', color=c, **kwargs)
9✔
1883
            _add_arrows_to_line2D(
9✔
1884
                ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1)
1885
        else:
1886
            out[idx] += [None, None]
9✔
1887

1888
        # Mark the start of the curve
1889
        if start_marker:
9✔
1890
            plt.plot(resp[0].real, resp[0].imag, start_marker,
9✔
1891
                     color=c, markersize=start_marker_size)
1892

1893
        # Mark the -1 point
1894
        plt.plot([-1], [0], 'r+')
9✔
1895

1896
        #
1897
        # Draw circles for gain crossover and sensitivity functions
1898
        #
1899
        theta = np.linspace(0, 2*np.pi, 100)
9✔
1900
        cos = np.cos(theta)
9✔
1901
        sin = np.sin(theta)
9✔
1902
        label_pos = 15
9✔
1903

1904
        # Display the unit circle, to read gain crossover frequency
1905
        if unit_circle:
9✔
1906
            plt.plot(cos, sin, **config.defaults['nyquist.circle_style'])
9✔
1907

1908
        # Draw circles for given magnitudes of sensitivity
1909
        if ms_circles is not None:
9✔
1910
            for ms in ms_circles:
9✔
1911
                pos_x = -1 + (1/ms)*cos
9✔
1912
                pos_y = (1/ms)*sin
9✔
1913
                plt.plot(
9✔
1914
                    pos_x, pos_y, **config.defaults['nyquist.circle_style'])
1915
                plt.text(pos_x[label_pos], pos_y[label_pos], ms)
9✔
1916

1917
        # Draw circles for given magnitudes of complementary sensitivity
1918
        if mt_circles is not None:
9✔
1919
            for mt in mt_circles:
9✔
1920
                if mt != 1:
9✔
1921
                    ct = -mt**2/(mt**2-1)  # Mt center
9✔
1922
                    rt = mt/(mt**2-1)  # Mt radius
9✔
1923
                    pos_x = ct+rt*cos
9✔
1924
                    pos_y = rt*sin
9✔
1925
                    plt.plot(
9✔
1926
                        pos_x, pos_y,
1927
                        **config.defaults['nyquist.circle_style'])
1928
                    plt.text(pos_x[label_pos], pos_y[label_pos], mt)
9✔
1929
                else:
1930
                    _, _, ymin, ymax = plt.axis()
9✔
1931
                    pos_y = np.linspace(ymin, ymax, 100)
9✔
1932
                    plt.vlines(
9✔
1933
                        -0.5, ymin=ymin, ymax=ymax,
1934
                        **config.defaults['nyquist.circle_style'])
1935
                    plt.text(-0.5, pos_y[label_pos], 1)
9✔
1936

1937
        # Label the frequencies of the points on the Nyquist curve
1938
        if label_freq:
9✔
1939
            ind = slice(None, None, label_freq)
9✔
1940
            omega_sys = np.imag(splane_contour[np.real(splane_contour) == 0])
9✔
1941
            for xpt, ypt, omegapt in zip(x[ind], y[ind], omega_sys[ind]):
9✔
1942
                # Convert to Hz
1943
                f = omegapt / (2 * np.pi)
9✔
1944

1945
                # Factor out multiples of 1000 and limit the
1946
                # result to the range [-8, 8].
1947
                pow1000 = max(min(get_pow1000(f), 8), -8)
9✔
1948

1949
                # Get the SI prefix.
1950
                prefix = gen_prefix(pow1000)
9✔
1951

1952
                # Apply the text. (Use a space before the text to
1953
                # prevent overlap with the data.)
1954
                #
1955
                # np.round() is used because 0.99... appears
1956
                # instead of 1.0, and this would otherwise be
1957
                # truncated to 0.
1958
                plt.text(xpt, ypt, ' ' +
9✔
1959
                         str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' +
1960
                         prefix + 'Hz')
1961

1962
    # Label the axes
1963
    ax.set_xlabel("Real axis")
9✔
1964
    ax.set_ylabel("Imaginary axis")
9✔
1965
    ax.grid(color="lightgray")
9✔
1966

1967
    # List of systems that are included in this plot
1968
    lines, labels = _get_line_labels(ax)
9✔
1969

1970
    # Add legend if there is more than one system plotted
1971
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
1972
        with plt.rc_context(rcParams):
9✔
1973
            legend = ax.legend(lines, labels, loc=legend_loc)
9✔
1974
    else:
1975
        legend = None
9✔
1976

1977
    # Add the title
1978
    sysnames = [response.sysname for response in nyquist_responses]
9✔
1979
    if ax_user is None and title is None:
9✔
1980
        title = "Nyquist plot for " + ", ".join(sysnames)
9✔
1981
        _update_plot_title(
9✔
1982
            title, fig=fig, rcParams=rcParams, frame=title_frame)
1983
    elif ax_user is None:
9✔
1984
        _update_plot_title(
9✔
1985
            title, fig=fig, rcParams=rcParams, frame=title_frame,
1986
            use_existing=False)
1987

1988
    # Legacy return pocessing
1989
    if plot is True or return_contour is not None:
9✔
1990
        if len(data) == 1:
9✔
1991
            counts, contours = counts[0], contours[0]
9✔
1992

1993
        # Return counts and (optionally) the contour we used
1994
        return (counts, contours) if return_contour else counts
9✔
1995

1996
    return ControlPlot(out, ax, fig, legend=legend)
9✔
1997

1998

1999
#
2000
# Function to compute Nyquist curve offsets
2001
#
2002
# This function computes a smoothly varying offset that starts and ends at
2003
# zero at the ends of a scaled segment.
2004
#
2005
def _compute_curve_offset(resp, mask, max_offset):
9✔
2006
    # Compute the arc length along the curve
2007
    s_curve = np.cumsum(
9✔
2008
        np.sqrt(np.diff(resp.real) ** 2 + np.diff(resp.imag) ** 2))
2009

2010
    # Initialize the offset
2011
    offset = np.zeros(resp.size)
9✔
2012
    arclen = np.zeros(resp.size)
9✔
2013

2014
    # Walk through the response and keep track of each continous component
2015
    i, nsegs = 0, 0
9✔
2016
    while i < resp.size:
9✔
2017
        # Skip the regular segment
2018
        while i < resp.size and mask[i]:
9✔
2019
            i += 1              # Increment the counter
9✔
2020
            if i == resp.size:
9✔
2021
                break
9✔
2022
            # Keep track of the arclength
2023
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2024

2025
        nsegs += 0.5
9✔
2026
        if i == resp.size:
9✔
2027
            break
9✔
2028

2029
        # Save the starting offset of this segment
2030
        seg_start = i
9✔
2031

2032
        # Walk through the scaled segment
2033
        while i < resp.size and not mask[i]:
9✔
2034
            i += 1
9✔
2035
            if i == resp.size:  # See if we are done with this segment
9✔
2036
                break
9✔
2037
            # Keep track of the arclength
2038
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2039

2040
        nsegs += 0.5
9✔
2041
        if i == resp.size:
9✔
2042
            break
9✔
2043

2044
        # Save the ending offset of this segment
2045
        seg_end = i
9✔
2046

2047
        # Now compute the scaling for this segment
2048
        s_segment = arclen[seg_end-1] - arclen[seg_start]
9✔
2049
        offset[seg_start:seg_end] = max_offset * s_segment/s_curve[-1] * \
9✔
2050
            np.sin(np.pi * (arclen[seg_start:seg_end]
2051
                            - arclen[seg_start])/s_segment)
2052

2053
    return offset
9✔
2054

2055

2056
#
2057
# Gang of Four plot
2058
#
2059
def gangof4_response(
9✔
2060
        P, C, omega=None, omega_limits=None, omega_num=None, Hz=False):
2061
    """Compute the response of the "Gang of 4" transfer functions for a system.
2062

2063
    Generates a 2x2 frequency response for the "Gang of 4" sensitivity
2064
    functions [T, PS; CS, S].
2065

2066
    Parameters
2067
    ----------
2068
    P, C : LTI
2069
        Linear input/output systems (process and control).
2070
    omega : array
2071
        Range of frequencies (list or bounds) in rad/sec.
2072
    omega_limits : array_like of two values
2073
        Set limits for plotted frequency range. If Hz=True the limits are
2074
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2075
        elements is equivalent to providing ``omega_limits``. Ignored if
2076
        data is not a list of systems.
2077
    omega_num : int
2078
        Number of samples to use for the frequeny range.  Defaults to
2079
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
2080
        not a list of systems.
2081
    Hz : bool, optional
2082
        If True, when computing frequency limits automatically set
2083
        limits to full decades in Hz instead of rad/s.
2084

2085
    Returns
2086
    -------
2087
    response : :class:`~control.FrequencyResponseData`
2088
        Frequency response with inputs 'r' and 'd' and outputs 'y', and 'u'
2089
        representing the 2x2 matrix of transfer functions in the Gang of 4.
2090

2091
    Examples
2092
    --------
2093
    >>> P = ct.tf([1], [1, 1])
2094
    >>> C = ct.tf([2], [1])
2095
    >>> response = ct.gangof4_response(P, C)
2096
    >>> lines = response.plot()
2097

2098
    """
2099
    if not P.issiso() or not C.issiso():
9✔
2100
        # TODO: Add MIMO go4 plots.
2101
        raise ControlMIMONotImplemented(
×
2102
            "Gang of four is currently only implemented for SISO systems.")
2103

2104
    # Compute the senstivity functions
2105
    L = P * C
9✔
2106
    S = feedback(1, L)
9✔
2107
    T = L * S
9✔
2108

2109
    # Select a default range if none is provided
2110
    # TODO: This needs to be made more intelligent
2111
    omega, _ = _determine_omega_vector(
9✔
2112
        [P, C, S], omega, omega_limits, omega_num, Hz=Hz)
2113

2114
    #
2115
    # bode_plot based implementation
2116
    #
2117

2118
    # Compute the response of the Gang of 4
2119
    resp_T = T(1j * omega)
9✔
2120
    resp_PS = (P * S)(1j * omega)
9✔
2121
    resp_CS = (C * S)(1j * omega)
9✔
2122
    resp_S = S(1j * omega)
9✔
2123

2124
    # Create a single frequency response data object with the underlying data
2125
    data = np.empty((2, 2, omega.size), dtype=complex)
9✔
2126
    data[0, 0, :] = resp_T
9✔
2127
    data[0, 1, :] = resp_PS
9✔
2128
    data[1, 0, :] = resp_CS
9✔
2129
    data[1, 1, :] = resp_S
9✔
2130

2131
    return FrequencyResponseData(
9✔
2132
        data, omega, outputs=['y', 'u'], inputs=['r', 'd'],
2133
        title=f"Gang of Four for P={P.name}, C={C.name}",
2134
        sysname=f"P={P.name}, C={C.name}", plot_phase=False)
2135

2136

2137
def gangof4_plot(
9✔
2138
        *args, omega=None, omega_limits=None, omega_num=None,
2139
        Hz=False, **kwargs):
2140
    """Plot the response of the "Gang of 4" transfer functions for a system.
2141

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

2145
        gangof4_plot(response[, ...])
2146
        gangof4_plot(P, C[, ...])
2147

2148
    Parameters
2149
    ----------
2150
    response : FrequencyPlotData
2151
        Gang of 4 frequency response from `gangof4_response`.
2152
    P, C : LTI
2153
        Linear input/output systems (process and control).
2154
    omega : array
2155
        Range of frequencies (list or bounds) in rad/sec.
2156
    omega_limits : array_like of two values
2157
        Set limits for plotted frequency range. If Hz=True the limits are
2158
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2159
        elements is equivalent to providing ``omega_limits``. Ignored if
2160
        data is not a list of systems.
2161
    omega_num : int
2162
        Number of samples to use for the frequeny range.  Defaults to
2163
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
2164
        not a list of systems.
2165
    Hz : bool, optional
2166
        If True, when computing frequency limits automatically set
2167
        limits to full decades in Hz instead of rad/s.
2168

2169
    Returns
2170
    -------
2171
    cplt : :class:`ControlPlot` object
2172
        Object containing the data that were plotted:
2173

2174
          * cplt.lines: 2x2 array of :class:`matplotlib.lines.Line2D`
2175
            objects for each line in the plot.  The value of each array
2176
            entry is a list of Line2D objects in that subplot.
2177

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

2180
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
2181

2182
          * cplt.legend: legend object(s) contained in the plot
2183

2184
        See :class:`ControlPlot` for more detailed information.
2185

2186
    """
2187
    if len(args) == 1 and isinstance(arg, FrequencyResponseData):
9✔
2188
        if any([kw is not None
×
2189
                for kw in [omega, omega_limits, omega_num, Hz]]):
2190
            raise ValueError(
×
2191
                "omega, omega_limits, omega_num, Hz not allowed when "
2192
                "given a Gang of 4 response as first argument")
2193
        return args[0].plot(kwargs)
×
2194
    else:
2195
        if len(args) > 3:
9✔
2196
            raise TypeError(
×
2197
                f"expecting 2 or 3 positional arguments; received {len(args)}")
2198
        omega = omega if len(args) < 3 else args[2]
9✔
2199
        args = args[0:2]
9✔
2200
        return gangof4_response(
9✔
2201
            *args, omega=omega, omega_limits=omega_limits,
2202
            omega_num=omega_num, Hz=Hz).plot(**kwargs)
2203

2204
#
2205
# Singular values plot
2206
#
2207
def singular_values_response(
9✔
2208
        sysdata, omega=None, omega_limits=None, omega_num=None, Hz=False):
2209
    """Singular value response for a system.
2210

2211
    Computes the singular values for a system or list of systems over
2212
    a (optional) frequency range.
2213

2214
    Parameters
2215
    ----------
2216
    sysdata : LTI or list of LTI
2217
        List of linear input/output systems (single system is OK).
2218
    omega : array_like
2219
        List of frequencies in rad/sec to be used for frequency response.
2220
    Hz : bool, optional
2221
        If True, when computing frequency limits automatically set
2222
        limits to full decades in Hz instead of rad/s.
2223

2224
    Returns
2225
    -------
2226
    response : FrequencyResponseData
2227
        Frequency response with the number of outputs equal to the
2228
        number of singular values in the response, and a single input.
2229

2230
    Other Parameters
2231
    ----------------
2232
    omega_limits : array_like of two values
2233
        Set limits for plotted frequency range. If Hz=True the limits are
2234
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2235
        elements is equivalent to providing ``omega_limits``.
2236
    omega_num : int, optional
2237
        Number of samples to use for the frequeny range.  Defaults to
2238
        config.defaults['freqplot.number_of_samples'].
2239

2240
    See Also
2241
    --------
2242
    singular_values_plot
2243

2244
    Examples
2245
    --------
2246
    >>> omegas = np.logspace(-4, 1, 1000)
2247
    >>> den = [75, 1]
2248
    >>> G = ct.tf([[[87.8], [-86.4]], [[108.2], [-109.6]]],
2249
    ...           [[den, den], [den, den]])
2250
    >>> response = ct.singular_values_response(G, omega=omegas)
2251

2252
    """
2253
    # Convert the first argument to a list
2254
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
2255

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

2259
    # Compute the frequency responses for the systems
2260
    responses = frequency_response(
9✔
2261
        syslist, omega=omega, omega_limits=omega_limits,
2262
        omega_num=omega_num, Hz=Hz, squeeze=False)
2263

2264
    # Calculate the singular values for each system in the list
2265
    svd_responses = []
9✔
2266
    for response in responses:
9✔
2267
        # Compute the singular values (permute indices to make things work)
2268
        fresp_permuted = response.fresp.transpose((2, 0, 1))
9✔
2269
        sigma = np.linalg.svd(fresp_permuted, compute_uv=False).transpose()
9✔
2270
        sigma_fresp = sigma.reshape(sigma.shape[0], 1, sigma.shape[1])
9✔
2271

2272
        # Save the singular values as an FRD object
2273
        svd_responses.append(
9✔
2274
            FrequencyResponseData(
2275
                sigma_fresp, response.omega, _return_singvals=True,
2276
                outputs=[f'$\\sigma_{{{k+1}}}$' for k in range(sigma.shape[0])],
2277
                inputs='inputs', dt=response.dt, plot_phase=False,
2278
                sysname=response.sysname, plot_type='svplot',
2279
                title=f"Singular values for {response.sysname}"))
2280

2281
    if isinstance(sysdata, (list, tuple)):
9✔
2282
        return FrequencyResponseList(svd_responses)
9✔
2283
    else:
2284
        return svd_responses[0]
9✔
2285

2286

2287
def singular_values_plot(
9✔
2288
        data, omega=None, *fmt, plot=None, omega_limits=None, omega_num=None,
2289
        ax=None, label=None, title=None, **kwargs):
2290
    """Plot the singular values for a system.
2291

2292
    Plot the singular values as a function of frequency for a system or
2293
    list of systems.  If multiple systems are plotted, each system in the
2294
    list is plotted in a different color.
2295

2296
    Parameters
2297
    ----------
2298
    data : list of `FrequencyResponseData`
2299
        List of :class:`FrequencyResponseData` objects.  For backward
2300
        compatibility, a list of LTI systems can also be given.
2301
    omega : array_like
2302
        List of frequencies in rad/sec over to plot over.
2303
    *fmt : :func:`matplotlib.pyplot.plot` format string, optional
2304
        Passed to `matplotlib` as the format string for all lines in the plot.
2305
        The `omega` parameter must be present (use omega=None if needed).
2306
    dB : bool
2307
        If True, plot result in dB.  Default is False.
2308
    Hz : bool
2309
        If True, plot frequency in Hz (omega must be provided in rad/sec).
2310
        Default value (False) set by config.defaults['freqplot.Hz'].
2311
    **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
2312
        Additional keywords passed to `matplotlib` to specify line properties.
2313

2314
    Returns
2315
    -------
2316
    cplt : :class:`ControlPlot` object
2317
        Object containing the data that were plotted:
2318

2319
          * cplt.lines: 1-D array of :class:`matplotlib.lines.Line2D` objects.
2320
            The size of the array matches the number of systems and the
2321
            value of the array is a list of Line2D objects for that system.
2322

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

2325
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
2326

2327
          * cplt.legend: legend object(s) contained in the plot
2328

2329
        See :class:`ControlPlot` for more detailed information.
2330

2331
    Other Parameters
2332
    ----------------
2333
    ax : matplotlib.axes.Axes, optional
2334
        The matplotlib axes to draw the figure on.  If not specified and
2335
        the current figure has a single axes, that axes is used.
2336
        Otherwise, a new figure is created.
2337
    grid : bool
2338
        If True, plot grid lines on gain and phase plots.  Default is set by
2339
        `config.defaults['freqplot.grid']`.
2340
    label : str or array_like of str, optional
2341
        If present, replace automatically generated label(s) with the given
2342
        label(s).  If sysdata is a list, strings should be specified for each
2343
        system.
2344
    legend_loc : int or str, optional
2345
        Include a legend in the given location. Default is 'center right',
2346
        with no legend for a single response.  Use False to suppress legend.
2347
    omega_limits : array_like of two values
2348
        Set limits for plotted frequency range. If Hz=True the limits are
2349
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2350
        elements is equivalent to providing ``omega_limits``.
2351
    omega_num : int, optional
2352
        Number of samples to use for the frequeny range.  Defaults to
2353
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
2354
        not a list of systems.
2355
    plot : bool, optional
2356
        (legacy) If given, `singular_values_plot` returns the legacy return
2357
        values of magnitude, phase, and frequency.  If False, just return
2358
        the values with no plot.
2359
    rcParams : dict
2360
        Override the default parameters used for generating plots.
2361
        Default is set up config.default['freqplot.rcParams'].
2362
    show_legend : bool, optional
2363
        Force legend to be shown if ``True`` or hidden if ``False``.  If
2364
        ``None``, then show legend when there is more than one line on an
2365
        axis or ``legend_loc`` or ``legend_map`` has been specified.
2366
    title : str, optional
2367
        Set the title of the plot.  Defaults to plot type and system name(s).
2368

2369
    See Also
2370
    --------
2371
    singular_values_response
2372

2373
    Notes
2374
    -----
2375
    1. If plot==False, the following legacy values are returned:
2376
         * mag : ndarray (or list of ndarray if len(data) > 1))
2377
             Magnitude of the response (deprecated).
2378
         * phase : ndarray (or list of ndarray if len(data) > 1))
2379
             Phase in radians of the response (deprecated).
2380
         * omega : ndarray (or list of ndarray if len(data) > 1))
2381
             Frequency in rad/sec (deprecated).
2382

2383
    """
2384
    # Keyword processing
2385
    color = kwargs.pop('color', None)
9✔
2386
    dB = config._get_param(
9✔
2387
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
2388
    Hz = config._get_param(
9✔
2389
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
2390
    grid = config._get_param(
9✔
2391
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
2392
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
2393
    title_frame = config._get_param(
9✔
2394
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
2395

2396
    # If argument was a singleton, turn it into a tuple
2397
    data = data if isinstance(data, (list, tuple)) else (data,)
9✔
2398

2399
    # Convert systems into frequency responses
2400
    if any([isinstance(response, (StateSpace, TransferFunction))
9✔
2401
            for response in data]):
2402
        responses = singular_values_response(
9✔
2403
                    data, omega=omega, omega_limits=omega_limits,
2404
                    omega_num=omega_num)
2405
    else:
2406
        # Generate warnings if frequency keywords were given
2407
        if omega_num is not None:
9✔
2408
            warnings.warn("`omega_num` ignored when passed response data")
9✔
2409
        elif omega is not None:
9✔
2410
            warnings.warn("`omega` ignored when passed response data")
9✔
2411

2412
        # Check to make sure omega_limits is sensible
2413
        if omega_limits is not None and \
9✔
2414
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
2415
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
2416

2417
        responses = data
9✔
2418

2419
    # Process label keyword
2420
    line_labels = _process_line_labels(label, len(data))
9✔
2421

2422
    # Process (legacy) plot keyword
2423
    if plot is not None:
9✔
2424
        warnings.warn(
×
2425
            "`singular_values_plot` return values of sigma, omega is "
2426
            "deprecated; use singular_values_response()", DeprecationWarning)
2427

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

2433
    # Extract the data we need for plotting
2434
    sigmas = [np.real(response.fresp[:, 0, :]) for response in responses]
9✔
2435
    omegas = [response.omega for response in responses]
9✔
2436

2437
    # Legacy processing for no plotting case
2438
    if plot is False:
9✔
2439
        if len(data) == 1:
×
2440
            return sigmas[0], omegas[0]
×
2441
        else:
2442
            return sigmas, omegas
×
2443

2444
    fig, ax_sigma = _process_ax_keyword(
9✔
2445
        ax, shape=(1, 1), squeeze=True, rcParams=rcParams)
2446
    ax_sigma.set_label('control-sigma')         # TODO: deprecate?
9✔
2447
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
2448
        kwargs, None, 'center right')
2449

2450
    # Get color offset for first (new) line to be drawn
2451
    color_offset, color_cycle = _get_color_offset(ax_sigma)
9✔
2452

2453
    # Create a list of lines for the output
2454
    out = np.empty(len(data), dtype=object)
9✔
2455

2456
    # Plot the singular values for each response
2457
    for idx_sys, response in enumerate(responses):
9✔
2458
        sigma = sigmas[idx_sys].transpose()     # frequency first for plotting
9✔
2459
        omega = omegas[idx_sys] / (2 * math.pi) if Hz else  omegas[idx_sys]
9✔
2460

2461
        if response.isdtime(strict=True):
9✔
2462
            nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
2463
        else:
2464
            nyq_freq = None
9✔
2465

2466
        # Determine the color to use for this response
2467
        color = _get_color(
9✔
2468
            color, fmt=fmt, offset=color_offset + idx_sys,
2469
            color_cycle=color_cycle)
2470

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

2474
        # Decide on the system name
2475
        sysname = response.sysname if response.sysname is not None \
9✔
2476
            else f"Unknown-{idx_sys}"
2477

2478
        # Get the label to use for the line
2479
        label = sysname if line_labels is None else line_labels[idx_sys]
9✔
2480

2481
        # Plot the data
2482
        if dB:
9✔
2483
            out[idx_sys] = ax_sigma.semilogx(
9✔
2484
                omega, 20 * np.log10(sigma), *fmt,
2485
                label=label, **color_arg, **kwargs)
2486
        else:
2487
            out[idx_sys] = ax_sigma.loglog(
9✔
2488
                omega, sigma, label=label, *fmt, **color_arg, **kwargs)
2489

2490
        # Plot the Nyquist frequency
2491
        if nyq_freq is not None:
9✔
2492
            ax_sigma.axvline(
9✔
2493
                nyq_freq, linestyle='--', label='_nyq_freq_' + sysname,
2494
                **color_arg)
2495

2496
    # If specific omega_limits were given, use them
2497
    if omega_limits is not None:
9✔
2498
        ax_sigma.set_xlim(omega_limits)
9✔
2499

2500
    # Add a grid to the plot + labeling
2501
    if grid:
9✔
2502
        ax_sigma.grid(grid, which='both')
9✔
2503

2504
    ax_sigma.set_ylabel(
9✔
2505
        "Singular Values [dB]" if dB else "Singular Values")
2506
    ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]")
9✔
2507

2508
    # List of systems that are included in this plot
2509
    lines, labels = _get_line_labels(ax_sigma)
9✔
2510

2511
    # Add legend if there is more than one system plotted
2512
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2513
        with plt.rc_context(rcParams):
9✔
2514
            legend = ax_sigma.legend(lines, labels, loc=legend_loc)
9✔
2515
    else:
2516
        legend = None
9✔
2517

2518
    # Add the title
2519
    if ax is None:
9✔
2520
        if title is None:
9✔
2521
            title = "Singular values for " + ", ".join(labels)
9✔
2522
        _update_plot_title(
9✔
2523
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2524
            use_existing=False)
2525

2526
    # Legacy return processing
2527
    if plot is not None:
9✔
2528
        if len(responses) == 1:
×
2529
            return sigmas[0], omegas[0]
×
2530
        else:
2531
            return sigmas, omegas
×
2532

2533
    return ControlPlot(out, ax_sigma, fig, legend=legend)
9✔
2534

2535
#
2536
# Utility functions
2537
#
2538
# This section of the code contains some utility functions for
2539
# generating frequency domain plots.
2540
#
2541

2542

2543
# Determine the frequency range to be used
2544
def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
9✔
2545
                            Hz=None, feature_periphery_decades=None):
2546
    """Determine the frequency range for a frequency-domain plot
2547
    according to a standard logic.
2548

2549
    If omega_in and omega_limits are both None, then omega_out is computed
2550
    on omega_num points according to a default logic defined by
2551
    _default_frequency_range and tailored for the list of systems syslist, and
2552
    omega_range_given is set to False.
2553

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

2559
    If omega_in is a list or tuple of length 2, it is interpreted as a
2560
    range and handled like omega_limits.  If omega_in is a list or tuple of
2561
    length 3, it is interpreted a range plus number of points and handled
2562
    like omega_limits and omega_num.
2563

2564
    If omega_in is an array or a list/tuple of length greater than
2565
    two, then omega_out is set to omega_in (as an array), and
2566
    omega_range_given is set to True
2567

2568
    Parameters
2569
    ----------
2570
    syslist : list of LTI
2571
        List of linear input/output systems (single system is OK).
2572
    omega_in : 1D array_like or None
2573
        Frequency range specified by the user.
2574
    omega_limits : 1D array_like or None
2575
        Frequency limits specified by the user.
2576
    omega_num : int
2577
        Number of points to be used for the frequency range (if the
2578
        frequency range is not user-specified).
2579
    Hz : bool, optional
2580
        If True, the limits (first and last value) of the frequencies
2581
        are set to full decades in Hz so it fits plotting with logarithmic
2582
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2583

2584
    Returns
2585
    -------
2586
    omega_out : 1D array
2587
        Frequency range to be used.
2588
    omega_range_given : bool
2589
        True if the frequency range was specified by the user, either through
2590
        omega_in or through omega_limits. False if both omega_in
2591
        and omega_limits are None.
2592

2593
    """
2594
    # Handle the special case of a range of frequencies
2595
    if omega_in is not None and omega_limits is not None:
9✔
2596
        warnings.warn(
×
2597
            "omega and omega_limits both specified; ignoring limits")
2598
    elif isinstance(omega_in, (list, tuple)) and len(omega_in) == 2:
9✔
2599
        omega_limits = omega_in
9✔
2600
        omega_in = None
9✔
2601

2602
    omega_range_given = True
9✔
2603
    if omega_in is None:
9✔
2604
        if omega_limits is None:
9✔
2605
            omega_range_given = False
9✔
2606
            # Select a default range if none is provided
2607
            omega_out = _default_frequency_range(
9✔
2608
                syslist, number_of_samples=omega_num, Hz=Hz,
2609
                feature_periphery_decades=feature_periphery_decades)
2610
        else:
2611
            omega_limits = np.asarray(omega_limits)
9✔
2612
            if len(omega_limits) != 2:
9✔
2613
                raise ValueError("len(omega_limits) must be 2")
×
2614
            omega_out = np.logspace(np.log10(omega_limits[0]),
9✔
2615
                                    np.log10(omega_limits[1]),
2616
                                    num=omega_num, endpoint=True)
2617
    else:
2618
        omega_out = np.copy(omega_in)
9✔
2619

2620
    return omega_out, omega_range_given
9✔
2621

2622

2623
# Compute reasonable defaults for axes
2624
def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
9✔
2625
                             feature_periphery_decades=None):
2626
    """Compute a default frequency range for frequency domain plots.
2627

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

2633
    Parameters
2634
    ----------
2635
    syslist : list of LTI
2636
        List of linear input/output systems (single system is OK)
2637
    Hz : bool, optional
2638
        If True, the limits (first and last value) of the frequencies
2639
        are set to full decades in Hz so it fits plotting with logarithmic
2640
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2641
    number_of_samples : int, optional
2642
        Number of samples to generate.  The default value is read from
2643
        ``config.defaults['freqplot.number_of_samples'].  If None, then the
2644
        default from `numpy.logspace` is used.
2645
    feature_periphery_decades : float, optional
2646
        Defines how many decades shall be included in the frequency range on
2647
        both sides of features (poles, zeros).  The default value is read from
2648
        ``config.defaults['freqplot.feature_periphery_decades']``.
2649

2650
    Returns
2651
    -------
2652
    omega : array
2653
        Range of frequencies in rad/sec
2654

2655
    Examples
2656
    --------
2657
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
2658
    >>> omega = ct._default_frequency_range(G)
2659
    >>> omega.min(), omega.max()
2660
    (0.1, 100.0)
2661

2662
    """
2663
    # Set default values for options
2664
    number_of_samples = config._get_param(
9✔
2665
        'freqplot', 'number_of_samples', number_of_samples)
2666
    feature_periphery_decades = config._get_param(
9✔
2667
        'freqplot', 'feature_periphery_decades', feature_periphery_decades, 1)
2668

2669
    # Find the list of all poles and zeros in the systems
2670
    features = np.array(())
9✔
2671
    freq_interesting = []
9✔
2672

2673
    # detect if single sys passed by checking if it is sequence-like
2674
    if not hasattr(syslist, '__iter__'):
9✔
2675
        syslist = (syslist,)
9✔
2676

2677
    for sys in syslist:
9✔
2678
        # For FRD systems, just use the response frequencies
2679
        if isinstance(sys, FrequencyResponseData):
9✔
2680
            # Add the min and max frequency, minus periphery decades
2681
            # (keeps frequency ranges from artificially expanding)
2682
            features = np.concatenate([features, np.array([
9✔
2683
                np.min(sys.omega) * 10**feature_periphery_decades,
2684
                np.max(sys.omega) / 10**feature_periphery_decades])])
2685
            continue
9✔
2686

2687
        try:
9✔
2688
            # Add new features to the list
2689
            if sys.isctime():
9✔
2690
                features_ = np.concatenate(
9✔
2691
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2692
                # Get rid of poles and zeros at the origin
2693
                toreplace = np.isclose(features_, 0.0)
9✔
2694
                if np.any(toreplace):
9✔
2695
                    features_ = features_[~toreplace]
9✔
2696
            elif sys.isdtime(strict=True):
9✔
2697
                fn = math.pi / sys.dt
9✔
2698
                # TODO: What distance to the Nyquist frequency is appropriate?
2699
                freq_interesting.append(fn * 0.9)
9✔
2700

2701
                features_ = np.concatenate(
9✔
2702
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2703
                # Get rid of poles and zeros on the real axis (imag==0)
2704
                # * origin and real < 0
2705
                # * at 1.: would result in omega=0. (logaritmic plot!)
2706
                toreplace = np.isclose(features_.imag, 0.0) & (
9✔
2707
                                    (features_.real <= 0.) |
2708
                                    (np.abs(features_.real - 1.0) < 1.e-10))
2709
                if np.any(toreplace):
9✔
2710
                    features_ = features_[~toreplace]
9✔
2711
                # TODO: improve (mapping pack to continuous time)
2712
                features_ = np.abs(np.log(features_) / (1.j * sys.dt))
9✔
2713
            else:
2714
                # TODO
2715
                raise NotImplementedError(
2716
                    "type of system in not implemented now")
2717
            features = np.concatenate([features, features_])
9✔
2718
        except NotImplementedError:
9✔
2719
            # Don't add any features for anything we don't understand
2720
            pass
9✔
2721

2722
    # Make sure there is at least one point in the range
2723
    if features.shape[0] == 0:
9✔
2724
        features = np.array([1.])
9✔
2725

2726
    if Hz:
9✔
2727
        features /= 2. * math.pi
9✔
2728
    features = np.log10(features)
9✔
2729
    lsp_min = np.rint(np.min(features) - feature_periphery_decades)
9✔
2730
    lsp_max = np.rint(np.max(features) + feature_periphery_decades)
9✔
2731
    if Hz:
9✔
2732
        lsp_min += np.log10(2. * math.pi)
9✔
2733
        lsp_max += np.log10(2. * math.pi)
9✔
2734

2735
    if freq_interesting:
9✔
2736
        lsp_min = min(lsp_min, np.log10(min(freq_interesting)))
9✔
2737
        lsp_max = max(lsp_max, np.log10(max(freq_interesting)))
9✔
2738

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

2742
    # Set the range to be an order of magnitude beyond any features
2743
    if number_of_samples:
9✔
2744
        omega = np.logspace(
9✔
2745
            lsp_min, lsp_max, num=number_of_samples, endpoint=True)
2746
    else:
2747
        omega = np.logspace(lsp_min, lsp_max, endpoint=True)
×
2748
    return omega
9✔
2749

2750

2751
#
2752
# Utility functions to create nice looking labels (KLD 5/23/11)
2753
#
2754

2755
def get_pow1000(num):
9✔
2756
    """Determine exponent for which significand of a number is within the
2757
    range [1, 1000).
2758
    """
2759
    # Based on algorithm from http://www.mail-archive.com/
2760
    # matplotlib-users@lists.sourceforge.net/msg14433.html, accessed 2010/11/7
2761
    # by Jason Heeris 2009/11/18
2762
    from decimal import Decimal
9✔
2763
    from math import floor
9✔
2764
    dnum = Decimal(str(num))
9✔
2765
    if dnum == 0:
9✔
2766
        return 0
9✔
2767
    elif dnum < 0:
9✔
2768
        dnum = -dnum
×
2769
    return int(floor(dnum.log10() / 3))
9✔
2770

2771

2772
def gen_prefix(pow1000):
9✔
2773
    """Return the SI prefix for a power of 1000.
2774
    """
2775
    # Prefixes according to Table 5 of [BIPM 2006] (excluding hecto,
2776
    # deca, deci, and centi).
2777
    if pow1000 < -8 or pow1000 > 8:
9✔
2778
        raise ValueError(
×
2779
            "Value is out of the range covered by the SI prefixes.")
2780
    return ['Y',  # yotta (10^24)
9✔
2781
            'Z',  # zetta (10^21)
2782
            'E',  # exa (10^18)
2783
            'P',  # peta (10^15)
2784
            'T',  # tera (10^12)
2785
            'G',  # giga (10^9)
2786
            'M',  # mega (10^6)
2787
            'k',  # kilo (10^3)
2788
            '',  # (10^0)
2789
            'm',  # milli (10^-3)
2790
            r'$\mu$',  # micro (10^-6)
2791
            'n',  # nano (10^-9)
2792
            'p',  # pico (10^-12)
2793
            'f',  # femto (10^-15)
2794
            'a',  # atto (10^-18)
2795
            'z',  # zepto (10^-21)
2796
            'y'][8 - pow1000]  # yocto (10^-24)
2797

2798

2799
# Function aliases
2800
bode = bode_plot
9✔
2801
nyquist = nyquist_plot
9✔
2802
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