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

python-control / python-control / 11994777565

24 Nov 2024 09:02AM UTC coverage: 94.693% (+0.001%) from 94.692%
11994777565

push

github

web-flow
Merge pull request #1065 from murrayrm/rlocus_singleton-21Nov2024

allow root locus maps with only 1 point

9172 of 9686 relevant lines covered (94.69%)

8.27 hits per line

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

93.55
control/pzmap.py
1
# pzmap.py - computations involving poles and zeros
2
#
3
# Original author: Richard M. Murray
4
# Date: 7 Sep 2009
5
#
6
# This file contains functions that compute poles, zeros and related
7
# quantities for a linear system, as well as the main functions for
8
# storing and plotting pole/zero and root locus diagrams.  (The actual
9
# computation of root locus diagrams is in rlocus.py.)
10
#
11

12
import itertools
9✔
13
import warnings
9✔
14
from math import pi
9✔
15

16
import matplotlib.pyplot as plt
9✔
17
import numpy as np
9✔
18
from numpy import cos, exp, imag, linspace, real, sin, sqrt
9✔
19

20
from . import config
9✔
21
from .config import _process_legacy_keyword
9✔
22
from .ctrlplot import ControlPlot, _get_color, _get_color_offset, \
9✔
23
    _get_line_labels, _process_ax_keyword, _process_legend_keywords, \
24
    _process_line_labels, _update_plot_title
25
from .freqplot import _freqplot_defaults
9✔
26
from .grid import nogrid, sgrid, zgrid
9✔
27
from .iosys import isctime, isdtime
9✔
28
from .lti import LTI
9✔
29
from .statesp import StateSpace
9✔
30
from .xferfcn import TransferFunction
9✔
31

32
__all__ = ['pole_zero_map', 'pole_zero_plot', 'pzmap', 'PoleZeroData']
9✔
33

34

35
# Define default parameter values for this module
36
_pzmap_defaults = {
9✔
37
    'pzmap.grid': None,                 # Plot omega-damping grid
38
    'pzmap.marker_size': 6,             # Size of the markers
39
    'pzmap.marker_width': 1.5,          # Width of the markers
40
    'pzmap.expansion_factor': 1.8,      # Amount to scale plots beyond features
41
    'pzmap.buffer_factor': 1.05,        # Buffer to leave around plot peaks
42
}
43

44
#
45
# Classes for keeping track of pzmap plots
46
#
47
# The PoleZeroData class keeps track of the information that is on a
48
# pole/zero plot.
49
#
50
# In addition to the locations of poles and zeros, you can also save a set
51
# of gains and loci for use in generating a root locus plot.  The gain
52
# variable is a 1D array consisting of a list of increasing gains.  The
53
# loci variable is a 2D array indexed by [gain_idx, root_idx] that can be
54
# plotted using the `pole_zero_plot` function.
55
#
56
# The PoleZeroList class is used to return a list of pole/zero plots.  It
57
# is a lightweight wrapper on the built-in list class that includes a
58
# `plot` method, allowing plotting a set of root locus diagrams.
59
#
60
class PoleZeroData:
9✔
61
    """Pole/zero data object.
62

63
    This class is used as the return type for computing pole/zero responses
64
    and root locus diagrams.  It contains information on the location of
65
    system poles and zeros, as well as the gains and loci for root locus
66
    diagrams.
67

68
    Attributes
69
    ----------
70
    poles : ndarray
71
        1D array of system poles.
72
    zeros : ndarray
73
        1D array of system zeros.
74
    gains : ndarray, optional
75
        1D array of gains for root locus plots.
76
    loci : ndarray, optiona
77
        2D array of poles, with each row corresponding to a gain.
78
    sysname : str, optional
79
        System name.
80
    sys : StateSpace or TransferFunction
81
        System corresponding to the data.
82

83
    """
84
    def __init__(
9✔
85
            self, poles, zeros, gains=None, loci=None, dt=None, sysname=None,
86
            sys=None):
87
        """Create a pole/zero map object.
88

89
        Parameters
90
        ----------
91
        poles : ndarray
92
            1D array of system poles.
93
        zeros : ndarray
94
            1D array of system zeros.
95
        gains : ndarray, optional
96
            1D array of gains for root locus plots.
97
        loci : ndarray, optiona
98
            2D array of poles, with each row corresponding to a gain.
99
        sysname : str, optional
100
            System name.
101
        sys : StateSpace or TransferFunction
102
            System corresponding to the data.
103

104
        """
105
        self.poles = poles
9✔
106
        self.zeros = zeros
9✔
107
        self.gains = gains
9✔
108
        self.loci = loci
9✔
109
        self.dt = dt
9✔
110
        self.sysname = sysname
9✔
111
        self.sys = sys
9✔
112

113
    # Implement functions to allow legacy assignment to tuple
114
    def __iter__(self):
9✔
115
        return iter((self.poles, self.zeros))
9✔
116

117
    def plot(self, *args, **kwargs):
9✔
118
        """Plot the pole/zero data.
119

120
        See :func:`~control.pole_zero_plot` for description of arguments
121
        and keywords.
122

123
        """
124
        return pole_zero_plot(self, *args, **kwargs)
9✔
125

126

127
class PoleZeroList(list):
9✔
128
    """List of PoleZeroData objects."""
129
    def plot(self, *args, **kwargs):
9✔
130
        """Plot pole/zero data.
131

132
        See :func:`~control.pole_zero_plot` for description of arguments
133
        and keywords.
134

135
        """
136
        return pole_zero_plot(self, *args, **kwargs)
9✔
137

138

139
# Pole/zero map
140
def pole_zero_map(sysdata):
9✔
141
    """Compute the pole/zero map for an LTI system.
142

143
    Parameters
144
    ----------
145
    sysdata : LTI system (StateSpace or TransferFunction)
146
        Linear system for which poles and zeros are computed.
147

148
    Returns
149
    -------
150
    pzmap_data : PoleZeroMap
151
        Pole/zero map containing the poles and zeros of the system.  Use
152
        `pzmap_data.plot()` or `pole_zero_plot(pzmap_data)` to plot the
153
        pole/zero map.
154

155
    """
156
    # Convert the first argument to a list
157
    syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
9✔
158

159
    responses = []
9✔
160
    for idx, sys in enumerate(syslist):
9✔
161
        responses.append(
9✔
162
            PoleZeroData(
163
                sys.poles(), sys.zeros(), dt=sys.dt, sysname=sys.name))
164

165
    if isinstance(sysdata, (list, tuple)):
9✔
166
        return PoleZeroList(responses)
9✔
167
    else:
168
        return responses[0]
9✔
169

170

171
# TODO: Implement more elegant cross-style axes. See:
172
#    https://matplotlib.org/2.0.2/examples/axes_grid/demo_axisline_style.html
173
#    https://matplotlib.org/2.0.2/examples/axes_grid/demo_curvelinear_grid.html
174
def pole_zero_plot(
9✔
175
        data, plot=None, grid=None, title=None, color=None, marker_size=None,
176
        marker_width=None, xlim=None, ylim=None, interactive=None, ax=None,
177
        scaling=None, initial_gain=None, label=None, **kwargs):
178
    """Plot a pole/zero map for a linear system.
179

180
    If the system data include root loci, a root locus diagram for the
181
    system is plotted.  When the root locus for a single system is plotted,
182
    clicking on a location on the root locus will mark the gain on all
183
    branches of the diagram and show the system gain and damping for the
184
    given pole in the axes title.  Set to False to turn off this behavior.
185

186
    Parameters
187
    ----------
188
    data : List of PoleZeroData objects or LTI systems
189
        List of pole/zero response data objects generated by pzmap_response()
190
        or rootlocus_response() that are to be plotted.  If a list of systems
191
        is given, the poles and zeros of those systems will be plotted.
192
    grid : bool or str, optional
193
        If `True` plot omega-damping grid, if `False` show imaginary axis
194
        for continuous time systems, unit circle for discrete time systems.
195
        If `empty`, do not draw any additonal lines.  Default value is set
196
        by config.default['pzmap.grid'] or config.default['rlocus.grid'].
197
    plot : bool, optional
198
        (legacy) If ``True`` a graph is generated with Matplotlib,
199
        otherwise the poles and zeros are only computed and returned.
200
        If this argument is present, the legacy value of poles and
201
        zeros is returned.
202

203
    Returns
204
    -------
205
    cplt : :class:`ControlPlot` object
206
        Object containing the data that were plotted:
207

208
          * cplt.lines: Array of :class:`matplotlib.lines.Line2D` objects
209
            for each set of markers in the plot. The shape of the array is
210
            given by (`nsys`, 2) where `nsys` is the number of systems or
211
            responses passed to the function.  The second index specifies
212
            the pzmap object type:
213

214
              - lines[idx, 0]: poles
215
              - lines[idx, 1]: zeros
216

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

219
          * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot.
220

221
          * cplt.legend: legend object(s) contained in the plot
222

223
        See :class:`ControlPlot` for more detailed information.
224

225
    poles, zeros: list of arrays
226
        (legacy) If the `plot` keyword is given, the system poles and zeros
227
        are returned.
228

229
    Other Parameters
230
    ----------------
231
    ax : matplotlib.axes.Axes, optional
232
        The matplotlib axes to draw the figure on.  If not specified and
233
        the current figure has a single axes, that axes is used.
234
        Otherwise, a new figure is created.
235
    color : matplotlib color spec, optional
236
        Specify the color of the markers and lines.
237
    initial_gain : float, optional
238
        If given, the specified system gain will be marked on the plot.
239
    interactive : bool, optional
240
        Turn off interactive mode for root locus plots.
241
    label : str or array_like of str, optional
242
        If present, replace automatically generated label(s) with given
243
        label(s).  If data is a list, strings should be specified for each
244
        system.
245
    legend_loc : int or str, optional
246
        Include a legend in the given location. Default is 'upper right',
247
        with no legend for a single response.  Use False to suppress legend.
248
    marker_color : str, optional
249
        Set the color of the markers used for poles and zeros.
250
    marker_size : int, optional
251
        Set the size of the markers used for poles and zeros.
252
    marker_width : int, optional
253
        Set the line width of the markers used for poles and zeros.
254
    rcParams : dict
255
        Override the default parameters used for generating plots.
256
        Default is set by config.default['ctrlplot.rcParams'].
257
    scaling : str or list, optional
258
        Set the type of axis scaling.  Can be 'equal' (default), 'auto', or
259
        a list of the form [xmin, xmax, ymin, ymax].
260
    show_legend : bool, optional
261
        Force legend to be shown if ``True`` or hidden if ``False``.  If
262
        ``None``, then show legend when there is more than one line on the
263
        plot or ``legend_loc`` has been specified.
264
    title : str, optional
265
        Set the title of the plot.  Defaults to plot type and system name(s).
266
    xlim : list, optional
267
        Set the limits for the x axis.
268
    ylim : list, optional
269
        Set the limits for the y axis.
270

271
    Notes
272
    -----
273
    1. By default, the pzmap function calls matplotlib.pyplot.axis('equal'),
274
       which means that trying to reset the axis limits may not behave as
275
       expected.  To change the axis limits, use the `scaling` keyword of
276
       use matplotlib.pyplot.gca().axis('auto') and then set the axis
277
       limits to the desired values.
278

279
    2. Pole/zero plots that use the continuous time omega-damping grid do
280
       not work with the ``ax`` keyword argument, due to the way that axes
281
       grids are implemented.  The ``grid`` argument must be set to
282
       ``False`` or ``'empty'`` when using the ``ax`` keyword argument.
283

284
    """
285
    # Get parameter values
286
    label = _process_line_labels(label)
9✔
287
    marker_size = config._get_param('pzmap', 'marker_size', marker_size, 6)
9✔
288
    marker_width = config._get_param('pzmap', 'marker_width', marker_width, 1.5)
9✔
289
    user_color = _process_legacy_keyword(kwargs, 'marker_color', 'color', color)
9✔
290
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
291
    user_ax = ax
9✔
292
    xlim_user, ylim_user = xlim, ylim
9✔
293

294
    # If argument was a singleton, turn it into a tuple
295
    if not isinstance(data, (list, tuple)):
9✔
296
        data = [data]
9✔
297

298
    # If we are passed a list of systems, compute response first
299
    if all([isinstance(
9✔
300
            sys, (StateSpace, TransferFunction)) for sys in data]):
301
        # Get the response, popping off keywords used there
302
        pzmap_responses = pole_zero_map(data)
9✔
303
    elif all([isinstance(d, PoleZeroData) for d in data]):
9✔
304
        pzmap_responses = data
9✔
305
    else:
306
        raise TypeError("unknown system data type")
9✔
307

308
    # Decide whether we are plotting any root loci
309
    rlocus_plot = any([resp.loci is not None for resp in pzmap_responses])
9✔
310

311
    # Turn on interactive mode by default, if allowed
312
    if interactive is None and rlocus_plot and len(pzmap_responses) == 1 \
9✔
313
       and pzmap_responses[0].sys is not None:
314
        interactive = True
9✔
315

316
    # Legacy return value processing
317
    if plot is not None:
9✔
318
        warnings.warn(
9✔
319
            "pole_zero_plot() return value of poles, zeros is deprecated; "
320
            "use pole_zero_map()", FutureWarning)
321

322
        # Extract out the values that we will eventually return
323
        poles = [response.poles for response in pzmap_responses]
9✔
324
        zeros = [response.zeros for response in pzmap_responses]
9✔
325

326
    if plot is False:
9✔
327
        if len(data) == 1:
9✔
328
            return poles[0], zeros[0]
9✔
329
        else:
330
            return poles, zeros
×
331

332
    # Initialize the figure
333
    fig, ax = _process_ax_keyword(
9✔
334
        user_ax, rcParams=rcParams, squeeze=True, create_axes=False)
335
    legend_loc, _, show_legend = _process_legend_keywords(
9✔
336
        kwargs, None, 'upper right')
337

338
    # Make sure there are no remaining keyword arguments
339
    if kwargs:
9✔
340
        raise TypeError("unrecognized keywords: ", str(kwargs))
9✔
341

342
    if ax is None:
9✔
343
        # Determine what type of grid to use
344
        if rlocus_plot:
9✔
345
            from .rlocus import _rlocus_defaults
9✔
346
            grid = config._get_param('rlocus', 'grid', grid, _rlocus_defaults)
9✔
347
        else:
348
            grid = config._get_param('pzmap', 'grid', grid, _pzmap_defaults)
9✔
349

350
        # Create the axes with the appropriate grid
351
        with plt.rc_context(rcParams):
9✔
352
            if grid and grid != 'empty':
9✔
353
                if all([isctime(dt=response.dt) for response in data]):
9✔
354
                    ax, fig = sgrid(scaling=scaling)
9✔
355
                elif all([isdtime(dt=response.dt) for response in data]):
9✔
356
                    ax, fig = zgrid(scaling=scaling)
9✔
357
                else:
358
                    raise ValueError(
9✔
359
                        "incompatible time bases; don't know how to grid")
360
                # Store the limits for later use
361
                xlim, ylim = ax.get_xlim(), ax.get_ylim()
9✔
362
            elif grid == 'empty':
9✔
363
                ax = plt.axes()
9✔
364
                xlim = ylim = [np.inf, -np.inf] # use data to set limits
9✔
365
            else:
366
                ax, fig = nogrid(data[0].dt, scaling=scaling)
9✔
367
                xlim, ylim = ax.get_xlim(), ax.get_ylim()
9✔
368
    else:
369
        # Store the limits for later use
370
        xlim, ylim = ax.get_xlim(), ax.get_ylim()
9✔
371
        if grid is not None:
9✔
372
            warnings.warn("axis already exists; grid keyword ignored")
9✔
373

374
    # Get color offset for the next line to be drawn
375
    color_offset, color_cycle = _get_color_offset(ax)
9✔
376

377
    # Create a list of lines for the output
378
    out = np.empty(
9✔
379
        (len(pzmap_responses), 3 if rlocus_plot else 2), dtype=object)
380
    for i, j in itertools.product(range(out.shape[0]), range(out.shape[1])):
9✔
381
        out[i, j] = []          # unique list in each element
9✔
382

383
    # Plot the responses (and keep track of axes limits)
384
    for idx, response in enumerate(pzmap_responses):
9✔
385
        poles = response.poles
9✔
386
        zeros = response.zeros
9✔
387

388
        # Get the color to use for this response
389
        color = _get_color(user_color, offset=color_offset + idx)
9✔
390

391
        # Plot the locations of the poles and zeros
392
        if len(poles) > 0:
9✔
393
            if label is None:
9✔
394
                label_ = response.sysname if response.loci is None else None
9✔
395
            else:
396
                label_ = label[idx]
9✔
397
            out[idx, 0] = ax.plot(
9✔
398
                real(poles), imag(poles), marker='x', linestyle='',
399
                markeredgecolor=color, markerfacecolor=color,
400
                markersize=marker_size, markeredgewidth=marker_width,
401
                color=color, label=label_)
402
        if len(zeros) > 0:
9✔
403
            out[idx, 1] = ax.plot(
9✔
404
                real(zeros), imag(zeros), marker='o', linestyle='',
405
                markeredgecolor=color, markerfacecolor='none',
406
                markersize=marker_size, markeredgewidth=marker_width,
407
                color=color)
408

409
        # Plot the loci, if present
410
        if response.loci is not None:
9✔
411
            label_ = response.sysname if label is None else label[idx]
9✔
412
            for locus in response.loci.transpose():
9✔
413
                out[idx, 2] += ax.plot(
9✔
414
                    real(locus), imag(locus), color=color, label=label_)
415

416
            # Compute the axis limits to use based on the response
417
            resp_xlim, resp_ylim = _compute_root_locus_limits(response)
9✔
418

419
            # Keep track of the current limits
420
            xlim = [min(xlim[0], resp_xlim[0]), max(xlim[1], resp_xlim[1])]
9✔
421
            ylim = [min(ylim[0], resp_ylim[0]), max(ylim[1], resp_ylim[1])]
9✔
422

423
            # Plot the initial gain, if given
424
            if initial_gain is not None:
9✔
425
                _mark_root_locus_gain(ax, response.sys, initial_gain)
9✔
426

427
            # TODO: add arrows to root loci (reuse Nyquist arrow code?)
428

429
    # Set the axis limits to something reasonable
430
    if rlocus_plot:
9✔
431
        # Set up the limits for the plot using information from loci
432
        ax.set_xlim(xlim if xlim_user is None else xlim_user)
9✔
433
        ax.set_ylim(ylim if ylim_user is None else ylim_user)
9✔
434
    else:
435
        # No root loci => only set axis limits if users specified them
436
        if xlim_user is not None:
9✔
437
            ax.set_xlim(xlim_user)
9✔
438
        if ylim_user is not None:
9✔
439
            ax.set_ylim(ylim_user)
9✔
440

441
    # List of systems that are included in this plot
442
    lines, labels = _get_line_labels(ax)
9✔
443

444
    # Add legend if there is more than one system plotted
445
    if show_legend or len(labels) > 1 and show_legend != False:
9✔
446
        if response.loci is None:
9✔
447
            # Use "x o" for the system label, via matplotlib tuple handler
448
            from matplotlib.legend_handler import HandlerTuple
9✔
449
            from matplotlib.lines import Line2D
9✔
450

451
            line_tuples = []
9✔
452
            for pole_line in lines:
9✔
453
                zero_line = Line2D(
9✔
454
                    [0], [0], marker='o', linestyle='',
455
                    markeredgecolor=pole_line.get_markerfacecolor(),
456
                    markerfacecolor='none', markersize=marker_size,
457
                    markeredgewidth=marker_width)
458
                handle = (pole_line, zero_line)
9✔
459
                line_tuples.append(handle)
9✔
460

461
            with plt.rc_context(rcParams):
9✔
462
                legend = ax.legend(
9✔
463
                    line_tuples, labels, loc=legend_loc,
464
                    handler_map={tuple: HandlerTuple(ndivide=None)})
465
        else:
466
            # Regular legend, with lines
467
            with plt.rc_context(rcParams):
9✔
468
                legend = ax.legend(lines, labels, loc=legend_loc)
9✔
469
    else:
470
        legend = None
9✔
471

472
    # Add the title
473
    if title is None:
9✔
474
        title = ("Root locus plot for " if rlocus_plot
9✔
475
                 else "Pole/zero plot for ") + ", ".join(labels)
476
    if user_ax is None:
9✔
477
        _update_plot_title(
9✔
478
            title, fig, rcParams=rcParams, frame='figure',
479
            use_existing=False)
480

481
    # Add dispather to handle choosing a point on the diagram
482
    if interactive:
9✔
483
        if len(pzmap_responses) > 1:
9✔
484
            raise NotImplementedError(
485
                "interactive mode only allowed for single system")
486
        elif pzmap_responses[0].sys == None:
9✔
487
            raise SystemError("missing system information")
×
488
        else:
489
            sys = pzmap_responses[0].sys
9✔
490

491
        # Define function to handle mouse clicks
492
        def _click_dispatcher(event):
9✔
493
            # Find the gain corresponding to the clicked point
494
            K, s = _find_root_locus_gain(event, sys, ax)
×
495

496
            if K is not None:
×
497
                # Mark the gain on the root locus diagram
498
                _mark_root_locus_gain(ax, sys, K)
×
499

500
                # Display the parameters in the axes title
501
                with plt.rc_context(rcParams):
×
502
                    ax.set_title(_create_root_locus_label(sys, K, s))
×
503

504
            ax.figure.canvas.draw()
×
505

506
        fig.canvas.mpl_connect('button_release_event', _click_dispatcher)
9✔
507

508
    # Legacy processing: return locations of poles and zeros as a tuple
509
    if plot is True:
9✔
510
        if len(data) == 1:
9✔
511
            return poles, zeros
9✔
512
        else:
513
            TypeError("system lists not supported with legacy return values")
×
514

515
    return ControlPlot(out, ax, fig, legend=legend)
9✔
516

517

518
# Utility function to find gain corresponding to a click event
519
def _find_root_locus_gain(event, sys, ax):
9✔
520
    # Get the current axis limits to set various thresholds
521
    xlim, ylim = ax.get_xlim(), ax.get_ylim()
9✔
522

523
    # Catch type error when event click is in the figure but not on curve
524
    try:
9✔
525
        s = complex(event.xdata, event.ydata)
9✔
526
        K = -1. / sys(s)
9✔
527
        K_xlim = -1. / sys(
9✔
528
            complex(event.xdata + 0.05 * abs(xlim[1] - xlim[0]), event.ydata))
529
        K_ylim = -1. / sys(
9✔
530
            complex(event.xdata, event.ydata + 0.05 * abs(ylim[1] - ylim[0])))
531
    except TypeError:
×
532
        K, s = float('inf'), None
×
533
        K_xlim = K_ylim = float('inf')
×
534

535
    #
536
    # Compute tolerances for deciding if we clicked on the root locus
537
    #
538
    # This is a bit of black magic that sets some limits for how close we
539
    # need to be to the root locus in order to consider it a click on the
540
    # actual curve.  Otherwise, we will just ignore the click.
541

542
    x_tolerance = 0.1 * abs((xlim[1] - xlim[0]))
9✔
543
    y_tolerance = 0.1 * abs((ylim[1] - ylim[0]))
9✔
544
    gain_tolerance = np.mean([x_tolerance, y_tolerance]) * 0.1 + \
9✔
545
        0.1 * max([abs(K_ylim.imag/K_ylim.real), abs(K_xlim.imag/K_xlim.real)])
546

547
    # Decide whether to pay attention to this event
548
    if abs(K.real) > 1e-8 and abs(K.imag / K.real) < gain_tolerance and \
9✔
549
       event.inaxes == ax.axes and K.real > 0.:
550
        return K.real, s
9✔
551
    else:
552
        return None, None
×
553

554

555
# Mark points corresponding to a given gain on root locus plot
556
def _mark_root_locus_gain(ax, sys, K):
9✔
557
    from .rlocus import _RLFindRoots, _systopoly1d
9✔
558

559
    # Remove any previous gain points
560
    for line in reversed(ax.lines):
9✔
561
        if line.get_label() == '_gain_point':
9✔
562
            line.remove()
9✔
563
            del line
9✔
564

565
    # Visualise clicked point, displaying all roots
566
    # TODO: allow marker parameters to be set
567
    nump, denp = _systopoly1d(sys)
9✔
568
    root_array = _RLFindRoots(nump, denp, K.real)
9✔
569
    ax.plot(
9✔
570
        [root.real for root in root_array], [root.imag for root in root_array],
571
        marker='s', markersize=6, zorder=20, label='_gain_point', color='k')
572

573

574
# Return a string identifying a clicked point
575
def _create_root_locus_label(sys, K, s):
9✔
576
    # Figure out the damping ratio
577
    if isdtime(sys, strict=True):
9✔
578
        zeta = -np.cos(np.angle(np.log(s)))
9✔
579
    else:
580
        zeta = -1 * s.real / abs(s)
9✔
581

582
    return "Clicked at: %.4g%+.4gj   gain = %.4g  damping = %.4g" % \
9✔
583
        (s.real, s.imag, K.real, zeta)
584

585

586
# Utility function to compute limits for root loci
587
def _compute_root_locus_limits(response):
9✔
588
    loci = response.loci
9✔
589

590
    # Start with information about zeros, if present
591
    if response.sys is not None and response.sys.zeros().size > 0:
9✔
592
        xlim = [
9✔
593
            min(0, np.min(response.sys.zeros().real)),
594
            max(0, np.max(response.sys.zeros().real))
595
        ]
596
        ylim = max(0, np.max(response.sys.zeros().imag))
9✔
597
    else:
598
        xlim, ylim = [np.inf, -np.inf], 0
9✔
599

600
    # Go through each locus and look for features
601
    rho = config._get_param('pzmap', 'buffer_factor')
9✔
602
    for locus in loci.transpose():
9✔
603
        # Include all starting points
604
        xlim = [min(xlim[0], locus[0].real), max(xlim[1], locus[0].real)]
9✔
605
        ylim = max(ylim, locus[0].imag)
9✔
606

607
        # Find the local maxima of root locus curve
608
        xpeaks = np.where(
9✔
609
            np.diff(np.abs(locus.real)) < 0, locus.real[0:-1], 0)
610
        if xpeaks.size > 0:
9✔
611
            xlim = [
9✔
612
                min(xlim[0], np.min(xpeaks) * rho),
613
                max(xlim[1], np.max(xpeaks) * rho)
614
            ]
615

616
        ypeaks = np.where(
9✔
617
            np.diff(np.abs(locus.imag)) < 0, locus.imag[0:-1], 0)
618
        if ypeaks.size > 0:
9✔
619
            ylim = max(ylim, np.max(ypeaks) * rho)
9✔
620

621
    if isctime(dt=response.dt):
9✔
622
        # Adjust the limits to include some space around features
623
        # TODO: use _k_max and project out to max k for all value?
624
        rho = config._get_param('pzmap', 'expansion_factor')
9✔
625
        xlim[0] = rho * xlim[0] if xlim[0] < 0 else 0
9✔
626
        xlim[1] = rho * xlim[1] if xlim[1] > 0 else 0
9✔
627
        ylim = rho * ylim if ylim > 0 else np.max(np.abs(xlim))
9✔
628

629
    # Make sure the limits make sense
630
    if xlim == [0, 0]:
9✔
631
        xlim = [-1, 1]
×
632
    if ylim == 0:
9✔
633
        ylim = 1
9✔
634

635
    return xlim, [-ylim, ylim]
9✔
636

637

638
pzmap = pole_zero_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