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

python-control / python-control / 10029876295

21 Jul 2024 04:45PM UTC coverage: 94.629%. Remained the same
10029876295

push

github

web-flow
Merge pull request #1033 from murrayrm/ctrlplot_refactor-27Jun2024

Move ctrlplot code prior to upcoming PR

8915 of 9421 relevant lines covered (94.63%)

8.25 hits per line

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

95.17
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 _add_arrows_to_line2D, _ctrlplot_rcParams, \
9✔
23
    _find_axes_center, _get_line_labels, _make_legend_labels, \
24
    _process_ax_keyword, _process_line_labels, _update_suptitle, suptitle
25
from .ctrlutil import unwrap
9✔
26
from .exception import ControlMIMONotImplemented
9✔
27
from .frdata import FrequencyResponseData
9✔
28
from .lti import LTI, _process_frequency_response, frequency_response
9✔
29
from .margins import stability_margins
9✔
30
from .statesp import StateSpace
9✔
31
from .xferfcn import TransferFunction
9✔
32

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

38
# Default values for module parameter variables
39
_freqplot_defaults = {
9✔
40
    'freqplot.rcParams': _ctrlplot_rcParams,
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.suptitle_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', legend_map=None, legend_loc=None,
91
        sharex=None, sharey=None, title=None, **kwargs):
92
    """Bode plot for a system.
93

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

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

125
    Returns
126
    -------
127
    lines : array of Line2D
128
        Array of Line2D objects for each line in the plot.  The shape of
129
        the array matches the subplots shape and the value of the array is a
130
        list of Line2D objects in that subplot.
131

132
    Other Parameters
133
    ----------------
134
    grid : bool
135
        If True, plot grid lines on gain and phase plots.  Default is set by
136
        `config.defaults['freqplot.grid']`.
137
    initial_phase : float
138
        Set the reference phase to use for the lowest frequency.  If set, the
139
        initial phase of the Bode plot will be set to the value closest to the
140
        value specified.  Units are in either degrees or radians, depending on
141
        the `deg` parameter. Default is -180 if wrap_phase is False, 0 if
142
        wrap_phase is True.
143
    label : str or array-like of str
144
        If present, replace automatically generated label(s) with the given
145
        label(s).  If sysdata is a list, strings should be specified for each
146
        system.  If MIMO, strings required for each system, output, and input.
147
    margins_method : str, optional
148
        Method to use in computing margins (see :func:`stability_margins`).
149
    omega_limits : array_like of two values
150
        Set limits for plotted frequency range. If Hz=True the limits are
151
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
152
        elements is equivalent to providing ``omega_limits``. Ignored if
153
        data is not a list of systems.
154
    omega_num : int
155
        Number of samples to use for the frequeny range.  Defaults to
156
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
157
        not a list of systems.
158
    plot : bool, optional
159
        (legacy) If given, `bode_plot` returns the legacy return values
160
        of magnitude, phase, and frequency.  If False, just return the
161
        values with no plot.
162
    rcParams : dict
163
        Override the default parameters used for generating plots.
164
        Default is set by config.default['freqplot.rcParams'].
165
    wrap_phase : bool or float
166
        If wrap_phase is `False` (default), then the phase will be unwrapped
167
        so that it is continuously increasing or decreasing.  If wrap_phase is
168
        `True` the phase will be restricted to the range [-180, 180) (or
169
        [:math:`-\\pi`, :math:`\\pi`) radians). If `wrap_phase` is specified
170
        as a float, the phase will be offset by 360 degrees if it falls below
171
        the specified value. Default value is `False` and can be set using
172
        config.defaults['freqplot.wrap_phase'].
173

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

177
    See Also
178
    --------
179
    frequency_response
180

181
    Notes
182
    -----
183
    1. Starting with python-control version 0.10, `bode_plot`returns an
184
       array of lines instead of magnitude, phase, and frequency. To
185
       recover the old behavior, call `bode_plot` with `plot=True`, which
186
       will force the legacy values (mag, phase, omega) to be returned
187
       (with a warning).  To obtain just the frequency response of a system
188
       (or list of systems) without plotting, use the
189
       :func:`~control.frequency_response` command.
190

191
    2. If a discrete time model is given, the frequency response is plotted
192
       along the upper branch of the unit circle, using the mapping ``z =
193
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
194
       is the discrete timebase.  If timebase not specified (``dt=True``),
195
       `dt` is set to 1.
196

197
    Examples
198
    --------
199
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
200
    >>> out = ct.bode_plot(G)
201

202
    """
203
    #
204
    # Process keywords and set defaults
205
    #
206

207
    # Make a copy of the kwargs dictionary since we will modify it
208
    kwargs = dict(kwargs)
9✔
209

210
    # Get values for params (and pop from list to allow keyword use in plot)
211
    dB = config._get_param(
9✔
212
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
213
    deg = config._get_param(
9✔
214
        'freqplot', 'deg', kwargs, _freqplot_defaults, pop=True)
215
    Hz = config._get_param(
9✔
216
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
217
    grid = config._get_param(
9✔
218
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
219
    wrap_phase = config._get_param(
9✔
220
        'freqplot', 'wrap_phase', kwargs, _freqplot_defaults, pop=True)
221
    initial_phase = config._get_param(
9✔
222
        'freqplot', 'initial_phase', kwargs, None, pop=True)
223
    rcParams = config._get_param(
9✔
224
        'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True)
225
    suptitle_frame = config._get_param(
9✔
226
        'freqplot', 'suptitle_frame', kwargs, _freqplot_defaults, pop=True)
227

228
    # Set the default labels
229
    freq_label = config._get_param(
9✔
230
        'freqplot', 'freq_label', kwargs, _freqplot_defaults, pop=True)
231
    if magnitude_label is None:
9✔
232
        magnitude_label = "Magnitude [dB]" if dB else "Magnitude"
9✔
233
    if phase_label is None:
9✔
234
        phase_label = "Phase [deg]" if deg else "Phase [rad]"
9✔
235

236
    # Use sharex and sharey as proxies for share_{magnitude, phase, frequency}
237
    if sharey is not None:
9✔
238
        if 'share_magnitude' in kwargs or 'share_phase' in kwargs:
9✔
239
            ValueError(
×
240
                "sharey cannot be present with share_magnitude/share_phase")
241
        kwargs['share_magnitude'] = sharey
9✔
242
        kwargs['share_phase'] = sharey
9✔
243
    if sharex is not None:
9✔
244
        if 'share_frequency' in kwargs:
9✔
245
            ValueError(
×
246
                "sharex cannot be present with share_frequency")
247
        kwargs['share_frequency'] = sharex
9✔
248

249
    # Legacy keywords for margins
250
    display_margins = config._process_legacy_keyword(
9✔
251
        kwargs, 'margins', 'display_margins', display_margins)
252
    if kwargs.pop('margin_info', False):
9✔
253
        warnings.warn(
×
254
            "keyword 'margin_info' is deprecated; "
255
            "use 'display_margins='overlay'")
256
        if display_margins is False:
×
257
            raise ValueError(
×
258
                "conflicting_keywords: `display_margins` and `margin_info`")
259
    margins_method = config._process_legacy_keyword(
9✔
260
        kwargs, 'method', 'margins_method', margins_method)
261

262
    if not isinstance(data, (list, tuple)):
9✔
263
        data = [data]
9✔
264

265
    #
266
    # Pre-process the data to be plotted (unwrap phase, limit frequencies)
267
    #
268
    # To maintain compatibility with legacy uses of bode_plot(), we do some
269
    # initial processing on the data, specifically phase unwrapping and
270
    # setting the initial value of the phase.  If bode_plot is called with
271
    # plot == False, then these values are returned to the user (instead of
272
    # the list of lines created, which is the new output for _plot functions.
273
    #
274

275
    # If we were passed a list of systems, convert to data
276
    if any([isinstance(
9✔
277
            sys, (StateSpace, TransferFunction)) for sys in data]):
278
        data = frequency_response(
9✔
279
            data, omega=omega, omega_limits=omega_limits,
280
            omega_num=omega_num, Hz=Hz)
281
    else:
282
        # Generate warnings if frequency keywords were given
283
        if omega_num is not None:
9✔
284
            warnings.warn("`omega_num` ignored when passed response data")
9✔
285
        elif omega is not None:
9✔
286
            warnings.warn("`omega` ignored when passed response data")
9✔
287

288
        # Check to make sure omega_limits is sensible
289
        if omega_limits is not None and \
9✔
290
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
291
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
292

293
    # If plot_phase is not specified, check the data first, otherwise true
294
    if plot_phase is None:
9✔
295
        plot_phase = True if data[0].plot_phase is None else data[0].plot_phase
9✔
296

297
    if not plot_magnitude and not plot_phase:
9✔
298
        raise ValueError(
9✔
299
            "plot_magnitude and plot_phase both False; no data to plot")
300

301
    mag_data, phase_data, omega_data = [], [], []
9✔
302
    for response in data:
9✔
303
        noutputs, ninputs = response.noutputs, response.ninputs
9✔
304

305
        if initial_phase is None:
9✔
306
            # Start phase in the range 0 to -360 w/ initial phase = 0
307
            # TODO: change this to 0 to 270 (?)
308
            # If wrap_phase is true, use 0 instead (phase \in (-pi, pi])
309
            initial_phase_value = -math.pi if wrap_phase is not True else 0
9✔
310
        elif isinstance(initial_phase, (int, float)):
9✔
311
            # Allow the user to override the default calculation
312
            if deg:
9✔
313
                initial_phase_value = initial_phase/180. * math.pi
9✔
314
            else:
315
                initial_phase_value = initial_phase
9✔
316
        else:
317
            raise ValueError("initial_phase must be a number.")
×
318

319
        # Reshape the phase to allow standard indexing
320
        phase = response.phase.copy().reshape((noutputs, ninputs, -1))
9✔
321

322
        # Shift and wrap the phase
323
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
324
            # Shift the phase if needed
325
            if abs(phase[i, j, 0] - initial_phase_value) > math.pi:
9✔
326
                phase[i, j] -= 2*math.pi * round(
9✔
327
                    (phase[i, j, 0] - initial_phase_value) / (2*math.pi))
328

329
            # Phase wrapping
330
            if wrap_phase is False:
9✔
331
                phase[i, j] = unwrap(phase[i, j]) # unwrap the phase
9✔
332
            elif wrap_phase is True:
9✔
333
                pass                                    # default calc OK
9✔
334
            elif isinstance(wrap_phase, (int, float)):
9✔
335
                phase[i, j] = unwrap(phase[i, j]) # unwrap phase first
9✔
336
                if deg:
9✔
337
                    wrap_phase *= math.pi/180.
9✔
338

339
                # Shift the phase if it is below the wrap_phase
340
                phase[i, j] += 2*math.pi * np.maximum(
9✔
341
                    0, np.ceil((wrap_phase - phase[i, j])/(2*math.pi)))
342
            else:
343
                raise ValueError("wrap_phase must be bool or float.")
×
344

345
        # Put the phase back into the original shape
346
        phase = phase.reshape(response.magnitude.shape)
9✔
347

348
        # Save the data for later use (legacy return values)
349
        mag_data.append(response.magnitude)
9✔
350
        phase_data.append(phase)
9✔
351
        omega_data.append(response.omega)
9✔
352

353
    #
354
    # Process `plot` keyword
355
    #
356
    # We use the `plot` keyword to track legacy usage of `bode_plot`.
357
    # Prior to v0.10, the `bode_plot` command returned mag, phase, and
358
    # omega.  Post v0.10, we return an array with the same shape as the
359
    # axes we use for plotting, with each array element containing a list
360
    # of lines drawn on that axes.
361
    #
362
    # There are three possibilities at this stage in the code:
363
    #
364
    # * plot == True: set explicitly by the user. Return mag, phase, omega,
365
    #   with a warning.
366
    #
367
    # * plot == False: set explicitly by the user. Return mag, phase,
368
    #   omega, with a warning.
369
    #
370
    # * plot == None: this is the new default setting.  Return an array of
371
    #   lines that were drawn.
372
    #
373
    # If `bode_plot` was called with no `plot` argument and the return
374
    # values were used, the new code will cause problems (you get an array
375
    # of lines instead of magnitude, phase, and frequency).  To recover the
376
    # old behavior, call `bode_plot` with `plot=True`.
377
    #
378
    # All of this should be removed in v0.11+ when we get rid of deprecated
379
    # code.
380
    #
381

382
    if plot is not None:
9✔
383
        warnings.warn(
9✔
384
            "`bode_plot` return values of mag, phase, omega is deprecated; "
385
            "use frequency_response()", DeprecationWarning)
386

387
    if plot is False:
9✔
388
        # Process the data to match what we were sent
389
        for i in range(len(mag_data)):
9✔
390
            mag_data[i] = _process_frequency_response(
9✔
391
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
392
            phase_data[i] = _process_frequency_response(
9✔
393
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
394

395
        if len(data) == 1:
9✔
396
            return mag_data[0], phase_data[0], omega_data[0]
9✔
397
        else:
398
            return mag_data, phase_data, omega_data
9✔
399
    #
400
    # Find/create axes
401
    #
402
    # Data are plotted in a standard subplots array, whose size depends on
403
    # which signals are being plotted and how they are combined.  The
404
    # baseline layout for data is to plot everything separately, with
405
    # the magnitude and phase for each output making up the rows and the
406
    # columns corresponding to the different inputs.
407
    #
408
    #      Input 0                 Input m
409
    # +---------------+       +---------------+
410
    # |  mag H_y0,u0  |  ...  |  mag H_y0,um  |
411
    # +---------------+       +---------------+
412
    # +---------------+       +---------------+
413
    # | phase H_y0,u0 |  ...  | phase H_y0,um |
414
    # +---------------+       +---------------+
415
    #         :                       :
416
    # +---------------+       +---------------+
417
    # |  mag H_yp,u0  |  ...  |  mag H_yp,um  |
418
    # +---------------+       +---------------+
419
    # +---------------+       +---------------+
420
    # | phase H_yp,u0 |  ...  | phase H_yp,um |
421
    # +---------------+       +---------------+
422
    #
423
    # Several operations are available that change this layout.
424
    #
425
    # * Omitting: either the magnitude or the phase plots can be omitted
426
    #   using the plot_magnitude and plot_phase keywords.
427
    #
428
    # * Overlay: inputs and/or outputs can be combined onto a single set of
429
    #   axes using the overlay_inputs and overlay_outputs keywords.  This
430
    #   basically collapses data along either the rows or columns, and a
431
    #   legend is generated.
432
    #
433

434
    # Decide on the maximum number of inputs and outputs
435
    ninputs, noutputs = 0, 0
9✔
436
    for response in data:       # TODO: make more pythonic/numpic
9✔
437
        ninputs = max(ninputs, response.ninputs)
9✔
438
        noutputs = max(noutputs, response.noutputs)
9✔
439

440
    # Figure how how many rows and columns to use + offsets for inputs/outputs
441
    if overlay_outputs and overlay_inputs:
9✔
442
        nrows = plot_magnitude + plot_phase
9✔
443
        ncols = 1
9✔
444
    elif overlay_outputs:
9✔
445
        nrows = plot_magnitude + plot_phase
9✔
446
        ncols = ninputs
9✔
447
    elif overlay_inputs:
9✔
448
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
449
            (noutputs if plot_phase else 0)
450
        ncols = 1
9✔
451
    else:
452
        nrows = (noutputs if plot_magnitude else 0) + \
9✔
453
            (noutputs if plot_phase else 0)
454
        ncols = ninputs
9✔
455

456
    if ax is None:
9✔
457
        # Set up default sharing of axis limits if not specified
458
        for kw in ['share_magnitude', 'share_phase', 'share_frequency']:
9✔
459
            if kw not in kwargs or kwargs[kw] is None:
9✔
460
                kwargs[kw] = config.defaults['freqplot.' + kw]
9✔
461

462
    fig, ax_array = _process_ax_keyword(ax, (
9✔
463
        nrows, ncols), squeeze=False, rcParams=rcParams, clear_text=True)
464

465
    # Get the values for sharing axes limits
466
    share_magnitude = kwargs.pop('share_magnitude', None)
9✔
467
    share_phase = kwargs.pop('share_phase', None)
9✔
468
    share_frequency = kwargs.pop('share_frequency', None)
9✔
469

470
    # Set up axes variables for easier access below
471
    if plot_magnitude and not plot_phase:
9✔
472
        mag_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
473
        for i in range(noutputs):
9✔
474
            for j in range(ninputs):
9✔
475
                if overlay_outputs and overlay_inputs:
9✔
476
                    mag_map[i, j] = (0, 0)
9✔
477
                elif overlay_outputs:
9✔
478
                    mag_map[i, j] = (0, j)
9✔
479
                elif overlay_inputs:
9✔
480
                    mag_map[i, j] = (i, 0)
×
481
                else:
482
                    mag_map[i, j] = (i, j)
9✔
483
        phase_map = np.full((noutputs, ninputs), None)
9✔
484
        share_phase = False
9✔
485

486
    elif plot_phase and not plot_magnitude:
9✔
487
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
488
        for i in range(noutputs):
9✔
489
            for j in range(ninputs):
9✔
490
                if overlay_outputs and overlay_inputs:
9✔
491
                    phase_map[i, j] = (0, 0)
×
492
                elif overlay_outputs:
9✔
493
                    phase_map[i, j] = (0, j)
×
494
                elif overlay_inputs:
9✔
495
                    phase_map[i, j] = (i, 0)
9✔
496
                else:
497
                    phase_map[i, j] = (i, j)
9✔
498
        mag_map = np.full((noutputs, ninputs), None)
9✔
499
        share_magnitude = False
9✔
500

501
    else:
502
        mag_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
503
        phase_map = np.empty((noutputs, ninputs), dtype=tuple)
9✔
504
        for i in range(noutputs):
9✔
505
            for j in range(ninputs):
9✔
506
                if overlay_outputs and overlay_inputs:
9✔
507
                    mag_map[i, j] = (0, 0)
×
508
                    phase_map[i, j] = (1, 0)
×
509
                elif overlay_outputs:
9✔
510
                    mag_map[i, j] = (0, j)
×
511
                    phase_map[i, j] = (1, j)
×
512
                elif overlay_inputs:
9✔
513
                    mag_map[i, j] = (i*2, 0)
×
514
                    phase_map[i, j] = (i*2 + 1, 0)
×
515
                else:
516
                    mag_map[i, j] = (i*2, j)
9✔
517
                    phase_map[i, j] = (i*2 + 1, j)
9✔
518

519
    # Identity map needed for setting up shared axes
520
    ax_map = np.empty((nrows, ncols), dtype=tuple)
9✔
521
    for i, j in itertools.product(range(nrows), range(ncols)):
9✔
522
        ax_map[i, j] = (i, j)
9✔
523

524
    #
525
    # Set up axes limit sharing
526
    #
527
    # This code uses the share_magnitude, share_phase, and share_frequency
528
    # keywords to decide which axes have shared limits and what ticklabels
529
    # to include.  The sharing code needs to come before the plots are
530
    # generated, but additional code for removing tick labels needs to come
531
    # *during* and *after* the plots are generated (see below).
532
    #
533
    # Note: if the various share_* keywords are None then a previous set of
534
    # axes are available and no updates should be made.
535
    #
536

537
    # Utility function to turn off sharing
538
    def _share_axes(ref, share_map, axis):
9✔
539
        ref_ax = ax_array[ref]
9✔
540
        for index in np.nditer(share_map, flags=["refs_ok"]):
9✔
541
            if index.item() == ref:
9✔
542
                continue
9✔
543
            if axis == 'x':
9✔
544
                ax_array[index.item()].sharex(ref_ax)
9✔
545
            elif axis == 'y':
9✔
546
                ax_array[index.item()].sharey(ref_ax)
9✔
547
            else:
548
                raise ValueError("axis must be 'x' or 'y'")
×
549

550
    # Process magnitude, phase, and frequency axes
551
    for name, value, map, axis in zip(
9✔
552
            ['share_magnitude', 'share_phase', 'share_frequency'],
553
            [ share_magnitude,   share_phase,   share_frequency],
554
            [ mag_map,           phase_map,     ax_map],
555
            [ 'y',               'y',           'x']):
556
        if value in [True, 'all']:
9✔
557
            _share_axes(map[0 if axis == 'y' else -1, 0], map, axis)
9✔
558
        elif axis == 'y' and value in ['row']:
9✔
559
            for i in range(noutputs if not overlay_outputs else 1):
9✔
560
                _share_axes(map[i, 0], map[i], 'y')
9✔
561
        elif axis == 'x' and value in ['col']:
9✔
562
            for j in range(ncols):
9✔
563
                _share_axes(map[-1, j], map[:, j], 'x')
9✔
564
        elif value in [False, 'none']:
9✔
565
            # TODO: turn off any sharing that is on
566
            pass
9✔
567
        elif value is not None:
9✔
568
            raise ValueError(
×
569
                f"unknown value for `{name}`: '{value}'")
570

571
    #
572
    # Plot the data
573
    #
574
    # The mag_map and phase_map arrays have the indices axes needed for
575
    # making the plots.  Labels are used on each axes for later creation of
576
    # legends.  The generic labels if of the form:
577
    #
578
    #     To output label, From input label, system name
579
    #
580
    # The input and output labels are omitted if overlay_inputs or
581
    # overlay_outputs is False, respectively.  The system name is always
582
    # included, since multiple calls to plot() will require a legend that
583
    # distinguishes which system signals are plotted.  The system name is
584
    # stripped off later (in the legend-handling code) if it is not needed.
585
    #
586
    # Note: if we are building on top of an existing plot, tick labels
587
    # should be preserved from the existing axes.  For log scale axes the
588
    # tick labels seem to appear no matter what => we have to detect if
589
    # they are present at the start and, it not, remove them after calling
590
    # loglog or semilogx.
591
    #
592

593
    # Create a list of lines for the output
594
    out = np.empty((nrows, ncols), dtype=object)
9✔
595
    for i in range(nrows):
9✔
596
        for j in range(ncols):
9✔
597
            out[i, j] = []      # unique list in each element
9✔
598

599
    # Process label keyword
600
    line_labels = _process_line_labels(label, len(data), ninputs, noutputs)
9✔
601

602
    # Utility function for creating line label
603
    def _make_line_label(response, output_index, input_index):
9✔
604
        label = ""              # start with an empty label
9✔
605

606
        # Add the output name if it won't appear as an axes label
607
        if noutputs > 1 and overlay_outputs:
9✔
608
            label += response.output_labels[output_index]
9✔
609

610
        # Add the input name if it won't appear as a column label
611
        if ninputs > 1 and overlay_inputs:
9✔
612
            label += ", " if label != "" else ""
9✔
613
            label += response.input_labels[input_index]
9✔
614

615
        # Add the system name (will strip off later if redundant)
616
        label += ", " if label != "" else ""
9✔
617
        label += f"{response.sysname}"
9✔
618

619
        return label
9✔
620

621
    for index, response in enumerate(data):
9✔
622
        # Get the (pre-processed) data in fully indexed form
623
        mag = mag_data[index].reshape((noutputs, ninputs, -1))
9✔
624
        phase = phase_data[index].reshape((noutputs, ninputs, -1))
9✔
625
        omega_sys, sysname = omega_data[index], response.sysname
9✔
626

627
        for i, j in itertools.product(range(noutputs), range(ninputs)):
9✔
628
            # Get the axes to use for magnitude and phase
629
            ax_mag = ax_array[mag_map[i, j]]
9✔
630
            ax_phase = ax_array[phase_map[i, j]]
9✔
631

632
            # Get the frequencies and convert to Hz, if needed
633
            omega_plot = omega_sys / (2 * math.pi) if Hz else omega_sys
9✔
634
            if response.isdtime(strict=True):
9✔
635
                nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
636

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

641
            # Generate a label
642
            if line_labels is None:
9✔
643
                label = _make_line_label(response, i, j)
9✔
644
            else:
645
                label = line_labels[index, i, j]
9✔
646

647
            # Magnitude
648
            if plot_magnitude:
9✔
649
                pltfcn = ax_mag.semilogx if dB else ax_mag.loglog
9✔
650

651
                # Plot the main data
652
                lines = pltfcn(
9✔
653
                    omega_plot, mag_plot, *fmt, label=label, **kwargs)
654
                out[mag_map[i, j]] += lines
9✔
655

656
                # Save the information needed for the Nyquist line
657
                if response.isdtime(strict=True):
9✔
658
                    ax_mag.axvline(
9✔
659
                        nyq_freq, color=lines[0].get_color(), linestyle='--',
660
                        label='_nyq_mag_' + sysname)
661

662
                # Add a grid to the plot
663
                ax_mag.grid(grid and not display_margins, which='both')
9✔
664

665
            # Phase
666
            if plot_phase:
9✔
667
                lines = ax_phase.semilogx(
9✔
668
                    omega_plot, phase_plot, *fmt, label=label, **kwargs)
669
                out[phase_map[i, j]] += lines
9✔
670

671
                # Save the information needed for the Nyquist line
672
                if response.isdtime(strict=True):
9✔
673
                    ax_phase.axvline(
9✔
674
                        nyq_freq, color=lines[0].get_color(), linestyle='--',
675
                        label='_nyq_phase_' + sysname)
676

677
                # Add a grid to the plot
678
                ax_phase.grid(grid and not display_margins, which='both')
9✔
679

680
        #
681
        # Display gain and phase margins (SISO only)
682
        #
683

684
        if display_margins:
9✔
685
            if ninputs > 1 or noutputs > 1:
9✔
686
                raise NotImplementedError(
687
                    "margins are not available for MIMO systems")
688

689
            # Compute stability margins for the system
690
            margins = stability_margins(response, method=margins_method)
9✔
691
            gm, pm, Wcg, Wcp = (margins[i] for i in [0, 1, 3, 4])
9✔
692

693
            # Figure out sign of the phase at the first gain crossing
694
            # (needed if phase_wrap is True)
695
            phase_at_cp = phase[
9✔
696
                0, 0, (np.abs(omega_data[0] - Wcp)).argmin()]
697
            if phase_at_cp >= 0.:
9✔
698
                phase_limit = 180.
×
699
            else:
700
                phase_limit = -180.
9✔
701

702
            if Hz:
9✔
703
                Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi)
9✔
704

705
            # Draw lines at gain and phase limits
706
            if plot_magnitude:
9✔
707
                ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':',
9✔
708
                               zorder=-20)
709
                mag_ylim = ax_mag.get_ylim()
9✔
710

711
            if plot_phase:
9✔
712
                ax_phase.axhline(y=phase_limit if deg else
9✔
713
                                 math.radians(phase_limit),
714
                                 color='k', linestyle=':', zorder=-20)
715
                phase_ylim = ax_phase.get_ylim()
9✔
716

717
            # Annotate the phase margin (if it exists)
718
            if plot_phase and pm != float('inf') and Wcp != float('nan'):
9✔
719
                # Draw dotted lines marking the gain crossover frequencies
720
                if plot_magnitude:
9✔
721
                    ax_mag.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
722
                ax_phase.axvline(Wcp, color='k', linestyle=':', zorder=-30)
9✔
723

724
                # Draw solid segments indicating the margins
725
                if deg:
9✔
726
                    ax_phase.semilogx(
9✔
727
                        [Wcp, Wcp], [phase_limit + pm, phase_limit],
728
                        color='k', zorder=-20)
729
                else:
730
                    ax_phase.semilogx(
9✔
731
                        [Wcp, Wcp], [math.radians(phase_limit) +
732
                                     math.radians(pm),
733
                                     math.radians(phase_limit)],
734
                        color='k', zorder=-20)
735

736
            # Annotate the gain margin (if it exists)
737
            if plot_magnitude and gm != float('inf') and \
9✔
738
               Wcg != float('nan'):
739
                # Draw dotted lines marking the phase crossover frequencies
740
                ax_mag.axvline(Wcg, color='k', linestyle=':', zorder=-30)
9✔
741
                if plot_phase:
9✔
742
                    ax_phase.axvline(Wcg, color='k', linestyle=':', zorder=-30)
9✔
743

744
                # Draw solid segments indicating the margins
745
                if dB:
9✔
746
                    ax_mag.semilogx(
9✔
747
                        [Wcg, Wcg], [0, -20*np.log10(gm)],
748
                        color='k', zorder=-20)
749
                else:
750
                    ax_mag.loglog(
9✔
751
                        [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20)
752

753
            if display_margins == 'overlay':
9✔
754
                # TODO: figure out how to handle case of multiple lines
755
                # Put the margin information in the lower left corner
756
                if plot_magnitude:
9✔
757
                    ax_mag.text(
9✔
758
                        0.04, 0.06,
759
                        'G.M.: %.2f %s\nFreq: %.2f %s' %
760
                        (20*np.log10(gm) if dB else gm,
761
                         'dB ' if dB else '',
762
                         Wcg, 'Hz' if Hz else 'rad/s'),
763
                        horizontalalignment='left',
764
                        verticalalignment='bottom',
765
                        transform=ax_mag.transAxes,
766
                        fontsize=8 if int(mpl.__version__[0]) == 1 else 6)
767

768
                if plot_phase:
9✔
769
                    ax_phase.text(
9✔
770
                        0.04, 0.06,
771
                        'P.M.: %.2f %s\nFreq: %.2f %s' %
772
                        (pm if deg else math.radians(pm),
773
                         'deg' if deg else 'rad',
774
                         Wcp, 'Hz' if Hz else 'rad/s'),
775
                        horizontalalignment='left',
776
                        verticalalignment='bottom',
777
                        transform=ax_phase.transAxes,
778
                        fontsize=8 if int(mpl.__version__[0]) == 1 else 6)
779

780
            else:
781
                # Put the title underneath the suptitle (one line per system)
782
                ax = ax_mag if ax_mag else ax_phase
9✔
783
                axes_title = ax.get_title()
9✔
784
                if axes_title is not None and axes_title != "":
9✔
785
                    axes_title += "\n"
×
786
                with plt.rc_context(rcParams):
9✔
787
                    ax.set_title(
9✔
788
                        axes_title + f"{sysname}: "
789
                        "Gm = %.2f %s(at %.2f %s), "
790
                        "Pm = %.2f %s (at %.2f %s)" %
791
                        (20*np.log10(gm) if dB else gm,
792
                         'dB ' if dB else '',
793
                         Wcg, 'Hz' if Hz else 'rad/s',
794
                         pm if deg else math.radians(pm),
795
                         'deg' if deg else 'rad',
796
                         Wcp, 'Hz' if Hz else 'rad/s'))
797

798
    #
799
    # Finishing handling axes limit sharing
800
    #
801
    # This code handles labels on Bode plots and also removes tick labels
802
    # on shared axes.  It needs to come *after* the plots are generated,
803
    # in order to handle two things:
804
    #
805
    # * manually generated labels and grids need to reflect the limits for
806
    #   shared axes, which we don't know until we have plotted everything;
807
    #
808
    # * the loglog and semilog functions regenerate the labels (not quite
809
    #   sure why, since using sharex and sharey in subplots does not have
810
    #   this behavior).
811
    #
812
    # Note: as before, if the various share_* keywords are None then a
813
    # previous set of axes are available and no updates are made. (TODO: true?)
814
    #
815

816
    for i in range(noutputs):
9✔
817
        for j in range(ninputs):
9✔
818
            # Utility function to generate phase labels
819
            def gen_zero_centered_series(val_min, val_max, period):
9✔
820
                v1 = np.ceil(val_min / period - 0.2)
9✔
821
                v2 = np.floor(val_max / period + 0.2)
9✔
822
                return np.arange(v1, v2 + 1) * period
9✔
823

824
            # Label the phase axes using multiples of 45 degrees
825
            if plot_phase:
9✔
826
                ax_phase = ax_array[phase_map[i, j]]
9✔
827

828
                # Set the labels
829
                if deg:
9✔
830
                    ylim = ax_phase.get_ylim()
9✔
831
                    num = np.floor((ylim[1] - ylim[0]) / 45)
9✔
832
                    factor = max(1, np.round(num / (32 / nrows)) * 2)
9✔
833
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
834
                        ylim[0], ylim[1], 45 * factor))
835
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
836
                        ylim[0], ylim[1], 15 * factor), minor=True)
837
                else:
838
                    ylim = ax_phase.get_ylim()
9✔
839
                    num = np.ceil((ylim[1] - ylim[0]) / (math.pi/4))
9✔
840
                    factor = max(1, np.round(num / (36 / nrows)) * 2)
9✔
841
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
842
                        ylim[0], ylim[1], math.pi / 4. * factor))
843
                    ax_phase.set_yticks(gen_zero_centered_series(
9✔
844
                        ylim[0], ylim[1], math.pi / 12. * factor), minor=True)
845

846
    # Turn off y tick labels for shared axes
847
    for i in range(0, noutputs):
9✔
848
        for j in range(1, ncols):
9✔
849
            if share_magnitude in [True, 'all', 'row']:
9✔
850
                ax_array[mag_map[i, j]].tick_params(labelleft=False)
9✔
851
            if share_phase in [True, 'all', 'row']:
9✔
852
                ax_array[phase_map[i, j]].tick_params(labelleft=False)
9✔
853

854
    # Turn off x tick labels for shared axes
855
    for i in range(0, nrows-1):
9✔
856
        for j in range(0, ncols):
9✔
857
            if share_frequency in [True, 'all', 'col']:
9✔
858
                ax_array[i, j].tick_params(labelbottom=False)
9✔
859

860
    # If specific omega_limits were given, use them
861
    if omega_limits is not None:
9✔
862
        for i, j in itertools.product(range(nrows), range(ncols)):
9✔
863
            ax_array[i, j].set_xlim(omega_limits)
9✔
864

865
    #
866
    # Label the axes (including header labels)
867
    #
868
    # Once the data are plotted, we label the axes.  The horizontal axes is
869
    # always frequency and this is labeled only on the bottom most row.  The
870
    # vertical axes can consist either of a single signal or a combination
871
    # of signals (when overlay_inputs or overlay_outputs is True)
872
    #
873
    # Input/output signals are give at the top of columns and left of rows
874
    # when these are individually plotted.
875
    #
876

877
    # Label the columns (do this first to get row labels in the right spot)
878
    for j in range(ncols):
9✔
879
        # If we have more than one column, label the individual responses
880
        if (noutputs > 1 and not overlay_outputs or ninputs > 1) \
9✔
881
           and not overlay_inputs:
882
            with plt.rc_context(rcParams):
9✔
883
                ax_array[0, j].set_title(f"From {data[0].input_labels[j]}")
9✔
884

885
        # Label the frequency axis
886
        ax_array[-1, j].set_xlabel(
9✔
887
            freq_label.format(units="Hz" if Hz else "rad/s"))
888

889
    # Label the rows
890
    for i in range(noutputs if not overlay_outputs else 1):
9✔
891
        if plot_magnitude:
9✔
892
            ax_mag = ax_array[mag_map[i, 0]]
9✔
893
            ax_mag.set_ylabel(magnitude_label)
9✔
894
        if plot_phase:
9✔
895
            ax_phase = ax_array[phase_map[i, 0]]
9✔
896
            ax_phase.set_ylabel(phase_label)
9✔
897

898
        if (noutputs > 1 or ninputs > 1) and not overlay_outputs:
9✔
899
            if plot_magnitude and plot_phase:
9✔
900
                # Get existing ylabel for left column and add a blank line
901
                ax_mag.set_ylabel("\n" + ax_mag.get_ylabel())
9✔
902
                ax_phase.set_ylabel("\n" + ax_phase.get_ylabel())
9✔
903

904
                # Find the midpoint between the row axes (+ tight_layout)
905
                _, ypos = _find_axes_center(fig, [ax_mag, ax_phase])
9✔
906

907
                # Get the bounding box including the labels
908
                inv_transform = fig.transFigure.inverted()
9✔
909
                mag_bbox = inv_transform.transform(
9✔
910
                    ax_mag.get_tightbbox(fig.canvas.get_renderer()))
911

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

915
                # Put a centered label as text outside the box
916
                fig.text(
9✔
917
                    0.8 * xpos, ypos, f"To {data[0].output_labels[i]}\n",
918
                    rotation=90, ha='left', va='center',
919
                    fontsize=rcParams['axes.titlesize'])
920
            else:
921
                # Only a single axes => add label to the left
922
                ax_array[i, 0].set_ylabel(
9✔
923
                    f"To {data[0].output_labels[i]}\n" +
924
                    ax_array[i, 0].get_ylabel())
925

926
    #
927
    # Update the plot title (= figure suptitle)
928
    #
929
    # If plots are built up by multiple calls to plot() and the title is
930
    # not given, then the title is updated to provide a list of unique text
931
    # items in each successive title.  For data generated by the frequency
932
    # response function this will generate a common prefix followed by a
933
    # list of systems (e.g., "Step response for sys[1], sys[2]").
934
    #
935

936
    # Set the initial title for the data (unique system names, preserving order)
937
    seen = set()
9✔
938
    sysnames = [response.sysname for response in data \
9✔
939
                if not (response.sysname in seen or seen.add(response.sysname))]
940
    if title is None:
9✔
941
        if data[0].title is None:
9✔
942
            title = "Bode plot for " + ", ".join(sysnames)
9✔
943
        else:
944
            title = data[0].title
9✔
945

946
    _update_suptitle(fig, title, rcParams=rcParams, frame=suptitle_frame)
9✔
947

948
    #
949
    # Create legends
950
    #
951
    # Legends can be placed manually by passing a legend_map array that
952
    # matches the shape of the suplots, with each item being a string
953
    # indicating the location of the legend for that axes (or None for no
954
    # legend).
955
    #
956
    # If no legend spec is passed, a minimal number of legends are used so
957
    # that each line in each axis can be uniquely identified.  The details
958
    # depends on the various plotting parameters, but the general rule is
959
    # to place legends in the top row and right column.
960
    #
961
    # Because plots can be built up by multiple calls to plot(), the legend
962
    # strings are created from the line labels manually.  Thus an initial
963
    # call to plot() may not generate any legends (eg, if no signals are
964
    # overlaid), but subsequent calls to plot() will need a legend for each
965
    # different response (system).
966
    #
967

968
    # Figure out where to put legends
969
    if legend_map is None:
9✔
970
        legend_map = np.full(ax_array.shape, None, dtype=object)
9✔
971
        if legend_loc == None:
9✔
972
            legend_loc = 'center right'
9✔
973

974
        # TODO: add in additional processing later
975

976
        # Put legend in the upper right
977
        legend_map[0, -1] = legend_loc
9✔
978

979
    # Create axis legends
980
    for i in range(nrows):
9✔
981
        for j in range(ncols):
9✔
982
            ax = ax_array[i, j]
9✔
983
            # Get the labels to use, removing common strings
984
            lines = [line for line in ax.get_lines()
9✔
985
                     if line.get_label()[0] != '_']
986
            labels = _make_legend_labels(
9✔
987
                [line.get_label() for line in lines],
988
                ignore_common=line_labels is not None)
989

990
            # Generate the label, if needed
991
            if len(labels) > 1 and legend_map[i, j] != None:
9✔
992
                with plt.rc_context(rcParams):
9✔
993
                    ax.legend(lines, labels, loc=legend_map[i, j])
9✔
994

995
    #
996
    # Legacy return pocessing
997
    #
998
    if plot is True:            # legacy usage; remove in future release
9✔
999
        # Process the data to match what we were sent
1000
        for i in range(len(mag_data)):
9✔
1001
            mag_data[i] = _process_frequency_response(
9✔
1002
                data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze)
1003
            phase_data[i] = _process_frequency_response(
9✔
1004
                data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze)
1005

1006
        if len(data) == 1:
9✔
1007
            return mag_data[0], phase_data[0], omega_data[0]
9✔
1008
        else:
1009
            return mag_data, phase_data, omega_data
9✔
1010

1011
    return out
9✔
1012

1013

1014
#
1015
# Nyquist plot
1016
#
1017

1018
# Default values for module parameter variables
1019
_nyquist_defaults = {
9✔
1020
    'nyquist.primary_style': ['-', '-.'],       # style for primary curve
1021
    'nyquist.mirror_style': ['--', ':'],        # style for mirror curve
1022
    'nyquist.arrows': 2,                        # number of arrows around curve
1023
    'nyquist.arrow_size': 8,                    # pixel size for arrows
1024
    'nyquist.encirclement_threshold': 0.05,     # warning threshold
1025
    'nyquist.indent_radius': 1e-4,              # indentation radius
1026
    'nyquist.indent_direction': 'right',        # indentation direction
1027
    'nyquist.indent_points': 50,                # number of points to insert
1028
    'nyquist.max_curve_magnitude': 20,          # clip large values
1029
    'nyquist.max_curve_offset': 0.02,           # offset of primary/mirror
1030
    'nyquist.start_marker': 'o',                # marker at start of curve
1031
    'nyquist.start_marker_size': 4,             # size of the marker
1032
    'nyquist.circle_style':                     # style for unit circles
1033
      {'color': 'black', 'linestyle': 'dashed', 'linewidth': 1}
1034
}
1035

1036

1037
class NyquistResponseData:
9✔
1038
    """Nyquist response data object.
1039

1040
    Nyquist contour analysis allows the stability and robustness of a
1041
    closed loop linear system to be evaluated using the open loop response
1042
    of the loop transfer function.  The NyquistResponseData class is used
1043
    by the :func:`~control.nyquist_response` function to return the
1044
    response of a linear system along the Nyquist 'D' contour.  The
1045
    response object can be used to obtain information about the Nyquist
1046
    response or to generate a Nyquist plot.
1047

1048
    Attributes
1049
    ----------
1050
    count : integer
1051
        Number of encirclements of the -1 point by the Nyquist curve for
1052
        a system evaluated along the Nyquist contour.
1053
    contour : complex array
1054
        The Nyquist 'D' contour, with appropriate indendtations to avoid
1055
        open loop poles and zeros near/on the imaginary axis.
1056
    response : complex array
1057
        The value of the linear system under study along the Nyquist contour.
1058
    dt : None or float
1059
        The system timebase.
1060
    sysname : str
1061
        The name of the system being analyzed.
1062
    return_contour: bool
1063
        If true, when the object is accessed as an iterable return two
1064
        elements": `count` (number of encirlements) and `contour`.  If
1065
        false (default), then return only `count`.
1066

1067
    """
1068
    def __init__(
9✔
1069
            self, count, contour, response, dt, sysname=None,
1070
            return_contour=False):
1071
        self.count = count
9✔
1072
        self.contour = contour
9✔
1073
        self.response = response
9✔
1074
        self.dt = dt
9✔
1075
        self.sysname = sysname
9✔
1076
        self.return_contour = return_contour
9✔
1077

1078
    # Implement iter to allow assigning to a tuple
1079
    def __iter__(self):
9✔
1080
        if self.return_contour:
9✔
1081
            return iter((self.count, self.contour))
9✔
1082
        else:
1083
            return iter((self.count, ))
9✔
1084

1085
    # Implement (thin) getitem to allow access via legacy indexing
1086
    def __getitem__(self, index):
9✔
1087
        return list(self.__iter__())[index]
×
1088

1089
    # Implement (thin) len to emulate legacy testing interface
1090
    def __len__(self):
9✔
1091
        return 2 if self.return_contour else 1
9✔
1092

1093
    def plot(self, *args, **kwargs):
9✔
1094
        return nyquist_plot(self, *args, **kwargs)
9✔
1095

1096

1097
class NyquistResponseList(list):
9✔
1098
    def plot(self, *args, **kwargs):
9✔
1099
        return nyquist_plot(self, *args, **kwargs)
9✔
1100

1101

1102
def nyquist_response(
9✔
1103
        sysdata, omega=None, plot=None, omega_limits=None, omega_num=None,
1104
        return_contour=False, warn_encirclements=True, warn_nyquist=True,
1105
        check_kwargs=True, **kwargs):
1106
    """Nyquist response for a system.
1107

1108
    Computes a Nyquist contour for the system over a (optional) frequency
1109
    range and evaluates the number of net encirclements.  The curve is
1110
    computed by evaluating the Nyqist segment along the positive imaginary
1111
    axis, with a mirror image generated to reflect the negative imaginary
1112
    axis.  Poles on or near the imaginary axis are avoided using a small
1113
    indentation.  The portion of the Nyquist contour at infinity is not
1114
    explicitly computed (since it maps to a constant value for any system
1115
    with a proper transfer function).
1116

1117
    Parameters
1118
    ----------
1119
    sysdata : LTI or list of LTI
1120
        List of linear input/output systems (single system is OK). Nyquist
1121
        curves for each system are plotted on the same graph.
1122
    omega : array_like, optional
1123
        Set of frequencies to be evaluated, in rad/sec.
1124

1125
    Returns
1126
    -------
1127
    responses : list of :class:`~control.NyquistResponseData`
1128
        For each system, a Nyquist response data object is returned.  If
1129
        `sysdata` is a single system, a single elemeent is returned (not a
1130
        list).  For each response, the following information is available:
1131
    response.count : int
1132
        Number of encirclements of the point -1 by the Nyquist curve.  If
1133
        multiple systems are given, an array of counts is returned.
1134
    response.contour : ndarray
1135
        The contour used to create the primary Nyquist curve segment.  To
1136
        obtain the Nyquist curve values, evaluate system(s) along contour.
1137

1138
    Other Parameters
1139
    ----------------
1140
    encirclement_threshold : float, optional
1141
        Define the threshold for generating a warning if the number of net
1142
        encirclements is a non-integer value.  Default value is 0.05 and can
1143
        be set using config.defaults['nyquist.encirclement_threshold'].
1144
    indent_direction : str, optional
1145
        For poles on the imaginary axis, set the direction of indentation to
1146
        be 'right' (default), 'left', or 'none'.
1147
    indent_points : int, optional
1148
        Number of points to insert in the Nyquist contour around poles that
1149
        are at or near the imaginary axis.
1150
    indent_radius : float, optional
1151
        Amount to indent the Nyquist contour around poles on or near the
1152
        imaginary axis. Portions of the Nyquist plot corresponding to indented
1153
        portions of the contour are plotted using a different line style.
1154
    omega_limits : array_like of two values
1155
        Set limits for plotted frequency range. If Hz=True the limits are
1156
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
1157
        elements is equivalent to providing ``omega_limits``.
1158
    omega_num : int, optional
1159
        Number of samples to use for the frequeny range.  Defaults to
1160
        config.defaults['freqplot.number_of_samples'].
1161
    warn_nyquist : bool, optional
1162
        If set to 'False', turn off warnings about frequencies above Nyquist.
1163
    warn_encirclements : bool, optional
1164
        If set to 'False', turn off warnings about number of encirclements not
1165
        meeting the Nyquist criterion.
1166

1167
    Notes
1168
    -----
1169
    1. If a discrete time model is given, the frequency response is computed
1170
       along the upper branch of the unit circle, using the mapping ``z =
1171
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
1172
       is the discrete timebase.  If timebase not specified (``dt=True``),
1173
       `dt` is set to 1.
1174

1175
    2. If a continuous-time system contains poles on or near the imaginary
1176
       axis, a small indentation will be used to avoid the pole.  The radius
1177
       of the indentation is given by `indent_radius` and it is taken to the
1178
       right of stable poles and the left of unstable poles.  If a pole is
1179
       exactly on the imaginary axis, the `indent_direction` parameter can be
1180
       used to set the direction of indentation.  Setting `indent_direction`
1181
       to `none` will turn off indentation.  If `return_contour` is True, the
1182
       exact contour used for evaluation is returned.
1183

1184
    3. For those portions of the Nyquist plot in which the contour is
1185
       indented to avoid poles, resuling in a scaling of the Nyquist plot,
1186
       the line styles are according to the settings of the `primary_style`
1187
       and `mirror_style` keywords.  By default the scaled portions of the
1188
       primary curve use a dotted line style and the scaled portion of the
1189
       mirror image use a dashdot line style.
1190

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

1195
    See Also
1196
    --------
1197
    nyquist_plot
1198

1199
    Examples
1200
    --------
1201
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1202
    >>> response = ct.nyquist_response(G)
1203
    >>> count = response.count
1204
    >>> lines = response.plot()
1205

1206
    """
1207
    # Get values for params
1208
    omega_num_given = omega_num is not None
9✔
1209
    omega_num = config._get_param('freqplot', 'number_of_samples', omega_num)
9✔
1210
    indent_radius = config._get_param(
9✔
1211
        'nyquist', 'indent_radius', kwargs, _nyquist_defaults, pop=True)
1212
    encirclement_threshold = config._get_param(
9✔
1213
        'nyquist', 'encirclement_threshold', kwargs,
1214
        _nyquist_defaults, pop=True)
1215
    indent_direction = config._get_param(
9✔
1216
        'nyquist', 'indent_direction', kwargs, _nyquist_defaults, pop=True)
1217
    indent_points = config._get_param(
9✔
1218
        'nyquist', 'indent_points', kwargs, _nyquist_defaults, pop=True)
1219

1220
    if check_kwargs and kwargs:
9✔
1221
        raise TypeError("unrecognized keywords: ", str(kwargs))
9✔
1222

1223
    # Convert the first argument to a list
1224
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
1225

1226
    # Determine the range of frequencies to use, based on args/features
1227
    omega, omega_range_given = _determine_omega_vector(
9✔
1228
        syslist, omega, omega_limits, omega_num, feature_periphery_decades=2)
1229

1230
    # If omega was not specified explicitly, start at omega = 0
1231
    if not omega_range_given:
9✔
1232
        if omega_num_given:
9✔
1233
            # Just reset the starting point
1234
            omega[0] = 0.0
9✔
1235
        else:
1236
            # Insert points between the origin and the first frequency point
1237
            omega = np.concatenate((
9✔
1238
                np.linspace(0, omega[0], indent_points), omega[1:]))
1239

1240
    # Go through each system and keep track of the results
1241
    responses = []
9✔
1242
    for idx, sys in enumerate(syslist):
9✔
1243
        if not sys.issiso():
9✔
1244
            # TODO: Add MIMO nyquist plots.
1245
            raise ControlMIMONotImplemented(
9✔
1246
                "Nyquist plot currently only supports SISO systems.")
1247

1248
        # Figure out the frequency range
1249
        if isinstance(sys, FrequencyResponseData) and sys.ifunc is None \
9✔
1250
           and not omega_range_given:
1251
            omega_sys = sys.omega               # use system frequencies
9✔
1252
        else:
1253
            omega_sys = np.asarray(omega)       # use common omega vector
9✔
1254

1255
        # Determine the contour used to evaluate the Nyquist curve
1256
        if sys.isdtime(strict=True):
9✔
1257
            # Restrict frequencies for discrete-time systems
1258
            nyq_freq = math.pi / sys.dt
9✔
1259
            if not omega_range_given:
9✔
1260
                # limit up to and including Nyquist frequency
1261
                omega_sys = np.hstack((
9✔
1262
                    omega_sys[omega_sys < nyq_freq], nyq_freq))
1263

1264
            # Issue a warning if we are sampling above Nyquist
1265
            if np.any(omega_sys * sys.dt > np.pi) and warn_nyquist:
9✔
1266
                warnings.warn("evaluation above Nyquist frequency")
9✔
1267

1268
        # do indentations in s-plane where it is more convenient
1269
        splane_contour = 1j * omega_sys
9✔
1270

1271
        # Bend the contour around any poles on/near the imaginary axis
1272
        if isinstance(sys, (StateSpace, TransferFunction)) \
9✔
1273
                and indent_direction != 'none':
1274
            if sys.isctime():
9✔
1275
                splane_poles = sys.poles()
9✔
1276
                splane_cl_poles = sys.feedback().poles()
9✔
1277
            else:
1278
                # map z-plane poles to s-plane. We ignore any at the origin
1279
                # to avoid numerical warnings because we know we
1280
                # don't need to indent for them
1281
                zplane_poles = sys.poles()
9✔
1282
                zplane_poles = zplane_poles[~np.isclose(abs(zplane_poles), 0.)]
9✔
1283
                splane_poles = np.log(zplane_poles) / sys.dt
9✔
1284

1285
                zplane_cl_poles = sys.feedback().poles()
9✔
1286
                # eliminate z-plane poles at the origin to avoid warnings
1287
                zplane_cl_poles = zplane_cl_poles[
9✔
1288
                    ~np.isclose(abs(zplane_cl_poles), 0.)]
1289
                splane_cl_poles = np.log(zplane_cl_poles) / sys.dt
9✔
1290

1291
            #
1292
            # Check to make sure indent radius is small enough
1293
            #
1294
            # If there is a closed loop pole that is near the imaginary axis
1295
            # at a point that is near an open loop pole, it is possible that
1296
            # indentation might skip or create an extraneous encirclement.
1297
            # We check for that situation here and generate a warning if that
1298
            # could happen.
1299
            #
1300
            for p_cl in splane_cl_poles:
9✔
1301
                # See if any closed loop poles are near the imaginary axis
1302
                if abs(p_cl.real) <= indent_radius:
9✔
1303
                    # See if any open loop poles are close to closed loop poles
1304
                    if len(splane_poles) > 0:
9✔
1305
                        p_ol = splane_poles[
9✔
1306
                            (np.abs(splane_poles - p_cl)).argmin()]
1307

1308
                        if abs(p_ol - p_cl) <= indent_radius and \
9✔
1309
                                warn_encirclements:
1310
                            warnings.warn(
9✔
1311
                                "indented contour may miss closed loop pole; "
1312
                                "consider reducing indent_radius to below "
1313
                                f"{abs(p_ol - p_cl):5.2g}", stacklevel=2)
1314

1315
            #
1316
            # See if we should add some frequency points near imaginary poles
1317
            #
1318
            for p in splane_poles:
9✔
1319
                # See if we need to process this pole (skip if on the negative
1320
                # imaginary axis or not near imaginary axis + user override)
1321
                if p.imag < 0 or abs(p.real) > indent_radius or \
9✔
1322
                   omega_range_given:
1323
                    continue
9✔
1324

1325
                # Find the frequencies before the pole frequency
1326
                below_points = np.argwhere(
9✔
1327
                    splane_contour.imag - abs(p.imag) < -indent_radius)
1328
                if below_points.size > 0:
9✔
1329
                    first_point = below_points[-1].item()
9✔
1330
                    start_freq = p.imag - indent_radius
9✔
1331
                else:
1332
                    # Add the points starting at the beginning of the contour
1333
                    assert splane_contour[0] == 0
9✔
1334
                    first_point = 0
9✔
1335
                    start_freq = 0
9✔
1336

1337
                # Find the frequencies after the pole frequency
1338
                above_points = np.argwhere(
9✔
1339
                    splane_contour.imag - abs(p.imag) > indent_radius)
1340
                last_point = above_points[0].item()
9✔
1341

1342
                # Add points for half/quarter circle around pole frequency
1343
                # (these will get indented left or right below)
1344
                splane_contour = np.concatenate((
9✔
1345
                    splane_contour[0:first_point+1],
1346
                    (1j * np.linspace(
1347
                        start_freq, p.imag + indent_radius, indent_points)),
1348
                    splane_contour[last_point:]))
1349

1350
            # Indent points that are too close to a pole
1351
            if len(splane_poles) > 0: # accomodate no splane poles if dtime sys
9✔
1352
                for i, s in enumerate(splane_contour):
9✔
1353
                    # Find the nearest pole
1354
                    p = splane_poles[(np.abs(splane_poles - s)).argmin()]
9✔
1355

1356
                    # See if we need to indent around it
1357
                    if abs(s - p) < indent_radius:
9✔
1358
                        # Figure out how much to offset (simple trigonometry)
1359
                        offset = np.sqrt(
9✔
1360
                            indent_radius ** 2 - (s - p).imag ** 2) \
1361
                            - (s - p).real
1362

1363
                        # Figure out which way to offset the contour point
1364
                        if p.real < 0 or (p.real == 0 and
9✔
1365
                                        indent_direction == 'right'):
1366
                            # Indent to the right
1367
                            splane_contour[i] += offset
9✔
1368

1369
                        elif p.real > 0 or (p.real == 0 and
9✔
1370
                                            indent_direction == 'left'):
1371
                            # Indent to the left
1372
                            splane_contour[i] -= offset
9✔
1373

1374
                        else:
1375
                            raise ValueError(
9✔
1376
                                "unknown value for indent_direction")
1377

1378
        # change contour to z-plane if necessary
1379
        if sys.isctime():
9✔
1380
            contour = splane_contour
9✔
1381
        else:
1382
            contour = np.exp(splane_contour * sys.dt)
9✔
1383

1384
        # Compute the primary curve
1385
        resp = sys(contour)
9✔
1386

1387
        # Compute CW encirclements of -1 by integrating the (unwrapped) angle
1388
        phase = -unwrap(np.angle(resp + 1))
9✔
1389
        encirclements = np.sum(np.diff(phase)) / np.pi
9✔
1390
        count = int(np.round(encirclements, 0))
9✔
1391

1392
        # Let the user know if the count might not make sense
1393
        if abs(encirclements - count) > encirclement_threshold and \
9✔
1394
           warn_encirclements:
1395
            warnings.warn(
9✔
1396
                "number of encirclements was a non-integer value; this can"
1397
                " happen is contour is not closed, possibly based on a"
1398
                " frequency range that does not include zero.")
1399

1400
        #
1401
        # Make sure that the enciriclements match the Nyquist criterion
1402
        #
1403
        # If the user specifies the frequency points to use, it is possible
1404
        # to miss enciriclements, so we check here to make sure that the
1405
        # Nyquist criterion is actually satisfied.
1406
        #
1407
        if isinstance(sys, (StateSpace, TransferFunction)):
9✔
1408
            # Count the number of open/closed loop RHP poles
1409
            if sys.isctime():
9✔
1410
                if indent_direction == 'right':
9✔
1411
                    P = (sys.poles().real > 0).sum()
9✔
1412
                else:
1413
                    P = (sys.poles().real >= 0).sum()
9✔
1414
                Z = (sys.feedback().poles().real >= 0).sum()
9✔
1415
            else:
1416
                if indent_direction == 'right':
9✔
1417
                    P = (np.abs(sys.poles()) > 1).sum()
9✔
1418
                else:
1419
                    P = (np.abs(sys.poles()) >= 1).sum()
×
1420
                Z = (np.abs(sys.feedback().poles()) >= 1).sum()
9✔
1421

1422
            # Check to make sure the results make sense; warn if not
1423
            if Z != count + P and warn_encirclements:
9✔
1424
                warnings.warn(
9✔
1425
                    "number of encirclements does not match Nyquist criterion;"
1426
                    " check frequency range and indent radius/direction",
1427
                    UserWarning, stacklevel=2)
1428
            elif indent_direction == 'none' and any(sys.poles().real == 0) and \
9✔
1429
                 warn_encirclements:
1430
                warnings.warn(
×
1431
                    "system has pure imaginary poles but indentation is"
1432
                    " turned off; results may be meaningless",
1433
                    RuntimeWarning, stacklevel=2)
1434

1435
        # Decide on system name
1436
        sysname = sys.name if sys.name is not None else f"Unknown-{idx}"
9✔
1437

1438
        responses.append(NyquistResponseData(
9✔
1439
            count, contour, resp, sys.dt, sysname=sysname,
1440
            return_contour=return_contour))
1441

1442
    if isinstance(sysdata, (list, tuple)):
9✔
1443
        return NyquistResponseList(responses)
9✔
1444
    else:
1445
        return responses[0]
9✔
1446

1447

1448
def nyquist_plot(
9✔
1449
        data, omega=None, plot=None, label_freq=0, color=None, label=None,
1450
        return_contour=None, title=None, legend_loc='upper right', ax=None,
1451
        unit_circle=False, mt_circles=None, ms_circles=None, **kwargs):
1452
    """Nyquist plot for a system.
1453

1454
    Generates a Nyquist plot for the system over a (optional) frequency
1455
    range.  The curve is computed by evaluating the Nyqist segment along
1456
    the positive imaginary axis, with a mirror image generated to reflect
1457
    the negative imaginary axis.  Poles on or near the imaginary axis are
1458
    avoided using a small indentation.  The portion of the Nyquist contour
1459
    at infinity is not explicitly computed (since it maps to a constant
1460
    value for any system with a proper transfer function).
1461

1462
    Parameters
1463
    ----------
1464
    data : list of LTI or NyquistResponseData
1465
        List of linear input/output systems (single system is OK) or
1466
        Nyquist ersponses (computed using :func:`~control.nyquist_response`).
1467
        Nyquist curves for each system are plotted on the same graph.
1468
    omega : array_like, optional
1469
        Set of frequencies to be evaluated, in rad/sec. Specifying
1470
        ``omega`` as a list of two elements is equivalent to providing
1471
        ``omega_limits``.
1472
    color : string, optional
1473
        Used to specify the color of the line and arrowhead.
1474
    unit_circle : bool, optional
1475
        If ``True``, display the unit circle, to read gain crossover frequency.
1476
    mt_circles : array_like, optional
1477
        Draw circles corresponding to the given magnitudes of sensitivity.
1478
    ms_circles : array_like, optional
1479
        Draw circles corresponding to the given magnitudes of complementary
1480
        sensitivity.
1481
    **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
1482
        Additional keywords (passed to `matplotlib`)
1483

1484
    Returns
1485
    -------
1486
    lines : array of Line2D
1487
        2D array of Line2D objects for each line in the plot.  The shape of
1488
        the array is given by (nsys, 4) where nsys is the number of systems
1489
        or Nyquist responses passed to the function.  The second index
1490
        specifies the segment type:
1491

1492
        * lines[idx, 0]: unscaled portion of the primary curve
1493
        * lines[idx, 1]: scaled portion of the primary curve
1494
        * lines[idx, 2]: unscaled portion of the mirror curve
1495
        * lines[idx, 3]: scaled portion of the mirror curve
1496

1497
    Other Parameters
1498
    ----------------
1499
    arrows : int or 1D/2D array of floats, optional
1500
        Specify the number of arrows to plot on the Nyquist curve.  If an
1501
        integer is passed. that number of equally spaced arrows will be
1502
        plotted on each of the primary segment and the mirror image.  If a 1D
1503
        array is passed, it should consist of a sorted list of floats between
1504
        0 and 1, indicating the location along the curve to plot an arrow.  If
1505
        a 2D array is passed, the first row will be used to specify arrow
1506
        locations for the primary curve and the second row will be used for
1507
        the mirror image.
1508
    arrow_size : float, optional
1509
        Arrowhead width and length (in display coordinates).  Default value is
1510
        8 and can be set using config.defaults['nyquist.arrow_size'].
1511
    arrow_style : matplotlib.patches.ArrowStyle, optional
1512
        Define style used for Nyquist curve arrows (overrides `arrow_size`).
1513
    encirclement_threshold : float, optional
1514
        Define the threshold for generating a warning if the number of net
1515
        encirclements is a non-integer value.  Default value is 0.05 and can
1516
        be set using config.defaults['nyquist.encirclement_threshold'].
1517
    indent_direction : str, optional
1518
        For poles on the imaginary axis, set the direction of indentation to
1519
        be 'right' (default), 'left', or 'none'.
1520
    indent_points : int, optional
1521
        Number of points to insert in the Nyquist contour around poles that
1522
        are at or near the imaginary axis.
1523
    indent_radius : float, optional
1524
        Amount to indent the Nyquist contour around poles on or near the
1525
        imaginary axis. Portions of the Nyquist plot corresponding to indented
1526
        portions of the contour are plotted using a different line style.
1527
    label : str or array-like of str
1528
        If present, replace automatically generated label(s) with the given
1529
        label(s).  If sysdata is a list, strings should be specified for each
1530
        system.
1531
    label_freq : int, optiona
1532
        Label every nth frequency on the plot.  If not specified, no labels
1533
        are generated.
1534
    max_curve_magnitude : float, optional
1535
        Restrict the maximum magnitude of the Nyquist plot to this value.
1536
        Portions of the Nyquist plot whose magnitude is restricted are
1537
        plotted using a different line style.
1538
    max_curve_offset : float, optional
1539
        When plotting scaled portion of the Nyquist plot, increase/decrease
1540
        the magnitude by this fraction of the max_curve_magnitude to allow
1541
        any overlaps between the primary and mirror curves to be avoided.
1542
    mirror_style : [str, str] or False
1543
        Linestyles for mirror image of the Nyquist curve.  The first element
1544
        is used for unscaled portions of the Nyquist curve, the second element
1545
        is used for portions that are scaled (using max_curve_magnitude).  If
1546
        `False` then omit completely.  Default linestyle (['--', ':']) is
1547
        determined by config.defaults['nyquist.mirror_style'].
1548
    omega_limits : array_like of two values
1549
        Set limits for plotted frequency range. If Hz=True the limits are
1550
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
1551
        elements is equivalent to providing ``omega_limits``.
1552
    omega_num : int, optional
1553
        Number of samples to use for the frequeny range.  Defaults to
1554
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
1555
        not a list of systems.
1556
    plot : bool, optional
1557
        (legacy) If given, `bode_plot` returns the legacy return values
1558
        of magnitude, phase, and frequency.  If False, just return the
1559
        values with no plot.
1560
    primary_style : [str, str], optional
1561
        Linestyles for primary image of the Nyquist curve.  The first
1562
        element is used for unscaled portions of the Nyquist curve,
1563
        the second element is used for portions that are scaled (using
1564
        max_curve_magnitude).  Default linestyle (['-', '-.']) is
1565
        determined by config.defaults['nyquist.mirror_style'].
1566
    rcParams : dict
1567
        Override the default parameters used for generating plots.
1568
        Default is set by config.default['freqplot.rcParams'].
1569
    return_contour : bool, optional
1570
        (legacy) If 'True', return the encirclement count and Nyquist
1571
        contour used to generate the Nyquist plot.
1572
    start_marker : str, optional
1573
        Matplotlib marker to use to mark the starting point of the Nyquist
1574
        plot.  Defaults value is 'o' and can be set using
1575
        config.defaults['nyquist.start_marker'].
1576
    start_marker_size : float, optional
1577
        Start marker size (in display coordinates).  Default value is
1578
        4 and can be set using config.defaults['nyquist.start_marker_size'].
1579
    warn_nyquist : bool, optional
1580
        If set to 'False', turn off warnings about frequencies above Nyquist.
1581
    warn_encirclements : bool, optional
1582
        If set to 'False', turn off warnings about number of encirclements not
1583
        meeting the Nyquist criterion.
1584

1585
    See Also
1586
    --------
1587
    nyquist_response
1588

1589
    Notes
1590
    -----
1591
    1. If a discrete time model is given, the frequency response is computed
1592
       along the upper branch of the unit circle, using the mapping ``z =
1593
       exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt`
1594
       is the discrete timebase.  If timebase not specified (``dt=True``),
1595
       `dt` is set to 1.
1596

1597
    2. If a continuous-time system contains poles on or near the imaginary
1598
       axis, a small indentation will be used to avoid the pole.  The radius
1599
       of the indentation is given by `indent_radius` and it is taken to the
1600
       right of stable poles and the left of unstable poles.  If a pole is
1601
       exactly on the imaginary axis, the `indent_direction` parameter can be
1602
       used to set the direction of indentation.  Setting `indent_direction`
1603
       to `none` will turn off indentation.  If `return_contour` is True, the
1604
       exact contour used for evaluation is returned.
1605

1606
    3. For those portions of the Nyquist plot in which the contour is
1607
       indented to avoid poles, resuling in a scaling of the Nyquist plot,
1608
       the line styles are according to the settings of the `primary_style`
1609
       and `mirror_style` keywords.  By default the scaled portions of the
1610
       primary curve use a dotted line style and the scaled portion of the
1611
       mirror image use a dashdot line style.
1612

1613
    Examples
1614
    --------
1615
    >>> G = ct.zpk([], [-1, -2, -3], gain=100)
1616
    >>> out = ct.nyquist_plot(G)
1617

1618
    """
1619
    #
1620
    # Keyword processing
1621
    #
1622
    # Keywords for the nyquist_plot function can either be keywords that
1623
    # are unique to this function, keywords that are intended for use by
1624
    # nyquist_response (if data is a list of systems), or keywords that
1625
    # are intended for the plotting commands.
1626
    #
1627
    # We first pop off all keywords that are used directly by this
1628
    # function.  If data is a list of systems, when then pop off keywords
1629
    # that correspond to nyquist_response() keywords.  The remaining
1630
    # keywords are passed to matplotlib (and will generate an error if
1631
    # unrecognized).
1632
    #
1633

1634
    # Get values for params (and pop from list to allow keyword use in plot)
1635
    arrows = config._get_param(
9✔
1636
        'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True)
1637
    arrow_size = config._get_param(
9✔
1638
        'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True)
1639
    arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None)
9✔
1640
    max_curve_magnitude = config._get_param(
9✔
1641
        'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True)
1642
    max_curve_offset = config._get_param(
9✔
1643
        'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True)
1644
    rcParams = config._get_param(
9✔
1645
        'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True)
1646
    start_marker = config._get_param(
9✔
1647
        'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True)
1648
    start_marker_size = config._get_param(
9✔
1649
        'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True)
1650
    suptitle_frame = config._get_param(
9✔
1651
        'freqplot', 'suptitle_frame', kwargs, _freqplot_defaults, pop=True)
1652

1653
    # Set line styles for the curves
1654
    def _parse_linestyle(style_name, allow_false=False):
9✔
1655
        style = config._get_param(
9✔
1656
            'nyquist', style_name, kwargs, _nyquist_defaults, pop=True)
1657
        if isinstance(style, str):
9✔
1658
            # Only one style provided, use the default for the other
1659
            style = [style, _nyquist_defaults['nyquist.' + style_name][1]]
9✔
1660
            warnings.warn(
9✔
1661
                "use of a single string for linestyle will be deprecated "
1662
                " in a future release", PendingDeprecationWarning)
1663
        if (allow_false and style is False) or \
9✔
1664
           (isinstance(style, list) and len(style) == 2):
1665
            return style
9✔
1666
        else:
1667
            raise ValueError(f"invalid '{style_name}': {style}")
9✔
1668

1669
    primary_style = _parse_linestyle('primary_style')
9✔
1670
    mirror_style = _parse_linestyle('mirror_style', allow_false=True)
9✔
1671

1672
    # Parse the arrows keyword
1673
    if not arrows:
9✔
1674
        arrow_pos = []
9✔
1675
    elif isinstance(arrows, int):
9✔
1676
        N = arrows
9✔
1677
        # Space arrows out, starting midway along each "region"
1678
        arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False)
9✔
1679
    elif isinstance(arrows, (list, np.ndarray)):
9✔
1680
        arrow_pos = np.sort(np.atleast_1d(arrows))
9✔
1681
    else:
1682
        raise ValueError("unknown or unsupported arrow location")
9✔
1683

1684
    # Set the arrow style
1685
    if arrow_style is None:
9✔
1686
        arrow_style = mpl.patches.ArrowStyle(
9✔
1687
            'simple', head_width=arrow_size, head_length=arrow_size)
1688

1689
    # If argument was a singleton, turn it into a tuple
1690
    if not isinstance(data, (list, tuple)):
9✔
1691
        data = [data]
9✔
1692

1693
    # Process label keyword
1694
    line_labels = _process_line_labels(label, len(data))
9✔
1695

1696
    # If we are passed a list of systems, compute response first
1697
    if all([isinstance(
9✔
1698
            sys, (StateSpace, TransferFunction, FrequencyResponseData))
1699
            for sys in data]):
1700
        # Get the response, popping off keywords used there
1701
        nyquist_responses = nyquist_response(
9✔
1702
            data, omega=omega, return_contour=return_contour,
1703
            omega_limits=kwargs.pop('omega_limits', None),
1704
            omega_num=kwargs.pop('omega_num', None),
1705
            warn_encirclements=kwargs.pop('warn_encirclements', True),
1706
            warn_nyquist=kwargs.pop('warn_nyquist', True),
1707
            indent_radius=kwargs.pop('indent_radius', None),
1708
            check_kwargs=False, **kwargs)
1709
    else:
1710
        nyquist_responses = data
9✔
1711

1712
    # Legacy return value processing
1713
    if plot is not None or return_contour is not None:
9✔
1714
        warnings.warn(
9✔
1715
            "`nyquist_plot` return values of count[, contour] is deprecated; "
1716
            "use nyquist_response()", DeprecationWarning)
1717

1718
        # Extract out the values that we will eventually return
1719
        counts = [response.count for response in nyquist_responses]
9✔
1720
        contours = [response.contour for response in nyquist_responses]
9✔
1721

1722
    if plot is False:
9✔
1723
        # Make sure we used all of the keywrods
1724
        if kwargs:
9✔
1725
            raise TypeError("unrecognized keywords: ", str(kwargs))
×
1726

1727
        if len(data) == 1:
9✔
1728
            counts, contours = counts[0], contours[0]
9✔
1729

1730
        # Return counts and (optionally) the contour we used
1731
        return (counts, contours) if return_contour else counts
9✔
1732

1733
    fig, ax = _process_ax_keyword(
9✔
1734
        ax, shape=(1, 1), squeeze=True, rcParams=rcParams)
1735

1736
    # Create a list of lines for the output
1737
    out = np.empty(len(nyquist_responses), dtype=object)
9✔
1738
    for i in range(out.shape[0]):
9✔
1739
        out[i] = []             # unique list in each element
9✔
1740

1741
    for idx, response in enumerate(nyquist_responses):
9✔
1742
        resp = response.response
9✔
1743
        if response.dt in [0, None]:
9✔
1744
            splane_contour = response.contour
9✔
1745
        else:
1746
            splane_contour = np.log(response.contour) / response.dt
9✔
1747

1748
        # Find the different portions of the curve (with scaled pts marked)
1749
        reg_mask = np.logical_or(
9✔
1750
            np.abs(resp) > max_curve_magnitude,
1751
            splane_contour.real != 0)
1752
        # reg_mask = np.logical_or(
1753
        #     np.abs(resp.real) > max_curve_magnitude,
1754
        #     np.abs(resp.imag) > max_curve_magnitude)
1755

1756
        scale_mask = ~reg_mask \
9✔
1757
            & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \
1758
            & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1]))
1759

1760
        # Rescale the points with large magnitude
1761
        rescale = np.logical_and(
9✔
1762
            reg_mask, abs(resp) > max_curve_magnitude)
1763
        resp[rescale] *= max_curve_magnitude / abs(resp[rescale])
9✔
1764

1765
        # Get the label to use for the line
1766
        label = response.sysname if line_labels is None else line_labels[idx]
9✔
1767

1768
        # Plot the regular portions of the curve (and grab the color)
1769
        x_reg = np.ma.masked_where(reg_mask, resp.real)
9✔
1770
        y_reg = np.ma.masked_where(reg_mask, resp.imag)
9✔
1771
        p = plt.plot(
9✔
1772
            x_reg, y_reg, primary_style[0], color=color, label=label, **kwargs)
1773
        c = p[0].get_color()
9✔
1774
        out[idx] += p
9✔
1775

1776
        # Figure out how much to offset the curve: the offset goes from
1777
        # zero at the start of the scaled section to max_curve_offset as
1778
        # we move along the curve
1779
        curve_offset = _compute_curve_offset(
9✔
1780
            resp, scale_mask, max_curve_offset)
1781

1782
        # Plot the scaled sections of the curve (changing linestyle)
1783
        x_scl = np.ma.masked_where(scale_mask, resp.real)
9✔
1784
        y_scl = np.ma.masked_where(scale_mask, resp.imag)
9✔
1785
        if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1786
            out[idx] += plt.plot(
9✔
1787
                x_scl * (1 + curve_offset),
1788
                y_scl * (1 + curve_offset),
1789
                primary_style[1], color=c, **kwargs)
1790
        else:
1791
            out[idx] += [None]
9✔
1792

1793
        # Plot the primary curve (invisible) for setting arrows
1794
        x, y = resp.real.copy(), resp.imag.copy()
9✔
1795
        x[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1796
        y[reg_mask] *= (1 + curve_offset[reg_mask])
9✔
1797
        p = plt.plot(x, y, linestyle='None', color=c)
9✔
1798

1799
        # Add arrows
1800
        ax = plt.gca()
9✔
1801
        _add_arrows_to_line2D(
9✔
1802
            ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1)
1803

1804
        # Plot the mirror image
1805
        if mirror_style is not False:
9✔
1806
            # Plot the regular and scaled segments
1807
            out[idx] += plt.plot(
9✔
1808
                x_reg, -y_reg, mirror_style[0], color=c, **kwargs)
1809
            if x_scl.count() >= 1 and y_scl.count() >= 1:
9✔
1810
                out[idx] += plt.plot(
9✔
1811
                    x_scl * (1 - curve_offset),
1812
                    -y_scl * (1 - curve_offset),
1813
                    mirror_style[1], color=c, **kwargs)
1814
            else:
1815
                out[idx] += [None]
9✔
1816

1817
            # Add the arrows (on top of an invisible contour)
1818
            x, y = resp.real.copy(), resp.imag.copy()
9✔
1819
            x[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1820
            y[reg_mask] *= (1 - curve_offset[reg_mask])
9✔
1821
            p = plt.plot(x, -y, linestyle='None', color=c, **kwargs)
9✔
1822
            _add_arrows_to_line2D(
9✔
1823
                ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1)
1824
        else:
1825
            out[idx] += [None, None]
9✔
1826

1827
        # Mark the start of the curve
1828
        if start_marker:
9✔
1829
            plt.plot(resp[0].real, resp[0].imag, start_marker,
9✔
1830
                     color=c, markersize=start_marker_size)
1831

1832
        # Mark the -1 point
1833
        plt.plot([-1], [0], 'r+')
9✔
1834

1835
        #
1836
        # Draw circles for gain crossover and sensitivity functions
1837
        #
1838
        theta = np.linspace(0, 2*np.pi, 100)
9✔
1839
        cos = np.cos(theta)
9✔
1840
        sin = np.sin(theta)
9✔
1841
        label_pos = 15
9✔
1842

1843
        # Display the unit circle, to read gain crossover frequency
1844
        if unit_circle:
9✔
1845
            plt.plot(cos, sin, **config.defaults['nyquist.circle_style'])
9✔
1846
        
1847
        # Draw circles for given magnitudes of sensitivity
1848
        if ms_circles is not None:
9✔
1849
            for ms in ms_circles:
9✔
1850
                pos_x = -1 + (1/ms)*cos
9✔
1851
                pos_y = (1/ms)*sin
9✔
1852
                plt.plot(
9✔
1853
                    pos_x, pos_y, **config.defaults['nyquist.circle_style'])
1854
                plt.text(pos_x[label_pos], pos_y[label_pos], ms)
9✔
1855

1856
        # Draw circles for given magnitudes of complementary sensitivity
1857
        if mt_circles is not None:
9✔
1858
            for mt in mt_circles:
9✔
1859
                if mt != 1:
9✔
1860
                    ct = -mt**2/(mt**2-1)  # Mt center
9✔
1861
                    rt = mt/(mt**2-1)  # Mt radius
9✔
1862
                    pos_x = ct+rt*cos
9✔
1863
                    pos_y = rt*sin
9✔
1864
                    plt.plot(
9✔
1865
                        pos_x, pos_y,
1866
                        **config.defaults['nyquist.circle_style'])
1867
                    plt.text(pos_x[label_pos], pos_y[label_pos], mt)
9✔
1868
                else:
1869
                    _, _, ymin, ymax = plt.axis()
9✔
1870
                    pos_y = np.linspace(ymin, ymax, 100)
9✔
1871
                    plt.vlines(
9✔
1872
                        -0.5, ymin=ymin, ymax=ymax,
1873
                        **config.defaults['nyquist.circle_style'])
1874
                    plt.text(-0.5, pos_y[label_pos], 1)
9✔
1875

1876
        # Label the frequencies of the points on the Nyquist curve
1877
        if label_freq:
9✔
1878
            ind = slice(None, None, label_freq)
9✔
1879
            omega_sys = np.imag(splane_contour[np.real(splane_contour) == 0])
9✔
1880
            for xpt, ypt, omegapt in zip(x[ind], y[ind], omega_sys[ind]):
9✔
1881
                # Convert to Hz
1882
                f = omegapt / (2 * np.pi)
9✔
1883

1884
                # Factor out multiples of 1000 and limit the
1885
                # result to the range [-8, 8].
1886
                pow1000 = max(min(get_pow1000(f), 8), -8)
9✔
1887

1888
                # Get the SI prefix.
1889
                prefix = gen_prefix(pow1000)
9✔
1890

1891
                # Apply the text. (Use a space before the text to
1892
                # prevent overlap with the data.)
1893
                #
1894
                # np.round() is used because 0.99... appears
1895
                # instead of 1.0, and this would otherwise be
1896
                # truncated to 0.
1897
                plt.text(xpt, ypt, ' ' +
9✔
1898
                         str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' +
1899
                         prefix + 'Hz')
1900

1901
    # Label the axes
1902
    ax.set_xlabel("Real axis")
9✔
1903
    ax.set_ylabel("Imaginary axis")
9✔
1904
    ax.grid(color="lightgray")
9✔
1905

1906
    # List of systems that are included in this plot
1907
    lines, labels = _get_line_labels(ax)
9✔
1908

1909
    # Add legend if there is more than one system plotted
1910
    if len(labels) > 1:
9✔
1911
        ax.legend(lines, labels, loc=legend_loc)
9✔
1912

1913
    # Add the title
1914
    if title is None:
9✔
1915
        title = "Nyquist plot for " + ", ".join(labels)
9✔
1916
    suptitle(title, fig=fig, rcParams=rcParams, frame=suptitle_frame)
9✔
1917

1918
    # Legacy return pocessing
1919
    if plot is True or return_contour is not None:
9✔
1920
        if len(data) == 1:
9✔
1921
            counts, contours = counts[0], contours[0]
9✔
1922

1923
        # Return counts and (optionally) the contour we used
1924
        return (counts, contours) if return_contour else counts
9✔
1925

1926
    return out
9✔
1927

1928

1929
#
1930
# Function to compute Nyquist curve offsets
1931
#
1932
# This function computes a smoothly varying offset that starts and ends at
1933
# zero at the ends of a scaled segment.
1934
#
1935
def _compute_curve_offset(resp, mask, max_offset):
9✔
1936
    # Compute the arc length along the curve
1937
    s_curve = np.cumsum(
9✔
1938
        np.sqrt(np.diff(resp.real) ** 2 + np.diff(resp.imag) ** 2))
1939

1940
    # Initialize the offset
1941
    offset = np.zeros(resp.size)
9✔
1942
    arclen = np.zeros(resp.size)
9✔
1943

1944
    # Walk through the response and keep track of each continous component
1945
    i, nsegs = 0, 0
9✔
1946
    while i < resp.size:
9✔
1947
        # Skip the regular segment
1948
        while i < resp.size and mask[i]:
9✔
1949
            i += 1              # Increment the counter
9✔
1950
            if i == resp.size:
9✔
1951
                break
9✔
1952
            # Keep track of the arclength
1953
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
1954

1955
        nsegs += 0.5
9✔
1956
        if i == resp.size:
9✔
1957
            break
9✔
1958

1959
        # Save the starting offset of this segment
1960
        seg_start = i
9✔
1961

1962
        # Walk through the scaled segment
1963
        while i < resp.size and not mask[i]:
9✔
1964
            i += 1
9✔
1965
            if i == resp.size:  # See if we are done with this segment
9✔
1966
                break
9✔
1967
            # Keep track of the arclength
1968
            arclen[i] = arclen[i-1] + np.abs(resp[i] - resp[i-1])
9✔
1969

1970
        nsegs += 0.5
9✔
1971
        if i == resp.size:
9✔
1972
            break
9✔
1973

1974
        # Save the ending offset of this segment
1975
        seg_end = i
9✔
1976

1977
        # Now compute the scaling for this segment
1978
        s_segment = arclen[seg_end-1] - arclen[seg_start]
9✔
1979
        offset[seg_start:seg_end] = max_offset * s_segment/s_curve[-1] * \
9✔
1980
            np.sin(np.pi * (arclen[seg_start:seg_end]
1981
                            - arclen[seg_start])/s_segment)
1982

1983
    return offset
9✔
1984

1985

1986
#
1987
# Gang of Four plot
1988
#
1989
def gangof4_response(
9✔
1990
        P, C, omega=None, omega_limits=None, omega_num=None, Hz=False):
1991
    """Compute the response of the "Gang of 4" transfer functions for a system.
1992

1993
    Generates a 2x2 frequency response for the "Gang of 4" sensitivity
1994
    functions [T, PS; CS, S].
1995

1996
    Parameters
1997
    ----------
1998
    P, C : LTI
1999
        Linear input/output systems (process and control).
2000
    omega : array
2001
        Range of frequencies (list or bounds) in rad/sec.
2002

2003
    Returns
2004
    -------
2005
    response : :class:`~control.FrequencyResponseData`
2006
        Frequency response with inputs 'r' and 'd' and outputs 'y', and 'u'
2007
        representing the 2x2 matrix of transfer functions in the Gang of 4.
2008

2009
    Examples
2010
    --------
2011
    >>> P = ct.tf([1], [1, 1])
2012
    >>> C = ct.tf([2], [1])
2013
    >>> response = ct.gangof4_response(P, C)
2014
    >>> lines = response.plot()
2015

2016
    """
2017
    if not P.issiso() or not C.issiso():
9✔
2018
        # TODO: Add MIMO go4 plots.
2019
        raise ControlMIMONotImplemented(
×
2020
            "Gang of four is currently only implemented for SISO systems.")
2021

2022
    # Compute the senstivity functions
2023
    L = P * C
9✔
2024
    S = feedback(1, L)
9✔
2025
    T = L * S
9✔
2026

2027
    # Select a default range if none is provided
2028
    # TODO: This needs to be made more intelligent
2029
    omega, _ = _determine_omega_vector(
9✔
2030
        [P, C, S], omega, omega_limits, omega_num, Hz=Hz)
2031

2032
    #
2033
    # bode_plot based implementation
2034
    #
2035

2036
    # Compute the response of the Gang of 4
2037
    resp_T = T(1j * omega)
9✔
2038
    resp_PS = (P * S)(1j * omega)
9✔
2039
    resp_CS = (C * S)(1j * omega)
9✔
2040
    resp_S = S(1j * omega)
9✔
2041

2042
    # Create a single frequency response data object with the underlying data
2043
    data = np.empty((2, 2, omega.size), dtype=complex)
9✔
2044
    data[0, 0, :] = resp_T
9✔
2045
    data[0, 1, :] = resp_PS
9✔
2046
    data[1, 0, :] = resp_CS
9✔
2047
    data[1, 1, :] = resp_S
9✔
2048

2049
    return FrequencyResponseData(
9✔
2050
        data, omega, outputs=['y', 'u'], inputs=['r', 'd'],
2051
        title=f"Gang of Four for P={P.name}, C={C.name}", plot_phase=False)
2052

2053

2054
def gangof4_plot(
9✔
2055
        P, C, omega=None, omega_limits=None, omega_num=None, **kwargs):
2056
    """Legacy Gang of 4 plot; use gangof4_response().plot() instead."""
2057
    return gangof4_response(
9✔
2058
        P, C, omega=omega, omega_limits=omega_limits,
2059
        omega_num=omega_num).plot(**kwargs)
2060

2061
#
2062
# Singular values plot
2063
#
2064
def singular_values_response(
9✔
2065
        sysdata, omega=None, omega_limits=None, omega_num=None, Hz=False):
2066
    """Singular value response for a system.
2067

2068
    Computes the singular values for a system or list of systems over
2069
    a (optional) frequency range.
2070

2071
    Parameters
2072
    ----------
2073
    sysdata : LTI or list of LTI
2074
        List of linear input/output systems (single system is OK).
2075
    omega : array_like
2076
        List of frequencies in rad/sec to be used for frequency response.
2077
    Hz : bool, optional
2078
        If True, when computing frequency limits automatically set
2079
        limits to full decades in Hz instead of rad/s.
2080

2081
    Returns
2082
    -------
2083
    response : FrequencyResponseData
2084
        Frequency response with the number of outputs equal to the
2085
        number of singular values in the response, and a single input.
2086

2087
    Other Parameters
2088
    ----------------
2089
    omega_limits : array_like of two values
2090
        Set limits for plotted frequency range. If Hz=True the limits are
2091
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2092
        elements is equivalent to providing ``omega_limits``.
2093
    omega_num : int, optional
2094
        Number of samples to use for the frequeny range.  Defaults to
2095
        config.defaults['freqplot.number_of_samples'].
2096

2097
    See Also
2098
    --------
2099
    singular_values_plot
2100

2101
    Examples
2102
    --------
2103
    >>> omegas = np.logspace(-4, 1, 1000)
2104
    >>> den = [75, 1]
2105
    >>> G = ct.tf([[[87.8], [-86.4]], [[108.2], [-109.6]]],
2106
    ...           [[den, den], [den, den]])
2107
    >>> response = ct.singular_values_response(G, omega=omegas)
2108

2109
    """
2110
    # Convert the first argument to a list
2111
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
2112

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

2116
    # Compute the frequency responses for the systems
2117
    responses = frequency_response(
9✔
2118
        syslist, omega=omega, omega_limits=omega_limits,
2119
        omega_num=omega_num, Hz=Hz, squeeze=False)
2120

2121
    # Calculate the singular values for each system in the list
2122
    svd_responses = []
9✔
2123
    for response in responses:
9✔
2124
        # Compute the singular values (permute indices to make things work)
2125
        fresp_permuted = response.fresp.transpose((2, 0, 1))
9✔
2126
        sigma = np.linalg.svd(fresp_permuted, compute_uv=False).transpose()
9✔
2127
        sigma_fresp = sigma.reshape(sigma.shape[0], 1, sigma.shape[1])
9✔
2128

2129
        # Save the singular values as an FRD object
2130
        svd_responses.append(
9✔
2131
            FrequencyResponseData(
2132
                sigma_fresp, response.omega, _return_singvals=True,
2133
                outputs=[f'$\\sigma_{{{k+1}}}$' for k in range(sigma.shape[0])],
2134
                inputs='inputs', dt=response.dt, plot_phase=False,
2135
                sysname=response.sysname, plot_type='svplot',
2136
                title=f"Singular values for {response.sysname}"))
2137

2138
    if isinstance(sysdata, (list, tuple)):
9✔
2139
        return FrequencyResponseList(svd_responses)
9✔
2140
    else:
2141
        return svd_responses[0]
9✔
2142

2143

2144
def singular_values_plot(
9✔
2145
        data, omega=None, *fmt, plot=None, omega_limits=None, omega_num=None,
2146
        ax=None, label=None, title=None, legend_loc='center right', **kwargs):
2147
    """Plot the singular values for a system.
2148

2149
    Plot the singular values as a function of frequency for a system or
2150
    list of systems.  If multiple systems are plotted, each system in the
2151
    list is plotted in a different color.
2152

2153
    Parameters
2154
    ----------
2155
    data : list of `FrequencyResponseData`
2156
        List of :class:`FrequencyResponseData` objects.  For backward
2157
        compatibility, a list of LTI systems can also be given.
2158
    omega : array_like
2159
        List of frequencies in rad/sec over to plot over.
2160
    *fmt : :func:`matplotlib.pyplot.plot` format string, optional
2161
        Passed to `matplotlib` as the format string for all lines in the plot.
2162
        The `omega` parameter must be present (use omega=None if needed).
2163
    dB : bool
2164
        If True, plot result in dB.  Default is False.
2165
    Hz : bool
2166
        If True, plot frequency in Hz (omega must be provided in rad/sec).
2167
        Default value (False) set by config.defaults['freqplot.Hz'].
2168
    **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
2169
        Additional keywords passed to `matplotlib` to specify line properties.
2170

2171
    Returns
2172
    -------
2173
    legend_loc : str, optional
2174
        For plots with multiple lines, a legend will be included in the
2175
        given location.  Default is 'center right'.  Use False to suppress.
2176
    lines : array of Line2D
2177
        1-D array of Line2D objects.  The size of the array matches
2178
        the number of systems and the value of the array is a list of
2179
        Line2D objects for that system.
2180
    mag : ndarray (or list of ndarray if len(data) > 1))
2181
        If plot=False, magnitude of the response (deprecated).
2182
    phase : ndarray (or list of ndarray if len(data) > 1))
2183
        If plot=False, phase in radians of the response (deprecated).
2184
    omega : ndarray (or list of ndarray if len(data) > 1))
2185
        If plot=False, frequency in rad/sec (deprecated).
2186

2187
    Other Parameters
2188
    ----------------
2189
    grid : bool
2190
        If True, plot grid lines on gain and phase plots.  Default is set by
2191
        `config.defaults['freqplot.grid']`.
2192
    label : str or array-like of str
2193
        If present, replace automatically generated label(s) with the given
2194
        label(s).  If sysdata is a list, strings should be specified for each
2195
        system.
2196
    omega_limits : array_like of two values
2197
        Set limits for plotted frequency range. If Hz=True the limits are
2198
        in Hz otherwise in rad/s.  Specifying ``omega`` as a list of two
2199
        elements is equivalent to providing ``omega_limits``.
2200
    omega_num : int, optional
2201
        Number of samples to use for the frequeny range.  Defaults to
2202
        config.defaults['freqplot.number_of_samples'].  Ignored if data is
2203
        not a list of systems.
2204
    plot : bool, optional
2205
        (legacy) If given, `singular_values_plot` returns the legacy return
2206
        values of magnitude, phase, and frequency.  If False, just return
2207
        the values with no plot.
2208
    rcParams : dict
2209
        Override the default parameters used for generating plots.
2210
        Default is set up config.default['freqplot.rcParams'].
2211

2212
    See Also
2213
    --------
2214
    singular_values_response
2215

2216
    """
2217
    # Keyword processing
2218
    dB = config._get_param(
9✔
2219
        'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
2220
    Hz = config._get_param(
9✔
2221
        'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True)
2222
    grid = config._get_param(
9✔
2223
        'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True)
2224
    rcParams = config._get_param(
9✔
2225
        'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True)
2226
    suptitle_frame = config._get_param(
9✔
2227
        'freqplot', 'suptitle_frame', kwargs, _freqplot_defaults, pop=True)
2228

2229
    # If argument was a singleton, turn it into a tuple
2230
    data = data if isinstance(data, (list, tuple)) else (data,)
9✔
2231

2232
    # Convert systems into frequency responses
2233
    if any([isinstance(response, (StateSpace, TransferFunction))
9✔
2234
            for response in data]):
2235
        responses = singular_values_response(
9✔
2236
                    data, omega=omega, omega_limits=omega_limits,
2237
                    omega_num=omega_num)
2238
    else:
2239
        # Generate warnings if frequency keywords were given
2240
        if omega_num is not None:
9✔
2241
            warnings.warn("`omega_num` ignored when passed response data")
9✔
2242
        elif omega is not None:
9✔
2243
            warnings.warn("`omega` ignored when passed response data")
9✔
2244

2245
        # Check to make sure omega_limits is sensible
2246
        if omega_limits is not None and \
9✔
2247
           (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]):
2248
            raise ValueError(f"invalid limits: {omega_limits=}")
9✔
2249

2250
        responses = data
9✔
2251

2252
    # Process label keyword
2253
    line_labels = _process_line_labels(label, len(data))
9✔
2254

2255
    # Process (legacy) plot keyword
2256
    if plot is not None:
9✔
2257
        warnings.warn(
×
2258
            "`singular_values_plot` return values of sigma, omega is "
2259
            "deprecated; use singular_values_response()", DeprecationWarning)
2260

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

2266
    # Extract the data we need for plotting
2267
    sigmas = [np.real(response.fresp[:, 0, :]) for response in responses]
9✔
2268
    omegas = [response.omega for response in responses]
9✔
2269

2270
    # Legacy processing for no plotting case
2271
    if plot is False:
9✔
2272
        if len(data) == 1:
×
2273
            return sigmas[0], omegas[0]
×
2274
        else:
2275
            return sigmas, omegas
×
2276

2277
    fig, ax_sigma = _process_ax_keyword(
9✔
2278
        ax, shape=(1, 1), squeeze=True, rcParams=rcParams)
2279
    ax_sigma.set_label('control-sigma')         # TODO: deprecate?
9✔
2280

2281
    # Handle color cycle manually as all singular values
2282
    # of the same systems are expected to be of the same color
2283
    color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color']
9✔
2284
    color_offset = 0
9✔
2285
    if len(ax_sigma.lines) > 0:
9✔
2286
        last_color = ax_sigma.lines[-1].get_color()
9✔
2287
        if last_color in color_cycle:
9✔
2288
            color_offset = color_cycle.index(last_color) + 1
9✔
2289

2290
    # Create a list of lines for the output
2291
    out = np.empty(len(data), dtype=object)
9✔
2292

2293
    # Plot the singular values for each response
2294
    for idx_sys, response in enumerate(responses):
9✔
2295
        sigma = sigmas[idx_sys].transpose()     # frequency first for plotting
9✔
2296
        omega = omegas[idx_sys] / (2 * math.pi) if Hz else  omegas[idx_sys]
9✔
2297

2298
        if response.isdtime(strict=True):
9✔
2299
            nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt)
9✔
2300
        else:
2301
            nyq_freq = None
9✔
2302

2303
        # See if the color was specified, otherwise rotate
2304
        if kwargs.get('color', None) or any(
9✔
2305
                [isinstance(arg, str) and
2306
                 any([c in arg for c in "bgrcmykw#"]) for arg in fmt]):
2307
            color_arg = {}                      # color set by *fmt, **kwargs
9✔
2308
        else:
2309
            color_arg = {'color': color_cycle[
9✔
2310
                (idx_sys + color_offset) % len(color_cycle)]}
2311

2312
        # Decide on the system name
2313
        sysname = response.sysname if response.sysname is not None \
9✔
2314
            else f"Unknown-{idx_sys}"
2315

2316
        # Get the label to use for the line
2317
        label = sysname if line_labels is None else line_labels[idx_sys]
9✔
2318

2319
        # Plot the data
2320
        if dB:
9✔
2321
            out[idx_sys] = ax_sigma.semilogx(
9✔
2322
                omega, 20 * np.log10(sigma), *fmt,
2323
                label=label, **color_arg, **kwargs)
2324
        else:
2325
            out[idx_sys] = ax_sigma.loglog(
9✔
2326
                omega, sigma, label=label, *fmt, **color_arg, **kwargs)
2327

2328
        # Plot the Nyquist frequency
2329
        if nyq_freq is not None:
9✔
2330
            ax_sigma.axvline(
9✔
2331
                nyq_freq, linestyle='--', label='_nyq_freq_' + sysname,
2332
                **color_arg)
2333

2334
    # If specific omega_limits were given, use them
2335
    if omega_limits is not None:
9✔
2336
        ax_sigma.set_xlim(omega_limits)
9✔
2337

2338
    # Add a grid to the plot + labeling
2339
    if grid:
9✔
2340
        ax_sigma.grid(grid, which='both')
9✔
2341

2342
    ax_sigma.set_ylabel(
9✔
2343
        "Singular Values [dB]" if dB else "Singular Values")
2344
    ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]")
9✔
2345

2346
    # List of systems that are included in this plot
2347
    lines, labels = _get_line_labels(ax_sigma)
9✔
2348

2349
    # Add legend if there is more than one system plotted
2350
    if len(labels) > 1 and legend_loc is not False:
9✔
2351
        with plt.rc_context(rcParams):
9✔
2352
            ax_sigma.legend(lines, labels, loc=legend_loc)
9✔
2353

2354
    # Add the title
2355
    if title is None:
9✔
2356
        title = "Singular values for " + ", ".join(labels)
9✔
2357
    suptitle(title, fig=fig, rcParams=rcParams, frame=suptitle_frame)
9✔
2358

2359
    # Legacy return processing
2360
    if plot is not None:
9✔
2361
        if len(responses) == 1:
×
2362
            return sigmas[0], omegas[0]
×
2363
        else:
2364
            return sigmas, omegas
×
2365

2366
    return out
9✔
2367

2368
#
2369
# Utility functions
2370
#
2371
# This section of the code contains some utility functions for
2372
# generating frequency domain plots.
2373
#
2374

2375

2376
# Determine the frequency range to be used
2377
def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
9✔
2378
                            Hz=None, feature_periphery_decades=None):
2379
    """Determine the frequency range for a frequency-domain plot
2380
    according to a standard logic.
2381

2382
    If omega_in and omega_limits are both None, then omega_out is computed
2383
    on omega_num points according to a default logic defined by
2384
    _default_frequency_range and tailored for the list of systems syslist, and
2385
    omega_range_given is set to False.
2386

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

2392
    If omega_in is a list or tuple of length 2, it is interpreted as a
2393
    range and handled like omega_limits.  If omega_in is a list or tuple of
2394
    length 3, it is interpreted a range plus number of points and handled
2395
    like omega_limits and omega_num.
2396

2397
    If omega_in is an array or a list/tuple of length greater than
2398
    two, then omega_out is set to omega_in (as an array), and
2399
    omega_range_given is set to True
2400

2401
    Parameters
2402
    ----------
2403
    syslist : list of LTI
2404
        List of linear input/output systems (single system is OK).
2405
    omega_in : 1D array_like or None
2406
        Frequency range specified by the user.
2407
    omega_limits : 1D array_like or None
2408
        Frequency limits specified by the user.
2409
    omega_num : int
2410
        Number of points to be used for the frequency range (if the
2411
        frequency range is not user-specified).
2412
    Hz : bool, optional
2413
        If True, the limits (first and last value) of the frequencies
2414
        are set to full decades in Hz so it fits plotting with logarithmic
2415
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2416

2417
    Returns
2418
    -------
2419
    omega_out : 1D array
2420
        Frequency range to be used.
2421
    omega_range_given : bool
2422
        True if the frequency range was specified by the user, either through
2423
        omega_in or through omega_limits. False if both omega_in
2424
        and omega_limits are None.
2425

2426
    """
2427
    # Handle the special case of a range of frequencies
2428
    if omega_in is not None and omega_limits is not None:
9✔
2429
        warnings.warn(
×
2430
            "omega and omega_limits both specified; ignoring limits")
2431
    elif isinstance(omega_in, (list, tuple)) and len(omega_in) == 2:
9✔
2432
        omega_limits = omega_in
9✔
2433
        omega_in = None
9✔
2434

2435
    omega_range_given = True
9✔
2436
    if omega_in is None:
9✔
2437
        if omega_limits is None:
9✔
2438
            omega_range_given = False
9✔
2439
            # Select a default range if none is provided
2440
            omega_out = _default_frequency_range(
9✔
2441
                syslist, number_of_samples=omega_num, Hz=Hz,
2442
                feature_periphery_decades=feature_periphery_decades)
2443
        else:
2444
            omega_limits = np.asarray(omega_limits)
9✔
2445
            if len(omega_limits) != 2:
9✔
2446
                raise ValueError("len(omega_limits) must be 2")
×
2447
            omega_out = np.logspace(np.log10(omega_limits[0]),
9✔
2448
                                    np.log10(omega_limits[1]),
2449
                                    num=omega_num, endpoint=True)
2450
    else:
2451
        omega_out = np.copy(omega_in)
9✔
2452

2453
    return omega_out, omega_range_given
9✔
2454

2455

2456
# Compute reasonable defaults for axes
2457
def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
9✔
2458
                             feature_periphery_decades=None):
2459
    """Compute a default frequency range for frequency domain plots.
2460

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

2466
    Parameters
2467
    ----------
2468
    syslist : list of LTI
2469
        List of linear input/output systems (single system is OK)
2470
    Hz : bool, optional
2471
        If True, the limits (first and last value) of the frequencies
2472
        are set to full decades in Hz so it fits plotting with logarithmic
2473
        scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
2474
    number_of_samples : int, optional
2475
        Number of samples to generate.  The default value is read from
2476
        ``config.defaults['freqplot.number_of_samples'].  If None, then the
2477
        default from `numpy.logspace` is used.
2478
    feature_periphery_decades : float, optional
2479
        Defines how many decades shall be included in the frequency range on
2480
        both sides of features (poles, zeros).  The default value is read from
2481
        ``config.defaults['freqplot.feature_periphery_decades']``.
2482

2483
    Returns
2484
    -------
2485
    omega : array
2486
        Range of frequencies in rad/sec
2487

2488
    Examples
2489
    --------
2490
    >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]])
2491
    >>> omega = ct._default_frequency_range(G)
2492
    >>> omega.min(), omega.max()
2493
    (0.1, 100.0)
2494

2495
    """
2496
    # Set default values for options
2497
    number_of_samples = config._get_param(
9✔
2498
        'freqplot', 'number_of_samples', number_of_samples)
2499
    feature_periphery_decades = config._get_param(
9✔
2500
        'freqplot', 'feature_periphery_decades', feature_periphery_decades, 1)
2501

2502
    # Find the list of all poles and zeros in the systems
2503
    features = np.array(())
9✔
2504
    freq_interesting = []
9✔
2505

2506
    # detect if single sys passed by checking if it is sequence-like
2507
    if not hasattr(syslist, '__iter__'):
9✔
2508
        syslist = (syslist,)
9✔
2509

2510
    for sys in syslist:
9✔
2511
        # For FRD systems, just use the response frequencies
2512
        if isinstance(sys, FrequencyResponseData):
9✔
2513
            # Add the min and max frequency, minus periphery decades
2514
            # (keeps frequency ranges from artificially expanding)
2515
            features = np.concatenate([features, np.array([
9✔
2516
                np.min(sys.omega) * 10**feature_periphery_decades,
2517
                np.max(sys.omega) / 10**feature_periphery_decades])])
2518
            continue
9✔
2519

2520
        try:
9✔
2521
            # Add new features to the list
2522
            if sys.isctime():
9✔
2523
                features_ = np.concatenate(
9✔
2524
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2525
                # Get rid of poles and zeros at the origin
2526
                toreplace = np.isclose(features_, 0.0)
9✔
2527
                if np.any(toreplace):
9✔
2528
                    features_ = features_[~toreplace]
9✔
2529
            elif sys.isdtime(strict=True):
9✔
2530
                fn = math.pi / sys.dt
9✔
2531
                # TODO: What distance to the Nyquist frequency is appropriate?
2532
                freq_interesting.append(fn * 0.9)
9✔
2533

2534
                features_ = np.concatenate(
9✔
2535
                    (np.abs(sys.poles()), np.abs(sys.zeros())))
2536
                # Get rid of poles and zeros on the real axis (imag==0)
2537
                # * origin and real < 0
2538
                # * at 1.: would result in omega=0. (logaritmic plot!)
2539
                toreplace = np.isclose(features_.imag, 0.0) & (
9✔
2540
                                    (features_.real <= 0.) |
2541
                                    (np.abs(features_.real - 1.0) < 1.e-10))
2542
                if np.any(toreplace):
9✔
2543
                    features_ = features_[~toreplace]
9✔
2544
                # TODO: improve (mapping pack to continuous time)
2545
                features_ = np.abs(np.log(features_) / (1.j * sys.dt))
9✔
2546
            else:
2547
                # TODO
2548
                raise NotImplementedError(
2549
                    "type of system in not implemented now")
2550
            features = np.concatenate([features, features_])
9✔
2551
        except NotImplementedError:
9✔
2552
            # Don't add any features for anything we don't understand
2553
            pass
9✔
2554

2555
    # Make sure there is at least one point in the range
2556
    if features.shape[0] == 0:
9✔
2557
        features = np.array([1.])
9✔
2558

2559
    if Hz:
9✔
2560
        features /= 2. * math.pi
9✔
2561
    features = np.log10(features)
9✔
2562
    lsp_min = np.rint(np.min(features) - feature_periphery_decades)
9✔
2563
    lsp_max = np.rint(np.max(features) + feature_periphery_decades)
9✔
2564
    if Hz:
9✔
2565
        lsp_min += np.log10(2. * math.pi)
9✔
2566
        lsp_max += np.log10(2. * math.pi)
9✔
2567

2568
    if freq_interesting:
9✔
2569
        lsp_min = min(lsp_min, np.log10(min(freq_interesting)))
9✔
2570
        lsp_max = max(lsp_max, np.log10(max(freq_interesting)))
9✔
2571

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

2575
    # Set the range to be an order of magnitude beyond any features
2576
    if number_of_samples:
9✔
2577
        omega = np.logspace(
9✔
2578
            lsp_min, lsp_max, num=number_of_samples, endpoint=True)
2579
    else:
2580
        omega = np.logspace(lsp_min, lsp_max, endpoint=True)
×
2581
    return omega
9✔
2582

2583

2584
#
2585
# Utility functions to create nice looking labels (KLD 5/23/11)
2586
#
2587

2588
def get_pow1000(num):
9✔
2589
    """Determine exponent for which significand of a number is within the
2590
    range [1, 1000).
2591
    """
2592
    # Based on algorithm from http://www.mail-archive.com/
2593
    # matplotlib-users@lists.sourceforge.net/msg14433.html, accessed 2010/11/7
2594
    # by Jason Heeris 2009/11/18
2595
    from decimal import Decimal
9✔
2596
    from math import floor
9✔
2597
    dnum = Decimal(str(num))
9✔
2598
    if dnum == 0:
9✔
2599
        return 0
9✔
2600
    elif dnum < 0:
9✔
2601
        dnum = -dnum
×
2602
    return int(floor(dnum.log10() / 3))
9✔
2603

2604

2605
def gen_prefix(pow1000):
9✔
2606
    """Return the SI prefix for a power of 1000.
2607
    """
2608
    # Prefixes according to Table 5 of [BIPM 2006] (excluding hecto,
2609
    # deca, deci, and centi).
2610
    if pow1000 < -8 or pow1000 > 8:
9✔
2611
        raise ValueError(
×
2612
            "Value is out of the range covered by the SI prefixes.")
2613
    return ['Y',  # yotta (10^24)
9✔
2614
            'Z',  # zetta (10^21)
2615
            'E',  # exa (10^18)
2616
            'P',  # peta (10^15)
2617
            'T',  # tera (10^12)
2618
            'G',  # giga (10^9)
2619
            'M',  # mega (10^6)
2620
            'k',  # kilo (10^3)
2621
            '',  # (10^0)
2622
            'm',  # milli (10^-3)
2623
            r'$\mu$',  # micro (10^-6)
2624
            'n',  # nano (10^-9)
2625
            'p',  # pico (10^-12)
2626
            'f',  # femto (10^-15)
2627
            'a',  # atto (10^-18)
2628
            'z',  # zepto (10^-21)
2629
            'y'][8 - pow1000]  # yocto (10^-24)
2630

2631

2632
# Function aliases
2633
bode = bode_plot
9✔
2634
nyquist = nyquist_plot
9✔
2635
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