88# storing and plotting pole/zero and root locus diagrams. (The actual
99# computation of root locus diagrams is in rlocus.py.)
1010#
11+ # TODO (Sep 2023):
12+ # * Test out ability to set line styles
13+ # - Make compatible with other plotting (and refactor?)
14+ # - Allow line fmt to be overwritten (including color=CN for different
15+ # colors for each segment?)
16+ # * Add ability to set style of root locus click point
17+ # - Sort out where default parameter values should live (pzmap vs rlocus)
18+ # * Decide whether click functionality should be in rlocus.py
19+ # * Add back print_gain option to sisotool (and any other options)
20+ #
1121
1222import numpy as np
1323from numpy import real , imag , linspace , exp , cos , sin , sqrt
2434from .freqplot import _freqplot_defaults , _get_line_labels
2535from . import config
2636
27- __all__ = ['pole_zero_map' , 'root_locus_map' , ' pole_zero_plot' , 'pzmap' ]
37+ __all__ = ['pole_zero_map' , 'pole_zero_plot' , 'pzmap' ]
2838
2939
3040# Define default parameter values for this module
3141_pzmap_defaults = {
32- 'pzmap.grid' : False , # Plot omega-damping grid
33- 'pzmap.marker_size' : 6 , # Size of the markers
34- 'pzmap.marker_width' : 1.5 , # Width of the markers
42+ 'pzmap.grid' : False , # Plot omega-damping grid
43+ 'pzmap.marker_size' : 6 , # Size of the markers
44+ 'pzmap.marker_width' : 1.5 , # Width of the markers
45+ 'pzmap.expansion_factor' : 2 , # Amount to scale plots beyond features
3546}
3647
37-
48+ #
3849# Classes for keeping track of pzmap plots
39- class RootLocusList (list ):
40- def plot (self , * args , ** kwargs ):
41- return pole_zero_plot (self , * args , ** kwargs )
42-
43-
44- class RootLocusData :
50+ #
51+ # The PoleZeroData class keeps track of the information that is on a
52+ # pole-zero plot.
53+ #
54+ # In addition to the locations of poles and zeros, you can also save a set
55+ # of gains and loci for use in generating a root locus plot. The gain
56+ # variable is a 1D array consisting of a list of increasing gains. The
57+ # loci variable is a 2D array indexed by [gain_idx, root_idx] that can be
58+ # plotted using the `pole_zero_plot` function.
59+ #
60+ # The PoleZeroList class is used to return a list of pole-zero plots. It
61+ # is a lightweight wrapper on the built-in list class that includes a
62+ # `plot` method, allowing plotting a set of root locus diagrams.
63+ #
64+ class PoleZeroData :
4565 def __init__ (
46- self , poles , zeros , gains = None , loci = None , dt = None , sysname = None ):
66+ self , poles , zeros , gains = None , loci = None , dt = None , sysname = None ,
67+ sys = None ):
4768 self .poles = poles
4869 self .zeros = zeros
4970 self .gains = gains
5071 self .loci = loci
5172 self .dt = dt
5273 self .sysname = sysname
74+ self .sys = sys
5375
5476 # Implement functions to allow legacy assignment to tuple
5577 def __iter__ (self ):
@@ -59,54 +81,25 @@ def plot(self, *args, **kwargs):
5981 return pole_zero_plot (self , * args , ** kwargs )
6082
6183
84+ class PoleZeroList (list ):
85+ def plot (self , * args , ** kwargs ):
86+ return pole_zero_plot (self , * args , ** kwargs )
87+
88+
6289# Pole/zero map
6390def pole_zero_map (sysdata ):
91+ # TODO: add docstring (from old pzmap?)
6492 # Convert the first argument to a list
6593 syslist = sysdata if isinstance (sysdata , (list , tuple )) else [sysdata ]
6694
6795 responses = []
6896 for idx , sys in enumerate (syslist ):
6997 responses .append (
70- RootLocusData (
98+ PoleZeroData (
7199 sys .poles (), sys .zeros (), dt = sys .dt , sysname = sys .name ))
72100
73101 if isinstance (sysdata , (list , tuple )):
74- return RootLocusList (responses )
75- else :
76- return responses [0 ]
77-
78-
79- # Root locus map
80- # TODO: use rlocus.py computation instead
81- def root_locus_map (sysdata , gains = None ):
82- # Convert the first argument to a list
83- syslist = sysdata if isinstance (sysdata , (list , tuple )) else [sysdata ]
84-
85- responses = []
86- for idx , sys in enumerate (syslist ):
87- from .rlocus import _systopoly1d , _default_gains
88- from .rlocus import _RLFindRoots , _RLSortRoots
89-
90- if not sys .issiso ():
91- raise ControlMIMONotImplemented (
92- "sys must be single-input single-output (SISO)" )
93-
94- # Convert numerator and denominator to polynomials if they aren't
95- nump , denp = _systopoly1d (sys [0 , 0 ])
96-
97- if gains is None :
98- kvect , root_array , _ , _ = _default_gains (nump , denp , None , None )
99- else :
100- kvect = np .atleast_1d (gains )
101- root_array = _RLFindRoots (nump , denp , kvect )
102- root_array = _RLSortRoots (root_array )
103-
104- responses .append (RootLocusData (
105- sys .poles (), sys .zeros (), kvect , root_array ,
106- dt = sys .dt , sysname = sys .name ))
107-
108- if isinstance (sysdata , (list , tuple )):
109- return RootLocusList (responses )
102+ return PoleZeroList (responses )
110103 else :
111104 return responses [0 ]
112105
@@ -117,12 +110,14 @@ def root_locus_map(sysdata, gains=None):
117110def pole_zero_plot (
118111 data , plot = None , grid = None , title = None , marker_color = None ,
119112 marker_size = None , marker_width = None , legend_loc = 'upper right' ,
120- xlim = None , ylim = None , ** kwargs ):
113+ xlim = None , ylim = None , interactive = False , ax = None ,
114+ initial_gain = None , ** kwargs ):
115+ # TODO: update docstring (see other response/plot functions for style)
121116 """Plot a pole/zero map for a linear system.
122117
123118 Parameters
124119 ----------
125- sysdata: List of RootLocusData objects or LTI systems
120+ sysdata: List of PoleZeroData objects or LTI systems
126121 List of pole/zero response data objects generated by pzmap_response
127122 or rootlocus_response() that are to be plotted. If a list of systems
128123 is given, the poles and zeros of those systems will be plotted.
@@ -165,6 +160,7 @@ def pole_zero_plot(
165160 freqplot_rcParams = config ._get_param (
166161 'freqplot' , 'rcParams' , kwargs , _freqplot_defaults ,
167162 pop = True , last = True )
163+ user_ax = ax
168164
169165 # If argument was a singleton, turn it into a tuple
170166 if not isinstance (data , (list , tuple )):
@@ -175,7 +171,7 @@ def pole_zero_plot(
175171 sys , (StateSpace , TransferFunction )) for sys in data ]):
176172 # Get the response, popping off keywords used there
177173 pzmap_responses = pole_zero_map (data )
178- elif all ([isinstance (d , RootLocusData ) for d in data ]):
174+ elif all ([isinstance (d , PoleZeroData ) for d in data ]):
179175 pzmap_responses = data
180176 else :
181177 raise TypeError ("unknown system data type" )
@@ -198,8 +194,13 @@ def pole_zero_plot(
198194
199195 # Initialize the figure
200196 # TODO: turn into standard utility function (from plotutil.py?)
201- fig = plt .gcf ()
202- axs = fig .get_axes ()
197+ if user_ax is None :
198+ fig = plt .gcf ()
199+ axs = fig .get_axes ()
200+ else :
201+ fig = ax .figure
202+ axs = [ax ]
203+
203204 if len (axs ) > 1 :
204205 # Need to generate a new figure
205206 fig , axs = plt .figure (), []
@@ -275,6 +276,10 @@ def pole_zero_plot(
275276 xlim = [min (xlim [0 ], resp_xlim [0 ]), max (xlim [1 ], resp_xlim [1 ])]
276277 ylim = [min (ylim [0 ], resp_ylim [0 ]), max (ylim [1 ], resp_ylim [1 ])]
277278
279+ # Plot the initial gain, if given
280+ if initial_gain is not None :
281+ _mark_root_locus_gain (ax , response .sys , initial_gain )
282+
278283 # TODO: add arrows to root loci (reuse Nyquist arrow code?)
279284
280285 # Set up the limits for the plot
@@ -313,8 +318,36 @@ def pole_zero_plot(
313318 # Add the title
314319 if title is None :
315320 title = "Pole/zero plot for " + ", " .join (labels )
316- with plt .rc_context (freqplot_rcParams ):
317- fig .suptitle (title )
321+ if user_ax is None :
322+ with plt .rc_context (freqplot_rcParams ):
323+ fig .suptitle (title )
324+
325+ # Add dispather to handle choosing a point on the diagram
326+ if interactive :
327+ if len (pzmap_responses ) > 1 :
328+ raise NotImplementedError (
329+ "interactive mode only allowed for single system" )
330+ elif pzmap_responses [0 ].sys == None :
331+ raise SystemError ("missing system information" )
332+ else :
333+ sys = pzmap_responses [0 ].sys
334+
335+ # Define function to handle mouse clicks
336+ def _click_dispatcher (event ):
337+ # Find the gain corresponding to the clicked point
338+ K , s = _find_root_locus_gain (event , sys , ax )
339+
340+ if K is not None :
341+ # Mark the gain on the root locus diagram
342+ _mark_root_locus_gain (ax , sys , K )
343+
344+ # Display the parameters in the axes title
345+ with plt .rc_context (freqplot_rcParams ):
346+ ax .set_title (_create_root_locus_label (sys , K , s ))
347+
348+ ax .figure .canvas .draw ()
349+
350+ fig .canvas .mpl_connect ('button_release_event' , _click_dispatcher )
318351
319352 # Legacy processing: return locations of poles and zeros as a tuple
320353 if plot is True :
@@ -326,7 +359,82 @@ def pole_zero_plot(
326359 return out
327360
328361
362+ # Utility function to find gain corresponding to a click event
363+ # TODO: project onto the root locus plot (here or above?)
364+ def _find_root_locus_gain (event , sys , ax ):
365+ # Get the current axis limits to set various thresholds
366+ xlim , ylim = ax .get_xlim (), ax .get_ylim ()
367+
368+ # Catch type error when event click is in the figure but not in an axis
369+ try :
370+ s = complex (event .xdata , event .ydata )
371+ K = - 1. / sys (s )
372+ K_xlim = - 1. / sys (
373+ complex (event .xdata + 0.05 * abs (xlim [1 ] - xlim [0 ]), event .ydata ))
374+ K_ylim = - 1. / sys (
375+ complex (event .xdata , event .ydata + 0.05 * abs (ylim [1 ] - ylim [0 ])))
376+
377+ except TypeError :
378+ K = float ('inf' )
379+ K_xlim = float ('inf' )
380+ K_ylim = float ('inf' )
381+
382+ #
383+ # Compute tolerances for deciding if we clicked on the root locus
384+ #
385+ # This is a bit of black magic that sets some limits for how close we
386+ # need to be to the root locus in order to consider it a click on the
387+ # actual curve. Otherwise, we will just ignore the click.
388+
389+ x_tolerance = 0.1 * abs ((xlim [1 ] - xlim [0 ]))
390+ y_tolerance = 0.1 * abs ((ylim [1 ] - ylim [0 ]))
391+ gain_tolerance = np .mean ([x_tolerance , y_tolerance ]) * 0.1 + \
392+ 0.1 * max ([abs (K_ylim .imag / K_ylim .real ), abs (K_xlim .imag / K_xlim .real )])
393+
394+ # Decide whether to pay attention to this event
395+ if abs (K .real ) > 1e-8 and abs (K .imag / K .real ) < gain_tolerance and \
396+ event .inaxes == ax .axes and K .real > 0. :
397+ return K .real , s
398+
399+ else :
400+ return None , s
401+
402+
403+ # Mark points corresponding to a given gain on root locus plot
404+ def _mark_root_locus_gain (ax , sys , K ):
405+ from .rlocus import _systopoly1d , _RLFindRoots
406+
407+ # Remove any previous gain points
408+ for line in reversed (ax .lines ):
409+ if line .get_label () == '_gain_point' :
410+ line .remove ()
411+ del line
412+
413+ # Visualise clicked point, displaying all roots
414+ # TODO: allow marker parameters to be set
415+ nump , denp = _systopoly1d (sys )
416+ root_array = _RLFindRoots (nump , denp , K .real )
417+ ax .plot (
418+ [root .real for root in root_array ], [root .imag for root in root_array ],
419+ marker = 's' , markersize = 6 , zorder = 20 , label = '_gain_point' , color = 'k' )
420+
421+
422+ # Return a string identifying a clicked point
423+ # TODO: project onto the root locus plot (here or above?)
424+ def _create_root_locus_label (sys , K , s ):
425+ # Figure out the damping ratio
426+ if isdtime (sys , strict = True ):
427+ zeta = - np .cos (np .angle (np .log (s )))
428+ else :
429+ zeta = - 1 * s .real / abs (s )
430+
431+ return "Clicked at: %.4g%+.4gj gain = %.4g damping = %.4g" % \
432+ (s .real , s .imag , K .real , zeta )
433+
434+
329435# Utility function to compute limits for root loci
436+ # TODO: compare to old code and recapture functionality (especially asymptotes)
437+ # TODO: (note that sys is now available => code here may not be needed)
330438def _compute_root_locus_limits (loci ):
331439 # Go through each locus
332440 xlim , ylim = [0 , 0 ], 0
@@ -345,7 +453,8 @@ def _compute_root_locus_limits(loci):
345453 ylim = max (ylim , np .max (ypeaks ))
346454
347455 # Adjust the limits to include some space around features
348- rho = 1.5
456+ # TODO: use _k_max and project out to max k for all value?
457+ rho = config ._get_param ('pzmap' , 'expansion_factor' )
349458 xlim [0 ] = rho * xlim [0 ] if xlim [0 ] < 0 else 0
350459 xlim [1 ] = rho * xlim [1 ] if xlim [1 ] > 0 else 0
351460 ylim = rho * ylim if ylim > 0 else np .max (np .abs (xlim ))
0 commit comments