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

python-control / python-control / 10342271480

11 Aug 2024 07:36PM UTC coverage: 94.694%. Remained the same
10342271480

push

github

web-flow
Merge pull request #1037 from murrayrm/cds_examples-06Jul2024

CDS 110 and CDS 112 Jupyter notebooks

9137 of 9649 relevant lines covered (94.69%)

8.27 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

213
    See Also
214
    --------
215
    frequency_response
216

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

227
    2. If a discrete time model is given, the frequency response is plotted
228
       along the upper branch of the unit circle, using the mapping ``z =
229
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
230
       is the discrete timebase.  If timebase not specified (``dt=True``),
231
       `dt` is set to 1.
232

233
    Examples
234
    --------
235
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
236
    >>> out = ct.bode_plot(G)
237

238
    """
239
    #
240
    # Process keywords and set defaults
241
    #
242

243
    # Make a copy of the kwargs dictionary since we will modify it
244
    kwargs = dict(kwargs)
9✔
245

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

263
    # Set the default labels
264
    freq_label = config._get_param(
9✔
265
        'freqplot', 'freq_label', kwargs, _freqplot_defaults, pop=True)
266
    if magnitude_label is None:
9✔
267
        magnitude_label = "Magnitude [dB]" if dB else "Magnitude"
9✔
268
    if phase_label is None:
9✔
269
        phase_label = "Phase [deg]" if deg else "Phase [rad]"
9✔
270

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

284
    # Legacy keywords for margins
285
    display_margins = config._process_legacy_keyword(
9✔
286
        kwargs, 'margins', 'display_margins', display_margins)
287
    if kwargs.pop('margin_info', False):
9✔
288
        warnings.warn(
×
289
            "keyword 'margin_info' is deprecated; "
290
            "use 'display_margins='overlay'")
291
        if display_margins is False:
×
292
            raise ValueError(
×
293
                "conflicting_keywords: `display_margins` and `margin_info`")
294
    margins_method = config._process_legacy_keyword(
9✔
295
        kwargs, 'method', 'margins_method', margins_method)
296

297
    if not isinstance(data, (list, tuple)):
9✔
298
        data = [data]
9✔
299

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

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

323
        # Check to make sure omega_limits is sensible
324
        if omega_limits is not None and \
9✔
325
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
326
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
327

328
    # If plot_phase is not specified, check the data first, otherwise true
329
    if plot_phase is None:
9✔
330
        plot_phase = True if data[0].plot_phase is None else data[0].plot_phase
9✔
331

332
    if not plot_magnitude and not plot_phase:
9✔
333
        raise ValueError(
9✔
334
            "plot_magnitude and plot_phase both False; no data to plot")
335

336
    mag_data, phase_data, omega_data = [], [], []
9✔
337
    for response in data:
9✔
338
        noutputs, ninputs = response.noutputs, response.ninputs
9✔
339

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

354
        # Reshape the phase to allow standard indexing
355
        phase = response.phase.copy().reshape((noutputs, ninputs, -1))
9✔
356

357
        # Shift and wrap the phase
358
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
359
            # Shift the phase if needed
360
            if abs(phase[i, j, 0] - initial_phase_value) > math.pi:
9✔
361
                phase[i, j] -= 2*math.pi * round(
9✔
362
                    (phase[i, j, 0] - initial_phase_value) / (2*math.pi))
363

364
            # Phase wrapping
365
            if wrap_phase is False:
9✔
366
                phase[i, j] = unwrap(phase[i, j]) # unwrap the phase
9✔
367
            elif wrap_phase is True:
9✔
368
                pass                                    # default calc OK
9✔
369
            elif isinstance(wrap_phase, (int, float)):
9✔
370
                phase[i, j] = unwrap(phase[i, j]) # unwrap phase first
9✔
371
                if deg:
9✔
372
                    wrap_phase *= math.pi/180.
9✔
373

374
                # Shift the phase if it is below the wrap_phase
375
                phase[i, j] += 2*math.pi * np.maximum(
9✔
376
                    0, np.ceil((wrap_phase - phase[i, j])/(2*math.pi)))
377
            else:
378
                raise ValueError("wrap_phase must be bool or float.")
×
379

380
        # Put the phase back into the original shape
381
        phase = phase.reshape(response.magnitude.shape)
9✔
382

383
        # Save the data for later use (legacy return values)
384
        mag_data.append(response.magnitude)
9✔
385
        phase_data.append(phase)
9✔
386
        omega_data.append(response.omega)
9✔
387

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

417
    if plot is not None:
9✔
418
        warnings.warn(
9✔
419
            "`bode_plot` return values of mag, phase, omega is deprecated; "
420
            "use frequency_response()", DeprecationWarning)
421

422
    if plot is False:
9✔
423
        # Process the data to match what we were sent
424
        for i in range(len(mag_data)):
9✔
425
            mag_data[i] = _process_frequency_response(
9✔
426
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
427
            phase_data[i] = _process_frequency_response(
9✔
428
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
429

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

469
    # Decide on the maximum number of inputs and outputs
470
    ninputs, noutputs = 0, 0
9✔
471
    for response in data:       # TODO: make more pythonic/numpic
9✔
472
        ninputs = max(ninputs, response.ninputs)
9✔
473
        noutputs = max(noutputs, response.noutputs)
9✔
474

475
    # Figure how how many rows and columns to use + offsets for inputs/outputs
476
    if overlay_outputs and overlay_inputs:
9✔
477
        nrows = plot_magnitude + plot_phase
9✔
478
        ncols = 1
9✔
479
    elif overlay_outputs:
9✔
480
        nrows = plot_magnitude + plot_phase
9✔
481
        ncols = ninputs
9✔
482
    elif overlay_inputs:
9✔
483
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
484
            (noutputs if plot_phase else 0)
485
        ncols = 1
9✔
486
    else:
487
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
488
            (noutputs if plot_phase else 0)
489
        ncols = ninputs
9✔
490

491
    if ax is None:
9✔
492
        # Set up default sharing of axis limits if not specified
493
        for kw in ['share_magnitude', 'share_phase', 'share_frequency']:
9✔
494
            if kw not in kwargs or kwargs[kw] is None:
9✔
495
                kwargs[kw] = config.defaults['freqplot.' + kw]
9✔
496

497
    fig, ax_array = _process_ax_keyword(
9✔
498
        ax, (nrows, ncols), squeeze=False, rcParams=rcParams, clear_text=True)
499
    legend_loc, legend_map, show_legend = _process_legend_keywords(
9✔
500
        kwargs, (nrows,ncols), 'center right')
501

502
    # Get the values for sharing axes limits
503
    share_magnitude = kwargs.pop('share_magnitude', None)
9✔
504
    share_phase = kwargs.pop('share_phase', None)
9✔
505
    share_frequency = kwargs.pop('share_frequency', None)
9✔
506

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

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

538
    else:
539
        mag_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
540
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
541
        for i in range(noutputs):
9✔
542
            for j in range(ninputs):
9✔
543
                if overlay_outputs and overlay_inputs:
9✔
544
                    mag_map[i, j] = (0, 0)
×
545
                    phase_map[i, j] = (1, 0)
×
546
                elif overlay_outputs:
9✔
547
                    mag_map[i, j] = (0, j)
×
548
                    phase_map[i, j] = (1, j)
×
549
                elif overlay_inputs:
9✔
550
                    mag_map[i, j] = (i*2, 0)
×
551
                    phase_map[i, j] = (i*2 + 1, 0)
×
552
                else:
553
                    mag_map[i, j] = (i*2, j)
9✔
554
                    phase_map[i, j] = (i*2 + 1, j)
9✔
555

556
    # Identity map needed for setting up shared axes
557
    ax_map = np.empty((nrows, ncols), dtype=tuple)
9✔
558
    for i, j in itertools.product(range(nrows), range(ncols)):
9✔
559
        ax_map[i, j] = (i, j)
9✔
560

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

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

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

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

630
    # Create a list of lines for the output
631
    out = np.empty((nrows, ncols), dtype=object)
9✔
632
    for i in range(nrows):
9✔
633
        for j in range(ncols):
9✔
634
            out[i, j] = []      # unique list in each element
9✔
635

636
    # Process label keyword
637
    line_labels = _process_line_labels(label, len(data), ninputs, noutputs)
9✔
638

639
    # Utility function for creating line label
640
    def _make_line_label(response, output_index, input_index):
9✔
641
        label = ""              # start with an empty label
9✔
642

643
        # Add the output name if it won't appear as an axes label
644
        if noutputs > 1 and overlay_outputs:
9✔
645
            label += response.output_labels[output_index]
9✔
646

647
        # Add the input name if it won't appear as a column label
648
        if ninputs > 1 and overlay_inputs:
9✔
649
            label += ", " if label != "" else ""
9✔
650
            label += response.input_labels[input_index]
9✔
651

652
        # Add the system name (will strip off later if redundant)
653
        label += ", " if label != "" else ""
9✔
654
        label += f"{response.sysname}"
9✔
655

656
        return label
9✔
657

658
    for index, response in enumerate(data):
9✔
659
        # Get the (pre-processed) data in fully indexed form
660
        mag = mag_data[index].reshape((noutputs, ninputs, -1))
9✔
661
        phase = phase_data[index].reshape((noutputs, ninputs, -1))
9✔
662
        omega_sys, sysname = omega_data[index], response.sysname
9✔
663

664
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
665
            # Get the axes to use for magnitude and phase
666
            ax_mag = ax_array[mag_map[i, j]]
9✔
667
            ax_phase = ax_array[phase_map[i, j]]
9✔
668

669
            # Get the frequencies and convert to Hz, if needed
670
            omega_plot = omega_sys / (2 * math.pi) if Hz else omega_sys
9✔
671
            if response.isdtime(strict=True):
9✔
672
                nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
673

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

678
            # Generate a label
679
            if line_labels is None:
9✔
680
                label = _make_line_label(response, i, j)
9✔
681
            else:
682
                label = line_labels[index, i, j]
9✔
683

684
            # Magnitude
685
            if plot_magnitude:
9✔
686
                pltfcn = ax_mag.semilogx if dB else ax_mag.loglog
9✔
687

688
                # Plot the main data
689
                lines = pltfcn(
9✔
690
                    omega_plot, mag_plot, *fmt, label=label, **kwargs)
691
                out[mag_map[i, j]] += lines
9✔
692

693
                # Save the information needed for the Nyquist line
694
                if response.isdtime(strict=True):
9✔
695
                    ax_mag.axvline(
9✔
696
                        nyq_freq, color=lines[0].get_color(), linestyle='--',
697
                        label='_nyq_mag_' + sysname)
698

699
                # Add a grid to the plot
700
                ax_mag.grid(grid and not display_margins, which='both')
9✔
701

702
            # Phase
703
            if plot_phase:
9✔
704
                lines = ax_phase.semilogx(
9✔
705
                    omega_plot, phase_plot, *fmt, label=label, **kwargs)
706
                out[phase_map[i, j]] += lines
9✔
707

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

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

717
        #
718
        # Display gain and phase margins (SISO only)
719
        #
720

721
        if display_margins:
9✔
722
            if ninputs > 1 or noutputs > 1:
9✔
723
                raise NotImplementedError(
724
                    "margins are not available for MIMO systems")
725

726
            # Compute stability margins for the system
727
            margins = stability_margins(response, method=margins_method)
9✔
728
            gm, pm, Wcg, Wcp = (margins[i] for i in [0, 1, 3, 4])
9✔
729

730
            # Figure out sign of the phase at the first gain crossing
731
            # (needed if phase_wrap is True)
732
            phase_at_cp = phase[
9✔
733
                0, 0, (np.abs(omega_data[0] - Wcp)).argmin()]
734
            if phase_at_cp >= 0.:
9✔
735
                phase_limit = 180.
×
736
            else:
737
                phase_limit = -180.
9✔
738

739
            if Hz:
9✔
740
                Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi)
9✔
741

742
            # Draw lines at gain and phase limits
743
            if plot_magnitude:
9✔
744
                ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':',
9✔
745
                               zorder=-20)
746
                mag_ylim = ax_mag.get_ylim()
9✔
747

748
            if plot_phase:
9✔
749
                ax_phase.axhline(y=phase_limit if deg else
9✔
750
                                 math.radians(phase_limit),
751
                                 color='k', linestyle=':', zorder=-20)
752
                phase_ylim = ax_phase.get_ylim()
9✔
753

754
            # Annotate the phase margin (if it exists)
755
            if plot_phase and pm != float('inf') and Wcp != float('nan'):
9✔
756
                # Draw dotted lines marking the gain crossover frequencies
757
                if plot_magnitude:
9✔
758
                    ax_mag.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
759
                ax_phase.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
760

761
                # Draw solid segments indicating the margins
762
                if deg:
9✔
763
                    ax_phase.semilogx(
9✔
764
                        [Wcp, Wcp], [phase_limit + pm, phase_limit],
765
                        color='k', zorder=-20)
766
                else:
767
                    ax_phase.semilogx(
9✔
768
                        [Wcp, Wcp], [math.radians(phase_limit) +
769
                                     math.radians(pm),
770
                                     math.radians(phase_limit)],
771
                        color='k', zorder=-20)
772

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

781
                # Draw solid segments indicating the margins
782
                if dB:
9✔
783
                    ax_mag.semilogx(
9✔
784
                        [Wcg, Wcg], [0, -20*np.log10(gm)],
785
                        color='k', zorder=-20)
786
                else:
787
                    ax_mag.loglog(
9✔
788
                        [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20)
789

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

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

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

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

853
    for i in range(noutputs):
9✔
854
        for j in range(ninputs):
9✔
855
            # Utility function to generate phase labels
856
            def gen_zero_centered_series(val_min, val_max, period):
9✔
857
                v1 = np.ceil(val_min / period - 0.2)
9✔
858
                v2 = np.floor(val_max / period + 0.2)
9✔
859
                return np.arange(v1, v2 + 1) * period
9✔
860

861
            # Label the phase axes using multiples of 45 degrees
862
            if plot_phase:
9✔
863
                ax_phase = ax_array[phase_map[i, j]]
9✔
864

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

883
    # Turn off y tick labels for shared axes
884
    for i in range(0, noutputs):
9✔
885
        for j in range(1, ncols):
9✔
886
            if share_magnitude in [True, 'all', 'row']:
9✔
887
                ax_array[mag_map[i, j]].tick_params(labelleft=False)
9✔
888
            if share_phase in [True, 'all', 'row']:
9✔
889
                ax_array[phase_map[i, j]].tick_params(labelleft=False)
9✔
890

891
    # Turn off x tick labels for shared axes
892
    for i in range(0, nrows-1):
9✔
893
        for j in range(0, ncols):
9✔
894
            if share_frequency in [True, 'all', 'col']:
9✔
895
                ax_array[i, j].tick_params(labelbottom=False)
9✔
896

897
    # If specific omega_limits were given, use them
898
    if omega_limits is not None:
9✔
899
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
900
            ax_array[i, j].set_xlim(omega_limits)
9✔
901

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

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

922
        # Label the frequency axis
923
        ax_array[-1, j].set_xlabel(
9✔
924
            freq_label.format(units="Hz" if Hz else "rad/s"))
925

926
    # Label the rows
927
    for i in range(noutputs if not overlay_outputs else 1):
9✔
928
        if plot_magnitude:
9✔
929
            ax_mag = ax_array[mag_map[i, 0]]
9✔
930
            ax_mag.set_ylabel(magnitude_label)
9✔
931
        if plot_phase:
9✔
932
            ax_phase = ax_array[phase_map[i, 0]]
9✔
933
            ax_phase.set_ylabel(phase_label)
9✔
934

935
        if (noutputs > 1 or ninputs > 1) and not overlay_outputs:
9✔
936
            if plot_magnitude and plot_phase:
9✔
937
                # Get existing ylabel for left column and add a blank line
938
                ax_mag.set_ylabel("\n" + ax_mag.get_ylabel())
9✔
939
                ax_phase.set_ylabel("\n" + ax_phase.get_ylabel())
9✔
940

941
                # Find the midpoint between the row axes (+ tight_layout)
942
                _, ypos = _find_axes_center(fig, [ax_mag, ax_phase])
9✔
943

944
                # Get the bounding box including the labels
945
                inv_transform = fig.transFigure.inverted()
9✔
946
                mag_bbox = inv_transform.transform(
9✔
947
                    ax_mag.get_tightbbox(fig.canvas.get_renderer()))
948

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

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

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

973
    # Set the initial title for the data (unique system names, preserving order)
974
    seen = set()
9✔
975
    sysnames = [response.sysname for response in data \
9✔
976
                if not (response.sysname in seen or seen.add(response.sysname))]
977

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

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

1010
    # Create axis legends
1011
    if show_legend != False:
9✔
1012
        # Figure out where to put legends
1013
        if legend_map is None:
9✔
1014
            legend_map = np.full(ax_array.shape, None, dtype=object)
9✔
1015
            legend_map[0, -1] = legend_loc
9✔
1016

1017
        legend_array = np.full(ax_array.shape, None, dtype=object)
9✔
1018
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
1019
            if legend_map[i, j] is None:
9✔
1020
                continue
9✔
1021
            ax = ax_array[i, j]
9✔
1022

1023
            # Get the labels to use, removing common strings
1024
            lines = [line for line in ax.get_lines()
9✔
1025
                     if line.get_label()[0] != '_']
1026
            labels = _make_legend_labels(
9✔
1027
                [line.get_label() for line in lines],
1028
                ignore_common=line_labels is not None)
1029

1030
            # Generate the label, if needed
1031
            if show_legend == True or len(labels) > 1:
9✔
1032
                with plt.rc_context(rcParams):
9✔
1033
                    legend_array[i, j] = ax.legend(
9✔
1034
                        lines, labels, loc=legend_map[i, j])
1035
    else:
1036
        legend_array = None
9✔
1037

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

1049
        if len(data) == 1:
9✔
1050
            return mag_data[0], phase_data[0], omega_data[0]
9✔
1051
        else:
1052
            return mag_data, phase_data, omega_data
9✔
1053

1054
    return ControlPlot(out, ax_array, fig, legend=legend_array)
9✔
1055

1056

1057
#
1058
# Nyquist plot
1059
#
1060

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

1079

1080
class NyquistResponseData:
9✔
1081
    """Nyquist response data object.
1082

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

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

1110
    """
1111
    def __init__(
9✔
1112
            self, count, contour, response, dt, sysname=None,
1113
            return_contour=False):
1114
        self.count = count
9✔
1115
        self.contour = contour
9✔
1116
        self.response = response
9✔
1117
        self.dt = dt
9✔
1118
        self.sysname = sysname
9✔
1119
        self.return_contour = return_contour
9✔
1120

1121
    # Implement iter to allow assigning to a tuple
1122
    def __iter__(self):
9✔
1123
        if self.return_contour:
9✔
1124
            return iter((self.count, self.contour))
9✔
1125
        else:
1126
            return iter((self.count, ))
9✔
1127

1128
    # Implement (thin) getitem to allow access via legacy indexing
1129
    def __getitem__(self, index):
9✔
1130
        return list(self.__iter__())[index]
×
1131

1132
    # Implement (thin) len to emulate legacy testing interface
1133
    def __len__(self):
9✔
1134
        return 2 if self.return_contour else 1
9✔
1135

1136
    def plot(self, *args, **kwargs):
9✔
1137
        return nyquist_plot(self, *args, **kwargs)
9✔
1138

1139

1140
class NyquistResponseList(list):
9✔
1141
    def plot(self, *args, **kwargs):
9✔
1142
        return nyquist_plot(self, *args, **kwargs)
9✔
1143

1144

1145
def nyquist_response(
9✔
1146
        sysdata, omega=None, plot=None, omega_limits=None, omega_num=None,
1147
        return_contour=False, warn_encirclements=True, warn_nyquist=True,
1148
        check_kwargs=True, **kwargs):
1149
    """Nyquist response for a system.
1150

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

1160
    Parameters
1161
    ----------
1162
    sysdata : LTI or list of LTI
1163
        List of linear input/output systems (single system is OK). Nyquist
1164
        curves for each system are plotted on the same graph.
1165
    omega : array_like, optional
1166
        Set of frequencies to be evaluated, in rad/sec.
1167

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

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

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

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

1227
    3. For those portions of the Nyquist plot in which the contour is
1228
       indented to avoid poles, resuling in a scaling of the Nyquist plot,
1229
       the line styles are according to the settings of the `primary_style`
1230
       and `mirror_style` keywords.  By default the scaled portions of the
1231
       primary curve use a dotted line style and the scaled portion of the
1232
       mirror image use a dashdot line style.
1233

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

1238
    See Also
1239
    --------
1240
    nyquist_plot
1241

1242
    Examples
1243
    --------
1244
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1245
    >>> response = ct.nyquist_response(G)
1246
    >>> count = response.count
1247
    >>> lines = response.plot()
1248

1249
    """
1250
    # Get values for params
1251
    omega_num_given = omega_num is not None
9✔
1252
    omega_num = config._get_param('freqplot', 'number_of_samples', omega_num)
9✔
1253
    indent_radius = config._get_param(
9✔
1254
        'nyquist', 'indent_radius', kwargs, _nyquist_defaults, pop=True)
1255
    encirclement_threshold = config._get_param(
9✔
1256
        'nyquist', 'encirclement_threshold', kwargs,
1257
        _nyquist_defaults, pop=True)
1258
    indent_direction = config._get_param(
9✔
1259
        'nyquist', 'indent_direction', kwargs, _nyquist_defaults, pop=True)
1260
    indent_points = config._get_param(
9✔
1261
        'nyquist', 'indent_points', kwargs, _nyquist_defaults, pop=True)
1262

1263
    if check_kwargs and kwargs:
9✔
1264
        raise TypeError("unrecognized keywords: ", str(kwargs))
9✔
1265

1266
    # Convert the first argument to a list
1267
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
1268

1269
    # Determine the range of frequencies to use, based on args/features
1270
    omega, omega_range_given = _determine_omega_vector(
9✔
1271
        syslist, omega, omega_limits, omega_num, feature_periphery_decades=2)
1272

1273
    # If omega was not specified explicitly, start at omega = 0
1274
    if not omega_range_given:
9✔
1275
        if omega_num_given:
9✔
1276
            # Just reset the starting point
1277
            omega[0] = 0.0
9✔
1278
        else:
1279
            # Insert points between the origin and the first frequency point
1280
            omega = np.concatenate((
9✔
1281
                np.linspace(0, omega[0], indent_points), omega[1:]))
1282

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

1291
        # Figure out the frequency range
1292
        if isinstance(sys, FrequencyResponseData) and sys.ifunc is None \
9✔
1293
           and not omega_range_given:
1294
            omega_sys = sys.omega               # use system frequencies
9✔
1295
        else:
1296
            omega_sys = np.asarray(omega)       # use common omega vector
9✔
1297

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

1307
            # Issue a warning if we are sampling above Nyquist
1308
            if np.any(omega_sys * sys.dt > np.pi) and warn_nyquist:
9✔
1309
                warnings.warn("evaluation above Nyquist frequency")
9✔
1310

1311
        # do indentations in s-plane where it is more convenient
1312
        splane_contour = 1j * omega_sys
9✔
1313

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

1328
                zplane_cl_poles = sys.feedback().poles()
9✔
1329
                # eliminate z-plane poles at the origin to avoid warnings
1330
                zplane_cl_poles = zplane_cl_poles[
9✔
1331
                    ~np.isclose(abs(zplane_cl_poles), 0.)]
1332
                splane_cl_poles = np.log(zplane_cl_poles) / sys.dt
9✔
1333

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

1351
                        if abs(p_ol - p_cl) <= indent_radius and \
9✔
1352
                                warn_encirclements:
1353
                            warnings.warn(
9✔
1354
                                "indented contour may miss closed loop pole; "
1355
                                "consider reducing indent_radius to below "
1356
                                f"{abs(p_ol - p_cl):5.2g}", stacklevel=2)
1357

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

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

1380
                # Find the frequencies after the pole frequency
1381
                above_points = np.argwhere(
9✔
1382
                    splane_contour.imag - abs(p.imag) > indent_radius)
1383
                last_point = above_points[0].item()
9✔
1384

1385
                # Add points for half/quarter circle around pole frequency
1386
                # (these will get indented left or right below)
1387
                splane_contour = np.concatenate((
9✔
1388
                    splane_contour[0:first_point+1],
1389
                    (1j * np.linspace(
1390
                        start_freq, p.imag + indent_radius, indent_points)),
1391
                    splane_contour[last_point:]))
1392

1393
            # Indent points that are too close to a pole
1394
            if len(splane_poles) > 0: # accomodate no splane poles if dtime sys
9✔
1395
                for i, s in enumerate(splane_contour):
9✔
1396
                    # Find the nearest pole
1397
                    p = splane_poles[(np.abs(splane_poles - s)).argmin()]
9✔
1398

1399
                    # See if we need to indent around it
1400
                    if abs(s - p) < indent_radius:
9✔
1401
                        # Figure out how much to offset (simple trigonometry)
1402
                        offset = np.sqrt(
9✔
1403
                            indent_radius ** 2 - (s - p).imag ** 2) \
1404
                            - (s - p).real
1405

1406
                        # Figure out which way to offset the contour point
1407
                        if p.real < 0 or (p.real == 0 and
9✔
1408
                                        indent_direction == 'right'):
1409
                            # Indent to the right
1410
                            splane_contour[i] += offset
9✔
1411

1412
                        elif p.real > 0 or (p.real == 0 and
9✔
1413
                                            indent_direction == 'left'):
1414
                            # Indent to the left
1415
                            splane_contour[i] -= offset
9✔
1416

1417
                        else:
1418
                            raise ValueError(
9✔
1419
                                "unknown value for indent_direction")
1420

1421
        # change contour to z-plane if necessary
1422
        if sys.isctime():
9✔
1423
            contour = splane_contour
9✔
1424
        else:
1425
            contour = np.exp(splane_contour * sys.dt)
9✔
1426

1427
        # Compute the primary curve
1428
        resp = sys(contour)
9✔
1429

1430
        # Compute CW encirclements of -1 by integrating the (unwrapped) angle
1431
        phase = -unwrap(np.angle(resp + 1))
9✔
1432
        encirclements = np.sum(np.diff(phase)) / np.pi
9✔
1433
        count = int(np.round(encirclements, 0))
9✔
1434

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

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

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

1478
        # Decide on system name
1479
        sysname = sys.name if sys.name is not None else f"Unknown-{idx}"
9✔
1480

1481
        responses.append(NyquistResponseData(
9✔
1482
            count, contour, resp, sys.dt, sysname=sysname,
1483
            return_contour=return_contour))
1484

1485
    if isinstance(sysdata, (list, tuple)):
9✔
1486
        return NyquistResponseList(responses)
9✔
1487
    else:
1488
        return responses[0]
9✔
1489

1490

1491
def nyquist_plot(
9✔
1492
        data, omega=None, plot=None, label_freq=0, color=None, label=None,
1493
        return_contour=None, title=None, ax=None,
1494
        unit_circle=False, mt_circles=None, ms_circles=None, **kwargs):
1495
    """Nyquist plot for a system.
1496

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

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

1525
    Returns
1526
    -------
1527
    cplt : :class:`ControlPlot` object
1528
        Object containing the data that were plotted:
1529

1530
          * cplt.lines: 2D array of :class:`matplotlib.lines.Line2D`
1531
            objects for each line in the plot.  The shape of the array is
1532
            given by (nsys, 4) where nsys is the number of systems or
1533
            Nyquist responses passed to the function.  The second index
1534
            specifies the segment type:
1535

1536
              - lines[idx, 0]: unscaled portion of the primary curve
1537
              - lines[idx, 1]: scaled portion of the primary curve
1538
              - lines[idx, 2]: unscaled portion of the mirror curve
1539
              - lines[idx, 3]: scaled portion of the mirror curve
1540

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

1543
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
1544

1545
          * cplt.legend: legend object(s) contained in the plot
1546

1547
        See :class:`ControlPlot` for more detailed information.
1548

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

1650
    See Also
1651
    --------
1652
    nyquist_response
1653

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

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

1671
    3. For those portions of the Nyquist plot in which the contour is
1672
       indented to avoid poles, resuling in a scaling of the Nyquist plot,
1673
       the line styles are according to the settings of the `primary_style`
1674
       and `mirror_style` keywords.  By default the scaled portions of the
1675
       primary curve use a dotted line style and the scaled portion of the
1676
       mirror image use a dashdot line style.
1677

1678
    Examples
1679
    --------
1680
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1681
    >>> out = ct.nyquist_plot(G)
1682

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

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

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

1734
    primary_style = _parse_linestyle('primary_style')
9✔
1735
    mirror_style = _parse_linestyle('mirror_style', allow_false=True)
9✔
1736

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

1749
    # Set the arrow style
1750
    if arrow_style is None:
9✔
1751
        arrow_style = mpl.patches.ArrowStyle(
9✔
1752
            'simple', head_width=arrow_size, head_length=arrow_size)
1753

1754
    # If argument was a singleton, turn it into a tuple
1755
    if not isinstance(data, (list, tuple)):
9✔
1756
        data = [data]
9✔
1757

1758
    # Process label keyword
1759
    line_labels = _process_line_labels(label, len(data))
9✔
1760

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

1777
    # Legacy return value processing
1778
    if plot is not None or return_contour is not None:
9✔
1779
        warnings.warn(
9✔
1780
            "`nyquist_plot` return values of count[, contour] is deprecated; "
1781
            "use nyquist_response()", DeprecationWarning)
1782

1783
        # Extract out the values that we will eventually return
1784
        counts = [response.count for response in nyquist_responses]
9✔
1785
        contours = [response.contour for response in nyquist_responses]
9✔
1786

1787
    if plot is False:
9✔
1788
        # Make sure we used all of the keywrods
1789
        if kwargs:
9✔
1790
            raise TypeError("unrecognized keywords: ", str(kwargs))
×
1791

1792
        if len(data) == 1:
9✔
1793
            counts, contours = counts[0], contours[0]
9✔
1794

1795
        # Return counts and (optionally) the contour we used
1796
        return (counts, contours) if return_contour else counts
9✔
1797

1798
    fig, ax = _process_ax_keyword(
9✔
1799
        ax_user, shape=(1, 1), squeeze=True, rcParams=rcParams)
1800
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
1801
        kwargs, None, 'upper right')
1802

1803
    # Create a list of lines for the output
1804
    out = np.empty(len(nyquist_responses), dtype=object)
9✔
1805
    for i in range(out.shape[0]):
9✔
1806
        out[i] = []             # unique list in each element
9✔
1807

1808
    for idx, response in enumerate(nyquist_responses):
9✔
1809
        resp = response.response
9✔
1810
        if response.dt in [0, None]:
9✔
1811
            splane_contour = response.contour
9✔
1812
        else:
1813
            splane_contour = np.log(response.contour) / response.dt
9✔
1814

1815
        # Find the different portions of the curve (with scaled pts marked)
1816
        reg_mask = np.logical_or(
9✔
1817
            np.abs(resp) > max_curve_magnitude,
1818
            splane_contour.real != 0)
1819
        # reg_mask = np.logical_or(
1820
        #     np.abs(resp.real) > max_curve_magnitude,
1821
        #     np.abs(resp.imag) > max_curve_magnitude)
1822

1823
        scale_mask = ~reg_mask \
9✔
1824
            & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \
1825
            & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1]))
1826

1827
        # Rescale the points with large magnitude
1828
        rescale = np.logical_and(
9✔
1829
            reg_mask, abs(resp) > max_curve_magnitude)
1830
        resp[rescale] *= max_curve_magnitude / abs(resp[rescale])
9✔
1831

1832
        # Get the label to use for the line
1833
        label = response.sysname if line_labels is None else line_labels[idx]
9✔
1834

1835
        # Plot the regular portions of the curve (and grab the color)
1836
        x_reg = np.ma.masked_where(reg_mask, resp.real)
9✔
1837
        y_reg = np.ma.masked_where(reg_mask, resp.imag)
9✔
1838
        p = plt.plot(
9✔
1839
            x_reg, y_reg, primary_style[0], color=color, label=label, **kwargs)
1840
        c = p[0].get_color()
9✔
1841
        out[idx] += p
9✔
1842

1843
        # Figure out how much to offset the curve: the offset goes from
1844
        # zero at the start of the scaled section to max_curve_offset as
1845
        # we move along the curve
1846
        curve_offset = _compute_curve_offset(
9✔
1847
            resp, scale_mask, max_curve_offset)
1848

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

1860
        # Plot the primary curve (invisible) for setting arrows
1861
        x, y = resp.real.copy(), resp.imag.copy()
9✔
1862
        x[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1863
        y[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1864
        p = plt.plot(x, y, linestyle='None', color=c)
9✔
1865

1866
        # Add arrows
1867
        ax = plt.gca()
9✔
1868
        _add_arrows_to_line2D(
9✔
1869
            ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1)
1870

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

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

1894
        # Mark the start of the curve
1895
        if start_marker:
9✔
1896
            plt.plot(resp[0].real, resp[0].imag, start_marker,
9✔
1897
                     color=c, markersize=start_marker_size)
1898

1899
        # Mark the -1 point
1900
        plt.plot([-1], [0], 'r+')
9✔
1901

1902
        #
1903
        # Draw circles for gain crossover and sensitivity functions
1904
        #
1905
        theta = np.linspace(0, 2*np.pi, 100)
9✔
1906
        cos = np.cos(theta)
9✔
1907
        sin = np.sin(theta)
9✔
1908
        label_pos = 15
9✔
1909

1910
        # Display the unit circle, to read gain crossover frequency
1911
        if unit_circle:
9✔
1912
            plt.plot(cos, sin, **config.defaults['nyquist.circle_style'])
9✔
1913

1914
        # Draw circles for given magnitudes of sensitivity
1915
        if ms_circles is not None:
9✔
1916
            for ms in ms_circles:
9✔
1917
                pos_x = -1 + (1/ms)*cos
9✔
1918
                pos_y = (1/ms)*sin
9✔
1919
                plt.plot(
9✔
1920
                    pos_x, pos_y, **config.defaults['nyquist.circle_style'])
1921
                plt.text(pos_x[label_pos], pos_y[label_pos], ms)
9✔
1922

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

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

1951
                # Factor out multiples of 1000 and limit the
1952
                # result to the range [-8, 8].
1953
                pow1000 = max(min(get_pow1000(f), 8), -8)
9✔
1954

1955
                # Get the SI prefix.
1956
                prefix = gen_prefix(pow1000)
9✔
1957

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

1968
    # Label the axes
1969
    ax.set_xlabel("Real axis")
9✔
1970
    ax.set_ylabel("Imaginary axis")
9✔
1971
    ax.grid(color="lightgray")
9✔
1972

1973
    # List of systems that are included in this plot
1974
    lines, labels = _get_line_labels(ax)
9✔
1975

1976
    # Add legend if there is more than one system plotted
1977
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
1978
        with plt.rc_context(rcParams):
9✔
1979
            legend = ax.legend(lines, labels, loc=legend_loc)
9✔
1980
    else:
1981
        legend = None
9✔
1982

1983
    # Add the title
1984
    sysnames = [response.sysname for response in nyquist_responses]
9✔
1985
    if ax_user is None and title is None:
9✔
1986
        title = "Nyquist plot for " + ", ".join(sysnames)
9✔
1987
        _update_plot_title(
9✔
1988
            title, fig=fig, rcParams=rcParams, frame=title_frame)
1989
    elif ax_user is None:
9✔
1990
        _update_plot_title(
9✔
1991
            title, fig=fig, rcParams=rcParams, frame=title_frame,
1992
            use_existing=False)
1993

1994
    # Legacy return pocessing
1995
    if plot is True or return_contour is not None:
9✔
1996
        if len(data) == 1:
9✔
1997
            counts, contours = counts[0], contours[0]
9✔
1998

1999
        # Return counts and (optionally) the contour we used
2000
        return (counts, contours) if return_contour else counts
9✔
2001

2002
    return ControlPlot(out, ax, fig, legend=legend)
9✔
2003

2004

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

2016
    # Initialize the offset
2017
    offset = np.zeros(resp.size)
9✔
2018
    arclen = np.zeros(resp.size)
9✔
2019

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

2031
        nsegs += 0.5
9✔
2032
        if i == resp.size:
9✔
2033
            break
9✔
2034

2035
        # Save the starting offset of this segment
2036
        seg_start = i
9✔
2037

2038
        # Walk through the scaled segment
2039
        while i < resp.size and not mask[i]:
9✔
2040
            i += 1
9✔
2041
            if i == resp.size:  # See if we are done with this segment
9✔
2042
                break
9✔
2043
            # Keep track of the arclength
2044
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
2045

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

2050
        # Save the ending offset of this segment
2051
        seg_end = i
9✔
2052

2053
        # Now compute the scaling for this segment
2054
        s_segment = arclen[seg_end-1] - arclen[seg_start]
9✔
2055
        offset[seg_start:seg_end] = max_offset * s_segment/s_curve[-1] * \
9✔
2056
            np.sin(np.pi * (arclen[seg_start:seg_end]
2057
                            - arclen[seg_start])/s_segment)
2058

2059
    return offset
9✔
2060

2061

2062
#
2063
# Gang of Four plot
2064
#
2065
def gangof4_response(
9✔
2066
        P, C, omega=None, omega_limits=None, omega_num=None, Hz=False):
2067
    """Compute the response of the "Gang of 4" transfer functions for a system.
2068

2069
    Generates a 2x2 frequency response for the "Gang of 4" sensitivity
2070
    functions [T, PS; CS, S].
2071

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

2091
    Returns
2092
    -------
2093
    response : :class:`~control.FrequencyResponseData`
2094
        Frequency response with inputs 'r' and 'd' and outputs 'y', and 'u'
2095
        representing the 2x2 matrix of transfer functions in the Gang of 4.
2096

2097
    Examples
2098
    --------
2099
    >>> P = ct.tf([1], [1, 1])
2100
    >>> C = ct.tf([2], [1])
2101
    >>> response = ct.gangof4_response(P, C)
2102
    >>> lines = response.plot()
2103

2104
    """
2105
    if not P.issiso() or not C.issiso():
9✔
2106
        # TODO: Add MIMO go4 plots.
2107
        raise ControlMIMONotImplemented(
×
2108
            "Gang of four is currently only implemented for SISO systems.")
2109

2110
    # Compute the senstivity functions
2111
    L = P * C
9✔
2112
    S = feedback(1, L)
9✔
2113
    T = L * S
9✔
2114

2115
    # Select a default range if none is provided
2116
    # TODO: This needs to be made more intelligent
2117
    omega, _ = _determine_omega_vector(
9✔
2118
        [P, C, S], omega, omega_limits, omega_num, Hz=Hz)
2119

2120
    #
2121
    # bode_plot based implementation
2122
    #
2123

2124
    # Compute the response of the Gang of 4
2125
    resp_T = T(1j * omega)
9✔
2126
    resp_PS = (P * S)(1j * omega)
9✔
2127
    resp_CS = (C * S)(1j * omega)
9✔
2128
    resp_S = S(1j * omega)
9✔
2129

2130
    # Create a single frequency response data object with the underlying data
2131
    data = np.empty((2, 2, omega.size), dtype=complex)
9✔
2132
    data[0, 0, :] = resp_T
9✔
2133
    data[0, 1, :] = resp_PS
9✔
2134
    data[1, 0, :] = resp_CS
9✔
2135
    data[1, 1, :] = resp_S
9✔
2136

2137
    return FrequencyResponseData(
9✔
2138
        data, omega, outputs=['y', 'u'], inputs=['r', 'd'],
2139
        title=f"Gang of Four for P={P.name}, C={C.name}",
2140
        sysname=f"P={P.name}, C={C.name}", plot_phase=False)
2141

2142

2143
def gangof4_plot(
9✔
2144
        *args, omega=None, omega_limits=None, omega_num=None,
2145
        Hz=False, **kwargs):
2146
    """Plot the response of the "Gang of 4" transfer functions for a system.
2147

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

2151
        gangof4_plot(response[, ...])
2152
        gangof4_plot(P, C[, ...])
2153

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

2175
    Returns
2176
    -------
2177
    cplt : :class:`ControlPlot` object
2178
        Object containing the data that were plotted:
2179

2180
          * cplt.lines: 2x2 array of :class:`matplotlib.lines.Line2D`
2181
            objects for each line in the plot.  The value of each array
2182
            entry is a list of Line2D objects in that subplot.
2183

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

2186
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
2187

2188
          * cplt.legend: legend object(s) contained in the plot
2189

2190
        See :class:`ControlPlot` for more detailed information.
2191

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

2210
#
2211
# Singular values plot
2212
#
2213
def singular_values_response(
9✔
2214
        sysdata, omega=None, omega_limits=None, omega_num=None, Hz=False):
2215
    """Singular value response for a system.
2216

2217
    Computes the singular values for a system or list of systems over
2218
    a (optional) frequency range.
2219

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

2230
    Returns
2231
    -------
2232
    response : FrequencyResponseData
2233
        Frequency response with the number of outputs equal to the
2234
        number of singular values in the response, and a single input.
2235

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

2246
    See Also
2247
    --------
2248
    singular_values_plot
2249

2250
    Examples
2251
    --------
2252
    >>> omegas = np.logspace(-4, 1, 1000)
2253
    >>> den = [75, 1]
2254
    >>> G = ct.tf([[[87.8], [-86.4]], [[108.2], [-109.6]]],
2255
    ...           [[den, den], [den, den]])
2256
    >>> response = ct.singular_values_response(G, omega=omegas)
2257

2258
    """
2259
    # Convert the first argument to a list
2260
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
2261

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

2265
    # Compute the frequency responses for the systems
2266
    responses = frequency_response(
9✔
2267
        syslist, omega=omega, omega_limits=omega_limits,
2268
        omega_num=omega_num, Hz=Hz, squeeze=False)
2269

2270
    # Calculate the singular values for each system in the list
2271
    svd_responses = []
9✔
2272
    for response in responses:
9✔
2273
        # Compute the singular values (permute indices to make things work)
2274
        fresp_permuted = response.fresp.transpose((2, 0, 1))
9✔
2275
        sigma = np.linalg.svd(fresp_permuted, compute_uv=False).transpose()
9✔
2276
        sigma_fresp = sigma.reshape(sigma.shape[0], 1, sigma.shape[1])
9✔
2277

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

2287
    if isinstance(sysdata, (list, tuple)):
9✔
2288
        return FrequencyResponseList(svd_responses)
9✔
2289
    else:
2290
        return svd_responses[0]
9✔
2291

2292

2293
def singular_values_plot(
9✔
2294
        data, omega=None, *fmt, plot=None, omega_limits=None, omega_num=None,
2295
        ax=None, label=None, title=None, **kwargs):
2296
    """Plot the singular values for a system.
2297

2298
    Plot the singular values as a function of frequency for a system or
2299
    list of systems.  If multiple systems are plotted, each system in the
2300
    list is plotted in a different color.
2301

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

2320
    Returns
2321
    -------
2322
    cplt : :class:`ControlPlot` object
2323
        Object containing the data that were plotted:
2324

2325
          * cplt.lines: 1-D array of :class:`matplotlib.lines.Line2D` objects.
2326
            The size of the array matches the number of systems and the
2327
            value of the array is a list of Line2D objects for that system.
2328

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

2331
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
2332

2333
          * cplt.legend: legend object(s) contained in the plot
2334

2335
        See :class:`ControlPlot` for more detailed information.
2336

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

2375
    See Also
2376
    --------
2377
    singular_values_response
2378

2379
    Notes
2380
    -----
2381
    1. If plot==False, the following legacy values are returned:
2382
         * mag : ndarray (or list of ndarray if len(data) > 1))
2383
             Magnitude of the response (deprecated).
2384
         * phase : ndarray (or list of ndarray if len(data) > 1))
2385
             Phase in radians of the response (deprecated).
2386
         * omega : ndarray (or list of ndarray if len(data) > 1))
2387
             Frequency in rad/sec (deprecated).
2388

2389
    """
2390
    # Keyword processing
2391
    color = kwargs.pop('color', None)
9✔
2392
    dB = config._get_param(
9✔
2393
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
2394
    Hz = config._get_param(
9✔
2395
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
2396
    grid = config._get_param(
9✔
2397
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
2398
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
2399
    title_frame = config._get_param(
9✔
2400
        'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True)
2401

2402
    # If argument was a singleton, turn it into a tuple
2403
    data = data if isinstance(data, (list, tuple)) else (data,)
9✔
2404

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

2418
        # Check to make sure omega_limits is sensible
2419
        if omega_limits is not None and \
9✔
2420
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
2421
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
2422

2423
        responses = data
9✔
2424

2425
    # Process label keyword
2426
    line_labels = _process_line_labels(label, len(data))
9✔
2427

2428
    # Process (legacy) plot keyword
2429
    if plot is not None:
9✔
2430
        warnings.warn(
×
2431
            "`singular_values_plot` return values of sigma, omega is "
2432
            "deprecated; use singular_values_response()", DeprecationWarning)
2433

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

2439
    # Extract the data we need for plotting
2440
    sigmas = [np.real(response.fresp[:, 0, :]) for response in responses]
9✔
2441
    omegas = [response.omega for response in responses]
9✔
2442

2443
    # Legacy processing for no plotting case
2444
    if plot is False:
9✔
2445
        if len(data) == 1:
×
2446
            return sigmas[0], omegas[0]
×
2447
        else:
2448
            return sigmas, omegas
×
2449

2450
    fig, ax_sigma = _process_ax_keyword(
9✔
2451
        ax, shape=(1, 1), squeeze=True, rcParams=rcParams)
2452
    ax_sigma.set_label('control-sigma')         # TODO: deprecate?
9✔
2453
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
2454
        kwargs, None, 'center right')
2455

2456
    # Get color offset for first (new) line to be drawn
2457
    color_offset, color_cycle = _get_color_offset(ax_sigma)
9✔
2458

2459
    # Create a list of lines for the output
2460
    out = np.empty(len(data), dtype=object)
9✔
2461

2462
    # Plot the singular values for each response
2463
    for idx_sys, response in enumerate(responses):
9✔
2464
        sigma = sigmas[idx_sys].transpose()     # frequency first for plotting
9✔
2465
        omega = omegas[idx_sys] / (2 * math.pi) if Hz else  omegas[idx_sys]
9✔
2466

2467
        if response.isdtime(strict=True):
9✔
2468
            nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
2469
        else:
2470
            nyq_freq = None
9✔
2471

2472
        # Determine the color to use for this response
2473
        color = _get_color(
9✔
2474
            color, fmt=fmt, offset=color_offset + idx_sys,
2475
            color_cycle=color_cycle)
2476

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

2480
        # Decide on the system name
2481
        sysname = response.sysname if response.sysname is not None \
9✔
2482
            else f"Unknown-{idx_sys}"
2483

2484
        # Get the label to use for the line
2485
        label = sysname if line_labels is None else line_labels[idx_sys]
9✔
2486

2487
        # Plot the data
2488
        if dB:
9✔
2489
            out[idx_sys] = ax_sigma.semilogx(
9✔
2490
                omega, 20 * np.log10(sigma), *fmt,
2491
                label=label, **color_arg, **kwargs)
2492
        else:
2493
            out[idx_sys] = ax_sigma.loglog(
9✔
2494
                omega, sigma, label=label, *fmt, **color_arg, **kwargs)
2495

2496
        # Plot the Nyquist frequency
2497
        if nyq_freq is not None:
9✔
2498
            ax_sigma.axvline(
9✔
2499
                nyq_freq, linestyle='--', label='_nyq_freq_' + sysname,
2500
                **color_arg)
2501

2502
    # If specific omega_limits were given, use them
2503
    if omega_limits is not None:
9✔
2504
        ax_sigma.set_xlim(omega_limits)
9✔
2505

2506
    # Add a grid to the plot + labeling
2507
    if grid:
9✔
2508
        ax_sigma.grid(grid, which='both')
9✔
2509

2510
    ax_sigma.set_ylabel(
9✔
2511
        "Singular Values [dB]" if dB else "Singular Values")
2512
    ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]")
9✔
2513

2514
    # List of systems that are included in this plot
2515
    lines, labels = _get_line_labels(ax_sigma)
9✔
2516

2517
    # Add legend if there is more than one system plotted
2518
    if show_legend == True or (show_legend != False and len(labels) > 1):
9✔
2519
        with plt.rc_context(rcParams):
9✔
2520
            legend = ax_sigma.legend(lines, labels, loc=legend_loc)
9✔
2521
    else:
2522
        legend = None
9✔
2523

2524
    # Add the title
2525
    if ax is None:
9✔
2526
        if title is None:
9✔
2527
            title = "Singular values for " + ", ".join(labels)
9✔
2528
        _update_plot_title(
9✔
2529
            title, fig=fig, rcParams=rcParams, frame=title_frame,
2530
            use_existing=False)
2531

2532
    # Legacy return processing
2533
    if plot is not None:
9✔
2534
        if len(responses) == 1:
×
2535
            return sigmas[0], omegas[0]
×
2536
        else:
2537
            return sigmas, omegas
×
2538

2539
    return ControlPlot(out, ax_sigma, fig, legend=legend)
9✔
2540

2541
#
2542
# Utility functions
2543
#
2544
# This section of the code contains some utility functions for
2545
# generating frequency domain plots.
2546
#
2547

2548

2549
# Determine the frequency range to be used
2550
def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
9✔
2551
                            Hz=None, feature_periphery_decades=None):
2552
    """Determine the frequency range for a frequency-domain plot
2553
    according to a standard logic.
2554

2555
    If omega_in and omega_limits are both None, then omega_out is computed
2556
    on omega_num points according to a default logic defined by
2557
    _default_frequency_range and tailored for the list of systems syslist, and
2558
    omega_range_given is set to False.
2559

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

2565
    If omega_in is a list or tuple of length 2, it is interpreted as a
2566
    range and handled like omega_limits.  If omega_in is a list or tuple of
2567
    length 3, it is interpreted a range plus number of points and handled
2568
    like omega_limits and omega_num.
2569

2570
    If omega_in is an array or a list/tuple of length greater than
2571
    two, then omega_out is set to omega_in (as an array), and
2572
    omega_range_given is set to True
2573

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

2590
    Returns
2591
    -------
2592
    omega_out : 1D array
2593
        Frequency range to be used.
2594
    omega_range_given : bool
2595
        True if the frequency range was specified by the user, either through
2596
        omega_in or through omega_limits. False if both omega_in
2597
        and omega_limits are None.
2598

2599
    """
2600
    # Handle the special case of a range of frequencies
2601
    if omega_in is not None and omega_limits is not None:
9✔
2602
        warnings.warn(
×
2603
            "omega and omega_limits both specified; ignoring limits")
2604
    elif isinstance(omega_in, (list, tuple)) and len(omega_in) == 2:
9✔
2605
        omega_limits = omega_in
9✔
2606
        omega_in = None
9✔
2607

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

2626
    return omega_out, omega_range_given
9✔
2627

2628

2629
# Compute reasonable defaults for axes
2630
def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
9✔
2631
                             feature_periphery_decades=None):
2632
    """Compute a default frequency range for frequency domain plots.
2633

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

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

2656
    Returns
2657
    -------
2658
    omega : array
2659
        Range of frequencies in rad/sec
2660

2661
    Examples
2662
    --------
2663
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
2664
    >>> omega = ct._default_frequency_range(G)
2665
    >>> omega.min(), omega.max()
2666
    (0.1, 100.0)
2667

2668
    """
2669
    # Set default values for options
2670
    number_of_samples = config._get_param(
9✔
2671
        'freqplot', 'number_of_samples', number_of_samples)
2672
    feature_periphery_decades = config._get_param(
9✔
2673
        'freqplot', 'feature_periphery_decades', feature_periphery_decades, 1)
2674

2675
    # Find the list of all poles and zeros in the systems
2676
    features = np.array(())
9✔
2677
    freq_interesting = []
9✔
2678

2679
    # detect if single sys passed by checking if it is sequence-like
2680
    if not hasattr(syslist, '__iter__'):
9✔
2681
        syslist = (syslist,)
9✔
2682

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

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

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

2728
    # Make sure there is at least one point in the range
2729
    if features.shape[0] == 0:
9✔
2730
        features = np.array([1.])
9✔
2731

2732
    if Hz:
9✔
2733
        features /= 2. * math.pi
9✔
2734
    features = np.log10(features)
9✔
2735
    lsp_min = np.rint(np.min(features) - feature_periphery_decades)
9✔
2736
    lsp_max = np.rint(np.max(features) + feature_periphery_decades)
9✔
2737
    if Hz:
9✔
2738
        lsp_min += np.log10(2. * math.pi)
9✔
2739
        lsp_max += np.log10(2. * math.pi)
9✔
2740

2741
    if freq_interesting:
9✔
2742
        lsp_min = min(lsp_min, np.log10(min(freq_interesting)))
9✔
2743
        lsp_max = max(lsp_max, np.log10(max(freq_interesting)))
9✔
2744

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

2748
    # Set the range to be an order of magnitude beyond any features
2749
    if number_of_samples:
9✔
2750
        omega = np.logspace(
9✔
2751
            lsp_min, lsp_max, num=number_of_samples, endpoint=True)
2752
    else:
2753
        omega = np.logspace(lsp_min, lsp_max, endpoint=True)
×
2754
    return omega
9✔
2755

2756

2757
#
2758
# Utility functions to create nice looking labels (KLD 5/23/11)
2759
#
2760

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

2777

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

2804

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

© 2026 Coveralls, Inc