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

python-control / python-control / 13023516033

29 Jan 2025 02:31AM UTC coverage: 94.649% (+0.001%) from 94.648%
13023516033

push

github

web-flow
Merge pull request #1104

Add test for Nyquist evaluation at a pole

9676 of 10223 relevant lines covered (94.65%)

8.26 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

230
    See Also
231
    --------
232
    frequency_response
233

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

673
        return label
9✔
674

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

958
                # Find the midpoint between the row axes (+ tight_layout)
959
                _, ypos = _find_axes_center(fig, [ax_mag, ax_phase])
9✔
960

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

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

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

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

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

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

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

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

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

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

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

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

1066
        if len(data) == 1:
9✔
1067
            return mag_data[0], phase_data[0], omega_data[0]
9✔
1068
        else:
1069
            return mag_data, phase_data, omega_data
9✔
1070

1071
    return ControlPlot(out, ax_array, fig, legend=legend_array)
9✔
1072

1073

1074
#
1075
# Nyquist plot
1076
#
1077

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

1096

1097
class NyquistResponseData:
9✔
1098
    """Nyquist response data object.
1099

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

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

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

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

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

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

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

1156

1157
class NyquistResponseList(list):
9✔
1158
    def plot(self, *args, **kwargs):
9✔
1159
        return nyquist_plot(self, *args, **kwargs)
9✔
1160

1161

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

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

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

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

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

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

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

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

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

1254
    See Also
1255
    --------
1256
    nyquist_plot
1257

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

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

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

1286
    if _check_kwargs and _kwargs:
9✔
1287
        raise TypeError("unrecognized keywords: ", str(_kwargs))
9✔
1288

1289
    # Convert the first argument to a list
1290
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
1291

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

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

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

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

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

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

1334
        # do indentations in s-plane where it is more convenient
1335
        splane_contour = 1j * omega_sys
9✔
1336

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

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

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

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

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

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

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

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

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

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

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

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

1440
                        else:
1441
                            raise ValueError(
9✔
1442
                                "unknown value for indent_direction")
1443

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

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

1456
        # Compute the primary curve
1457
        resp = sys(contour)
9✔
1458

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

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

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

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

1507
        # Decide on system name
1508
        sysname = sys.name if sys.name is not None else f"Unknown-{idx}"
9✔
1509

1510
        responses.append(NyquistResponseData(
9✔
1511
            count, contour, resp, sys.dt, sysname=sysname,
1512
            return_contour=return_contour))
1513

1514
    if isinstance(sysdata, (list, tuple)):
9✔
1515
        return NyquistResponseList(responses)
9✔
1516
    else:
1517
        return responses[0]
9✔
1518

1519

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

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

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

1554
    Returns
1555
    -------
1556
    cplt : :class:`ControlPlot` object
1557
        Object containing the data that were plotted:
1558

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

1565
              - lines[idx, 0]: unscaled portion of the primary curve
1566
              - lines[idx, 1]: scaled portion of the primary curve
1567
              - lines[idx, 2]: unscaled portion of the mirror curve
1568
              - lines[idx, 3]: scaled portion of the mirror curve
1569

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

1572
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
1573

1574
          * cplt.legend: legend object(s) contained in the plot
1575

1576
        See :class:`ControlPlot` for more detailed information.
1577

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

1683
    See Also
1684
    --------
1685
    nyquist_response
1686

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

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

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

1711
    Examples
1712
    --------
1713
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1714
    >>> out = ct.nyquist_plot(G)
1715

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

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

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

1767
    primary_style = _parse_linestyle('primary_style')
9✔
1768
    mirror_style = _parse_linestyle('mirror_style', allow_false=True)
9✔
1769

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

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

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

1791
    # Process label keyword
1792
    line_labels = _process_line_labels(label, len(data))
9✔
1793

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

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

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

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

1824
        if len(data) == 1:
9✔
1825
            counts, contours = counts[0], contours[0]
9✔
1826

1827
        # Return counts and (optionally) the contour we used
1828
        return (counts, contours) if return_contour else counts
9✔
1829

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

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

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

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

1855
        scale_mask = ~reg_mask \
9✔
1856
            & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \
1857
            & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1]))
1858

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

1864
        # Get the label to use for the line
1865
        label = response.sysname if line_labels is None else line_labels[idx]
9✔
1866

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

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

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

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

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

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

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

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

1931
        # Mark the -1 point
1932
        plt.plot([-1], [0], 'r+')
9✔
1933

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

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

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

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

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

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

1987
                # Get the SI prefix.
1988
                prefix = gen_prefix(pow1000)
9✔
1989

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

2000
    # Label the axes
2001
    ax.set_xlabel("Real axis")
9✔
2002
    ax.set_ylabel("Imaginary axis")
9✔
2003
    ax.grid(color="lightgray")
9✔
2004

2005
    # List of systems that are included in this plot
2006
    lines, labels = _get_line_labels(ax)
9✔
2007

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

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

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

2031
        # Return counts and (optionally) the contour we used
2032
        return (counts, contours) if return_contour else counts
9✔
2033

2034
    return ControlPlot(out, ax, fig, legend=legend)
9✔
2035

2036

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

2048
    # Initialize the offset
2049
    offset = np.zeros(resp.size)
9✔
2050
    arclen = np.zeros(resp.size)
9✔
2051

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

2063
        nsegs += 0.5
9✔
2064
        if i == resp.size:
9✔
2065
            break
9✔
2066

2067
        # Save the starting offset of this segment
2068
        seg_start = i
9✔
2069

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

2078
        nsegs += 0.5
9✔
2079
        if i == resp.size:
9✔
2080
            break
9✔
2081

2082
        # Save the ending offset of this segment
2083
        seg_end = i
9✔
2084

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

2091
    return offset
9✔
2092

2093

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

2101
    Generates a 2x2 frequency response for the "Gang of 4" sensitivity
2102
    functions [T, PS; CS, S].
2103

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

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

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

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

2142
    # Compute the senstivity functions
2143
    L = P * C
9✔
2144
    S = feedback(1, L)
9✔
2145
    T = L * S
9✔
2146

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

2152
    #
2153
    # bode_plot based implementation
2154
    #
2155

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

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

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

2174

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

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

2183
        gangof4_plot(response[, ...])
2184
        gangof4_plot(P, C[, ...])
2185

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

2207
    Returns
2208
    -------
2209
    cplt : :class:`ControlPlot` object
2210
        Object containing the data that were plotted:
2211

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

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

2218
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
2219

2220
          * cplt.legend: legend object(s) contained in the plot
2221

2222
        See :class:`ControlPlot` for more detailed information.
2223

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

2242

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

2250
    Computes the singular values for a system or list of systems over
2251
    a (optional) frequency range.
2252

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

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

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

2279
    See Also
2280
    --------
2281
    singular_values_plot
2282

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

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

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

2298
    # Compute the frequency responses for the systems
2299
    responses = frequency_response(
9✔
2300
        syslist, omega=omega, omega_limits=omega_limits,
2301
        omega_num=omega_num, Hz=Hz, squeeze=False)
2302

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

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

2320
    if isinstance(sysdata, (list, tuple)):
9✔
2321
        return FrequencyResponseList(svd_responses)
9✔
2322
    else:
2323
        return svd_responses[0]
9✔
2324

2325

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

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

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

2353
    Returns
2354
    -------
2355
    cplt : :class:`ControlPlot` object
2356
        Object containing the data that were plotted:
2357

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

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

2364
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
2365

2366
          * cplt.legend: legend object(s) contained in the plot
2367

2368
        See :class:`ControlPlot` for more detailed information.
2369

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

2415
    See Also
2416
    --------
2417
    singular_values_response
2418

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

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

2442
    # If argument was a singleton, turn it into a tuple
2443
    data = data if isinstance(data, (list, tuple)) else (data,)
9✔
2444

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

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

2463
        responses = data
9✔
2464

2465
    # Process label keyword
2466
    line_labels = _process_line_labels(label, len(data))
9✔
2467

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

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

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

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

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

2496
    # Get color offset for first (new) line to be drawn
2497
    color_offset, color_cycle = _get_color_offset(ax_sigma)
9✔
2498

2499
    # Create a list of lines for the output
2500
    out = np.empty(len(data), dtype=object)
9✔
2501

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

2507
        if response.isdtime(strict=True):
9✔
2508
            nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
2509
        else:
2510
            nyq_freq = None
9✔
2511

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

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

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

2524
        # Get the label to use for the line
2525
        label = sysname if line_labels is None else line_labels[idx_sys]
9✔
2526

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

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

2542
    # If specific omega_limits were given, use them
2543
    if omega_limits is not None:
9✔
2544
        ax_sigma.set_xlim(omega_limits)
9✔
2545

2546
    # Add a grid to the plot + labeling
2547
    if grid:
9✔
2548
        ax_sigma.grid(grid, which='both')
9✔
2549

2550
    ax_sigma.set_ylabel(
9✔
2551
        "Singular Values [dB]" if dB else "Singular Values")
2552
    ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]")
9✔
2553

2554
    # List of systems that are included in this plot
2555
    lines, labels = _get_line_labels(ax_sigma)
9✔
2556

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

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

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

2579
    return ControlPlot(out, ax_sigma, fig, legend=legend)
9✔
2580

2581
#
2582
# Utility functions
2583
#
2584
# This section of the code contains some utility functions for
2585
# generating frequency domain plots.
2586
#
2587

2588

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

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

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

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

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

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

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

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

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

2666
    return omega_out, omega_range_given
9✔
2667

2668

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

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

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

2696
    Returns
2697
    -------
2698
    omega : array
2699
        Range of frequencies in rad/sec
2700

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

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

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

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

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

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

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

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

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

2781
    if freq_interesting:
9✔
2782
        lsp_min = min(lsp_min, np.log10(min(freq_interesting)))
9✔
2783
        lsp_max = max(lsp_max, np.log10(max(freq_interesting)))
9✔
2784

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

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

2796

2797
#
2798
# Utility functions to create nice looking labels (KLD 5/23/11)
2799
#
2800

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

2817

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

2844

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

© 2025 Coveralls, Inc