|
1 | 1 | # pzmap.py - computations involving poles and zeros
|
2 | 2 | #
|
3 |
| -# Author: Richard M. Murray |
| 3 | +# Original author: Richard M. Murray |
4 | 4 | # Date: 7 Sep 2009
|
5 | 5 | #
|
6 | 6 | # This file contains functions that compute poles, zeros and related
|
7 | 7 | # quantities for a linear system.
|
8 | 8 | #
|
9 |
| -# Copyright (c) 2009 by California Institute of Technology |
10 |
| -# All rights reserved. |
11 |
| -# |
12 |
| -# Redistribution and use in source and binary forms, with or without |
13 |
| -# modification, are permitted provided that the following conditions |
14 |
| -# are met: |
15 |
| -# |
16 |
| -# 1. Redistributions of source code must retain the above copyright |
17 |
| -# notice, this list of conditions and the following disclaimer. |
18 |
| -# |
19 |
| -# 2. Redistributions in binary form must reproduce the above copyright |
20 |
| -# notice, this list of conditions and the following disclaimer in the |
21 |
| -# documentation and/or other materials provided with the distribution. |
22 |
| -# |
23 |
| -# 3. Neither the name of the California Institute of Technology nor |
24 |
| -# the names of its contributors may be used to endorse or promote |
25 |
| -# products derived from this software without specific prior |
26 |
| -# written permission. |
27 |
| -# |
28 |
| -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
29 |
| -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
30 |
| -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS |
31 |
| -# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH |
32 |
| -# OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
33 |
| -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
34 |
| -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF |
35 |
| -# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND |
36 |
| -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, |
37 |
| -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT |
38 |
| -# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF |
39 |
| -# SUCH DAMAGE. |
40 |
| -# |
41 | 9 |
|
| 10 | +import numpy as np |
42 | 11 | from numpy import real, imag, linspace, exp, cos, sin, sqrt
|
| 12 | +import matplotlib.pyplot as plt |
43 | 13 | from math import pi
|
| 14 | +import itertools |
| 15 | +import warnings |
| 16 | + |
44 | 17 | from .lti import LTI
|
45 | 18 | from .iosys import isdtime, isctime
|
46 | 19 | from .grid import sgrid, zgrid, nogrid
|
| 20 | +from .statesp import StateSpace |
| 21 | +from .xferfcn import TransferFunction |
| 22 | +from .freqplot import _freqplot_defaults, _get_line_labels |
47 | 23 | from . import config
|
48 | 24 |
|
49 |
| -__all__ = ['pzmap'] |
| 25 | +__all__ = ['pzmap_response', 'pzmap_plot', 'pzmap'] |
50 | 26 |
|
51 | 27 |
|
52 | 28 | # Define default parameter values for this module
|
53 | 29 | _pzmap_defaults = {
|
54 | 30 | 'pzmap.grid': False, # Plot omega-damping grid
|
55 |
| - 'pzmap.plot': True, # Generate plot using Matplotlib |
| 31 | + 'pzmap.marker_size': 6, # Size of the markers |
| 32 | + 'pzmap.marker_width': 1.5, # Width of the markers |
56 | 33 | }
|
57 | 34 |
|
58 | 35 |
|
| 36 | +# Classes for keeping track of pzmap plots |
| 37 | +class PoleZeroResponseList(list): |
|
341A
38 | + def plot(self, *args, **kwargs): |
| 39 | + return pzmap_plot(self, *args, **kwargs) |
| 40 | + |
| 41 | + |
| 42 | +class PoleZeroResponseData: |
| 43 | + def __init__( |
| 44 | + self, poles, zeros, gains=None, loci=None, dt=None, sysname=None): |
| 45 | + self.poles = poles |
| 46 | + self.zeros = zeros |
<
F438
/code> | 47 | + self.gains = gains |
| 48 | + self.loci = loci |
| 49 | + self.dt = dt |
| 50 | + self.sysname = sysname |
| 51 | + |
| 52 | + # Implement functions to allow legacy assignment to tuple |
| 53 | + def __iter__(self): |
| 54 | + return iter((self.poles, self.zeros)) |
| 55 | + |
| 56 | + def plot(self, *args, **kwargs): |
| 57 | + return pzmap_plot(self, *args, **kwargs) |
| 58 | + |
| 59 | + |
| 60 | +# pzmap response funciton |
| 61 | +def pzmap_response(sysdata): |
| 62 | + # Convert the first argument to a list |
| 63 | + syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata] |
| 64 | + |
| 65 | + responses = [] |
| 66 | + for idx, sys in enumerate(syslist): |
| 67 | + responses.append( |
| 68 | + PoleZeroResponseData( |
| 69 | + sys.poles(), sys.zeros(), dt=sys.dt, sysname=sys.name)) |
| 70 | + |
| 71 | + if isinstance(sysdata, (list, tuple)): |
| 72 | + return PoleZeroResponseList(responses) |
| 73 | + else: |
| 74 | + return responses[0] |
| 75 | + |
| 76 | + |
59 | 77 | # TODO: Implement more elegant cross-style axes. See:
|
60 |
| -# http://matplotlib.sourceforge.net/examples/axes_grid/demo_axisline_style.html |
61 |
| -# http://matplotlib.sourceforge.net/examples/axes_grid/demo_curvelinear_grid.html |
62 |
| -def pzmap(sys, plot=None, grid=None, title='Pole Zero Map', **kwargs): |
| 78 | +# https://matplotlib.org/2.0.2/examples/axes_grid/demo_axisline_style.html |
| 79 | +# https://matplotlib.org/2.0.2/examples/axes_grid/demo_curvelinear_grid.html |
| 80 | +def pzmap_plot( |
| 81 | + data, plot=None, grid=None, title=None, marker_color=None, |
| 82 | + marker_size=None, marker_width=None, legend_loc='upper right', |
| 83 | + **kwargs): |
63 | 84 | """Plot a pole/zero map for a linear system.
|
64 | 85 |
|
65 | 86 | Parameters
|
66 | 87 | ----------
|
67 |
| - sys: LTI (StateSpace or TransferFunction) |
68 |
| - Linear system for which poles and zeros are computed. |
69 |
| - plot: bool, optional |
70 |
| - If ``True`` a graph is generated with Matplotlib, |
71 |
| - otherwise the poles and zeros are only computed and returned. |
| 88 | + sysdata: List of PoleZeroResponseData objects or LTI systems |
| 89 | + List of pole/zero response data objects generated by pzmap_response |
| 90 | + or rootlocus_response() that are to be plotted. If a list of systems |
| 91 | + is given, the poles and zeros of those systems will be plotted. |
72 | 92 | grid: boolean (default = False)
|
73 | 93 | If True plot omega-damping grid.
|
| 94 | + plot: bool, optional |
| 95 | + (legacy) If ``True`` a graph is generated with Matplotlib, |
| 96 | + otherwise the poles and zeros are only computed and returned. |
| 97 | + If this argument is present, the legacy value of poles and |
| 98 | + zero is returned. |
74 | 99 |
|
75 | 100 | Returns
|
76 | 101 | -------
|
77 |
| - poles: array |
78 |
| - The systems poles |
79 |
| - zeros: array |
80 |
| - The system's zeros. |
| 102 | + lines : List of Line2D |
| 103 | + Array of Line2D objects for each set of markers in the plot. The |
| 104 | + shape of the array is given by (nsys, 2) where nsys is the number |
| 105 | + of systems or Nyquist responses passed to the function. The second |
| 106 | + index specifies the pzmap object type: |
| 107 | +
|
| 108 | + * lines[idx, 0]: poles |
| 109 | + * lines[idx, 1]: zeros |
| 110 | +
|
| 111 | + poles, zeros: list of arrays |
| 112 | + (legacy) If the `plot` keyword is given, the system poles and zeros |
| 113 | + are returned. |
81 | 114 |
|
82 |
| - Notes |
| 115 | + Notes (TODO: update) |
83 | 116 | -----
|
84 | 117 | The pzmap function calls matplotlib.pyplot.axis('equal'), which means
|
85 | 118 | that trying to reset the axis limits may not behave as expected. To
|
86 | 119 | change the axis limits, use matplotlib.pyplot.gca().axis('auto') and
|
87 | 120 | then set the axis limits to the desired values.
|
88 | 121 |
|
89 | 122 | """
|
90 |
| - # Check to see if legacy 'Plot' keyword was used |
91 |
| - if 'Plot' in kwargs: |
92 |
| - import warnings |
93 |
| - warnings.warn("'Plot' keyword is deprecated in pzmap; use 'plot'", |
94 |
| - FutureWarning) |
95 |
| - plot = kwargs.pop('Plot') |
96 |
| - |
97 |
| - # Make sure there were no extraneous keywords |
98 |
| - if kwargs: |
99 |
| - raise TypeError("unrecognized keywords: ", str(kwargs)) |
100 |
| - |
101 | 123 | # Get parameter values
|
102 |
| - plot = config._get_param('pzmap', 'plot', plot, True) |
103 | 124 | grid = config._get_param('pzmap', 'grid', grid, False)
|
| 125 | + marker_size = config._get_param('pzmap', 'marker_size', marker_size, 6) |
| 126 | + marker_width = config._get_param('pzmap', 'marker_width', marker_width, 1.5) |
| 127 | + fre
A851
qplot_rcParams = config._get_param( |
| 128 | + 'freqplot', 'rcParams', kwargs, _freqplot_defaults, |
| 129 | + pop=True, last=True) |
| 130 | + |
| 131 | + # If argument was a singleton, turn it into a tuple |
| 132 | + if not isinstance(data, (list, tuple)): |
| 133 | + data = [data] |
104 | 134 |
|
105 |
| - if not isinstance(sys, LTI): |
106 |
| - raise TypeError('Argument ``sys``: must be a linear system.') |
| 135 | + # If we are passed a list of systems, compute response first |
| 136 | + if all([isinstance( |
| 137 | + sys, (StateSpace, TransferFunction)) for sys in data]): |
| 138 | + # Get the response, popping off keywords used there |
| 139 | + pzmap_responses = pzmap_response(data) |
| 140 | + elif all([isinstance(d, PoleZeroResponseData) for d in data]): |
| 141 | + pzmap_responses = data |
| 142 | + else: |
| 143 | + raise TypeError("unknown system data type") |
107 | 144 |
|
108 |
| - poles = sys.poles() |
109 |
| - zeros = sys.zeros() |
| 145 | + # Legacy return value processing |
| 146 | + if plot is not None: |
| 147 | + warnings.warn( |
| 148 | + "`pzmap_plot` return values of poles, zeros is deprecated; " |
| 149 | + "use pzmap_response()", DeprecationWarning) |
| 150 | + |
| 151 | + # Extract out the values that we will eventually return |
| 152 | + poles = [response.poles for response in pzmap_responses] |
| 153 | + zeros = [response.zeros for response in pzmap_responses] |
| 154 | + |
| 155 | + if plot is False: |
| 156 | + if len(data) == 1: |
| 157 | + return poles[0], zeros[0] |
| 158 | + else: |
| 159 | + return poles, zeros |
110 | 160 |
|
111 |
| - if (plot): |
112 |
| - import matplotlib.pyplot as plt |
| 161 | + # Initialize the figure |
| 162 | + # TODO: turn into standard utility function |
| 163 | + fig = plt.gcf() |
| 164 | + axs = fig.get_axes() |
| 165 | + if len(axs) > 1: |
| 166 | + # Need to generate a new figure |
| 167 | + fig, axs = plt.figure(), [] |
113 | 168 |
|
| 169 | + with plt.rc_context(freqplot_rcParams): |
114 | 170 | if grid:
|
115 |
| - if isdtime(sys, strict=True): |
| 171 | + plt.clf() |
| 172 | + if all([response.isctime() for response in data]): |
| 173 | + ax, fig = sgrid() |
| 174 | + elif all([response.isdtime() for response in data]): |
116 | 175 | ax, fig = zgrid()
|
117 | 176 | else:
|
118 |
| - ax, fig = sgrid() |
119 |
| - else: |
| 177 | + ValueError( |
| 178 | + "incompatible time responses; don't know how to grid") |
| 179 | + elif len(axs) == 0: |
120 | 180 | ax, fig = nogrid()
|
| 181 | + else: |
| 182 | + # Use the existing axes |
| 183 | + ax = axs[0] |
| 184 | + |
| 185 | + # Handle color cycle manually as all singular values |
| 186 | + # of the same systems are expected to be of the same color |
| 187 | + color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color'] |
| 188 | + color_offset = 0 |
| 189 | + if len(ax.lines) > 0: |
| 190 | + last_color = ax.lines[-1].get_color() |
| 191 | + if last_color in color_cycle: |
| 192 | + color_offset = color_cycle.index(last_color) + 1 |
| 193 | + |
| 194 | + # Create a list of lines for the output |
| 195 | + out = np.empty((len(pzmap_responses), 2), dtype=object) |
| 196 | + for i, j in itertools.product(range(out.shape[0]), range(out.shape[1])): |
| 197 | + out[i, j] = [] # unique list in each element |
| 198 | + |
| 199 | + for idx, response in enumerate(pzmap_responses): |
| 200 | + poles = response.poles |
| 201 | + zeros = response.zeros |
| 202 | + |
| 203 | + # Get the color to use for this system |
| 204 | + if marker_color is None: |
| 205 | + color = color_cycle[(color_offset + idx) % len(color_cycle)] |
| 206 | + else: |
| 207 | + color = maker_color |
121 | 208 |
|
122 | 209 | # Plot the locations of the poles and zeros
|
123 | 210 | if len(pol
90A1
es) > 0:
|
124 |
| - ax.scatter(real(poles), imag(poles), s=50, marker='x', |
125 |
| - facecolors='k') |
| 211 | + out[idx, 0] = ax.plot( |
| 212 | + real(poles), imag(poles), marker='x', linestyle='', |
| 213 | + markeredgecolor=color, markerfacecolor=color, |
| 214 | + markersize=marker_size, markeredgewidth=marker_width, |
| 215 | + label=response.sysname) |
126 | 216 | if len(zeros) > 0:
|
127 |
| - ax.scatter(real(zeros), imag(zeros), s=50, marker='o', |
128 |
| - facecolors='none', edgecolors='k') |
| 217 | + out[idx, 1] = ax.plot( |
| 218 | + real(zeros), imag(zeros), marker='o', linestyle='', |
| 219 | + markeredgecolor=color, markerfacecolor='none', |
| 220 | + markersize=marker_size, markeredgewidth=marker_width) |
| 221 | + |
| 222 | + # List of systems that are included in this plot |
| 223 | + lines, labels = _get_line_labels(ax) |
| 224 | + |
| 225 | + # Update the lines to use tuples for poles and zeros |
| 226 | + from matplotlib.lines import Line2D |
| 227 | + from matplotlib.legend_handler import HandlerTuple |
| 228 | + line_tuples = [] |
| 229 | + for pole_line in lines: |
| 230 | + zero_line = Line2D( |
| 231 | + [0], [0], marker='o', linestyle='', |
| 232 | + markeredgecolor=pole_line.get_markerfacecolor(), |
| 233 | + markerfacecolor='none', markersize=marker_size, |
| 234 | + markeredgewidth=marker_width) |
| 235 | + handle = (pole_line, zero_line) |
| 236 | + line_tuples.append(handle) |
| 237 | + print(line_tuples) |
| 238 | + |
| 239 | + # Add legend if there is more than one system plotted |
| 240 | + if len(labels) > 1 and legend_loc is not False: |
| 241 | + with plt.rc_context(freqplot_rcParams): |
| 242 | + ax.legend( |
| 243 | + line_tuples, labels, loc=legend_loc, |
| 244 | + handler_map={tuple: HandlerTuple(ndivide=None)}) |
| 245 | + |
| 246 | + # Add the title |
| 247 | + if title is None: |
| 248 | + title = "Pole/zero map for " + ", ".join(labels) |
| 249 | + with plt.rc_context(freqplot_rcParams): |
| 250 | + fig.suptitle(title) |
| 251 | + |
| 252 | + # Legacy processing: return locations of poles and zeros as a tuple |
| 253 | + if plot is True: |
| 254 | + if len(data) == 1: |
| 255 | + return poles, zeros |
| 256 | + else: |
| 257 | + TypeError("system lists not supported with legacy return values") |
| 258 | + |
| 259 | + return out |
129 | 260 |
|
130 |
| - plt.title(title) |
131 | 261 |
|
132 |
| - # Return locations of poles and zeros as a tuple |
133 |
| - return poles, zeros |
| 262 | +pzmap = pzmap_plot |
0 commit comments